Skip to main content

Trail / A real .NET project, in G#

Know what's in
your workspace.

Scan local project files. Compute their hashes with a bounded worker pool. Get a sorted JSON report you can inspect, compare, or use in another tool.

Published SDK 0.4.591 · .NET 10 · No extra packages or accounts

From download to a useful result

Unzip. Run. Inspect.

From the extracted Trail folder, run the bundled demo. Add an extension to narrow the report. Your files are read, never modified or uploaded.

dotnet run -- demo
dotnet run -- demo .txt

The output shown is the verified bundled example, not a live browser execution.

Bundled demo / verified result

Files
3
Bytes
72
Failures
0
  • hello.txt18 bytes
  • notes/todo.txt24 bytes
  • settings.json30 bytes
Inspect the full JSON report
{
"files": [
{
"path": "hello.txt",
"bytes": 18,
"sha256": "c80d4e778bd12b66dcb08e3273608b7af435eb9001e7bbd721bc7f5186cf21ff",
"error": null
},
{
"path": "notes/todo.txt",
"bytes": 24,
"sha256": "7fb2aae299d6b1b79ea0b8674121487eadceabf03cb9cbfdc5388515f81f7438",
"error": null
},
{
"path": "settings.json",
"bytes": 30,
"sha256": "1677b4eb884b716cd9caecc580a41af8c9a01c6fab176787d3c4212ec42f87ff",
"error": null
}
],
"totalBytes": 72,
"failed": 0
}

Concurrency you can follow

A small pipeline.
Clear ownership.

Discovery supplies paths. Workers inspect files. One collector owns the report. Each stage closes its output, and scopes join the work they own.

Discovery feeds an eight-item jobs channel; four workers send records through an eight-item results channel to one report collector.

Model success and failure.

A nullable hash or error makes the outcome visible in the data model.

Program.gs / data model excerpt
public data class FileEntry(Path string, Bytes int64, Sha256 string?, Error string?)
public data class Inventory(Files[]FileEntry, TotalBytes int64, Failed int32)

Express the worker directly.

Channel direction tells you what a stage can read and write.

Program.gs / worker excerpt
func worker(root string, jobs in chan[string], results out chan[FileEntry]) {
for path in jobs {
results <- inspect(path, root)
}
}

Use the runtime you know

.NET does the
heavy lifting.

File streams, SHA-256, JSON serialization, and collections come from the base class library. G# supplies the data models, nullable flow, channel syntax, and scope boundaries.

IO errors become report entries and a nonzero exit code. Traversal failures are reported instead of producing a misleading partial success.

Explore the implementation
Program.gs / file inspection excerpt
func inspect(path string, root string) FileEntry {
let relative = Path.GetRelativePath(root, path).Replace('\\', '/')
try {
if (File.GetAttributes(path) & FileAttributes.ReparsePoint) == FileAttributes.ReparsePoint {
return FileEntry(relative, 0, nil, "Symbolic links are not read.")
}
using let stream = File.OpenRead(path)
let digest = SHA256.HashData(stream)
return FileEntry(relative, stream.Length, Convert.ToHexString(digest).ToLowerInvariant(), nil)
} catch (e IOException) {
return FileEntry(relative, 0, nil, e.Message)
} catch (e UnauthorizedAccessException) {
return FileEntry(relative, 0, nil, e.Message)
}
}
Open full-size capture: Trail's G# source in Visual Studio Code with .NET Path method completion open.
Actual Visual Studio Code 1.137.0, G# extension 0.4.591. The editor resolves .NET APIs in the runnable Trail project.
See the same project running
Open full-size capture: Trail running in the VS Code terminal and producing its JSON inventory for the bundled text files.
Real terminal output from dotnet run --no-build -- demo .txt, not a simulated console.

Small enough to understand.
Complete enough to change.

This is a local project-file example, not a filesystem sandbox. Symbolic links encountered during enumeration are skipped, files can change during a scan, and report metadata is retained in memory.

Read the complete Program.gs
Program.gs
package Trail

import System
import System.Collections.Generic
import System.IO
import System.Security.Cryptography
import System.Text.Json

public data class FileEntry(Path string, Bytes int64, Sha256 string?, Error string?)
public data class Inventory(Files[]FileEntry, TotalBytes int64, Failed int32)

func inspect(path string, root string) FileEntry {
let relative = Path.GetRelativePath(root, path).Replace('\\', '/')
try {
if (File.GetAttributes(path) & FileAttributes.ReparsePoint) == FileAttributes.ReparsePoint {
return FileEntry(relative, 0, nil, "Symbolic links are not read.")
}
using let stream = File.OpenRead(path)
let digest = SHA256.HashData(stream)
return FileEntry(relative, stream.Length, Convert.ToHexString(digest).ToLowerInvariant(), nil)
} catch (e IOException) {
return FileEntry(relative, 0, nil, e.Message)
} catch (e UnauthorizedAccessException) {
return FileEntry(relative, 0, nil, e.Message)
}
}

func discover(root string, extension string?, jobs out chan[string]) {
try {
let options = EnumerationOptions{
RecurseSubdirectories: true,
IgnoreInaccessible: false,
AttributesToSkip: FileAttributes.ReparsePoint
}
for path in Directory.EnumerateFiles(root, "*", options) {
if let suffix = extension {
if !path.EndsWith(suffix, StringComparison.OrdinalIgnoreCase) {
continue
}
}
jobs <- path
}
} finally {
jobs.Close()
}
}

func worker(root string, jobs in chan[string], results out chan[FileEntry]) {
for path in jobs {
results <- inspect(path, root)
}
}

func inspectAll(root string, jobs in chan[string], results out chan[FileEntry]) {
try {
scope {
for id in 0 ... 4 {
go worker(root, jobs, results)
}
}
} finally {
results.Close()
}
}

func inventory(root string, extension string?) Inventory {
let jobs = chan[string](8)
let results = chan[FileEntry](8)
let entries = List[FileEntry]()
scope {
go discover(root, extension, jobs)
go inspectAll(root, jobs, results)
for entry in results {
entries.Add(entry)
}
}
entries.Sort((left FileEntry, right FileEntry) -> String.CompareOrdinal(left.Path, right.Path))
var bytes int64 = 0
var failed = 0
for entry in entries {
bytes = checked(bytes + entry.Bytes)
if entry.Error != nil {
failed++
}
}
return Inventory(entries.ToArray(), bytes, failed)
}

func run(args[]string) int32 {
const usage = "Usage: Trail <folder> [extension]\nExample: Trail demo .txt"
if args.Length == 2 && args[1] == "--help" {
Console.WriteLine(usage)
return 0
}
if args.Length < 2 || args.Length > 3 {
Console.Error.WriteLine(usage)
return 2
}
let root = Path.GetFullPath(args[1])
if !Directory.Exists(root) {
Console.Error.WriteLine("Trail: the folder does not exist.")
return 2
}
if (File.GetAttributes(root) & FileAttributes.ReparsePoint) == FileAttributes.ReparsePoint {
Console.Error.WriteLine("Trail: choose a real folder, not a symbolic link.")
return 2
}
let extension string? = args.Length == 3 ? args[2]: nil
if let suffix = extension {
if suffix.Length < 2 || !suffix.StartsWith(".") || suffix.Contains("/") || suffix.Contains("\\") {
Console.Error.WriteLine("Trail: an extension must look like .txt, without a path.")
return 2
}
}
let report = inventory(root, extension)
let options = JsonSerializerOptions{
WriteIndented: true,
IncludeFields: true,
PropertyNamingPolicy: JsonNamingPolicy.CamelCase
}
Console.WriteLine(JsonSerializer.Serialize[Inventory](report, options))
return report.Failed == 0 ? 0: 1
}

try {
Environment.ExitCode = run(Environment.GetCommandLineArgs())
} catch (e Exception) {
Console.Error.WriteLine("Trail: " + e.Message)
Environment.ExitCode = 1
}