Skip to content

Tooling Primitives

Scott Singleton edited this page May 10, 2026 · 1 revision

Tooling Primitives

The cross-cutting types every build script reaches for: paths, well-known directories, runtime preconditions.

AbsolutePath

Replaces stringly-typed path manipulation. Always normalised, always absolute.

Construction

var p = AbsolutePath.Create("./relative/or/absolute");   // resolved against CWD
AbsolutePath.Create(null);     // throws ArgumentNullException
AbsolutePath.Create("");       // throws ArgumentException

There's an implicit conversion to string (so any string-taking API works without ceremony). There's deliberately no implicit conversion from stringAbsolutePath.Create(...) is required at the boundary, because relative paths can change meaning silently when normalised.

Composition

The / operator combines path segments:

var artifacts = RootDirectory / "artifacts";
var nupkg = artifacts / $"MyApp.{Version}.nupkg";
var deepFile = RootDirectory / "src" / "Foo" / "bin" / "Debug" / "net10.0" / "Foo.dll";

Right-hand side can be a relative segment (combined) or an absolute path (replaces, matching Path.Combine semantics).

Components

var p = AbsolutePath.Create("/repo/src/foo.bar.txt");
p.Parent             // /repo/src
p.Name               // "foo.bar.txt"
p.NameWithoutExtension  // "foo.bar"
p.Extension          // ".txt"

Existence

p.Exists()           // file or directory
p.FileExists()
p.DirectoryExists()

Mutating ops (return this for chaining)

p.EnsureDirectoryExists()       // mkdir -p
p.DeleteFile()
p.DeleteDirectory(recursive: true)
p.Delete()                      // polymorphic: deletes file or directory; no-op if missing
p.CopyTo(destination, overwrite: false)   // recursive for dirs
p.MoveTo(destination, overwrite: false)

Read / write

p.ReadAllText()
p.ReadAllLines()
p.ReadAllBytes()
p.WriteAllText("hello")         // creates parent dirs implicitly
p.WriteAllLines(new[] {"a","b"})
p.WriteAllBytes(bytes)

Hashing

p.Sha256()                      // hex of file contents
AbsolutePath.Sha256Of("inline")  // hex of arbitrary string (UTF-8)

The hash format is lowercase hex, 64 chars. Useful for input-hash declarations on idempotent targets:

Target Compile => _ => _
    .Idempotent()
    .InputHash(() => SourceDirectory.Sha256())  // pseudo: realistically use globbing + per-file hash
    .Executes(...);

Children + globbing

p.EnumerateFiles()              // top-level files
p.EnumerateDirectories()        // top-level subdirs

// Recursive globbing — supports **, *, ?
p.GlobFiles("**/*.cs")
p.GlobFiles("src/**/*.cs", "tests/**/*.cs")     // multiple patterns unioned + deduped
p.GlobDirectories("**/bin", "**/obj")

Powered by Microsoft.Extensions.FileSystemGlobbing under the hood.

Common recipes

// Clean every bin/obj in the tree
RootDirectory
    .GlobDirectories("**/bin", "**/obj")
    .ForEach(d => d.Delete());

// Hash every csproj for cache invalidation
var hashes = RootDirectory
    .GlobFiles("**/*.csproj")
    .Select(f => f.Sha256());
var combined = AbsolutePath.Sha256Of(string.Join('|', hashes));

// Copy artifacts into a flat publish dir
var publish = RootDirectory / "publish";
publish.EnsureDirectoryExists();
SourceDirectory
    .GlobFiles("**/bin/Release/net10.0/*.dll")
    .ForEach(dll => dll.CopyTo(publish / dll.Name));

RootDirectory & TemporaryDirectory

Static properties on TampBuild (so any subclass and any target's lambda can use them).

RootDirectory

The root of the consumer repository. Resolved by walking up from the build assembly's location, stopping at the first directory containing any of:

  1. .git/
  2. .tamp/ (a Tamp-specific marker; more on this below)
  3. *.slnx
  4. *.sln

Cached after first access. Throws if no marker is found above the assembly.

public class Build : TampBuild
{
    AbsolutePath SourceDirectory => RootDirectory / "src";
    AbsolutePath ArtifactsDirectory => RootDirectory / "artifacts";
}

TemporaryDirectory

Tamp's per-build scratch space. Lives at RootDirectory / ".tamp" / "temp", created on first access. Used by [NuGetPackage] for tool caches, and available for any target that needs scratch:

Target ExtractDownload => _ => _.Executes(() =>
{
    var tmp = TemporaryDirectory / "downloads";
    tmp.EnsureDirectoryExists();
    // … extract into tmp …
});

The .tamp/ directory is also a marker for RootDirectory resolution — useful in repos that don't have a solution at the root. Just mkdir .tamp and Tamp will treat that as the root.

Verify

Runtime preconditions for build scripts. Throws InvalidOperationException with a clear message when a check fails. Named Verify rather than Assert to dodge the namespace collision with xUnit's Assert in your own test files.

using Tamp;

Verify.NotNull(Solution);                      // throws if null
Verify.NotNullOrEmpty(Configuration.ToString());
Verify.NotNullOrWhiteSpace(Environment);
Verify.True(Version.Major >= 1);               // captured expression in failure message
Verify.False(string.IsNullOrEmpty(token));
Verify.NotEmpty(testProjects);
Verify.Single(testProjects);                   // throws unless exactly one element
Verify.Fail("unreachable");                    // throw with custom message

[CallerArgumentExpression] captures the source expression for default failure messages, so Verify.True(x > 10) fails with Expected true: x > 10, not just Expected true.

When to reach for Verify

Inside target action bodies, when you've assumed something is true and want a clear message rather than a NullReferenceException if it isn't:

Target Deploy => _ => _
    .Requires(() => Solution != null)        // declarative precondition
    .Executes(() =>
    {
        var manifest = Solution!.Path.Parent!.Parent;
        Verify.NotNull(manifest);             // belt-and-braces; defensive
        Verify.True(manifest.DirectoryExists());
        // …
    });

Requires is the better declarative form; Verify is for the cases that come up imperatively inside action bodies.

See also

Clone this wiki locally