tree-type 0.1.0

Rust macros for creating type-safe filesystem tree structures
Documentation

tree-type

Type-safe path navigation macros for Rust projects with fixed directory structures.

Quick Example

use tree_type::tree_type;

// Define your directory structure
tree_type! {
    ProjectRoot {
        src/ {
            lib("lib.rs"),
            main("main.rs")
        },
        target/,
        readme("README.md")
    }
}

// Each path gets its own type
fn process_source(src: &ProjectRootSrc) -> std::io::Result<()> {
    let lib_file = src.lib();      // ProjectRootSrcLib
    let main_file = src.main();    // ProjectRootSrcMain
    
    let code = lib_file.read_to_string()?;
    println!("Found {} lines", code.lines().count());
    Ok(())
}

// Type-safe navigation
let project = ProjectRoot::new("/my/project");
let src = project.src();           // ProjectRootSrc
let readme = project.readme();     // ProjectRootReadme

process_source(&src)?;

// Setup entire structure
project.setup()?;  // Creates src/, target/, and all files

Overview

tree-type provides macros for creating type-safe filesystem path types:

  • tree_type! - Define a tree of path types with automatic navigation methods
  • dir_type! - Convenience wrapper for simple directory types (no children)
  • file_type! - Convenience macro for simple file types (single file with operations)

Features

  • Type Safety: Each path in your directory structure gets its own type
  • Navigation Methods: Automatic generation of navigation methods
  • Custom Names: Support for custom type names and filenames
  • Dynamic IDs: ID-based navigation for dynamic directory structures
  • Rich Operations: Built-in filesystem operations (create, read, write, remove, etc.)

Installation

Add to your Cargo.toml:

[dependencies]
tree-type = "0.1.0"

Features

All features are opt-in to minimize dependencies:

  • serde (opt-in): Adds Serialize/Deserialize derives to all path types

  • enhanced-errors (opt-in): Enhanced error messages for filesystem operations

    • fs-err: Better error messages for all filesystem operations
    • path_facts: Detailed path information in error context
  • walk (opt-in): Directory traversal methods

    • walkdir: Recursive directory iteration
    • Adds walk_dir(), walk(), size_in_bytes(), and lsl() methods to directory types
  • pattern-validation (opt-in): Regex pattern validation for dynamic ID blocks

    • regex: Regular expression matching
    • once_cell: Lazy static pattern compilation
    • Enables #[pattern(regex)] attribute on dynamic ID blocks
# With serde support
[dependencies]
tree-type = { version = "0.1.0", features = ["serde"] }

# With enhanced error messages
[dependencies]
tree-type = { version = "0.1.0", features = ["enhanced-errors"] }

# With directory walking
[dependencies]
tree-type = { version = "0.1.0", features = ["walk"] }

# With pattern validation
[dependencies]
tree-type = { version = "0.1.0", features = ["pattern-validation"] }

# With all features
[dependencies]
tree-type = { version = "0.1.0", features = ["serde", "enhanced-errors", "walk", "pattern-validation"] }

Default: Minimal dependencies (only paste)

Usage

Basic Tree Structure

use tree_type::tree_type;

tree_type! {
    ProjectRoot {
        src/,
        target/,
        readme("README.md")
    }
}

let project = ProjectRoot::new("/path/to/project");
let src = project.src();           // ProjectRootSrc
let readme = project.readme();     // ProjectRootReadme

assert_eq!(src.as_path(), Path::new("/path/to/project/src"));
assert_eq!(readme.as_path(), Path::new("/path/to/project/README.md"));

Custom Filenames

tree_type! {
    UserHome {
        ssh/(".ssh") {
            ecdsa_public("id_ecdsa.pub"),
            ed25519_public("id_ed25519.pub")
        }
    }
}

let home = UserHome::new("/home/user");
let ssh = home.ssh();                    // UserHomeSsh (maps to .ssh)
let key = ssh.ecdsa_public();            // UserHomeSshEcdsaPublic (maps to id_ecdsa.pub)

Custom Type Names

tree_type! {
    RepoGitRefsDir {
        heads/ as HeadsDir,
        tags/ as TagsDir
    }
}

let refs = RepoGitRefsDir::new("/repo/.git/refs");
let heads = refs.heads();  // HeadsDir (not RepoGitRefsDirHeads)

Dynamic IDs

tree_type! {
    IssuesDir {
        [id: String]/ as IssueDir {
            file("issue.md") as IssueFile,
            metadata("metadata.json") as IssueMetadata
        }
    }
}

let issues = IssuesDir::new("/repo/issues");
let issue = issues.id("42");
let file = issue.file();

// Convenience methods
let file = issues.id_file("42");
let metadata = issues.id_metadata("42");

Dynamic ID Attributes

Dynamic ID blocks support several attributes for validation and default instances:

Content Validation

Attributes inside dynamic blocks work like regular directories - they validate the contents of each matching directory:

tree_type! {
    TasksDir {
        [id: String]/ as TaskDir {
            #[required]
            metadata("metadata.toml"),
            #[optional]
            notes("notes.md")
        }
    }
}

// Every directory in tasks/ must contain metadata.toml
// and may optionally contain notes.md
Pattern Validation

Validate directory names against regex patterns (requires pattern-validation feature):

tree_type! {
    TasksDir {
        #[pattern(r"^task-\d+$")]
        [id: String]/ as TaskDir {
            #[required]
            metadata("metadata.toml")
        }
    }
}

// Only directories matching "task-1", "task-42", etc. are valid
// Validation fails for "invalid-name" or "task-abc"

Enable the feature in Cargo.toml:

[dependencies]
tree-type = { version = "0.1.0", features = ["pattern-validation"] }
Validation Functions

Use custom validation functions for complex naming rules:

fn is_valid_task_id(name: &str) -> bool {
    name.starts_with("task-") && name[5..].parse::<u32>().is_ok()
}

tree_type! {
    TasksDir {
        #[validate(is_valid_task_id)]
        [id: String]/ as TaskDir {
            #[required]
            metadata("metadata.toml")
        }
    }
}

// Validation uses your custom function
// No additional features required
Default Instances

Create a default directory instance during setup:

tree_type! {
    TasksDir {
        #[default("example")]
        [id: String]/ as TaskDir {
            #[required]
            metadata("metadata.toml")
        }
    }
}

let tasks = TasksDir::new("/repo/tasks");
tasks.setup()?;  // Creates tasks/example/ directory

// Combine with other attributes
tree_type! {
    TasksDir {
        #[default("example")]
        #[pattern(r"^task-\d+$")]
        [id: String]/ as TaskDir {
            #[required]
            metadata("metadata.toml")
        }
    }
}

Default File Content

Create files with default content if they don't exist:

use tree_type::{tree_type, CreateDefaultOutcome};

fn default_config(file: &ProjectRootConfig) -> Result<String, std::io::Error> {
    Ok(format!("# Config for {}\n", file.as_path().display()))
}

tree_type! {
    ProjectRoot {
        #[default = default_config]
        config("config.toml"),
    }
}

let project = ProjectRoot::new("/path/to/project");
let config = project.config();

match config.create_default() {
    Ok(CreateDefaultOutcome::Created) => println!("Created new config"),
    Ok(CreateDefaultOutcome::AlreadyExists) => println!("Config already exists"),
    Err(e) => eprintln!("Error: {}", e),
}

The default function:

  • Takes &FileType as parameter (self-aware, can access path)
  • Returns Result<String, E> where E can be any error type
  • Allows network requests, file I/O, parsing, etc.
  • Errors are propagated to the caller

Setting Up with Default Files

The setup() method automatically creates files with default content:

fn default_readme(_file: &ProjectRootReadme) -> Result<String, std::io::Error> {
    Ok("# My Project\n\nWelcome!\n".to_string())
}

fn default_config(_file: &ProjectRootConfig) -> Result<String, std::io::Error> {
    Ok("[settings]\ndefault = true\n".to_string())
}

tree_type! {
    ProjectRoot {
        src/,
        #[default = default_readme]
        readme("README.md"),
        #[default = default_config]
        config("config.toml"),
    }
}

let project = ProjectRoot::new("/path/to/project");

// Creates src/ directory AND readme + config files with default content
match project.setup() {
    Ok(()) => println!("Project structure created successfully"),
    Err(errors) => {
        for error in errors {
            match error {
                BuildError::Directory(path, e) => eprintln!("Dir error at {:?}: {}", path, e),
                BuildError::File(path, e) => eprintln!("File error at {:?}: {}", path, e),
            }
        }
    }
}

The setup() method:

  • Creates all directories in the tree
  • Calls create_default() on all files with #[default = function] attribute
  • Collects all errors and continues processing (doesn't fail fast)
  • Returns Result<(), Vec<BuildError>> with all errors if any occurred
  • Skips files that already exist (won't overwrite)

This provides a convenient one-call setup for entire project structures including both directories and files with default content.

Symbolic Links

Create symbolic links to other files in the tree:

use tree_type::tree_type;

tree_type! {
    Config {
        #[default("production settings")]
        production("prod.toml"),
        #[default("staging settings")]
        staging("staging.toml"),
        #[default("development settings")]
        development("dev.toml"),
        // Sibling symlink (identifier syntax)
        #[symlink = production]
        active("active.toml")
    }
}

let config = Config::new("/etc/myapp");

// Create the symlink (target file created automatically due to #[default])
config.setup().unwrap();  // Creates active.toml -> prod.toml

// Now active.toml points to production.toml
assert!(config.active().exists());

Cross-Directory Symlinks:

Use path syntax for symlinks across directories:

tree_type! {
    App {
        config/ {
            #[default("main configuration")]
            main("config.toml")
        },
        data/ {
            // Cross-directory symlink (path syntax)
            #[symlink = /config/main]
            config_link("config.toml")
        }
    }
}

Key Points:

  • Symlinks are created when setup() is called on the parent directory
  • Sibling symlinks: Use identifier #[symlink = target]
  • Cross-directory symlinks: Use path string #[symlink = /path/to/target]
  • Platform support: Unix-only (Linux, macOS, BSD)

Common Use Cases:

tree_type! {
    App {
        config/ {
            // Multiple environments with active symlink
            #[default("production config")]
            prod("prod.toml"),
            #[default("staging config")]
            staging("staging.toml"),
            #[default("development config")]
            dev("dev.toml"),
            #[symlink = prod]
            active("active.toml")
        },
        data/ {
            // Backup with symlink
            #[default("current data")]
            current("data.db"),
            #[symlink = current]
            backup("backup.db"),
            // Cross-directory link to config
            #[symlink = /config/active]
            config_copy("config.toml")
        }
    }
}

Limitations:

  • No individual ensure() method on symlinks (use parent's setup())
  • Compile-time validation requires symlink targets to have #[default = ...] or #[required] attributes
  • No circular reference detection
  • Path resolution is relative to parent directory

Troubleshooting Symlinks:

If you encounter symlink compilation errors, here are common solutions:

  1. "symlink target 'X' may not exist at runtime":

    • Add #[default("content")] to the target file to ensure it exists during setup()
    • Or add #[required] if the file should be created manually before setup()
  2. "symlink target 'X' not found in tree structure":

    • Ensure the target identifier exists in the same directory for sibling symlinks
    • Use absolute path syntax /path/to/target for cross-directory symlinks
  3. Symlinks not created during setup():

    • Verify the target file exists or has default content
    • Check that setup() is called on the parent directory containing the symlink

Example fixes:

tree_type! {
    Config {
        // ✅ Good: Target has default content
        #[default("production config")]
        production("prod.toml"),
        #[symlink = production]
        active("active.toml"),
        
        // ✅ Good: Target is required (user creates manually)
        #[required]
        custom("custom.toml"),
        #[symlink = custom]
        backup("backup.toml")
    }
}

See docs/symlink-validation-limitations.md for detailed information.

Validation Attributes

Mark paths as required or optional for validation:

tree_type! {
    ProjectRoot {
        #[required]
        src/,
        #[optional]
        cache/,
        #[required]
        config("config.toml")
    }
}

let project = ProjectRoot::new("/path/to/project");

// Validate checks required paths exist
let report = project.validate(Recursive::No);
if !report.is_ok() {
    for error in report.errors {
        eprintln!("Missing required path: {:?}", error.path);
    }
}

// Ensure validates and creates missing required paths
project.ensure(Recursive::No)?;

Attributes work with all syntax forms:

tree_type! {
    ProjectRoot {
        // Directories with custom types
        #[required]
        tasks/ as TasksDir,
        
        // Directories with children (parent required, children default to optional)
        #[required]
        backlog/ {
            p1/,  // Optional by default
            #[required]
            p2/  // Explicitly required
        },
        
        // Files with custom types
        #[required]
        config("config.toml") as ConfigFile
    }
}

Default behavior:

  • Paths without attributes are #[optional] by default
  • Children of required parents are still optional unless marked #[required]

When to use explicit #[optional]:

  • For documentation when mixing with #[required] siblings
  • To make intent clear in code reviews
  • When optionality is a key design decision
tree_type! {
    ProjectRoot {
        // Explicit optional improves clarity when mixed with required
        #[required]
        src/,
        #[required]
        tests/,
        #[optional]
        cache/,    // Clearly intentional
        #[optional]
        temp/      // Not forgotten
    }
}

Standalone Types

Create individual file or directory types:

use tree_type::{file_type, dir_type};

file_type!(ConfigFile);
dir_type!(CacheDir);

let config = ConfigFile::new("/etc/app/config.toml");
if config.exists() {
    let contents = config.read_to_string()?;
}

let cache = CacheDir::new("/var/cache/app");
cache.setup()?;

File Operations

File types support:

  • display() - Get Display object for formatting paths
  • read_to_string() - Read file as string (enhanced errors with enhanced-errors feature)
  • read() - Read file as bytes
  • write() - Write content to file
  • create_default() - Create file with default content if it doesn't exist (requires #[default = function] attribute)
  • exists() - Check if file exists
  • remove() - Delete file
  • fs_metadata() - Get file metadata
  • secure() (Unix only) - Set permissions to 0o600

Note: With the enhanced-errors feature, read_to_string() includes detailed path information in error messages.

Directory Operations

Directory types support:

  • display() - Get Display object for formatting paths
  • create_all() - Create directory and parents
  • create() - Create directory (parent must exist)
  • setup() - Create directory and all child directories/files recursively
  • validate(recursive) - Validate tree structure without creating anything
  • ensure(recursive) - Validate and create missing required paths
  • exists() - Check if directory exists
  • read_dir() - List directory contents
  • remove() - Remove empty directory
  • remove_all() - Remove directory recursively
  • fs_metadata() - Get directory metadata

With walk feature enabled:

  • walk_dir() - Walk directory tree (returns iterator)
  • walk() - Walk with callbacks for dirs/files
  • size_in_bytes() - Calculate total size recursively
  • lsl() - List directory contents (debug output)

Setting Up Directory Trees

The setup() method creates the entire directory structure defined in your tree:

tree_type! {
    ProjectRoot {
        src/,
        target/,
        config/ {
            local/,
            prod/
        }
    }
}

let project = ProjectRoot::new("/path/to/project");
project.setup()?; // Creates src/, target/, config/, config/local/, config/prod/

For dynamic ID directories, setup() discovers existing ID directories and builds their children:

tree_type! {
    IssuesDir {
        [id: String]/ as IssueDir {
            attachments/,
            comments/
        }
    }
}

let issues = IssuesDir::new("/repo/issues");
// Manually create some issue directories
issues.id("1").create()?;
issues.id("2").create()?;

// Setup discovers existing issue dirs and creates their children
issues.setup()?; // Creates attachments/ and comments/ for each existing issue

The setup() method:

  • Creates the directory itself plus all descendants recursively
  • Calls create_default() on all files with #[default = function] attribute
  • For dynamic IDs, discovers existing directories and calls setup() on each
  • Collects all errors and continues processing (doesn't fail fast)
  • Returns Result<(), Vec<BuildError>> with all errors if any occurred
  • Is idempotent - safe to call multiple times

Validation API

Validate directory structures without creating them:

use tree_type::{Recursive, ValidationReport};

let project = ProjectRoot::new("/path/to/project");

// Validate entire tree
let report = project.validate(Recursive::Yes);
if !report.is_ok() {
    for error in report.errors {
        eprintln!("Error at {:?}: {}", error.path, error.message);
    }
}

// Validate and create missing required paths
match project.ensure(Recursive::Yes) {
    Ok(report) => {
        if report.is_ok() {
            println!("All required paths exist");
        }
    }
    Err(errors) => eprintln!("Failed to create paths: {:?}", errors),
}

The validation API provides:

  • validate(recursive) - Read-only validation, returns ValidationReport
  • ensure(recursive) - Validates then creates missing required paths
  • Recursive::Yes - Validate/ensure entire tree
  • Recursive::No - Validate/ensure only this level

Examples

See the tests for comprehensive examples including:

  • Nested directory structures
  • Multiple levels of dynamic IDs
  • Mixed file and directory types
  • Custom filenames and type names

Changelog

Version 0.2.0 (2025-12-05)

Bug Fixes:

  • Fixed required files with default content functions not being created by setup() or ensure()
  • Files with #[required, default = fn] now correctly created in all scenarios:
    • Root level files
    • Nested directories
    • Dynamic ID blocks
    • Custom type files
  • Fixed ensure() method to create directories before validating, preventing false validation failures
  • Added recursive ensure() processing for directories with children

Implementation:

  • Added ~180 lines of pattern matching rules across 5 macro helper sections
  • Pattern normalization for #[required, default = ...] and #[optional, default = ...] combinations
  • Validation logic updated to skip errors for files with defaults (since ensure() will create them)

License

This crate was extracted from the fireforge project.