Thanks to visit codestin.com
Credit goes to lib.rs

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

MIT license

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 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:

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 &FileType as 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 paths
  • read_to_string() - Read file as string
  • read() - Read file as bytes
  • write() - Write content to file
  • create_default() - Create file with default content if it doesn't exist
  • exists() - Check if file exists
  • remove() - Delete file
  • fs_metadata() - Get file metadata
  • secure() (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 paths
  • create_all() - Create directory and parents
  • create() - [deprecated] Create directory (parent must exist)
  • setup() - [deprecated] Create directory and all child directories/files recursively
  • validate() - [deprecated] Validate tree structure without creating anything
  • ensure() - 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

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() returns None for 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