chore(docs): add documentations

This commit is contained in:
Ray Sinurat 2026-02-06 03:31:45 -06:00
parent 19b82e6313
commit 289aa82ded
20 changed files with 245 additions and 20 deletions

View file

@ -11,6 +11,7 @@ use std::path::{Path, PathBuf};
use std::process::Command; use std::process::Command;
use std::time::Instant; use std::time::Instant;
/// Applies the dotfile configuration, deploying files and installing packages.
#[tracing::instrument(skip_all, fields(dry_run, prune))] #[tracing::instrument(skip_all, fields(dry_run, prune))]
pub fn run(config_path: Option<PathBuf>, dry_run: bool, prune: bool) -> anyhow::Result<()> { pub fn run(config_path: Option<PathBuf>, dry_run: bool, prune: bool) -> anyhow::Result<()> {
let start = Instant::now(); let start = Instant::now();

View file

@ -1,6 +1,7 @@
use super::{find_config_file, parse_config, type_check}; use super::{find_config_file, parse_config, type_check};
use std::path::PathBuf; use std::path::PathBuf;
/// Validates a config file (parse + type check).
#[tracing::instrument(skip_all)] #[tracing::instrument(skip_all)]
pub fn run(config_path: Option<PathBuf>) -> anyhow::Result<()> { pub fn run(config_path: Option<PathBuf>) -> anyhow::Result<()> {
let path = find_config_file(config_path)?; let path = find_config_file(config_path)?;

View file

@ -1,6 +1,7 @@
use doot_core::{Config, encryption::AgeEncryption}; use doot_core::{Config, encryption::AgeEncryption};
use std::path::PathBuf; use std::path::PathBuf;
/// Decrypts an age-encrypted file.
#[tracing::instrument(skip_all, fields(file = %file.display()))] #[tracing::instrument(skip_all, fields(file = %file.display()))]
pub fn run(file: PathBuf, identity: Option<PathBuf>) -> anyhow::Result<()> { pub fn run(file: PathBuf, identity: Option<PathBuf>) -> anyhow::Result<()> {
let config = Config::default(); let config = Config::default();

View file

@ -5,6 +5,7 @@ use doot_lang::Evaluator;
use doot_lang::evaluator::PermissionRule; use doot_lang::evaluator::PermissionRule;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
/// Shows diffs between source and deployed dotfiles.
#[tracing::instrument(skip_all, fields(all))] #[tracing::instrument(skip_all, fields(all))]
pub fn run(config_path: Option<PathBuf>, all: bool) -> anyhow::Result<()> { pub fn run(config_path: Option<PathBuf>, all: bool) -> anyhow::Result<()> {
let path = find_config_file(config_path)?; let path = find_config_file(config_path)?;

View file

@ -9,6 +9,7 @@ use std::io::{self, Write};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::Command; use std::process::Command;
/// Opens a deployed file for editing and optionally re-applies.
#[tracing::instrument(skip_all, fields(target = %target, auto_apply, skip_prompt))] #[tracing::instrument(skip_all, fields(target = %target, auto_apply, skip_prompt))]
pub fn run( pub fn run(
config_path: Option<PathBuf>, config_path: Option<PathBuf>,

View file

@ -1,6 +1,7 @@
use doot_core::{Config, encryption::AgeEncryption}; use doot_core::{Config, encryption::AgeEncryption};
use std::path::PathBuf; use std::path::PathBuf;
/// Encrypts a file using age encryption.
#[tracing::instrument(skip_all, fields(file = %file.display()))] #[tracing::instrument(skip_all, fields(file = %file.display()))]
pub fn run(file: PathBuf, recipient: Option<String>) -> anyhow::Result<()> { pub fn run(file: PathBuf, recipient: Option<String>) -> anyhow::Result<()> {
let config_dir = Config::default_config_dir(); let config_dir = Config::default_config_dir();

View file

@ -1,6 +1,8 @@
use super::find_config_file; use super::find_config_file;
use doot_core::deploy::diff::DiffDisplay;
use std::path::PathBuf; use std::path::PathBuf;
/// Formats or checks formatting of a `.doot` config file.
#[tracing::instrument(skip_all, fields(check))] #[tracing::instrument(skip_all, fields(check))]
pub fn run(config_path: Option<PathBuf>, check: bool) -> anyhow::Result<()> { pub fn run(config_path: Option<PathBuf>, check: bool) -> anyhow::Result<()> {
let path = find_config_file(config_path)?; let path = find_config_file(config_path)?;
@ -10,7 +12,8 @@ pub fn run(config_path: Option<PathBuf>, check: bool) -> anyhow::Result<()> {
if check { if check {
if formatted != source { if formatted != source {
eprintln!("{} would be reformatted", path.display()); let diff = DiffDisplay::diff_strings(&source, &formatted);
eprintln!("{}\n{}", path.display(), diff);
std::process::exit(1); std::process::exit(1);
} else { } else {
println!("{} is formatted correctly", path.display()); println!("{} is formatted correctly", path.display());
@ -27,9 +30,12 @@ pub fn run(config_path: Option<PathBuf>, check: bool) -> anyhow::Result<()> {
#[tracing::instrument(level = "trace", skip_all)] #[tracing::instrument(level = "trace", skip_all)]
fn format_source(source: &str) -> String { fn format_source(source: &str) -> String {
// Use an indent stack to track nesting levels from raw whitespace.
// When indentation increases, push a new level; when it decreases,
// pop back. This handles files with inconsistent indent widths.
let mut result = String::new(); let mut result = String::new();
let mut indent_level = 0;
let mut prev_was_blank = false; let mut prev_was_blank = false;
let mut indent_stack: Vec<usize> = vec![0]; // raw whitespace widths
for line in source.lines() { for line in source.lines() {
let trimmed = line.trim(); let trimmed = line.trim();
@ -43,29 +49,25 @@ fn format_source(source: &str) -> String {
} }
prev_was_blank = false; prev_was_blank = false;
if trimmed.starts_with('#') { let leading = line.len() - line.trim_start().len();
result.push_str(&" ".repeat(indent_level));
result.push_str(trimmed); if leading > *indent_stack.last().unwrap() {
result.push('\n'); // Deeper nesting
continue; indent_stack.push(leading);
} else if leading < *indent_stack.last().unwrap() {
// Dedent: pop until we find a level <= current
while indent_stack.len() > 1 && *indent_stack.last().unwrap() > leading {
indent_stack.pop();
}
} }
let dedent_keywords = ["else"]; let level = indent_stack.len() - 1;
let should_dedent = dedent_keywords.iter().any(|k| trimmed.starts_with(k)); result.push_str(&" ".repeat(level));
if should_dedent && indent_level > 0 {
indent_level -= 1;
}
result.push_str(&" ".repeat(indent_level));
result.push_str(trimmed); result.push_str(trimmed);
result.push('\n'); result.push('\n');
if trimmed.ends_with(':') && !trimmed.starts_with('#') {
indent_level += 1;
}
} }
// Trim trailing blank lines
while result.ends_with("\n\n") { while result.ends_with("\n\n") {
result.pop(); result.pop();
} }
@ -76,3 +78,157 @@ fn format_source(source: &str) -> String {
result result
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_preserves_top_level_blocks() {
let input = "\
dotfile:
src: fish
dst: ~/.config/fish
dotfile:
src: nvim
dst: ~/.config/nvim
dotfile:
src: git
dst: ~/.config/git
";
let result = format_source(input);
assert_eq!(result, input);
}
#[test]
fn test_normalizes_4space_to_2space() {
let input = "\
dotfile:
src: fish
dst: ~/.config/fish
";
let expected = "\
dotfile:
src: fish
dst: ~/.config/fish
";
assert_eq!(format_source(input), expected);
}
#[test]
fn test_nested_blocks() {
let input = "\
dotfile:
src: fish
if os == \"linux\":
package: apt
else:
package: brew
";
let result = format_source(input);
assert_eq!(result, input);
}
#[test]
fn test_collapses_consecutive_blank_lines() {
let input = "\
dotfile:
src: fish
dst: ~/.config/fish
";
let expected = "\
dotfile:
src: fish
dst: ~/.config/fish
";
assert_eq!(format_source(input), expected);
}
#[test]
fn test_trailing_blank_lines_trimmed() {
let input = "\
dotfile:
src: fish
";
let expected = "\
dotfile:
src: fish
";
assert_eq!(format_source(input), expected);
}
#[test]
fn test_comments_preserve_indent() {
let input = "\
# top-level comment
dotfile:
# nested comment
src: fish
";
let result = format_source(input);
assert_eq!(result, input);
}
#[test]
fn test_no_indented_lines() {
let input = "\
one
two
three
";
let result = format_source(input);
assert_eq!(result, input);
}
#[test]
fn test_mixed_indent_normalizes() {
let input = "\
dotfile:
src: fish
if cond:
package: apt
";
let expected = "\
dotfile:
src: fish
if cond:
package: apt
";
assert_eq!(format_source(input), expected);
}
#[test]
fn test_inconsistent_indent_widths() {
// Mix of 6-space, 2-space, and 4-space indentation (GCD = 2)
let input = "\
dotfile:
source = \"config/*\"
target = config_dir()
if cond:
package: \"fish\"
if other:
package: \"bat\"
";
let expected = "\
dotfile:
source = \"config/*\"
target = config_dir()
if cond:
package: \"fish\"
if other:
package: \"bat\"
";
assert_eq!(format_source(input), expected);
}
}

View file

@ -1,6 +1,7 @@
use doot_core::Config; use doot_core::Config;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
/// Initializes a new doot project directory structure.
#[tracing::instrument(skip_all)] #[tracing::instrument(skip_all)]
pub fn run(path: Option<PathBuf>) -> anyhow::Result<()> { pub fn run(path: Option<PathBuf>) -> anyhow::Result<()> {
let source_dir = path.unwrap_or_else(Config::default_source_dir); let source_dir = path.unwrap_or_else(Config::default_source_dir);

View file

@ -1,3 +1,4 @@
/// Starts the doot language server.
#[tracing::instrument] #[tracing::instrument]
pub fn run() -> anyhow::Result<()> { pub fn run() -> anyhow::Result<()> {
println!("doot language server"); println!("doot language server");

View file

@ -1,3 +1,5 @@
//! CLI command handlers.
pub mod apply; pub mod apply;
pub mod check; pub mod check;
pub mod decrypt; pub mod decrypt;
@ -17,6 +19,7 @@ use doot_core::Config;
use doot_lang::{Lexer, Parser, TypeChecker}; use doot_lang::{Lexer, Parser, TypeChecker};
use std::path::PathBuf; use std::path::PathBuf;
/// Resolves the config file path, checking the given path or default locations.
#[tracing::instrument(skip_all)] #[tracing::instrument(skip_all)]
pub fn find_config_file(base: Option<PathBuf>) -> anyhow::Result<PathBuf> { pub fn find_config_file(base: Option<PathBuf>) -> anyhow::Result<PathBuf> {
if let Some(path) = base { if let Some(path) = base {
@ -48,6 +51,7 @@ fn byte_offset_to_line(source: &str, offset: usize) -> usize {
+ 1 + 1
} }
/// Parses a `.doot` config file into a program AST.
#[tracing::instrument(skip_all, fields(path = %path.display()))] #[tracing::instrument(skip_all, fields(path = %path.display()))]
pub fn parse_config(path: &PathBuf) -> anyhow::Result<doot_lang::Program> { pub fn parse_config(path: &PathBuf) -> anyhow::Result<doot_lang::Program> {
let source = std::fs::read_to_string(path)?; let source = std::fs::read_to_string(path)?;
@ -78,6 +82,7 @@ pub fn parse_config(path: &PathBuf) -> anyhow::Result<doot_lang::Program> {
Ok(program) Ok(program)
} }
/// Runs the type checker on a parsed program.
#[tracing::instrument(skip_all)] #[tracing::instrument(skip_all)]
pub fn type_check( pub fn type_check(
program: &doot_lang::Program, program: &doot_lang::Program,

View file

@ -2,6 +2,7 @@ use super::{find_config_file, parse_config, type_check};
use doot_lang::Evaluator; use doot_lang::Evaluator;
use std::path::PathBuf; use std::path::PathBuf;
/// Installs packages defined in the config.
#[tracing::instrument(skip_all)] #[tracing::instrument(skip_all)]
pub fn install(config_path: Option<PathBuf>) -> anyhow::Result<()> { pub fn install(config_path: Option<PathBuf>) -> anyhow::Result<()> {
let path = find_config_file(config_path)?; let path = find_config_file(config_path)?;
@ -51,6 +52,7 @@ pub fn install(config_path: Option<PathBuf>) -> anyhow::Result<()> {
Ok(()) Ok(())
} }
/// Updates the system package index.
#[tracing::instrument(skip_all)] #[tracing::instrument(skip_all)]
pub fn update() -> anyhow::Result<()> { pub fn update() -> anyhow::Result<()> {
let manager = doot_core::package::detect_package_manager() let manager = doot_core::package::detect_package_manager()
@ -64,6 +66,7 @@ pub fn update() -> anyhow::Result<()> {
Ok(()) Ok(())
} }
/// Lists configured packages and their install status.
#[tracing::instrument(skip_all)] #[tracing::instrument(skip_all)]
pub fn list(config_path: Option<PathBuf>) -> anyhow::Result<()> { pub fn list(config_path: Option<PathBuf>) -> anyhow::Result<()> {
let path = find_config_file(config_path)?; let path = find_config_file(config_path)?;

View file

@ -4,6 +4,7 @@ use doot_core::{
}; };
use std::path::PathBuf; use std::path::PathBuf;
/// Rolls back to a previous snapshot.
#[tracing::instrument(skip_all)] #[tracing::instrument(skip_all)]
pub fn run(_config_path: Option<PathBuf>, snapshot_name: Option<String>) -> anyhow::Result<()> { pub fn run(_config_path: Option<PathBuf>, snapshot_name: Option<String>) -> anyhow::Result<()> {
let config = Config::default(); let config = Config::default();

View file

@ -4,6 +4,7 @@ use doot_core::{
}; };
use std::path::PathBuf; use std::path::PathBuf;
/// Creates a named snapshot of the current deployment state.
#[tracing::instrument(skip_all, fields(name = %name))] #[tracing::instrument(skip_all, fields(name = %name))]
pub fn run(_config_path: Option<PathBuf>, name: String) -> anyhow::Result<()> { pub fn run(_config_path: Option<PathBuf>, name: String) -> anyhow::Result<()> {
let config = Config::default(); let config = Config::default();

View file

@ -3,6 +3,7 @@ use doot_core::state::{StateStore, SyncStatus};
use doot_lang::Evaluator; use doot_lang::Evaluator;
use std::path::PathBuf; use std::path::PathBuf;
/// Shows the deployment status of managed dotfiles.
#[tracing::instrument(skip_all)] #[tracing::instrument(skip_all)]
pub fn run(config_path: Option<PathBuf>) -> anyhow::Result<()> { pub fn run(config_path: Option<PathBuf>) -> anyhow::Result<()> {
let path = find_config_file(config_path)?; let path = find_config_file(config_path)?;

View file

@ -19,6 +19,7 @@ use ratatui::{
use std::io; use std::io;
use std::path::PathBuf; use std::path::PathBuf;
/// Launches the interactive TUI for managing dotfiles.
#[tracing::instrument(skip_all)] #[tracing::instrument(skip_all)]
pub fn run(config_path: Option<PathBuf>) -> anyhow::Result<()> { pub fn run(config_path: Option<PathBuf>) -> anyhow::Result<()> {
enable_raw_mode()?; enable_raw_mode()?;

View file

@ -1,9 +1,24 @@
//! doot CLI — a modern dotfiles manager with a typed DSL.
//!
//! ```sh
//! doot init # scaffold a new project
//! doot check # validate config
//! doot apply # deploy dotfiles & install packages
//! doot apply -n # dry-run
//! doot status # show deployment state
//! doot diff # show pending changes
//! doot fmt # format config file
//! doot fmt --check # check formatting (CI)
//! doot package install # install configured packages
//! ```
mod commands; mod commands;
use clap::{Parser, Subcommand, ValueEnum}; use clap::{Parser, Subcommand, ValueEnum};
use std::path::PathBuf; use std::path::PathBuf;
use tracing_subscriber::EnvFilter; use tracing_subscriber::EnvFilter;
/// Top-level CLI arguments.
#[derive(Parser)] #[derive(Parser)]
#[command(name = "doot", version)] #[command(name = "doot", version)]
#[command(about = "A modern dotfiles manager with a typed DSL", long_about = None)] #[command(about = "A modern dotfiles manager with a typed DSL", long_about = None)]
@ -31,6 +46,7 @@ struct Cli {
log_format: LogFormatArg, log_format: LogFormatArg,
} }
/// Log verbosity level.
#[derive(Clone, ValueEnum)] #[derive(Clone, ValueEnum)]
enum LogLevelArg { enum LogLevelArg {
Trace, Trace,
@ -40,20 +56,25 @@ enum LogLevelArg {
Error, Error,
} }
/// Log output format.
#[derive(Clone, ValueEnum)] #[derive(Clone, ValueEnum)]
enum LogFormatArg { enum LogFormatArg {
Text, Text,
Json, Json,
} }
/// Available subcommands.
#[derive(Subcommand)] #[derive(Subcommand)]
enum Commands { enum Commands {
/// Initialize a new doot project: `doot init [PATH]`
Init { Init {
/// Source directory for dotfiles (default: ~/.config/doot) /// Source directory for dotfiles (default: ~/.config/doot)
path: Option<PathBuf>, path: Option<PathBuf>,
}, },
/// Deploy dotfiles and install packages: `doot apply [-n] [--prune]`
Apply { Apply {
/// Preview changes without writing: `doot apply -n`
#[arg(short = 'n', long)] #[arg(short = 'n', long)]
dry_run: bool, dry_run: bool,
@ -62,52 +83,71 @@ enum Commands {
prune: bool, prune: bool,
}, },
/// Show diffs between source and deployed files: `doot diff [-a]`
Diff { Diff {
/// Include unchanged files
#[arg(short, long)] #[arg(short, long)]
all: bool, all: bool,
}, },
/// Show deployment status of managed dotfiles: `doot status`
Status, Status,
/// Validate config (parse + type check): `doot check`
Check, Check,
/// Format config file: `doot fmt [--check]`
Fmt { Fmt {
/// Check formatting without modifying (exits 1 if unformatted)
#[arg(short, long)] #[arg(short, long)]
check: bool, check: bool,
}, },
/// Roll back to a previous snapshot: `doot rollback [SNAPSHOT]`
Rollback { Rollback {
/// Snapshot name (defaults to latest)
snapshot: Option<String>, snapshot: Option<String>,
}, },
/// Save a named snapshot of current state: `doot snapshot <NAME>`
Snapshot { Snapshot {
/// Snapshot name
name: String, name: String,
}, },
/// Encrypt a file with age: `doot encrypt <FILE> [-r RECIPIENT]`
Encrypt { Encrypt {
/// File to encrypt
file: PathBuf, file: PathBuf,
/// Recipient public key
#[arg(short, long)] #[arg(short, long)]
recipient: Option<String>, recipient: Option<String>,
}, },
/// Decrypt an age-encrypted file: `doot decrypt <FILE> [-i IDENTITY]`
Decrypt { Decrypt {
/// File to decrypt
file: PathBuf, file: PathBuf,
/// Path to age identity file
#[arg(short, long)] #[arg(short, long)]
identity: Option<PathBuf>, identity: Option<PathBuf>,
}, },
/// Manage system packages: `doot package {install|update|list}`
Package { Package {
#[command(subcommand)] #[command(subcommand)]
action: PackageAction, action: PackageAction,
}, },
/// Start the doot language server: `doot lsp`
Lsp, Lsp,
/// Launch interactive TUI: `doot tui`
Tui, Tui,
/// Open source file in editor for a deployed target /// Open source file in editor for a deployed target: `doot edit <TARGET> [-a] [-y]`
Edit { Edit {
/// Target path or dotfile name (e.g., ~/.config/nvim or nvim) /// Target path or dotfile name (e.g., ~/.config/nvim or nvim)
target: String, target: String,
@ -122,13 +162,18 @@ enum Commands {
}, },
} }
/// Package subcommands.
#[derive(Subcommand)] #[derive(Subcommand)]
enum PackageAction { enum PackageAction {
/// Install packages from config: `doot package install`
Install, Install,
/// Update system package index: `doot package update`
Update, Update,
/// List configured packages: `doot package list`
List, List,
} }
/// Entry point — parses CLI args, sets up logging, and dispatches commands.
fn main() -> anyhow::Result<()> { fn main() -> anyhow::Result<()> {
let cli = Cli::parse(); let cli = Cli::parse();

View file

@ -2,6 +2,7 @@ use super::{PackageError, PackageManager};
use std::io::Write; use std::io::Write;
use std::process::{Command, Stdio}; use std::process::{Command, Stdio};
/// APT package manager (Debian/Ubuntu).
pub struct Apt { pub struct Apt {
dry_run: bool, dry_run: bool,
use_sudo: bool, use_sudo: bool,

View file

@ -1,6 +1,7 @@
use super::{PackageError, PackageManager}; use super::{PackageError, PackageManager};
use std::process::Command; use std::process::Command;
/// Homebrew package manager (macOS/Linux).
pub struct Brew { pub struct Brew {
dry_run: bool, dry_run: bool,
} }

View file

@ -2,6 +2,7 @@ use super::{PackageError, PackageManager};
use std::io::Write; use std::io::Write;
use std::process::{Command, Stdio}; use std::process::{Command, Stdio};
/// Pacman package manager (Arch Linux).
pub struct Pacman { pub struct Pacman {
dry_run: bool, dry_run: bool,
use_sudo: bool, use_sudo: bool,

View file

@ -1,6 +1,7 @@
use super::{PackageError, PackageManager}; use super::{PackageError, PackageManager};
use std::process::Command; use std::process::Command;
/// Yay AUR helper (Arch Linux).
pub struct Yay { pub struct Yay {
dry_run: bool, dry_run: bool,
} }