feat(notfiles): add detect subcommand for stow/chezmoi/notfiles auto-detection
Some checks failed
Public Repo Readiness / Check Public Repo Readiness (push) Has been cancelled

This commit is contained in:
2026-06-14 00:30:27 -04:00
parent 32cb5f7516
commit 268c981545
4 changed files with 525 additions and 23 deletions

View File

@@ -16,6 +16,10 @@ pub struct Cli {
#[arg(long, short, global = true)] #[arg(long, short, global = true)]
pub verbose: bool, pub verbose: bool,
/// Output in JSON format
#[arg(long, global = true)]
pub json: bool,
#[command(subcommand)] #[command(subcommand)]
pub command: Command, pub command: Command,
} }
@@ -50,4 +54,31 @@ pub enum Command {
/// Specific packages to check (default: all) /// Specific packages to check (default: all)
packages: Vec<String>, packages: Vec<String>,
}, },
/// 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<String>,
},
/// 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<String>,
},
} }

View File

@@ -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<DetectedPackage>,
}
#[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<PathBuf>,
}
/// 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<DetectedManager> {
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<DetectedManager> {
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<DetectedPackage> {
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<DetectedPackage> {
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<DetectedPackage> {
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<Vec<String>> {
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<String> = 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<PathBuf> {
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::<Vec<_>>(),
})
}).collect::<Vec<_>>(),
})
})
.collect();
println!(
"{}",
serde_json::to_string_pretty(&items).unwrap_or_default()
);
}

View File

@@ -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 adapters;
pub mod cli; pub mod cli;
pub mod detect;
pub mod ignore; pub mod ignore;
pub mod linker; pub mod linker;
pub mod package; pub mod package;
pub mod ports; pub mod ports;
pub mod status; pub mod status;
use anyhow::Result;
use std::path::Path; use std::path::Path;
use notcore::NotfilesError;
use notcore::reporter::Reporter;
pub use adapters::FileStoreImpl; pub use adapters::FileStoreImpl;
pub use linker::{LinkOptions, State}; pub use linker::{LinkOptions, LinkResult, State};
pub use package::{resolve_packages, resolve_packages_with_store}; pub use package::{
discover_packages_filtered, resolve_packages, resolve_packages_filtered_with_store,
resolve_packages_with_store,
};
pub use ports::FileStore; pub use ports::FileStore;
pub fn link(dotfiles_dir: &Path, packages: &[String], opts: &LinkOptions) -> Result<State> { /// Link all (or specified) packages using the real filesystem and
link_with_store(dotfiles_dir, packages, opts, &adapters::FileStoreImpl) /// terminal output.
pub fn link(
dotfiles_dir: &Path,
packages: &[String],
opts: &LinkOptions,
) -> Result<State, NotfilesError> {
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( pub fn link_with_store(
dotfiles_dir: &Path, dotfiles_dir: &Path,
packages: &[String], packages: &[String],
opts: &LinkOptions, opts: &LinkOptions,
fs: &dyn FileStore, fs: &dyn FileStore,
) -> Result<State> { reporter: &dyn Reporter,
) -> Result<State, NotfilesError> {
let config = notcore::Config::load(dotfiles_dir)?; let config = notcore::Config::load(dotfiles_dir)?;
config.validate()?;
let mut state = linker::State::load(dotfiles_dir, fs)?; 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 { 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)?; state.save(dotfiles_dir, fs)?;
Ok(state) Ok(state)
} }
pub fn unlink(dotfiles_dir: &Path, packages: &[String], opts: &LinkOptions) -> Result<()> { /// Remove managed symlinks/copies for all (or specified) packages.
unlink_with_store(dotfiles_dir, packages, opts, &adapters::FileStoreImpl) 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( pub fn unlink_with_store(
dotfiles_dir: &Path, dotfiles_dir: &Path,
packages: &[String], packages: &[String],
opts: &LinkOptions, opts: &LinkOptions,
fs: &dyn FileStore, fs: &dyn FileStore,
) -> Result<()> { reporter: &dyn Reporter,
) -> Result<(), NotfilesError> {
let mut state = linker::State::load(dotfiles_dir, fs)?; let mut state = linker::State::load(dotfiles_dir, fs)?;
let pkgs = if packages.is_empty() { let pkgs = if packages.is_empty() {
state state
@@ -57,7 +115,7 @@ pub fn unlink_with_store(
packages.to_vec() packages.to_vec()
}; };
for pkg in &pkgs { 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)?; state.save(dotfiles_dir, fs)?;
Ok(()) Ok(())

View File

@@ -1,11 +1,14 @@
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use clap::Parser; use clap::{CommandFactory, Parser};
use std::fs; use std::fs;
use notcore::Config; use notcore::Config;
use notcore::reporter::Reporter;
use notfiles::adapters::{JsonReporter, TerminalReporter};
use notfiles::cli::{Cli, Command}; use notfiles::cli::{Cli, Command};
use notfiles::detect;
use notfiles::linker::{LinkOptions, State}; 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}; use notfiles::{adapters, linker, status};
fn main() -> Result<()> { fn main() -> Result<()> {
@@ -16,7 +19,24 @@ fn main() -> Result<()> {
let dotfiles_dir = fs::canonicalize(&dotfiles_dir) let dotfiles_dir = fs::canonicalize(&dotfiles_dir)
.with_context(|| format!("dotfiles directory not found: {}", dotfiles_dir.display()))?; .with_context(|| format!("dotfiles directory not found: {}", dotfiles_dir.display()))?;
let reporter: Box<dyn Reporter> = if cli.json {
Box::new(JsonReporter)
} else {
Box::new(TerminalReporter)
};
match cli.command { 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::Init => cmd_init(&dotfiles_dir)?,
Command::Link { Command::Link {
force, force,
@@ -25,8 +45,9 @@ fn main() -> Result<()> {
} => { } => {
let fs = &adapters::FileStoreImpl; let fs = &adapters::FileStoreImpl;
let config = Config::load(&dotfiles_dir)?; let config = Config::load(&dotfiles_dir)?;
config.validate()?;
let mut state = State::load(&dotfiles_dir, fs)?; 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 { let opts = LinkOptions {
force, force,
no_backup, no_backup,
@@ -38,19 +59,26 @@ fn main() -> Result<()> {
println!("\x1b[36m(dry run)\x1b[0m"); println!("\x1b[36m(dry run)\x1b[0m");
} }
let mut results = Vec::new();
for pkg in &pkgs { for pkg in &pkgs {
if cli.verbose || cli.dry_run { if cli.verbose || cli.dry_run {
println!("Linking {pkg}..."); 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 { if !cli.dry_run {
state.save(&dotfiles_dir, fs)?; state.save(&dotfiles_dir, fs)?;
let count: usize = pkgs let count: usize = results.iter().map(|r| r.linked + r.copied).sum();
.iter()
.map(|p| state.entries_for_package(p).len())
.sum();
println!( println!(
"\x1b[32mLinked {count} file{} across {} package{}.\x1b[0m", "\x1b[32mLinked {count} file{} across {} package{}.\x1b[0m",
if count == 1 { "" } else { "s" }, if count == 1 { "" } else { "s" },
@@ -95,7 +123,7 @@ fn main() -> Result<()> {
if cli.verbose || cli.dry_run { if cli.verbose || cli.dry_run {
println!("Unlinking {pkg}..."); 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 { if !cli.dry_run {
@@ -107,18 +135,103 @@ 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<String> = {
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 } => { Command::Status { packages } => {
let fs = &adapters::FileStoreImpl; let fs = &adapters::FileStoreImpl;
let config = Config::load(&dotfiles_dir)?; let config = Config::load(&dotfiles_dir)?;
config.validate()?;
let state = State::load(&dotfiles_dir, fs)?; 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 { for pkg in &pkgs {
let entries = status::package_status(&dotfiles_dir, &config, &state, pkg, fs); let entries = status::package_status(&dotfiles_dir, &config, &state, pkg, fs);
if cli.json {
status::print_status_json(pkg, &entries);
} else {
status::print_status(pkg, &entries); status::print_status(pkg, &entries);
} }
} }
} }
}
Ok(()) Ok(())
} }