# tree-type
Type-safe path navigation macros for Rust projects with fixed directory structures.
## Quick Example
```rust
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`:
```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
```toml
# 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
```rust
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
```rust
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
```rust
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
```rust
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:
```rust
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):
```rust
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`:
```toml
[dependencies]
tree-type = { version = "0.1.0", features = ["pattern-validation"] }
```
##### Validation Functions
Use custom validation functions for complex naming rules:
```rust
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:
```rust
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:
```rust
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:
```rust
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:
```rust
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:
```rust
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**:
```rust
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:
```rust
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:
```rust
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:**
```rust
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
```rust
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:
```rust
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:
```rust
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:
```rust
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:
```rust
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](src/macros/tree_type.rs) 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](https://github.com/kemitix/fireforge) project.