3 unstable releases
Uses new Rust 2024
| 0.2.0 | Jan 5, 2026 |
|---|---|
| 0.1.1 | Dec 25, 2025 |
| 0.1.0 | Dec 23, 2025 |
#648 in Filesystem
Used in git-next
93KB
904 lines
tree-type
Type-safe path navigation macros for Rust projects with fixed directory structures.
Quick Example
Basic usage example showing type-safe navigation and setup.
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 (which cna be overridden)
fn process_source(src: &ProjectRootSrc) -> std::io::Result<()> {
let lib_rs_file = src.lib(); // ProjectRootSrcLib
let main_rs_file = src.main(); // ProjectRootSrcMain
Ok(())
}
let project = ProjectRoot::new(project_root)?;
let src = project.src(); // ProjectRootSrc
let readme = project.readme(); // ProjectRootReadme
process_source(&src)?;
// process_source(&readme)?; // would be a compilation error
// 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 methodsdir_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:
| feature | description | dependencies |
|---|---|---|
serde |
Adds Serialize/Deserialize derives to all path types | serde |
enhanced-errors |
Enhanced error messages for filesystem operations | fs-err, path_facts |
walk |
Directory traversal methods | walkdir |
pattern-validation |
Regex pattern validation for dynamic ID blocks | regex |
# With all features
[dependencies]
tree-type = { version = "0.1.0", features = ["serde", "enhanced-errors", "walk", "pattern-validation"] }
Usage Examples
Basic Tree Structure
Specify the type that represent the root of your tree, it will be a directory. Then within
{} specify the identifiers of the files and directories that are children of the
root. Directories as identified by having a trailing / after their identifier, otherwise
they are files.
use tree_type::tree_type;
tree_type! {
ProjectRoot {
src/,
target/,
readme("README.md")
}
}
let project = ProjectRoot::new(project_root.clone())?;
let src = project.src(); // ProjectRootSrc
let readme = project.readme(); // ProjectRootReadme
assert_eq!(src.as_path(), project_root.join("src"));
assert_eq!(readme.as_path(), project_root.join("README.md"));
Custom Filenames
By default the filename will be the same as the identifier, (as long is it is valid for the filesystem).
To specify an alternative filename, e.g. one where the filename isn't a valid Rust
identifier, specify the filename as identifier/("file-name"). Note that the directory
indicator (/) comes after the identifier, not the directory name.
use tree_type::tree_type;
tree_type! {
UserHome {
ssh/(".ssh") {
ecdsa_public("id_ecdsa.pub"),
ed25519_public("id_ed25519.pub")
}
}
}
let home = UserHome::new(home_dir.clone())?;
let ssh = home.ssh(); // UserHomeSsh (maps to .ssh)
let key = ssh.ecdsa_public(); // UserHomeSshEcdsaPublic (maps to id_ecdsa.pub)
assert_eq!(ssh.as_path(), home_dir.join(".ssh"));
assert_eq!(key.as_path(), home_dir.join(".ssh/id_ecdsa.pub"));
Custom Type Names
tree_type will generate type names for each file and directory by appending the capitalised
identifier to the parent type, unless you override this with as.
use tree_type::tree_type;
tree_type! {
ProjectRoot {
src/ { // as ProjectRootSrc
main("main.rs") // as ProjectRootSrcMain
},
readme("README.md") as ReadmeFile // default would have been ProjectRootReadme
}
}
let project = ProjectRoot::new(project_dir)?;
let src: ProjectRootSrc = project.src();
let main: ProjectRootSrcMain = src.main();
let readme: ReadmeFile = project.readme();
}
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("CHANGELOG\n")]
changelog("CHANGELOG"),
#[default("# My Project\n")] // create file with the string as content
readme("README.md"),
#[default(default_config)]
config("config.toml"),
}
}
let project = ProjectRoot::new(project_path)?;
let changelog = project.changelog();
let readme = project.readme();
let config = project.config();
changelog.write("existing content")?;
assert!(changelog.exists()); // an existing file
assert!(!readme.exists()); // don't exist yet
assert!(!config.exists());
match project.setup() {
Ok(_) => println!("Project structure created successfully"),
Err(errors) => {
for error in errors {
match error {
tree_type::BuildError::Directory(path, e) => eprintln!("Dir error at {:?}: {}", path, e),
tree_type::BuildError::File(path, e) => eprintln!("File error at {:?}: {}", path, e),
}
}
}
}
assert!(readme.exists()); // created and set to default content
assert_eq!(readme.read_to_string()?, "# My Project\n");
assert!(config.exists()); // created and function sets the content
assert!(config.read_to_string()?.starts_with("# Config for "));
assert!(changelog.exists()); // existing file is left unchanged
assert_eq!(changelog.read_to_string()?, "existing content");
The function f in #[default(f)]:
- Takes
&FileTypeas parameter (self-aware, can access own 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
The setup() method:
- Creates all directories in the tree
- Creates all files with a
#[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)
Dynamic ID
Dynamic ID support allows you to define parameterized paths in your directory structure where the actual directory/file names are determined at runtime using ID parameters.
use tree_type::tree_type;
use tree_type::ValidatorResult;
fn is_valid_log_name(log_file: &LogFile) -> ValidatorResult {
let mut result = ValidatorResult::default();
let file_name = log_file.file_name();
if !file_name.starts_with("log-") {
result.errors.push(format!("log_file name '{file_name}' must start with 'log-'"));
}
result
}
tree_type! {
Root {
users/ {
[user_id: String]/ as UserDir { // Dynamic directory
#[required]
#[default("{}")]
profile("profile.json"),
settings("settings.toml"),
posts/ {
[post_id: u32] as PostFile // nested dynamic
}
}
},
logs/ {
#[validate(is_valid_log_name)]
[log_name: String] as LogFile // Dynamic file (no trailing slash)
}
}
}
let root = Root::new(root_dir.clone())?;
let user_dir: UserDir = root.users().user_id("42");
let _result = user_dir.setup();
assert_eq!(user_dir.as_path(), root_dir.join("users/42"));
assert!(user_dir.profile().exists()); // required + default
assert!(!user_dir.settings().exists()); // not required and/or no default
assert_eq!(user_dir.settings().as_path(), root_dir.join("users/42/settings.toml"));
let log_file = root.logs().log_name("foo.log");
assert_eq!(log_file.as_path(), root_dir.join("logs/foo.log"));
assert!(!log_file.exists()); // we need to create this ourselves
// FIXME: can't validate a filename until the file exists
log_file.write("bar")?;
// validation fails because `foo.log` doesn't start with `log-`
// FIXME: `validate()` should return a `Result<T, E>` rather then a ValidationReport
let report = root.logs().validate();
assert!(!report.is_ok());
assert_eq!(report.errors.len(), 1);
assert!(report.errors[0].message.contains("must start with 'log-'"));
File Type Macro
The file_type macro provides for when you only need to work with a single file rather
than a directory structure. You would use it instead of tree_type when you only need to
manage one file, not a directory tree, or when you need to treat several files in a directory
tree in a more generic way.
The tree_type macro uses the file_type macro to represent any files defined in it.
use tree_type::file_type;
file_type!(ConfigFile);
let config_file = ConfigFile::new(root_dir.join("config.toml"))?;
config_file.write("# new config file")?;
assert!(config_file.exists());
let config = config_file.read_to_string()?;
assert_eq!(config, "# new config file");
File Operations
File types support:
display()- Get Display object for formatting pathsread_to_string()- Read file as stringread()- Read file as byteswrite()- Write content to filecreate_default()- Create file with default content if it doesn't existexists()- Check if file existsremove()- Delete filefs_metadata()- Get file metadatasecure()(Unix only) - Set permissions to 0o600
use tree_type::tree_type;
tree_type! {
ProjectRoot {
readme("README.md") as Readme
}
}
let project = ProjectRoot::new(project_dir)?;
let readme = project.readme();
// Write content to file
readme.write("# Hello World")?;
assert!(readme.exists());
// Read content back
let content = readme.read_to_string()?;
assert_eq!(content, "# Hello World");
Directory Type Macro
The dir_type macro provides for when you only need to work with a single directory rather
than a nested directory structure. You would use it instead of tree_type when you only need
to manage one directory, not a directory tree, or when you need to treat several directories
in a more generic way.
The tree_type macro uses the dir_type macro to represent the directories defined in it.
use tree_type::dir_type;
dir_type!(ConfigDir);
fn handle_config(dir: &ConfigDir) -> std::io::Result<()> {
if dir.exists() {
// ...
} else {
// ...
}
Ok(())
}
let config_dir = ConfigDir::new(root_dir.join("config"))?;
config_dir.create_all()?;
handle_config(&config_dir)?;
Directory Operations
Directory types support:
display()- Get Display object for formatting pathscreate_all()- Create directory and parentscreate()- [deprecated] Create directory (parent must exist)setup()- [deprecated] Create directory and all child directories/files recursivelyvalidate()- [deprecated] Validate tree structure without creating anythingensure()- Validate and create missing required pathsexists()- Check if directory existsread_dir()- List directory contentsremove()- Remove empty directoryremove_all()- Remove directory recursivelyfs_metadata()- Get directory metadata
With walk feature enabled:
walk_dir()- Walk directory tree (returns iterator)walk()- Walk with callbacks for dirs/filessize_in_bytes()- Calculate total size recursively
Symbolic links
Create (soft) symbolic links to other files or directories in the tree.
This feature is only available on unix-like environments (i.e. #[cfg(unix)]).
use tree_type::tree_type;
tree_type! {
App {
config/ {
#[default("production settings")]
production("prod.toml"),
#[default("staging settings")]
staging("staging.toml"),
#[default("development settings")]
development("dev.toml"),
#[symlink(production)] // sibling
active("active.toml")
},
data/ {
#[symlink(/config/production)] // cross-directory
config("config.toml"),
}
}
}
let app = App::new(app_path)?;
let _result = app.setup();
assert!(app.config().active().exists());
assert!(app.data().config().exists());
// /config/active.toml -> /config/prod.toml
assert_eq!(app.config().active().read_to_string()?, "production settings");
// /data/config -> /config/active.toml -> /config/prod.toml
assert_eq!(app.data().config().read_to_string()?, "production settings");
Symlink targets must exist, so the target should have a #[required] attribute for
directories, or #[default...] attribute for files.
Parent Navigation
The parent() method provides type-safe navigation to parent directories. Tree-type offers three different parent() method variants depending on the type you're working with:
Method Variants Comparison
| Type | Method Signature | Return Type | Behavior |
|---|---|---|---|
GenericFile |
parent(&self) |
GenericDir |
Always succeeds - files must have parents |
GenericDir |
parent(&self) |
Option<GenericDir> |
May fail for root directories |
| Generated types | parent(&self) |
Exact parent type | Type-safe, no Option needed |
| Generated root types | parent(&self) |
Option<GenericDir |
May fail if Root type is root directory |
GenericFile Parent Method
Files always have a parent directory, so GenericFile::parent() returns GenericDir directly:
use tree_type::GenericFile;
use std::path::Path;
let file = GenericFile::new("/path/to/file.txt")?;
let parent_dir = file.parent(); // Returns GenericDir
assert_eq!(parent_dir.as_path(), Path::new("/path/to"));
GenericDir Parent Method
Directories may not have a parent (root directories), so GenericDir::parent() returns Option<GenericDir>:
use tree_type::GenericDir;
let dir = GenericDir::new("/path/to/dir")?;
if let Some(parent_dir) = dir.parent() {
println!("Parent: {parent_dir}");
} else {
println!("This is a root directory");
}
Generated Type Parent Method
Generated types from tree_type! macro provide type-safe parent navigation that returns the exact parent type:
#![expect(deprecated)]
use tree_type::tree_type;
use tree_type::GenericDir;
tree_type! {
ProjectRoot {
src/ as SrcDir {
main("main.rs") as MainFile
}
}
}
let project = ProjectRoot::new("/project")?;
let src = project.src();
let main_file = src.main();
// Type-safe parent navigation - no Option needed
let main_parent: SrcDir = main_file.parent();
let src_parent: ProjectRoot = src.parent();
let project_parent: Option<GenericDir> = project.parent();
Safety Notes
GenericFile::parent()may panic if the file path has no parent (extremely rare)GenericDir::parent()returnsNonefor root directories- Generated type
parent()methods are guaranteed to return valid parent types
Contributing
Contributions are welcome! Please feel free to submit a Pull Request at https://codeberg.org/kemitix/tree-type/issues
License: MIT
Dependencies
~0.1–3.5MB
~62K SLoC