diff --git a/Cargo.lock b/Cargo.lock index 94245fd..07c7fa5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -37,9 +37,9 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.13" +version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" [[package]] name = "anstyle-parse" @@ -121,9 +121,9 @@ checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" [[package]] name = "cc" -version = "1.2.56" +version = "1.2.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +checksum = "e1e928d4b69e3077709075a938a05ffbedfa53a84c8f766efbf8220bb1ff60e1" dependencies = [ "find-msvc-tools", "shlex", @@ -188,9 +188,9 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "colorchoice" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "core-foundation-sys" @@ -425,15 +425,15 @@ checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" [[package]] name = "itoa" -version = "1.0.17" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "js-sys" -version = "0.3.91" +version = "0.3.93" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +checksum = "797146bb2677299a1eb6b7b50a890f4c361b29ef967addf5b2fa45dae1bb6d7d" dependencies = [ "once_cell", "wasm-bindgen", @@ -453,9 +453,9 @@ checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" [[package]] name = "libredox" -version = "0.1.14" +version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" +checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08" dependencies = [ "libc", ] @@ -478,6 +478,17 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "notcore" +version = "0.1.0" +dependencies = [ + "anyhow", + "dirs", + "serde", + "thiserror", + "toml", +] + [[package]] name = "notfiles" version = "0.1.0" @@ -488,12 +499,24 @@ dependencies = [ "clap", "dirs", "globset", + "notcore", "serde", "tempfile", - "thiserror", "toml", ] +[[package]] +name = "nothooks" +version = "0.1.0" + +[[package]] +name = "notsecrets" +version = "0.1.0" + +[[package]] +name = "notstrap" +version = "0.1.0" + [[package]] name = "num-traits" version = "0.2.19" @@ -853,9 +876,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.114" +version = "0.2.116" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +checksum = "7dc0882f7b5bb01ae8c5215a1230832694481c1a4be062fd410e12ea3da5b631" dependencies = [ "cfg-if", "once_cell", @@ -866,9 +889,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.114" +version = "0.2.116" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +checksum = "75973d3066e01d035dbedaad2864c398df42f8dd7b1ea057c35b8407c015b537" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -876,9 +899,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.114" +version = "0.2.116" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +checksum = "91af5e4be765819e0bcfee7322c14374dc821e35e72fa663a830bbc7dc199eac" dependencies = [ "bumpalo", "proc-macro2", @@ -889,9 +912,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.114" +version = "0.2.116" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +checksum = "c9bf0406a78f02f336bf1e451799cca198e8acde4ffa278f0fb20487b150a633" dependencies = [ "unicode-ident", ] diff --git a/crates/notfiles/Cargo.toml b/crates/notfiles/Cargo.toml index 4b9eaaf..6c54364 100644 --- a/crates/notfiles/Cargo.toml +++ b/crates/notfiles/Cargo.toml @@ -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 } diff --git a/src/cli.rs b/crates/notfiles/src/cli.rs similarity index 100% rename from src/cli.rs rename to crates/notfiles/src/cli.rs diff --git a/src/ignore.rs b/crates/notfiles/src/ignore.rs similarity index 97% rename from src/ignore.rs rename to crates/notfiles/src/ignore.rs index 1f6474b..880ffcd 100644 --- a/src/ignore.rs +++ b/crates/notfiles/src/ignore.rs @@ -1,7 +1,7 @@ use globset::{Glob, GlobSet, GlobSetBuilder}; use std::path::Path; -use crate::error::NotfilesError; +use notcore::NotfilesError; pub struct IgnoreMatcher { globset: GlobSet, @@ -17,6 +17,7 @@ impl IgnoreMatcher { .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); diff --git a/crates/notfiles/src/lib.rs b/crates/notfiles/src/lib.rs index ff7bd09..449e191 100644 --- a/crates/notfiles/src/lib.rs +++ b/crates/notfiles/src/lib.rs @@ -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 { + 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::>() + .into_iter() + .collect::>() + } else { + packages.to_vec() + }; + for pkg in &pkgs { + linker::unlink_package(dotfiles_dir, &mut state, pkg, opts)?; + } + state.save(dotfiles_dir)?; + Ok(()) +} diff --git a/src/linker.rs b/crates/notfiles/src/linker.rs similarity index 95% rename from src/linker.rs rename to crates/notfiles/src/linker.rs index 97522af..6f5419e 100644 --- a/src/linker.rs +++ b/crates/notfiles/src/linker.rs @@ -4,10 +4,8 @@ use std::path::{Path, PathBuf}; use chrono::Utc; use serde::{Deserialize, Serialize}; -use crate::config::{Config, Method}; -use crate::error::NotfilesError; +use notcore::{Config, Method, NotfilesError, expand_tilde}; use crate::package::collect_files; -use crate::paths::expand_tilde; const STATE_FILE: &str = ".notfiles-state.toml"; @@ -132,15 +130,13 @@ pub fn link_package( } // 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)?; + 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)?; } } diff --git a/src/main.rs b/crates/notfiles/src/main.rs similarity index 90% rename from src/main.rs rename to crates/notfiles/src/main.rs index 50e9758..f447c29 100644 --- a/src/main.rs +++ b/crates/notfiles/src/main.rs @@ -1,21 +1,12 @@ -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 std::fs; -use cli::{Cli, Command}; -use config::Config; -use linker::{LinkOptions, State}; -use package::resolve_packages; +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(); @@ -66,6 +57,7 @@ fn main() -> Result<()> { } 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 @@ -94,7 +86,6 @@ fn main() -> Result<()> { 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}..."); @@ -104,7 +95,11 @@ fn main() -> Result<()> { if !cli.dry_run { state.save(&dotfiles_dir)?; - println!("\x1b[32mUnlinked {} package{}.\x1b[0m", pkgs.len(), if pkgs.len() == 1 { "" } else { "s" }); + println!( + "\x1b[32mUnlinked {} package{}.\x1b[0m", + pkgs.len(), + if pkgs.len() == 1 { "" } else { "s" } + ); } } Command::Status { packages } => { @@ -128,7 +123,7 @@ fn cmd_init(dotfiles_dir: &std::path::Path) -> Result<()> { println!("notfiles.toml already exists."); return Ok(()); } - fs::write(&config_path, config::starter_toml())?; + fs::write(&config_path, notcore::config::starter_toml())?; println!("Created notfiles.toml"); Ok(()) } diff --git a/src/package.rs b/crates/notfiles/src/package.rs similarity index 98% rename from src/package.rs rename to crates/notfiles/src/package.rs index 3279202..a88ac35 100644 --- a/src/package.rs +++ b/crates/notfiles/src/package.rs @@ -1,8 +1,7 @@ use std::fs; use std::path::{Path, PathBuf}; -use crate::config::Config; -use crate::error::NotfilesError; +use notcore::{Config, NotfilesError}; use crate::ignore::IgnoreMatcher; /// Discover available packages (subdirectories of the dotfiles dir). diff --git a/src/status.rs b/crates/notfiles/src/status.rs similarity index 98% rename from src/status.rs rename to crates/notfiles/src/status.rs index 36990f1..fdd0b11 100644 --- a/src/status.rs +++ b/crates/notfiles/src/status.rs @@ -1,10 +1,9 @@ use std::fs; use std::path::{Path, PathBuf}; -use crate::config::{Config, Method}; +use notcore::{Config, Method, expand_tilde}; use crate::linker::State; use crate::package::collect_files; -use crate::paths::expand_tilde; #[derive(Debug, PartialEq)] pub enum FileStatus { diff --git a/tests/integration.rs b/crates/notfiles/tests/integration.rs similarity index 100% rename from tests/integration.rs rename to crates/notfiles/tests/integration.rs diff --git a/src/config.rs b/src/config.rs deleted file mode 100644 index 68410e0..0000000 --- a/src/config.rs +++ /dev/null @@ -1,163 +0,0 @@ -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, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Defaults { - #[serde(default = "default_target")] - pub target: String, - #[serde(default = "default_ignore")] - pub ignore: Vec, -} - -impl Default for Defaults { - fn default() -> Self { - Self { - target: default_target(), - ignore: default_ignore(), - } - } -} - -fn default_target() -> String { - "~".to_string() -} - -fn default_ignore() -> Vec { - 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, - pub target: Option, - #[serde(default)] - pub ignore: Vec, -} - -#[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 { - 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")); - } -} diff --git a/src/error.rs b/src/error.rs deleted file mode 100644 index 38979e7..0000000 --- a/src/error.rs +++ /dev/null @@ -1,25 +0,0 @@ -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), -} diff --git a/src/paths.rs b/src/paths.rs deleted file mode 100644 index 40b1be1..0000000 --- a/src/paths.rs +++ /dev/null @@ -1,39 +0,0 @@ -use std::path::PathBuf; - -use crate::error::NotfilesError; - -/// Expand a leading `~` to the user's home directory. -pub fn expand_tilde(path: &str) -> Result { - 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")); - } - -}