diff --git a/Cargo.toml b/Cargo.toml index f0fbfac..f601e30 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,19 +1,27 @@ -[package] -name = "notfiles" -version = "0.1.0" -edition = "2024" -description = "A modern dotfiles manager — a pure Rust alternative to GNU Stow" +[workspace] +members = [ + "crates/notcore", + "crates/notfiles", + "crates/notsecrets", + "crates/nothooks", + "crates/notstrap", +] +resolver = "2" -[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" +[workspace.dependencies] +anyhow = "1" +clap = { version = "4", features = ["derive"] } +chrono = { version = "0.4", default-features = false, features = ["clock"] } +dirs = "6" +globset = "0.4" +serde = { version = "1", features = ["derive"] } +thiserror = "2" +toml = "0.8" +which = "7" +rpassword = "7" +tempfile = "3" +assert_fs = "1" +notcore = { path = "crates/notcore" } +notfiles = { path = "crates/notfiles" } +notsecrets = { path = "crates/notsecrets" } +nothooks = { path = "crates/nothooks" } diff --git a/crates/notcore/Cargo.toml b/crates/notcore/Cargo.toml new file mode 100644 index 0000000..ee3fbd4 --- /dev/null +++ b/crates/notcore/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "notcore" +version = "0.1.0" +edition = "2024" + +[dependencies] +serde = { workspace = true } +toml = { workspace = true } +thiserror = { workspace = true } +dirs = { workspace = true } +anyhow = { workspace = true } diff --git a/crates/notcore/src/config.rs b/crates/notcore/src/config.rs new file mode 100644 index 0000000..ed0982b --- /dev/null +++ b/crates/notcore/src/config.rs @@ -0,0 +1,162 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::path::Path; + +use crate::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"] + +# [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/crates/notcore/src/error.rs b/crates/notcore/src/error.rs new file mode 100644 index 0000000..38979e7 --- /dev/null +++ b/crates/notcore/src/error.rs @@ -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), +} diff --git a/crates/notcore/src/lib.rs b/crates/notcore/src/lib.rs new file mode 100644 index 0000000..07416d0 --- /dev/null +++ b/crates/notcore/src/lib.rs @@ -0,0 +1,9 @@ +pub mod config; +pub mod error; +pub mod paths; +pub mod types; + +pub use config::{Config, Defaults, Method, PackageConfig}; +pub use error::NotfilesError; +pub use paths::{dotfiles_dir, expand_tilde}; +pub use types::{HookPhase, HookSpec, PackageSpec, Report, Step, StepStatus}; diff --git a/crates/notcore/src/paths.rs b/crates/notcore/src/paths.rs new file mode 100644 index 0000000..56a40c9 --- /dev/null +++ b/crates/notcore/src/paths.rs @@ -0,0 +1,42 @@ +use std::path::PathBuf; + +use crate::NotfilesError; + +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)) +} + +pub fn dotfiles_dir() -> Option { + dirs::home_dir().map(|h| h.join("dotfiles")) +} + +#[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")); + } +} diff --git a/crates/notcore/src/types.rs b/crates/notcore/src/types.rs new file mode 100644 index 0000000..98376e0 --- /dev/null +++ b/crates/notcore/src/types.rs @@ -0,0 +1,92 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum HookPhase { + Dot, + Setup, +} + +impl std::fmt::Display for HookPhase { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + HookPhase::Dot => write!(f, "dot"), + HookPhase::Setup => write!(f, "setup"), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HookSpec { + pub name: String, + pub script: String, + pub phase: HookPhase, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PackageSpec { + pub name: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum StepStatus { + Ok, + Skipped, + Failed(String), +} + +#[derive(Debug, Clone)] +pub struct Step { + pub name: String, + pub status: StepStatus, +} + +#[derive(Debug, Default)] +pub struct Report { + pub steps: Vec, +} + +impl Report { + pub fn add(&mut self, name: impl Into, status: StepStatus) { + self.steps.push(Step { name: name.into(), status }); + } + + pub fn print(&self) { + for step in &self.steps { + let icon = match &step.status { + StepStatus::Ok => "\x1b[32m✓\x1b[0m", + StepStatus::Skipped => "\x1b[33m-\x1b[0m", + StepStatus::Failed(_) => "\x1b[31m✗\x1b[0m", + }; + let detail = match &step.status { + StepStatus::Failed(msg) => format!(" ({msg})"), + _ => String::new(), + }; + println!("{icon} {}{detail}", step.name); + } + } + + pub fn has_failures(&self) -> bool { + self.steps.iter().any(|s| matches!(s.status, StepStatus::Failed(_))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_report_has_failures() { + let mut r = Report::default(); + r.add("step1", StepStatus::Ok); + assert!(!r.has_failures()); + r.add("step2", StepStatus::Failed("oops".into())); + assert!(r.has_failures()); + } + + #[test] + fn test_hook_phase_display() { + assert_eq!(HookPhase::Dot.to_string(), "dot"); + assert_eq!(HookPhase::Setup.to_string(), "setup"); + } +} diff --git a/crates/notfiles/Cargo.toml b/crates/notfiles/Cargo.toml new file mode 100644 index 0000000..4b9eaaf --- /dev/null +++ b/crates/notfiles/Cargo.toml @@ -0,0 +1,4 @@ +[package] +name = "notfiles" +version = "0.1.0" +edition = "2024" diff --git a/crates/notfiles/src/lib.rs b/crates/notfiles/src/lib.rs new file mode 100644 index 0000000..ff7bd09 --- /dev/null +++ b/crates/notfiles/src/lib.rs @@ -0,0 +1 @@ +// placeholder diff --git a/crates/nothooks/Cargo.toml b/crates/nothooks/Cargo.toml new file mode 100644 index 0000000..bf738cf --- /dev/null +++ b/crates/nothooks/Cargo.toml @@ -0,0 +1,4 @@ +[package] +name = "nothooks" +version = "0.1.0" +edition = "2024" diff --git a/crates/nothooks/src/lib.rs b/crates/nothooks/src/lib.rs new file mode 100644 index 0000000..ff7bd09 --- /dev/null +++ b/crates/nothooks/src/lib.rs @@ -0,0 +1 @@ +// placeholder diff --git a/crates/notsecrets/Cargo.toml b/crates/notsecrets/Cargo.toml new file mode 100644 index 0000000..bccbc1c --- /dev/null +++ b/crates/notsecrets/Cargo.toml @@ -0,0 +1,4 @@ +[package] +name = "notsecrets" +version = "0.1.0" +edition = "2024" diff --git a/crates/notsecrets/src/lib.rs b/crates/notsecrets/src/lib.rs new file mode 100644 index 0000000..ff7bd09 --- /dev/null +++ b/crates/notsecrets/src/lib.rs @@ -0,0 +1 @@ +// placeholder diff --git a/crates/notstrap/Cargo.toml b/crates/notstrap/Cargo.toml new file mode 100644 index 0000000..dced39c --- /dev/null +++ b/crates/notstrap/Cargo.toml @@ -0,0 +1,4 @@ +[package] +name = "notstrap" +version = "0.1.0" +edition = "2024" diff --git a/crates/notstrap/src/lib.rs b/crates/notstrap/src/lib.rs new file mode 100644 index 0000000..ff7bd09 --- /dev/null +++ b/crates/notstrap/src/lib.rs @@ -0,0 +1 @@ +// placeholder