diff --git a/crates/notfiles/src/cli.rs b/crates/notfiles/src/cli.rs index 2ef8693..670f503 100644 --- a/crates/notfiles/src/cli.rs +++ b/crates/notfiles/src/cli.rs @@ -16,6 +16,10 @@ pub struct Cli { #[arg(long, short, global = true)] pub verbose: bool, + /// Output in JSON format + #[arg(long, global = true)] + pub json: bool, + #[command(subcommand)] pub command: Command, } @@ -50,4 +54,31 @@ pub enum Command { /// Specific packages to check (default: all) packages: Vec, }, + + /// Validate config without making changes (CI-friendly) + Check, + + /// Generate shell completions + Completions { + /// Shell to generate for + #[arg(value_enum)] + shell: clap_complete::Shell, + }, + + /// Show differences between source and target for copy-method packages + Diff { + /// Specific packages to diff (default: all copy-method packages) + packages: Vec, + }, + + /// Auto-detect existing dotfile managers (stow, chezmoi, etc.) + Detect, + + /// Move existing files into a package and replace with symlinks + Adopt { + /// Package to adopt files into + package: String, + /// File paths (relative to target dir) to adopt + files: Vec, + }, } diff --git a/crates/notfiles/src/detect.rs b/crates/notfiles/src/detect.rs new file mode 100644 index 0000000..2f36aca --- /dev/null +++ b/crates/notfiles/src/detect.rs @@ -0,0 +1,300 @@ +//! Auto-detection of existing dotfile managers. +//! +//! Scans common dotfile repo locations and identifies what's managing them. + +use std::path::{Path, PathBuf}; + +use serde_json::json; + +/// A dotfile manager detected on disk. +#[derive(Debug)] +pub struct DetectedManager { + pub kind: ManagerKind, + pub root: PathBuf, + pub packages: Vec, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum ManagerKind { + /// GNU Stow — packages are top-level dirs, files mirror $HOME layout. + Stow, + /// chezmoi — uses `.chezmoi.*` config and `dot_` prefix convention. + Chezmoi, + /// Notfiles — has a `notfiles.toml` config file. + Notfiles, + /// Unknown structure — looks like a dotfile repo but manager unclear. + Unknown, +} + +impl std::fmt::Display for ManagerKind { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ManagerKind::Stow => write!(f, "stow"), + ManagerKind::Chezmoi => write!(f, "chezmoi"), + ManagerKind::Notfiles => write!(f, "notfiles"), + ManagerKind::Unknown => write!(f, "unknown"), + } + } +} + +/// A package (top-level directory) inside a detected dotfile repo. +#[derive(Debug)] +pub struct DetectedPackage { + pub name: String, + /// Files relative to the package directory (mirrors home layout). + pub files: Vec, +} + +/// Locations to probe relative to `$HOME`. +const CANDIDATE_DIRS: &[&str] = &[ + "dotfiles", + ".dotfiles", + "dot", + ".dot", + "dots", + ".dots", + "config/dotfiles", + ".config/dotfiles", +]; + +/// Detect all dotfile managers reachable from `home`. +pub fn detect(home: &Path) -> Vec { + let mut found = Vec::new(); + + for rel in CANDIDATE_DIRS { + let dir = home.join(rel); + if dir.is_dir() + && let Some(m) = probe(&dir) + { + found.push(m); + } + } + + // chezmoi source dir at its default non-standard location + let chezmoi_src = home.join(".local/share/chezmoi"); + if chezmoi_src.is_dir() && !found.iter().any(|m| m.root == chezmoi_src) { + found.push(DetectedManager { + kind: ManagerKind::Chezmoi, + packages: enumerate_chezmoi_packages(&chezmoi_src), + root: chezmoi_src, + }); + } + + found +} + +fn probe(dir: &Path) -> Option { + let kind = classify(dir); + let packages = match kind { + ManagerKind::Stow | ManagerKind::Unknown => enumerate_stow_packages(dir), + ManagerKind::Chezmoi => enumerate_chezmoi_packages(dir), + ManagerKind::Notfiles => enumerate_notfiles_packages(dir), + }; + Some(DetectedManager { + kind, + root: dir.to_path_buf(), + packages, + }) +} + +fn classify(dir: &Path) -> ManagerKind { + if dir.join("notfiles.toml").exists() { + return ManagerKind::Notfiles; + } + if dir.join(".chezmoi.toml.tmpl").exists() + || dir.join(".chezmoi.yaml.tmpl").exists() + || dir.join(".chezmoi.json.tmpl").exists() + || dir.join(".chezmoi.toml").exists() + || dir.join(".chezmoi.yaml").exists() + { + return ManagerKind::Chezmoi; + } + if dir.join(".stow-local-ignore").exists() + || dir.join(".stowrc").exists() + || has_stow_structure(dir) + { + return ManagerKind::Stow; + } + ManagerKind::Unknown +} + +/// Heuristic: looks like stow if ≥2 top-level subdirs contain dot-dirs or +/// known config paths (`.config/`, `.local/`, `Library/`, etc.). +fn has_stow_structure(dir: &Path) -> bool { + let Ok(entries) = std::fs::read_dir(dir) else { + return false; + }; + let mut stow_like = 0usize; + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_dir() { + continue; + } + let name = entry.file_name(); + let s = name.to_string_lossy(); + if s.starts_with('.') || matches!(s.as_ref(), "target" | "src") { + continue; + } + if package_looks_like_stow(&path) { + stow_like += 1; + if stow_like >= 2 { + return true; + } + } + } + false +} + +fn package_looks_like_stow(pkg_dir: &Path) -> bool { + let Ok(entries) = std::fs::read_dir(pkg_dir) else { + return false; + }; + for entry in entries.flatten() { + let name = entry.file_name(); + let s = name.to_string_lossy(); + if s.starts_with('.') || s == "Library" { + return true; + } + } + false +} + +fn enumerate_stow_packages(root: &Path) -> Vec { + let Ok(entries) = std::fs::read_dir(root) else { + return vec![]; + }; + let mut pkgs = Vec::new(); + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_dir() { + continue; + } + let name = entry.file_name().to_string_lossy().to_string(); + if name.starts_with('.') || matches!(name.as_str(), "target" | "src" | "docs" | "tests") { + continue; + } + let files = walk_files(&path, &path); + pkgs.push(DetectedPackage { name, files }); + } + pkgs.sort_by(|a, b| a.name.cmp(&b.name)); + pkgs +} + +fn enumerate_chezmoi_packages(root: &Path) -> Vec { + let files = walk_files(root, root); + if files.is_empty() { + vec![] + } else { + vec![DetectedPackage { + name: "(source)".into(), + files, + }] + } +} + +fn enumerate_notfiles_packages(root: &Path) -> Vec { + let toml_path = root.join("notfiles.toml"); + if let Ok(content) = std::fs::read_to_string(&toml_path) + && let Some(names) = parse_include_list(&content) + { + return names + .into_iter() + .map(|name| { + let pkg_dir = root.join(&name); + let files = walk_files(&pkg_dir, &pkg_dir); + DetectedPackage { name, files } + }) + .collect(); + } + enumerate_stow_packages(root) +} + +/// Minimal parse of `include = ["a", "b"]` from TOML. +fn parse_include_list(toml: &str) -> Option> { + let line = toml + .lines() + .find(|l| l.trim_start().starts_with("include"))?; + let bracket_start = line.find('[')?; + let bracket_end = line.find(']')?; + let inner = &line[bracket_start + 1..bracket_end]; + let names: Vec = inner + .split(',') + .map(|s| s.trim().trim_matches('"').trim_matches('\'').to_string()) + .filter(|s| !s.is_empty()) + .collect(); + if names.is_empty() { None } else { Some(names) } +} + +/// Recursively collect file paths relative to `base`. +fn walk_files(dir: &Path, base: &Path) -> Vec { + let mut files = Vec::new(); + let Ok(entries) = std::fs::read_dir(dir) else { + return files; + }; + for entry in entries.flatten() { + let path = entry.path(); + let name = entry.file_name().to_string_lossy().to_string(); + if matches!(name.as_str(), ".git" | ".DS_Store") { + continue; + } + if path.is_dir() { + files.extend(walk_files(&path, base)); + } else if path.is_file() + && let Ok(rel) = path.strip_prefix(base) + { + files.push(rel.to_path_buf()); + } + } + files +} + +// ── Output ──────────────────────────────────────────────────────────────────── + +pub fn print_detected(managers: &[DetectedManager]) { + if managers.is_empty() { + println!("No dotfile managers detected."); + return; + } + println!("Detected dotfile managers:\n"); + for m in managers { + let pkg_count = m.packages.len(); + let file_count: usize = m.packages.iter().map(|p| p.files.len()).sum(); + let pkg_names: Vec<&str> = m.packages.iter().map(|p| p.name.as_str()).collect(); + println!(" \x1b[1m{}\x1b[0m {}", m.kind, m.root.display()); + println!( + " {} package{}, {} file{}", + pkg_count, + if pkg_count == 1 { "" } else { "s" }, + file_count, + if file_count == 1 { "" } else { "s" }, + ); + if !pkg_names.is_empty() { + println!(" packages: {}", pkg_names.join(", ")); + } + println!(); + } +} + +pub fn print_detected_json(managers: &[DetectedManager]) { + let items: Vec<_> = managers + .iter() + .map(|m| { + json!({ + "manager": m.kind.to_string(), + "root": m.root.to_string_lossy(), + "packages": m.packages.iter().map(|p| { + json!({ + "name": p.name, + "files": p.files.iter() + .map(|f| f.to_string_lossy().to_string()) + .collect::>(), + }) + }).collect::>(), + }) + }) + .collect(); + println!( + "{}", + serde_json::to_string_pretty(&items).unwrap_or_default() + ); +} diff --git a/crates/notfiles/src/lib.rs b/crates/notfiles/src/lib.rs index d3d46ca..ca0f4aa 100644 --- a/crates/notfiles/src/lib.rs +++ b/crates/notfiles/src/lib.rs @@ -1,49 +1,107 @@ +//! A modern dotfiles manager — pure Rust alternative to GNU Stow. +//! +//! Symlinks (or copies) files from organized "package" directories +//! into a target location (typically `~`). Supports include/exclude +//! filtering, platform gates, and multiple output formats. +//! +//! # Quick start +//! +//! ```no_run +//! use notfiles::{link, LinkOptions}; +//! use std::path::Path; +//! +//! let opts = LinkOptions { +//! force: false, +//! no_backup: false, +//! dry_run: false, +//! verbose: true, +//! }; +//! let state = link(Path::new("/home/user/dotfiles"), &[], &opts) +//! .expect("link failed"); +//! ``` + pub mod adapters; pub mod cli; +pub mod detect; pub mod ignore; pub mod linker; pub mod package; pub mod ports; pub mod status; -use anyhow::Result; use std::path::Path; +use notcore::NotfilesError; +use notcore::reporter::Reporter; + pub use adapters::FileStoreImpl; -pub use linker::{LinkOptions, State}; -pub use package::{resolve_packages, resolve_packages_with_store}; +pub use linker::{LinkOptions, LinkResult, State}; +pub use package::{ + discover_packages_filtered, resolve_packages, resolve_packages_filtered_with_store, + resolve_packages_with_store, +}; pub use ports::FileStore; -pub fn link(dotfiles_dir: &Path, packages: &[String], opts: &LinkOptions) -> Result { - link_with_store(dotfiles_dir, packages, opts, &adapters::FileStoreImpl) +/// Link all (or specified) packages using the real filesystem and +/// terminal output. +pub fn link( + dotfiles_dir: &Path, + packages: &[String], + opts: &LinkOptions, +) -> Result { + let reporter = adapters::TerminalReporter; + link_with_store( + dotfiles_dir, + packages, + opts, + &adapters::FileStoreImpl, + &reporter, + ) } +/// Link packages with injectable filesystem and reporter (for testing). pub fn link_with_store( dotfiles_dir: &Path, packages: &[String], opts: &LinkOptions, fs: &dyn FileStore, -) -> Result { + reporter: &dyn Reporter, +) -> Result { let config = notcore::Config::load(dotfiles_dir)?; + config.validate()?; let mut state = linker::State::load(dotfiles_dir, fs)?; - let pkgs = resolve_packages_with_store(dotfiles_dir, packages, fs)?; + let pkgs = package::resolve_packages_filtered_with_store(dotfiles_dir, packages, &config, fs)?; for pkg in &pkgs { - linker::link_package(dotfiles_dir, &config, &mut state, pkg, opts, fs)?; + linker::link_package(dotfiles_dir, &config, &mut state, pkg, opts, fs, reporter)?; } state.save(dotfiles_dir, fs)?; Ok(state) } -pub fn unlink(dotfiles_dir: &Path, packages: &[String], opts: &LinkOptions) -> Result<()> { - unlink_with_store(dotfiles_dir, packages, opts, &adapters::FileStoreImpl) +/// Remove managed symlinks/copies for all (or specified) packages. +pub fn unlink( + dotfiles_dir: &Path, + packages: &[String], + opts: &LinkOptions, +) -> Result<(), NotfilesError> { + let reporter = adapters::TerminalReporter; + unlink_with_store( + dotfiles_dir, + packages, + opts, + &adapters::FileStoreImpl, + &reporter, + ) } +/// Unlink packages with injectable filesystem and reporter. pub fn unlink_with_store( dotfiles_dir: &Path, packages: &[String], opts: &LinkOptions, fs: &dyn FileStore, -) -> Result<()> { + reporter: &dyn Reporter, +) -> Result<(), NotfilesError> { let mut state = linker::State::load(dotfiles_dir, fs)?; let pkgs = if packages.is_empty() { state @@ -57,7 +115,7 @@ pub fn unlink_with_store( packages.to_vec() }; for pkg in &pkgs { - linker::unlink_package(dotfiles_dir, &mut state, pkg, opts, fs)?; + linker::unlink_package(dotfiles_dir, &mut state, pkg, opts, fs, reporter)?; } state.save(dotfiles_dir, fs)?; Ok(()) diff --git a/crates/notfiles/src/main.rs b/crates/notfiles/src/main.rs index 9d4e057..a4aaff2 100644 --- a/crates/notfiles/src/main.rs +++ b/crates/notfiles/src/main.rs @@ -1,11 +1,14 @@ use anyhow::{Context, Result}; -use clap::Parser; +use clap::{CommandFactory, Parser}; use std::fs; use notcore::Config; +use notcore::reporter::Reporter; +use notfiles::adapters::{JsonReporter, TerminalReporter}; use notfiles::cli::{Cli, Command}; +use notfiles::detect; use notfiles::linker::{LinkOptions, State}; -use notfiles::package::resolve_packages_with_store; +use notfiles::package::{resolve_packages_filtered_with_store, resolve_packages_with_store}; use notfiles::{adapters, linker, status}; fn main() -> Result<()> { @@ -16,7 +19,24 @@ fn main() -> Result<()> { let dotfiles_dir = fs::canonicalize(&dotfiles_dir) .with_context(|| format!("dotfiles directory not found: {}", dotfiles_dir.display()))?; + let reporter: Box = if cli.json { + Box::new(JsonReporter) + } else { + Box::new(TerminalReporter) + }; + match cli.command { + Command::Detect => { + let home = std::env::var("HOME") + .map(std::path::PathBuf::from) + .unwrap_or_else(|_| dotfiles_dir.clone()); + let managers = detect::detect(&home); + if cli.json { + detect::print_detected_json(&managers); + } else { + detect::print_detected(&managers); + } + } Command::Init => cmd_init(&dotfiles_dir)?, Command::Link { force, @@ -25,8 +45,9 @@ fn main() -> Result<()> { } => { let fs = &adapters::FileStoreImpl; let config = Config::load(&dotfiles_dir)?; + config.validate()?; let mut state = State::load(&dotfiles_dir, fs)?; - let pkgs = resolve_packages_with_store(&dotfiles_dir, &packages, fs)?; + let pkgs = resolve_packages_filtered_with_store(&dotfiles_dir, &packages, &config, fs)?; let opts = LinkOptions { force, no_backup, @@ -38,19 +59,26 @@ fn main() -> Result<()> { println!("\x1b[36m(dry run)\x1b[0m"); } + let mut results = Vec::new(); for pkg in &pkgs { if cli.verbose || cli.dry_run { println!("Linking {pkg}..."); } - linker::link_package(&dotfiles_dir, &config, &mut state, pkg, &opts, fs)?; + let result = linker::link_package( + &dotfiles_dir, + &config, + &mut state, + pkg, + &opts, + fs, + &*reporter, + )?; + results.push(result); } if !cli.dry_run { state.save(&dotfiles_dir, fs)?; - let count: usize = pkgs - .iter() - .map(|p| state.entries_for_package(p).len()) - .sum(); + let count: usize = results.iter().map(|r| r.linked + r.copied).sum(); println!( "\x1b[32mLinked {count} file{} across {} package{}.\x1b[0m", if count == 1 { "" } else { "s" }, @@ -95,7 +123,7 @@ fn main() -> Result<()> { if cli.verbose || cli.dry_run { println!("Unlinking {pkg}..."); } - linker::unlink_package(&dotfiles_dir, &mut state, pkg, &opts, fs)?; + linker::unlink_package(&dotfiles_dir, &mut state, pkg, &opts, fs, &*reporter)?; } if !cli.dry_run { @@ -107,15 +135,100 @@ fn main() -> Result<()> { ); } } + Command::Completions { shell } => { + clap_complete::generate( + shell, + &mut Cli::command(), + "notfiles", + &mut std::io::stdout(), + ); + } + Command::Check => { + let fs = &adapters::FileStoreImpl; + let config = Config::load(&dotfiles_dir)?; + config.validate()?; + let all = resolve_packages_filtered_with_store(&dotfiles_dir, &[], &config, fs)?; + + if cli.json { + let skipped: Vec = { + let all_unfiltered = + notfiles::package::resolve_packages_with_store(&dotfiles_dir, &[], fs)?; + all_unfiltered + .into_iter() + .filter(|p| !all.contains(p)) + .collect() + }; + let obj = serde_json::json!({ + "valid": true, + "packages": all, + "skipped": skipped, + }); + println!("{}", serde_json::to_string(&obj).unwrap_or_default()); + } else { + println!("notfiles.toml: \x1b[32mvalid\x1b[0m"); + println!("Packages: {} ({} total)", all.join(", "), all.len()); + } + } + Command::Diff { packages } => { + let fs = &adapters::FileStoreImpl; + let config = Config::load(&dotfiles_dir)?; + config.validate()?; + let state = State::load(&dotfiles_dir, fs)?; + let pkgs = resolve_packages_filtered_with_store(&dotfiles_dir, &packages, &config, fs)?; + + for pkg in &pkgs { + let entries = status::diff_package(&dotfiles_dir, &config, &state, pkg, fs); + status::print_diff(pkg, &entries); + } + } + Command::Adopt { package, files } => { + let fs = &adapters::FileStoreImpl; + let config = Config::load(&dotfiles_dir)?; + let mut state = State::load(&dotfiles_dir, fs)?; + let opts = LinkOptions { + force: false, + no_backup: false, + dry_run: cli.dry_run, + verbose: cli.verbose, + }; + + if cli.dry_run { + println!("\x1b[36m(dry run)\x1b[0m"); + } + + let result = linker::adopt_files( + &dotfiles_dir, + &config, + &mut state, + &package, + &files, + &opts, + fs, + &*reporter, + )?; + + if !cli.dry_run { + println!( + "\x1b[32mAdopted {} file{} into {package}.\x1b[0m", + result.linked, + if result.linked == 1 { "" } else { "s" }, + ); + } + } Command::Status { packages } => { let fs = &adapters::FileStoreImpl; let config = Config::load(&dotfiles_dir)?; + config.validate()?; let state = State::load(&dotfiles_dir, fs)?; - let pkgs = resolve_packages_with_store(&dotfiles_dir, &packages, fs)?; + let pkgs = resolve_packages_filtered_with_store(&dotfiles_dir, &packages, &config, fs)?; for pkg in &pkgs { let entries = status::package_status(&dotfiles_dir, &config, &state, pkg, fs); - status::print_status(pkg, &entries); + if cli.json { + status::print_status_json(pkg, &entries); + } else { + status::print_status(pkg, &entries); + } } } }