Initial commit
This commit is contained in:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
/target
|
||||||
|
.envrc
|
||||||
44
CLAUDE.md
Normal file
44
CLAUDE.md
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## What is notfiles?
|
||||||
|
|
||||||
|
A modern dotfiles manager written in Rust — a pure Rust alternative to GNU Stow. It symlinks (or copies) files from organized "package" directories into a target location (typically `~`).
|
||||||
|
|
||||||
|
## Build & Test Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo build # build
|
||||||
|
cargo test # run all tests (unit + integration)
|
||||||
|
cargo test --lib # unit tests only
|
||||||
|
cargo test --test integration # integration tests only
|
||||||
|
cargo test <test_name> # run a single test by name
|
||||||
|
cargo clippy # lint
|
||||||
|
cargo fmt --check # check formatting
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
The binary has four subcommands: `init`, `link`, `unlink`, `status`. CLI parsing uses clap derive in `src/cli.rs`; command dispatch happens in `src/main.rs`.
|
||||||
|
|
||||||
|
**Core flow for `link`:** `main` → `resolve_packages` (discover subdirs or validate requested names) → `collect_files` (recursive walk with ignore filtering) → `linker::link_package` (create symlinks or copies, record in state).
|
||||||
|
|
||||||
|
Key modules:
|
||||||
|
- **linker** — Creates/removes symlinks or copies. Manages `State` (serialized to `.notfiles-state.toml` in the dotfiles dir) which tracks every linked file with source, target, method, and timestamp. Handles conflict detection, `--force` backups, and empty-parent cleanup on unlink.
|
||||||
|
- **config** — Parses `notfiles.toml`. Provides per-package overrides for method (symlink/copy), target directory, and ignore patterns. Falls back to `[defaults]` section values.
|
||||||
|
- **package** — Discovers packages (non-hidden subdirectories of the dotfiles dir) and recursively collects files, filtering through `IgnoreMatcher`.
|
||||||
|
- **ignore** — Glob-based ignore matching using `globset`. Matches both full relative paths and individual path components.
|
||||||
|
- **paths** — `expand_tilde` utility.
|
||||||
|
- **status** — Compares expected state (files on disk + config) against actual state (symlinks/copies + state file) to report linked/copied/missing/conflict/orphan.
|
||||||
|
- **error** — `NotfilesError` enum via `thiserror`.
|
||||||
|
|
||||||
|
Integration tests (`tests/integration.rs`) run the compiled binary against temp directories using `tempfile`.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
The config file is `notfiles.toml` at the dotfiles directory root. Each subdirectory of the dotfiles dir is a "package". Per-package config can override the link method (`symlink` or `copy`), target directory, and additional ignore patterns.
|
||||||
|
|
||||||
|
## Edition
|
||||||
|
|
||||||
|
Uses Rust edition 2024.
|
||||||
1111
Cargo.lock
generated
Normal file
1111
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
19
Cargo.toml
Normal file
19
Cargo.toml
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
[package]
|
||||||
|
name = "notfiles"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
description = "A modern dotfiles manager — a pure Rust alternative to GNU Stow"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
clap = { version = "4", features = ["derive"] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
toml = "0.8"
|
||||||
|
thiserror = "2"
|
||||||
|
anyhow = "1"
|
||||||
|
globset = "0.4"
|
||||||
|
dirs = "6"
|
||||||
|
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile = "3"
|
||||||
|
assert_fs = "1"
|
||||||
53
src/cli.rs
Normal file
53
src/cli.rs
Normal 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>,
|
||||||
|
},
|
||||||
|
}
|
||||||
163
src/config.rs
Normal file
163
src/config.rs
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use crate::error::NotfilesError;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct Config {
|
||||||
|
#[serde(default)]
|
||||||
|
pub defaults: Defaults,
|
||||||
|
#[serde(default)]
|
||||||
|
pub packages: HashMap<String, PackageConfig>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Defaults {
|
||||||
|
#[serde(default = "default_target")]
|
||||||
|
pub target: String,
|
||||||
|
#[serde(default = "default_ignore")]
|
||||||
|
pub ignore: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Defaults {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
target: default_target(),
|
||||||
|
ignore: default_ignore(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_target() -> String {
|
||||||
|
"~".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_ignore() -> Vec<String> {
|
||||||
|
vec![
|
||||||
|
".git".to_string(),
|
||||||
|
".DS_Store".to_string(),
|
||||||
|
"README.md".to_string(),
|
||||||
|
"LICENSE".to_string(),
|
||||||
|
"notfiles.toml".to_string(),
|
||||||
|
".notfiles-state.toml".to_string(),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct PackageConfig {
|
||||||
|
#[serde(default)]
|
||||||
|
pub method: Option<Method>,
|
||||||
|
pub target: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub ignore: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum Method {
|
||||||
|
#[default]
|
||||||
|
Symlink,
|
||||||
|
Copy,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for Method {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
Method::Symlink => write!(f, "symlink"),
|
||||||
|
Method::Copy => write!(f, "copy"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Config {
|
||||||
|
pub fn load(dotfiles_dir: &Path) -> Result<Self, NotfilesError> {
|
||||||
|
let config_path = dotfiles_dir.join("notfiles.toml");
|
||||||
|
if !config_path.exists() {
|
||||||
|
return Ok(Config::default());
|
||||||
|
}
|
||||||
|
let content = std::fs::read_to_string(&config_path)
|
||||||
|
.map_err(|e| NotfilesError::Config(format!("reading {}: {e}", config_path.display())))?;
|
||||||
|
let config: Config = toml::from_str(&content)
|
||||||
|
.map_err(|e| NotfilesError::Config(format!("parsing {}: {e}", config_path.display())))?;
|
||||||
|
Ok(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn method_for(&self, package: &str) -> Method {
|
||||||
|
self.packages
|
||||||
|
.get(package)
|
||||||
|
.and_then(|p| p.method)
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn target_for(&self, package: &str) -> &str {
|
||||||
|
self.packages
|
||||||
|
.get(package)
|
||||||
|
.and_then(|p| p.target.as_deref())
|
||||||
|
.unwrap_or(&self.defaults.target)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ignore_patterns_for(&self, package: &str) -> Vec<&str> {
|
||||||
|
let mut patterns: Vec<&str> = self.defaults.ignore.iter().map(|s| s.as_str()).collect();
|
||||||
|
if let Some(pkg) = self.packages.get(package) {
|
||||||
|
for p in &pkg.ignore {
|
||||||
|
patterns.push(p.as_str());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
patterns
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn starter_toml() -> &'static str {
|
||||||
|
r#"[defaults]
|
||||||
|
target = "~"
|
||||||
|
ignore = [".git", ".DS_Store", "README.md", "LICENSE", "notfiles.toml"]
|
||||||
|
|
||||||
|
# Per-package overrides (only needed for non-default behavior):
|
||||||
|
# [packages.ssh]
|
||||||
|
# method = "copy"
|
||||||
|
# ignore = ["known_hosts"]
|
||||||
|
#
|
||||||
|
# [packages.scripts]
|
||||||
|
# target = "~/bin"
|
||||||
|
"#
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_default_config() {
|
||||||
|
let config = Config::default();
|
||||||
|
assert_eq!(config.defaults.target, "~");
|
||||||
|
assert!(config.defaults.ignore.contains(&".git".to_string()));
|
||||||
|
assert_eq!(config.method_for("anything"), Method::Symlink);
|
||||||
|
assert_eq!(config.target_for("anything"), "~");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_config() {
|
||||||
|
let toml_str = r#"
|
||||||
|
[defaults]
|
||||||
|
target = "~"
|
||||||
|
ignore = [".git"]
|
||||||
|
|
||||||
|
[packages.ssh]
|
||||||
|
method = "copy"
|
||||||
|
ignore = ["known_hosts"]
|
||||||
|
|
||||||
|
[packages.scripts]
|
||||||
|
target = "~/bin"
|
||||||
|
"#;
|
||||||
|
let config: Config = toml::from_str(toml_str).unwrap();
|
||||||
|
assert_eq!(config.method_for("ssh"), Method::Copy);
|
||||||
|
assert_eq!(config.method_for("scripts"), Method::Symlink);
|
||||||
|
assert_eq!(config.target_for("scripts"), "~/bin");
|
||||||
|
assert_eq!(config.target_for("ssh"), "~");
|
||||||
|
|
||||||
|
let ssh_ignores = config.ignore_patterns_for("ssh");
|
||||||
|
assert!(ssh_ignores.contains(&".git"));
|
||||||
|
assert!(ssh_ignores.contains(&"known_hosts"));
|
||||||
|
}
|
||||||
|
}
|
||||||
25
src/error.rs
Normal file
25
src/error.rs
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum NotfilesError {
|
||||||
|
#[error("config file error: {0}")]
|
||||||
|
Config(String),
|
||||||
|
|
||||||
|
#[error("package not found: {name}")]
|
||||||
|
PackageNotFound { name: String },
|
||||||
|
|
||||||
|
#[error("conflict at {path}: {reason}")]
|
||||||
|
Conflict { path: PathBuf, reason: String },
|
||||||
|
|
||||||
|
#[error("path error: {0}")]
|
||||||
|
Path(String),
|
||||||
|
|
||||||
|
#[error("state file error: {0}")]
|
||||||
|
State(String),
|
||||||
|
|
||||||
|
#[error("{0}")]
|
||||||
|
Io(#[from] std::io::Error),
|
||||||
|
|
||||||
|
#[error("{0}")]
|
||||||
|
Other(String),
|
||||||
|
}
|
||||||
81
src/ignore.rs
Normal file
81
src/ignore.rs
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
use globset::{Glob, GlobSet, GlobSetBuilder};
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use crate::error::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.
|
||||||
|
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")));
|
||||||
|
}
|
||||||
|
}
|
||||||
292
src/linker.rs
Normal file
292
src/linker.rs
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use chrono::Utc;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::config::{Config, Method};
|
||||||
|
use crate::error::NotfilesError;
|
||||||
|
use crate::package::collect_files;
|
||||||
|
use crate::paths::expand_tilde;
|
||||||
|
|
||||||
|
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() {
|
||||||
|
if !parent.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
134
src/main.rs
Normal file
134
src/main.rs
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
mod cli;
|
||||||
|
mod config;
|
||||||
|
mod error;
|
||||||
|
mod ignore;
|
||||||
|
mod linker;
|
||||||
|
mod package;
|
||||||
|
mod paths;
|
||||||
|
mod status;
|
||||||
|
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use clap::Parser;
|
||||||
|
|
||||||
|
use cli::{Cli, Command};
|
||||||
|
use config::Config;
|
||||||
|
use linker::{LinkOptions, State};
|
||||||
|
use package::resolve_packages;
|
||||||
|
|
||||||
|
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 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");
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = &config; // loaded but not needed for unlink
|
||||||
|
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, config::starter_toml())?;
|
||||||
|
println!("Created notfiles.toml");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
130
src/package.rs
Normal file
130
src/package.rs
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use crate::config::Config;
|
||||||
|
use crate::error::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")]);
|
||||||
|
}
|
||||||
|
}
|
||||||
39
src/paths.rs
Normal file
39
src/paths.rs
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use crate::error::NotfilesError;
|
||||||
|
|
||||||
|
/// Expand a leading `~` to the user's home directory.
|
||||||
|
pub fn expand_tilde(path: &str) -> Result<PathBuf, NotfilesError> {
|
||||||
|
if path == "~" {
|
||||||
|
return dirs::home_dir().ok_or_else(|| NotfilesError::Path("cannot determine home directory".into()));
|
||||||
|
}
|
||||||
|
if let Some(rest) = path.strip_prefix("~/") {
|
||||||
|
let home = dirs::home_dir()
|
||||||
|
.ok_or_else(|| NotfilesError::Path("cannot determine home directory".into()))?;
|
||||||
|
return Ok(home.join(rest));
|
||||||
|
}
|
||||||
|
Ok(PathBuf::from(path))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_expand_tilde_home() {
|
||||||
|
let home = dirs::home_dir().unwrap();
|
||||||
|
assert_eq!(expand_tilde("~").unwrap(), home);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_expand_tilde_subpath() {
|
||||||
|
let home = dirs::home_dir().unwrap();
|
||||||
|
assert_eq!(expand_tilde("~/foo/bar").unwrap(), home.join("foo/bar"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_expand_tilde_absolute() {
|
||||||
|
assert_eq!(expand_tilde("/usr/bin").unwrap(), PathBuf::from("/usr/bin"));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
128
src/status.rs
Normal file
128
src/status.rs
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use crate::config::{Config, Method};
|
||||||
|
use crate::linker::State;
|
||||||
|
use crate::package::collect_files;
|
||||||
|
use crate::paths::expand_tilde;
|
||||||
|
|
||||||
|
#[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());
|
||||||
|
}
|
||||||
|
}
|
||||||
364
tests/integration.rs
Normal file
364
tests/integration.rs
Normal 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());
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user