diff --git a/crates/notcore/src/config.rs b/crates/notcore/src/config.rs index 4d9dbb2..6db7814 100644 --- a/crates/notcore/src/config.rs +++ b/crates/notcore/src/config.rs @@ -1,7 +1,7 @@ use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; use std::collections::HashMap; -use std::path::Path; +use std::path::{Path, PathBuf}; use crate::NotfilesError; @@ -102,13 +102,28 @@ where toml::from_str(&content).map_err(|err| parse_error(path, err)) } +/// Returns the default config path: `~/.config/notfiles/notfiles.toml`. +pub fn default_config_path() -> PathBuf { + dirs::config_dir() + .unwrap_or_else(|| PathBuf::from("~/.config")) + .join("notfiles") + .join("notfiles.toml") +} + +/// Returns the default dotfiles directory: `~/.notfiles`. +pub fn default_dotfiles_dir() -> PathBuf { + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from("~")) + .join(".notfiles") +} + impl Config { - pub fn load(dotfiles_dir: &Path) -> Result { - let config_path = dotfiles_dir.join("notfiles.toml"); + /// Load config from an explicit path. Returns `Config::default()` if the file does not exist. + pub fn load_from(config_path: &Path) -> Result { if !config_path.exists() { return Ok(Config::default()); } - let content = std::fs::read_to_string(&config_path).map_err(|e| { + 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| { @@ -117,6 +132,12 @@ impl Config { Ok(config) } + /// Load config from `/notfiles.toml` (legacy location). + /// Prefer [`Config::load_from`] with an explicit path in new call sites. + pub fn load(dotfiles_dir: &Path) -> Result { + Self::load_from(&dotfiles_dir.join("notfiles.toml")) + } + pub fn method_for(&self, package: &str) -> Method { self.packages .get(package) diff --git a/crates/notfiles/src/cli.rs b/crates/notfiles/src/cli.rs index fdaa300..0de71eb 100644 --- a/crates/notfiles/src/cli.rs +++ b/crates/notfiles/src/cli.rs @@ -4,10 +4,14 @@ 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) + /// Path to the dotfiles directory (default: ~/.notfiles) #[arg(long, global = true)] pub dir: Option, + /// Path to the config file (default: ~/.config/notfiles/notfiles.toml) + #[arg(long, global = true)] + pub config: Option, + /// Show what would be done without making changes #[arg(long, global = true)] pub dry_run: bool, diff --git a/crates/notfiles/src/main.rs b/crates/notfiles/src/main.rs index a4aaff2..e5c8788 100644 --- a/crates/notfiles/src/main.rs +++ b/crates/notfiles/src/main.rs @@ -15,10 +15,26 @@ fn main() -> Result<()> { let cli = Cli::parse(); let dotfiles_dir = cli .dir - .unwrap_or_else(|| std::env::current_dir().expect("cannot determine current directory")); + .unwrap_or_else(notcore::config::default_dotfiles_dir); + + // Init is the only command allowed when the dotfiles dir doesn't exist yet. + if matches!(cli.command, Command::Init) { + return cmd_init(&dotfiles_dir, cli.config.as_deref()); + } + + if !dotfiles_dir.exists() { + anyhow::bail!( + "dotfiles directory not found: {}\nRun `notfiles init` to create it.", + dotfiles_dir.display() + ); + } let dotfiles_dir = fs::canonicalize(&dotfiles_dir) .with_context(|| format!("dotfiles directory not found: {}", dotfiles_dir.display()))?; + let config_path = cli + .config + .unwrap_or_else(notcore::config::default_config_path); + let reporter: Box = if cli.json { Box::new(JsonReporter) } else { @@ -37,14 +53,14 @@ fn main() -> Result<()> { detect::print_detected(&managers); } } - Command::Init => cmd_init(&dotfiles_dir)?, + Command::Init => unreachable!("handled above"), Command::Link { force, no_backup, packages, } => { let fs = &adapters::FileStoreImpl; - let config = Config::load(&dotfiles_dir)?; + let config = Config::load_from(&config_path)?; config.validate()?; let mut state = State::load(&dotfiles_dir, fs)?; let pkgs = resolve_packages_filtered_with_store(&dotfiles_dir, &packages, &config, fs)?; @@ -89,7 +105,7 @@ fn main() -> Result<()> { } Command::Unlink { packages } => { let fs = &adapters::FileStoreImpl; - let config = Config::load(&dotfiles_dir)?; + let config = Config::load_from(&config_path)?; let _ = &config; // loaded but not needed for unlink let mut state = State::load(&dotfiles_dir, fs)?; let pkgs = if packages.is_empty() { @@ -145,7 +161,7 @@ fn main() -> Result<()> { } Command::Check => { let fs = &adapters::FileStoreImpl; - let config = Config::load(&dotfiles_dir)?; + let config = Config::load_from(&config_path)?; config.validate()?; let all = resolve_packages_filtered_with_store(&dotfiles_dir, &[], &config, fs)?; @@ -171,7 +187,7 @@ fn main() -> Result<()> { } Command::Diff { packages } => { let fs = &adapters::FileStoreImpl; - let config = Config::load(&dotfiles_dir)?; + let config = Config::load_from(&config_path)?; config.validate()?; let state = State::load(&dotfiles_dir, fs)?; let pkgs = resolve_packages_filtered_with_store(&dotfiles_dir, &packages, &config, fs)?; @@ -183,7 +199,7 @@ fn main() -> Result<()> { } Command::Adopt { package, files } => { let fs = &adapters::FileStoreImpl; - let config = Config::load(&dotfiles_dir)?; + let config = Config::load_from(&config_path)?; let mut state = State::load(&dotfiles_dir, fs)?; let opts = LinkOptions { force: false, @@ -217,7 +233,7 @@ fn main() -> Result<()> { } Command::Status { packages } => { let fs = &adapters::FileStoreImpl; - let config = Config::load(&dotfiles_dir)?; + let config = Config::load_from(&config_path)?; config.validate()?; let state = State::load(&dotfiles_dir, fs)?; let pkgs = resolve_packages_filtered_with_store(&dotfiles_dir, &packages, &config, fs)?; @@ -236,13 +252,29 @@ fn main() -> Result<()> { Ok(()) } -fn cmd_init(dotfiles_dir: &std::path::Path) -> Result<()> { - let config_path = dotfiles_dir.join("notfiles.toml"); +fn cmd_init( + dotfiles_dir: &std::path::Path, + config_override: Option<&std::path::Path>, +) -> Result<()> { + if !dotfiles_dir.exists() { + fs::create_dir_all(dotfiles_dir) + .with_context(|| format!("creating dotfiles dir: {}", dotfiles_dir.display()))?; + println!("Created {}", dotfiles_dir.display()); + } + + let config_path = config_override + .map(std::path::Path::to_path_buf) + .unwrap_or_else(notcore::config::default_config_path); + if config_path.exists() { - println!("notfiles.toml already exists."); + println!("{} already exists.", config_path.display()); return Ok(()); } + if let Some(parent) = config_path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("creating config dir: {}", parent.display()))?; + } fs::write(&config_path, notcore::config::starter_toml())?; - println!("Created notfiles.toml"); + println!("Created {}", config_path.display()); Ok(()) } diff --git a/crates/notfiles/tests/integration.rs b/crates/notfiles/tests/integration.rs index a63d7dc..273533d 100644 --- a/crates/notfiles/tests/integration.rs +++ b/crates/notfiles/tests/integration.rs @@ -14,9 +14,12 @@ fn notfiles_bin() -> std::path::PathBuf { } fn run(dotfiles: &Path, args: &[&str]) -> (String, String, bool) { + let config = dotfiles.join("notfiles.toml"); let output = Command::new(notfiles_bin()) .arg("--dir") .arg(dotfiles) + .arg("--config") + .arg(&config) .args(args) .output() .expect("failed to run notfiles"); @@ -61,7 +64,7 @@ fn test_init_creates_config() { let (stdout, _, ok) = run(&dotfiles, &["init"]); assert!(ok); - assert!(stdout.contains("Created notfiles.toml")); + assert!(stdout.contains("Created"), "expected Created in: {stdout}"); assert!(dotfiles.join("notfiles.toml").exists()); }