# 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.
```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 (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`:
```toml
[dependencies]
tree-type = "0.1.0"
```
### Features
All features are opt-in to minimize 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 |
```toml
# With all features
[dependencies]
tree-type = { version = "0.1.0", features = ["serde", "enhanced-errors", "walk", "pattern-validation"] }
```
### Unlimited Nesting (codegen-v2)
The default code generation limits dynamic ID nesting to 3 levels. Enable `codegen-v2` for unlimited nesting depth:
```toml
[dependencies]
tree-type = { version = "0.1.0", features = ["codegen-v2"] }
```
This allows structures like:
```rust
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`, `Hash`
- With `serde` feature: also `Serialize`, `Deserialize`
```rust
#[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()) }
}
```
### Transparent Serialization (codegen-v2)
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:**
- Generates `#[repr(transparent)]` for zero-cost FFI compatibility
- Generates `#[serde(transparent)]` when serde feature enabled
- Types serialize as their inner path value, not as wrapper objects
**Example:**
```rust
#![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:**
- Only works with `codegen-v2` feature
- Types must be single-field wrappers (compiler enforced)
- Serde serialization requires `serde` feature
## 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.
```rust
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.
```rust
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`.
```rust
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:
```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("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.
```rust
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.
```rust
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
```rust
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.
```rust
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
## 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)]`).
```rust
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
| `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:
```rust
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>`:
```rust
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:
```rust
#![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