| Crates.io | tree-type |
| lib.rs | tree-type |
| version | 0.4.3 |
| created_at | 2025-12-23 21:12:39.159239+00 |
| updated_at | 2026-01-21 13:56:22.077482+00 |
| description | Rust macros for creating type-safe filesystem tree structures |
| homepage | |
| repository | https://codeberg.org/kemitix/tree-type |
| max_upload_size | |
| id | 2002347 |
| size | 371,788 |
Type-safe path navigation macros for Rust projects with fixed directory structures.
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
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)Add to your Cargo.toml:
[dependencies]
tree-type = "0.1.0"
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 |
codegen-v2 |
Unlimited nesting depth for dynamic IDs (experimental) | none |
# With all features
[dependencies]
tree-type = { version = "0.1.0", features = ["serde", "enhanced-errors", "walk", "pattern-validation"] }
The default code generation limits dynamic ID nesting to 3 levels. Enable codegen-v2 for unlimited nesting depth:
[dependencies]
tree-type = { version = "0.1.0", features = ["codegen-v2"] }
This allows structures like:
tree_type! {
Root {
[org: String]/ {
[team: String]/ {
[project: String]/ {
[env: String]/ {
[service: String] // 5+ levels supported
}
}
}
}
}
}
Custom type requirements: When using custom types for dynamic IDs, they must implement:
Display, PartialOrd, Ord, Hashserde feature: also Serialize, Deserialize#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
struct OrgId(String);
impl std::fmt::Display for OrgId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
impl From<&str> for OrgId {
fn from(s: &str) -> Self { OrgId(s.to_string()) }
}
The #[transparent] attribute enables transparent serialization and memory representation for generated types. This feature requires codegen-v2 and works with the serde feature.
What it does:
#[repr(transparent)] for zero-cost FFI compatibility#[serde(transparent)] when serde feature enabledExample:
#![cfg(all(feature = "codegen-v2", feature = "serde"))]
use tree_type::tree_type;
tree_type! {
Root {
#[transparent]
#[default("config content")]
config("config.toml")
}
}
let root = Root::new("/app")?;
root.sync()?;
let config = root.config();
// Serializes as path string: "/app/config.toml"
// NOT as object: {"path": "/app/config.toml"}
let json = serde_json::to_string(&config)?;
assert!(!json.contains("\"path\""));
Requirements:
codegen-v2 featureserde featureSpecify 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"));
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"));
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();
}
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)]:
&FileType as parameter (self-aware, can access own path)Result<String, E> where E can be any error typeThe setup() method:
#[default(function)] attributeResult<(), Vec<BuildError>> with all errors if any occurredDynamic 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-'"));
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 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 0o600use 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");
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 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 metadataWith walk feature enabled:
walk_dir() - Walk directory tree (returns iterator)walk() - Walk with callbacks for dirs/filessize_in_bytes() - Calculate total size recursivelyCreate (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.
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:
| 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 MethodFiles 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 MethodDirectories 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 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();
GenericFile::parent() may panic if the file path has no parent (extremely rare)GenericDir::parent() returns None for root directoriesparent() methods are guaranteed to return valid parent typesContributions are welcome! Please feel free to submit a Pull Request at https://codeberg.org/kemitix/tree-type/issues
License: MIT