feat(workspace): migrate notfiles into crates/ workspace layout

Move src/ and tests/ into crates/notfiles/ as a proper lib+bin crate.
Internal config/error/paths modules replaced with notcore imports.
All 31 workspace tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Joseph O'Brien
2026-03-31 17:23:16 -04:00
parent 6b0bd3beb6
commit ed11aa9bc3
13 changed files with 137 additions and 283 deletions

View File

@@ -2,3 +2,26 @@
name = "notfiles"
version = "0.1.0"
edition = "2024"
description = "A modern dotfiles manager — pure Rust alternative to GNU Stow"
[[bin]]
name = "notfiles"
path = "src/main.rs"
[lib]
name = "notfiles"
path = "src/lib.rs"
[dependencies]
notcore = { workspace = true }
clap = { workspace = true }
globset = { workspace = true }
chrono = { workspace = true }
serde = { workspace = true }
toml = { workspace = true }
anyhow = { workspace = true }
dirs = { workspace = true }
[dev-dependencies]
tempfile = { workspace = true }
assert_fs = { workspace = true }

View File

@@ -0,0 +1,53 @@
use clap::{Parser, Subcommand};
use std::path::PathBuf;
#[derive(Parser, Debug)]
#[command(name = "notfiles", about = "A modern dotfiles manager")]
pub struct Cli {
/// Path to the dotfiles directory (default: current directory)
#[arg(long, global = true)]
pub dir: Option<PathBuf>,
/// Show what would be done without making changes
#[arg(long, global = true)]
pub dry_run: bool,
/// Show verbose output
#[arg(long, short, global = true)]
pub verbose: bool,
#[command(subcommand)]
pub command: Command,
}
#[derive(Subcommand, Debug)]
pub enum Command {
/// Create a starter notfiles.toml
Init,
/// Create symlinks/copies for packages
Link {
/// Overwrite existing files (backs up first)
#[arg(long)]
force: bool,
/// Skip creating backups when using --force
#[arg(long)]
no_backup: bool,
/// Specific packages to link (default: all)
packages: Vec<String>,
},
/// Remove managed symlinks/copies
Unlink {
/// Specific packages to unlink (default: all)
packages: Vec<String>,
},
/// Show link state per package
Status {
/// Specific packages to check (default: all)
packages: Vec<String>,
},
}

View File

@@ -0,0 +1,82 @@
use globset::{Glob, GlobSet, GlobSetBuilder};
use std::path::Path;
use notcore::NotfilesError;
pub struct IgnoreMatcher {
globset: GlobSet,
}
impl IgnoreMatcher {
pub fn new(patterns: &[&str]) -> Result<Self, NotfilesError> {
let mut builder = GlobSetBuilder::new();
for pattern in patterns {
// Match the pattern as a filename component and also as a path suffix.
let glob = Glob::new(pattern)
.or_else(|_| Glob::new(&format!("**/{pattern}")))
.map_err(|e| NotfilesError::Other(format!("invalid ignore pattern '{pattern}': {e}")))?;
builder.add(glob);
// Also add a recursive variant so "foo" matches "a/foo" etc.
#[allow(clippy::collapsible_if)]
if !pattern.contains('/') && !pattern.starts_with("**/") {
if let Ok(g) = Glob::new(&format!("**/{pattern}")) {
builder.add(g);
}
}
}
let globset = builder
.build()
.map_err(|e| NotfilesError::Other(format!("building ignore set: {e}")))?;
Ok(Self { globset })
}
/// Check if a relative path should be ignored.
pub fn is_ignored(&self, relative_path: &Path) -> bool {
if self.globset.is_match(relative_path) {
return true;
}
// Also check each component individually (for directory-level ignores like ".git").
for component in relative_path.components() {
if self.globset.is_match(component.as_os_str()) {
return true;
}
}
false
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
#[test]
fn test_basic_ignore() {
let m = IgnoreMatcher::new(&[".git", "README.md"]).unwrap();
assert!(m.is_ignored(Path::new(".git")));
assert!(m.is_ignored(Path::new("README.md")));
assert!(!m.is_ignored(Path::new("config")));
}
#[test]
fn test_nested_ignore() {
let m = IgnoreMatcher::new(&[".DS_Store"]).unwrap();
assert!(m.is_ignored(Path::new(".DS_Store")));
assert!(m.is_ignored(Path::new("subdir/.DS_Store")));
}
#[test]
fn test_glob_pattern() {
let m = IgnoreMatcher::new(&["*.bak"]).unwrap();
assert!(m.is_ignored(Path::new("file.bak")));
assert!(m.is_ignored(Path::new("subdir/file.bak")));
assert!(!m.is_ignored(Path::new("file.txt")));
}
#[test]
fn test_directory_component_ignore() {
let m = IgnoreMatcher::new(&[".git"]).unwrap();
assert!(m.is_ignored(Path::new(".git/config")));
assert!(m.is_ignored(Path::new(".git/objects/abc")));
}
}

View File

@@ -1 +1,46 @@
// placeholder
pub mod cli;
pub mod ignore;
pub mod linker;
pub mod package;
pub mod status;
use anyhow::Result;
use std::path::Path;
pub use linker::{LinkOptions, State};
pub use package::resolve_packages;
pub fn link(
dotfiles_dir: &Path,
packages: &[String],
opts: &LinkOptions,
) -> Result<State> {
let config = notcore::Config::load(dotfiles_dir)?;
let mut state = State::load(dotfiles_dir)?;
let pkgs = resolve_packages(dotfiles_dir, packages)?;
for pkg in &pkgs {
linker::link_package(dotfiles_dir, &config, &mut state, pkg, opts)?;
}
state.save(dotfiles_dir)?;
Ok(state)
}
pub fn unlink(dotfiles_dir: &Path, packages: &[String], opts: &LinkOptions) -> Result<()> {
let mut state = State::load(dotfiles_dir)?;
let pkgs = if packages.is_empty() {
state
.entries
.iter()
.map(|e| e.package.clone())
.collect::<std::collections::HashSet<_>>()
.into_iter()
.collect::<Vec<_>>()
} else {
packages.to_vec()
};
for pkg in &pkgs {
linker::unlink_package(dotfiles_dir, &mut state, pkg, opts)?;
}
state.save(dotfiles_dir)?;
Ok(())
}

View File

@@ -0,0 +1,288 @@
use std::fs;
use std::path::{Path, PathBuf};
use chrono::Utc;
use serde::{Deserialize, Serialize};
use notcore::{Config, Method, NotfilesError, expand_tilde};
use crate::package::collect_files;
const STATE_FILE: &str = ".notfiles-state.toml";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct StateEntry {
pub package: String,
pub source: String,
pub target: String,
pub method: Method,
pub linked_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct State {
#[serde(default)]
pub entries: Vec<StateEntry>,
}
impl State {
pub fn load(dotfiles_dir: &Path) -> Result<Self, NotfilesError> {
let path = dotfiles_dir.join(STATE_FILE);
if !path.exists() {
return Ok(State::default());
}
let content = fs::read_to_string(&path)
.map_err(|e| NotfilesError::State(format!("reading state: {e}")))?;
let state: State = toml::from_str(&content)
.map_err(|e| NotfilesError::State(format!("parsing state: {e}")))?;
Ok(state)
}
pub fn save(&self, dotfiles_dir: &Path) -> Result<(), NotfilesError> {
let path = dotfiles_dir.join(STATE_FILE);
let content = toml::to_string_pretty(self)
.map_err(|e| NotfilesError::State(format!("serializing state: {e}")))?;
fs::write(&path, content)?;
Ok(())
}
pub fn entries_for_package(&self, package: &str) -> Vec<&StateEntry> {
self.entries.iter().filter(|e| e.package == package).collect()
}
pub fn remove_package(&mut self, package: &str) {
self.entries.retain(|e| e.package != package);
}
pub fn add_entry(&mut self, entry: StateEntry) {
// Remove existing entry for same source+target, then add new
self.entries.retain(|e| !(e.source == entry.source && e.target == entry.target));
self.entries.push(entry);
}
}
pub struct LinkOptions {
pub force: bool,
pub no_backup: bool,
pub dry_run: bool,
pub verbose: bool,
}
pub fn link_package(
dotfiles_dir: &Path,
config: &Config,
state: &mut State,
package: &str,
opts: &LinkOptions,
) -> Result<(), NotfilesError> {
let package_dir = dotfiles_dir.join(package);
let method = config.method_for(package);
let target_base = expand_tilde(config.target_for(package))?;
let files = collect_files(&package_dir, config, package)?;
if files.is_empty() {
if opts.verbose {
println!(" {package}: no files to link");
}
return Ok(());
}
for relative in &files {
let source = package_dir.join(relative);
let target = target_base.join(relative);
let source_display = format!("{package}/{}", relative.display());
// Check if already correctly linked
if is_already_linked(&source, &target, method) {
if opts.verbose {
println!(" \x1b[90mskip\x1b[0m {source_display} (already linked)");
}
continue;
}
// Conflict detection
if target.exists() || target.symlink_metadata().is_ok() {
if !opts.force {
return Err(NotfilesError::Conflict {
path: target.clone(),
reason: format!(
"already exists (use --force to overwrite); source: {source_display}"
),
});
}
// Force mode: backup then remove
if !opts.no_backup {
let backup = backup_path(&target);
if opts.dry_run {
println!(" \x1b[33mwould backup\x1b[0m {} -> {}", target.display(), backup.display());
} else {
if opts.verbose {
println!(" \x1b[33mbackup\x1b[0m {} -> {}", target.display(), backup.display());
}
fs::rename(&target, &backup)?;
}
} else if !opts.dry_run {
if target.is_dir() {
fs::remove_dir_all(&target)?;
} else {
fs::remove_file(&target)?;
}
}
}
// Create parent directories
if let Some(parent) = target.parent().filter(|p| !p.exists()) {
if opts.dry_run {
if opts.verbose {
println!(" \x1b[90mwould create dir\x1b[0m {}", parent.display());
}
} else {
fs::create_dir_all(parent)?;
}
}
// Create link or copy
let action_word = match method {
Method::Symlink => "link",
Method::Copy => "copy",
};
if opts.dry_run {
println!(" \x1b[36mwould {action_word}\x1b[0m {source_display} -> {}", target.display());
} else {
match method {
Method::Symlink => {
#[cfg(unix)]
std::os::unix::fs::symlink(&source, &target)?;
#[cfg(not(unix))]
fs::copy(&source, &target)?;
}
Method::Copy => {
fs::copy(&source, &target)?;
}
}
if opts.verbose {
println!(" \x1b[32m{action_word}\x1b[0m {source_display} -> {}", target.display());
}
state.add_entry(StateEntry {
package: package.to_string(),
source: source.to_string_lossy().to_string(),
target: target.to_string_lossy().to_string(),
method,
linked_at: Utc::now().to_rfc3339(),
});
}
}
Ok(())
}
pub fn unlink_package(
_dotfiles_dir: &Path,
state: &mut State,
package: &str,
opts: &LinkOptions,
) -> Result<(), NotfilesError> {
let entries: Vec<StateEntry> = state.entries_for_package(package).into_iter().cloned().collect();
if entries.is_empty() {
if opts.verbose {
println!(" {package}: nothing to unlink");
}
return Ok(());
}
for entry in &entries {
let target = PathBuf::from(&entry.target);
if !target.exists() && target.symlink_metadata().is_err() {
if opts.verbose {
println!(" \x1b[90mskip\x1b[0m {} (already gone)", target.display());
}
continue;
}
match entry.method {
Method::Symlink => {
// Verify it's a symlink pointing to our source
if let Ok(link_target) = fs::read_link(&target) {
let source = PathBuf::from(&entry.source);
if link_target != source {
if opts.verbose {
println!(
" \x1b[33mskip\x1b[0m {} (symlink points elsewhere)",
target.display()
);
}
continue;
}
} else {
if opts.verbose {
println!(" \x1b[33mskip\x1b[0m {} (not a symlink)", target.display());
}
continue;
}
}
Method::Copy => {
// For copies, trust the state file
}
}
if opts.dry_run {
println!(" \x1b[36mwould remove\x1b[0m {}", target.display());
} else {
if target.is_dir() {
fs::remove_dir_all(&target)?;
} else {
fs::remove_file(&target)?;
}
if opts.verbose {
println!(" \x1b[31mremove\x1b[0m {}", target.display());
}
// Clean up empty parent dirs
cleanup_empty_parents(&target);
}
}
if !opts.dry_run {
state.remove_package(package);
}
Ok(())
}
fn is_already_linked(source: &Path, target: &Path, method: Method) -> bool {
match method {
Method::Symlink => {
if let Ok(link_target) = fs::read_link(target) {
link_target == source
} else {
false
}
}
Method::Copy => false, // Always re-copy
}
}
fn backup_path(path: &Path) -> PathBuf {
let timestamp = Utc::now().format("%Y%m%d%H%M%S");
let name = path.to_string_lossy();
PathBuf::from(format!("{name}.notfiles-backup-{timestamp}"))
}
fn cleanup_empty_parents(path: &Path) {
let mut dir = path.parent();
while let Some(parent) = dir {
// Stop at home dir or root
if Some(parent.to_path_buf()) == dirs::home_dir() || parent == Path::new("/") {
break;
}
if fs::read_dir(parent).map(|mut d| d.next().is_none()).unwrap_or(false) {
let _ = fs::remove_dir(parent);
dir = parent.parent();
} else {
break;
}
}
}

129
crates/notfiles/src/main.rs Normal file
View File

@@ -0,0 +1,129 @@
use anyhow::{Context, Result};
use clap::Parser;
use std::fs;
use notfiles::cli::{Cli, Command};
use notfiles::linker::{LinkOptions, State};
use notfiles::package::resolve_packages;
use notfiles::{linker, status};
use notcore::Config;
fn main() -> Result<()> {
let cli = Cli::parse();
let dotfiles_dir = cli
.dir
.unwrap_or_else(|| std::env::current_dir().expect("cannot determine current directory"));
let dotfiles_dir = fs::canonicalize(&dotfiles_dir)
.with_context(|| format!("dotfiles directory not found: {}", dotfiles_dir.display()))?;
match cli.command {
Command::Init => cmd_init(&dotfiles_dir)?,
Command::Link {
force,
no_backup,
packages,
} => {
let config = Config::load(&dotfiles_dir)?;
let mut state = State::load(&dotfiles_dir)?;
let pkgs = resolve_packages(&dotfiles_dir, &packages)?;
let opts = LinkOptions {
force,
no_backup,
dry_run: cli.dry_run,
verbose: cli.verbose,
};
if cli.dry_run {
println!("\x1b[36m(dry run)\x1b[0m");
}
for pkg in &pkgs {
if cli.verbose || cli.dry_run {
println!("Linking {pkg}...");
}
linker::link_package(&dotfiles_dir, &config, &mut state, pkg, &opts)?;
}
if !cli.dry_run {
state.save(&dotfiles_dir)?;
let count: usize = pkgs.iter().map(|p| state.entries_for_package(p).len()).sum();
println!(
"\x1b[32mLinked {count} file{} across {} package{}.\x1b[0m",
if count == 1 { "" } else { "s" },
pkgs.len(),
if pkgs.len() == 1 { "" } else { "s" },
);
}
}
Command::Unlink { packages } => {
let config = Config::load(&dotfiles_dir)?;
let _ = &config; // loaded but not needed for unlink
let mut state = State::load(&dotfiles_dir)?;
let pkgs = if packages.is_empty() {
state
.entries
.iter()
.map(|e| e.package.clone())
.collect::<std::collections::HashSet<_>>()
.into_iter()
.collect::<Vec<_>>()
} else {
// Validate requested packages exist in state
let _ = resolve_packages(&dotfiles_dir, &packages).or_else(|_| {
// Package dir might be gone but state entries exist — that's fine for unlink
Ok::<Vec<String>, anyhow::Error>(packages.clone())
});
packages
};
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");
}
for pkg in &pkgs {
if cli.verbose || cli.dry_run {
println!("Unlinking {pkg}...");
}
linker::unlink_package(&dotfiles_dir, &mut state, pkg, &opts)?;
}
if !cli.dry_run {
state.save(&dotfiles_dir)?;
println!(
"\x1b[32mUnlinked {} package{}.\x1b[0m",
pkgs.len(),
if pkgs.len() == 1 { "" } else { "s" }
);
}
}
Command::Status { packages } => {
let config = Config::load(&dotfiles_dir)?;
let state = State::load(&dotfiles_dir)?;
let pkgs = resolve_packages(&dotfiles_dir, &packages)?;
for pkg in &pkgs {
let entries = status::package_status(&dotfiles_dir, &config, &state, pkg);
status::print_status(pkg, &entries);
}
}
}
Ok(())
}
fn cmd_init(dotfiles_dir: &std::path::Path) -> Result<()> {
let config_path = dotfiles_dir.join("notfiles.toml");
if config_path.exists() {
println!("notfiles.toml already exists.");
return Ok(());
}
fs::write(&config_path, notcore::config::starter_toml())?;
println!("Created notfiles.toml");
Ok(())
}

View File

@@ -0,0 +1,129 @@
use std::fs;
use std::path::{Path, PathBuf};
use notcore::{Config, NotfilesError};
use crate::ignore::IgnoreMatcher;
/// Discover available packages (subdirectories of the dotfiles dir).
pub fn discover_packages(dotfiles_dir: &Path) -> Result<Vec<String>, NotfilesError> {
let mut packages = Vec::new();
for entry in fs::read_dir(dotfiles_dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
let name = entry.file_name().to_string_lossy().to_string();
// Skip hidden dirs and common non-package dirs
if !name.starts_with('.') {
packages.push(name);
}
}
}
packages.sort();
Ok(packages)
}
/// Resolve which packages to operate on: if specific names given, validate them;
/// otherwise discover all.
pub fn resolve_packages(
dotfiles_dir: &Path,
requested: &[String],
) -> Result<Vec<String>, NotfilesError> {
let available = discover_packages(dotfiles_dir)?;
if requested.is_empty() {
return Ok(available);
}
for name in requested {
if !available.contains(name) {
return Err(NotfilesError::PackageNotFound { name: name.clone() });
}
}
Ok(requested.to_vec())
}
/// Recursively walk a package directory and return all file paths (relative to the package dir).
pub fn collect_files(
package_dir: &Path,
config: &Config,
package_name: &str,
) -> Result<Vec<PathBuf>, NotfilesError> {
let patterns = config.ignore_patterns_for(package_name);
let matcher = IgnoreMatcher::new(&patterns)?;
let mut files = Vec::new();
walk_dir(package_dir, package_dir, &matcher, &mut files)?;
files.sort();
Ok(files)
}
fn walk_dir(
base: &Path,
current: &Path,
matcher: &IgnoreMatcher,
files: &mut Vec<PathBuf>,
) -> Result<(), NotfilesError> {
for entry in fs::read_dir(current)? {
let entry = entry?;
let path = entry.path();
let relative = path.strip_prefix(base).unwrap().to_path_buf();
if matcher.is_ignored(&relative) {
continue;
}
if path.is_dir() {
walk_dir(base, &path, matcher, files)?;
} else {
files.push(relative);
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_discover_packages() {
let tmp = TempDir::new().unwrap();
fs::create_dir(tmp.path().join("zsh")).unwrap();
fs::create_dir(tmp.path().join("git")).unwrap();
fs::create_dir(tmp.path().join(".git")).unwrap();
let pkgs = discover_packages(tmp.path()).unwrap();
assert_eq!(pkgs, vec!["git", "zsh"]);
}
#[test]
fn test_resolve_specific_packages() {
let tmp = TempDir::new().unwrap();
fs::create_dir(tmp.path().join("zsh")).unwrap();
fs::create_dir(tmp.path().join("git")).unwrap();
let pkgs = resolve_packages(tmp.path(), &["zsh".into()]).unwrap();
assert_eq!(pkgs, vec!["zsh"]);
}
#[test]
fn test_resolve_missing_package() {
let tmp = TempDir::new().unwrap();
fs::create_dir(tmp.path().join("zsh")).unwrap();
let err = resolve_packages(tmp.path(), &["nope".into()]).unwrap_err();
assert!(err.to_string().contains("nope"));
}
#[test]
fn test_collect_files_with_ignore() {
let tmp = TempDir::new().unwrap();
let pkg = tmp.path().join("zsh");
fs::create_dir_all(pkg.join(".config/zsh")).unwrap();
fs::write(pkg.join(".config/zsh/zshrc"), "# zshrc").unwrap();
fs::write(pkg.join("README.md"), "readme").unwrap();
fs::write(pkg.join(".DS_Store"), "junk").unwrap();
let config = Config::default();
let files = collect_files(&pkg, &config, "zsh").unwrap();
assert_eq!(files, vec![PathBuf::from(".config/zsh/zshrc")]);
}
}

View File

@@ -0,0 +1,127 @@
use std::fs;
use std::path::{Path, PathBuf};
use notcore::{Config, Method, expand_tilde};
use crate::linker::State;
use crate::package::collect_files;
#[derive(Debug, PartialEq)]
pub enum FileStatus {
Linked,
Copied,
Missing,
Conflict,
Orphan,
}
impl std::fmt::Display for FileStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
FileStatus::Linked => write!(f, "\x1b[32mlinked\x1b[0m"),
FileStatus::Copied => write!(f, "\x1b[32mcopied\x1b[0m"),
FileStatus::Missing => write!(f, "\x1b[31mmissing\x1b[0m"),
FileStatus::Conflict => write!(f, "\x1b[33mconflict\x1b[0m"),
FileStatus::Orphan => write!(f, "\x1b[35morphan\x1b[0m"),
}
}
}
pub struct StatusEntry {
pub source_display: String,
pub target: PathBuf,
pub status: FileStatus,
}
pub fn package_status(
dotfiles_dir: &Path,
config: &Config,
state: &State,
package: &str,
) -> Vec<StatusEntry> {
let mut results = Vec::new();
let package_dir = dotfiles_dir.join(package);
let method = config.method_for(package);
let target_base = match expand_tilde(config.target_for(package)) {
Ok(p) => p,
Err(_) => return results,
};
// Check files that should exist
if let Ok(files) = collect_files(&package_dir, config, package) {
for relative in &files {
let source = package_dir.join(relative);
let target = target_base.join(relative);
let source_display = format!("{package}/{}", relative.display());
let status = match method {
Method::Symlink => {
if let Ok(link_target) = fs::read_link(&target) {
if link_target == source {
FileStatus::Linked
} else {
FileStatus::Conflict
}
} else if target.exists() {
FileStatus::Conflict
} else {
FileStatus::Missing
}
}
Method::Copy => {
let has_state = state.entries.iter().any(|e| {
e.package == package
&& e.target == target.to_string_lossy().as_ref()
});
if has_state && target.exists() {
FileStatus::Copied
} else if target.exists() {
FileStatus::Conflict
} else {
FileStatus::Missing
}
}
};
results.push(StatusEntry {
source_display,
target,
status,
});
}
}
// Check for orphans: entries in state that no longer have a source file
for entry in state.entries_for_package(package) {
let source = PathBuf::from(&entry.source);
if !source.exists() {
let target = PathBuf::from(&entry.target);
// Only add if we didn't already report this target
if !results.iter().any(|r| r.target == target) {
results.push(StatusEntry {
source_display: format!(
"{package}/{}",
source
.strip_prefix(&package_dir)
.unwrap_or(&source)
.display()
),
target,
status: FileStatus::Orphan,
});
}
}
}
results
}
pub fn print_status(package: &str, entries: &[StatusEntry]) {
if entries.is_empty() {
println!(" {package}: (empty)");
return;
}
println!(" \x1b[1m{package}\x1b[0m:");
for entry in entries {
println!(" {} {} -> {}", entry.status, entry.source_display, entry.target.display());
}
}

View File

@@ -0,0 +1,364 @@
use std::fs;
use std::path::Path;
use std::process::Command;
use tempfile::TempDir;
fn notfiles_bin() -> std::path::PathBuf {
// Built by `cargo test`
let mut path = std::env::current_exe().unwrap();
path.pop(); // remove test binary name
path.pop(); // remove `deps`
path.push("notfiles");
path
}
fn run(dotfiles: &Path, args: &[&str]) -> (String, String, bool) {
let output = Command::new(notfiles_bin())
.arg("--dir")
.arg(dotfiles)
.args(args)
.output()
.expect("failed to run notfiles");
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
(stdout, stderr, output.status.success())
}
fn setup_dotfiles(tmp: &TempDir) {
let dotfiles = tmp.path().join("dotfiles");
let target = tmp.path().join("home");
fs::create_dir_all(&dotfiles).unwrap();
fs::create_dir_all(&target).unwrap();
// Create a zsh package
let zsh = dotfiles.join("zsh");
fs::create_dir_all(zsh.join(".config/zsh")).unwrap();
fs::write(zsh.join(".config/zsh/zshrc"), "# zshrc content").unwrap();
fs::write(zsh.join(".zshenv"), "# zshenv content").unwrap();
// Create a git package
let git = dotfiles.join("git");
fs::create_dir_all(&git).unwrap();
fs::write(git.join(".gitconfig"), "[user]\nname = Test").unwrap();
// Write config pointing target to our temp home
let config = format!(
r#"[defaults]
target = "{}"
ignore = [".git", ".DS_Store", "README.md", "LICENSE", "notfiles.toml", ".notfiles-state.toml"]
"#,
target.display()
);
fs::write(dotfiles.join("notfiles.toml"), config).unwrap();
}
#[test]
fn test_init_creates_config() {
let tmp = TempDir::new().unwrap();
let dotfiles = tmp.path().join("dotfiles");
fs::create_dir_all(&dotfiles).unwrap();
let (stdout, _, ok) = run(&dotfiles, &["init"]);
assert!(ok);
assert!(stdout.contains("Created notfiles.toml"));
assert!(dotfiles.join("notfiles.toml").exists());
}
#[test]
fn test_init_idempotent() {
let tmp = TempDir::new().unwrap();
let dotfiles = tmp.path().join("dotfiles");
fs::create_dir_all(&dotfiles).unwrap();
run(&dotfiles, &["init"]);
let (stdout, _, ok) = run(&dotfiles, &["init"]);
assert!(ok);
assert!(stdout.contains("already exists"));
}
#[test]
fn test_link_and_status() {
let tmp = TempDir::new().unwrap();
setup_dotfiles(&tmp);
let dotfiles = tmp.path().join("dotfiles");
let target = tmp.path().join("home");
let (stdout, stderr, ok) = run(&dotfiles, &["link", "--verbose"]);
assert!(ok, "link failed: stdout={stdout} stderr={stderr}");
// Verify symlinks exist
let zshrc = target.join(".config/zsh/zshrc");
assert!(zshrc.exists(), "zshrc should exist");
assert!(zshrc.symlink_metadata().unwrap().file_type().is_symlink());
let gitconfig = target.join(".gitconfig");
assert!(gitconfig.exists());
assert!(gitconfig.symlink_metadata().unwrap().file_type().is_symlink());
// State file should exist
assert!(dotfiles.join(".notfiles-state.toml").exists());
// Status should show linked
let (stdout, _, ok) = run(&dotfiles, &["status"]);
assert!(ok);
assert!(stdout.contains("linked"));
}
#[test]
fn test_link_idempotent() {
let tmp = TempDir::new().unwrap();
setup_dotfiles(&tmp);
let dotfiles = tmp.path().join("dotfiles");
let (_, _, ok) = run(&dotfiles, &["link"]);
assert!(ok);
// Link again — should succeed (skips already linked)
let (_, _, ok) = run(&dotfiles, &["link"]);
assert!(ok);
}
#[test]
fn test_link_specific_package() {
let tmp = TempDir::new().unwrap();
setup_dotfiles(&tmp);
let dotfiles = tmp.path().join("dotfiles");
let target = tmp.path().join("home");
let (_, _, ok) = run(&dotfiles, &["link", "zsh"]);
assert!(ok);
assert!(target.join(".config/zsh/zshrc").exists());
assert!(!target.join(".gitconfig").exists());
}
#[test]
fn test_unlink() {
let tmp = TempDir::new().unwrap();
setup_dotfiles(&tmp);
let dotfiles = tmp.path().join("dotfiles");
let target = tmp.path().join("home");
run(&dotfiles, &["link"]);
assert!(target.join(".gitconfig").exists());
let (_, _, ok) = run(&dotfiles, &["unlink", "--verbose"]);
assert!(ok);
assert!(!target.join(".gitconfig").exists());
assert!(!target.join(".config/zsh/zshrc").exists());
}
#[test]
fn test_unlink_specific_package() {
let tmp = TempDir::new().unwrap();
setup_dotfiles(&tmp);
let dotfiles = tmp.path().join("dotfiles");
let target = tmp.path().join("home");
run(&dotfiles, &["link"]);
let (_, _, ok) = run(&dotfiles, &["unlink", "git"]);
assert!(ok);
assert!(!target.join(".gitconfig").exists());
// zsh should still be linked
assert!(target.join(".config/zsh/zshrc").exists());
}
#[test]
fn test_conflict_without_force() {
let tmp = TempDir::new().unwrap();
setup_dotfiles(&tmp);
let dotfiles = tmp.path().join("dotfiles");
let target = tmp.path().join("home");
// Pre-create a conflicting file
fs::write(target.join(".gitconfig"), "existing content").unwrap();
let (_, stderr, ok) = run(&dotfiles, &["link"]);
assert!(!ok, "should fail on conflict");
assert!(
stderr.contains("conflict") || stderr.contains("already exists"),
"stderr: {stderr}"
);
}
#[test]
fn test_force_with_backup() {
let tmp = TempDir::new().unwrap();
setup_dotfiles(&tmp);
let dotfiles = tmp.path().join("dotfiles");
let target = tmp.path().join("home");
// Pre-create conflicting file
fs::write(target.join(".gitconfig"), "old content").unwrap();
let (_, _, ok) = run(&dotfiles, &["link", "--force", "--verbose"]);
assert!(ok);
// The link should now exist
assert!(target.join(".gitconfig").symlink_metadata().unwrap().file_type().is_symlink());
// A backup should exist
let backups: Vec<_> = fs::read_dir(&target)
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| {
e.file_name()
.to_string_lossy()
.starts_with(".gitconfig.notfiles-backup-")
})
.collect();
assert_eq!(backups.len(), 1);
let backup_content = fs::read_to_string(backups[0].path()).unwrap();
assert_eq!(backup_content, "old content");
}
#[test]
fn test_force_no_backup() {
let tmp = TempDir::new().unwrap();
setup_dotfiles(&tmp);
let dotfiles = tmp.path().join("dotfiles");
let target = tmp.path().join("home");
fs::write(target.join(".gitconfig"), "old content").unwrap();
let (_, _, ok) = run(&dotfiles, &["link", "--force", "--no-backup"]);
assert!(ok);
// No backup should exist
let backups: Vec<_> = fs::read_dir(&target)
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| {
e.file_name()
.to_string_lossy()
.contains("notfiles-backup")
})
.collect();
assert_eq!(backups.len(), 0);
}
#[test]
fn test_copy_method() {
let tmp = TempDir::new().unwrap();
setup_dotfiles(&tmp);
let dotfiles = tmp.path().join("dotfiles");
let target = tmp.path().join("home");
// Create an ssh package with copy method
let ssh = dotfiles.join("ssh");
fs::create_dir_all(ssh.join(".ssh")).unwrap();
fs::write(ssh.join(".ssh/config"), "Host *\n AddKeysToAgent yes").unwrap();
// Update config to use copy for ssh
let config = format!(
r#"[defaults]
target = "{}"
ignore = [".git", ".DS_Store", "README.md", "LICENSE", "notfiles.toml", ".notfiles-state.toml"]
[packages.ssh]
method = "copy"
"#,
target.display()
);
fs::write(dotfiles.join("notfiles.toml"), config).unwrap();
let (_, _, ok) = run(&dotfiles, &["link", "ssh", "--verbose"]);
assert!(ok);
let ssh_config = target.join(".ssh/config");
assert!(ssh_config.exists());
// Should NOT be a symlink
assert!(!ssh_config.symlink_metadata().unwrap().file_type().is_symlink());
assert_eq!(fs::read_to_string(&ssh_config).unwrap(), "Host *\n AddKeysToAgent yes");
// Status should show "copied"
let (stdout, _, _) = run(&dotfiles, &["status", "ssh"]);
assert!(stdout.contains("copied"));
// Unlink should remove the copy
let (_, _, ok) = run(&dotfiles, &["unlink", "ssh"]);
assert!(ok);
assert!(!ssh_config.exists());
}
#[test]
fn test_dry_run() {
let tmp = TempDir::new().unwrap();
setup_dotfiles(&tmp);
let dotfiles = tmp.path().join("dotfiles");
let target = tmp.path().join("home");
let (stdout, _, ok) = run(&dotfiles, &["--dry-run", "link"]);
assert!(ok);
assert!(stdout.contains("dry run"));
assert!(stdout.contains("would link"));
// Nothing should actually be created
assert!(!target.join(".gitconfig").exists());
assert!(!dotfiles.join(".notfiles-state.toml").exists());
}
#[test]
fn test_status_missing() {
let tmp = TempDir::new().unwrap();
setup_dotfiles(&tmp);
let dotfiles = tmp.path().join("dotfiles");
// Don't link anything — status should show missing
let (stdout, _, ok) = run(&dotfiles, &["status"]);
assert!(ok);
assert!(stdout.contains("missing"));
}
#[test]
fn test_ignore_patterns() {
let tmp = TempDir::new().unwrap();
setup_dotfiles(&tmp);
let dotfiles = tmp.path().join("dotfiles");
let target = tmp.path().join("home");
// Add a README to a package — it should be ignored
fs::write(dotfiles.join("zsh/README.md"), "read me").unwrap();
fs::write(dotfiles.join("zsh/.DS_Store"), "junk").unwrap();
let (_, _, ok) = run(&dotfiles, &["link"]);
assert!(ok);
assert!(!target.join("README.md").exists());
assert!(!target.join(".DS_Store").exists());
// But real files should be linked
assert!(target.join(".config/zsh/zshrc").exists());
}
#[test]
fn test_package_not_found() {
let tmp = TempDir::new().unwrap();
setup_dotfiles(&tmp);
let dotfiles = tmp.path().join("dotfiles");
let (_, stderr, ok) = run(&dotfiles, &["link", "nonexistent"]);
assert!(!ok);
assert!(stderr.contains("nonexistent"), "stderr: {stderr}");
}
#[test]
fn test_unlink_cleans_empty_dirs() {
let tmp = TempDir::new().unwrap();
setup_dotfiles(&tmp);
let dotfiles = tmp.path().join("dotfiles");
let target = tmp.path().join("home");
let (_, _, ok) = run(&dotfiles, &["link", "zsh"]);
assert!(ok);
assert!(target.join(".config/zsh").is_dir());
let (_, _, ok) = run(&dotfiles, &["unlink", "zsh"]);
assert!(ok);
// The .config/zsh directory should be cleaned up
assert!(!target.join(".config/zsh").exists());
}