feat(cli): default dotfiles dir to ~/.notfiles, config to ~/.config/notfiles/notfiles.toml
Some checks failed
Public Repo Readiness / Check Public Repo Readiness (push) Has been cancelled

- Add `default_dotfiles_dir()` (~/.notfiles) and `default_config_path()`
  (~/.config/notfiles/notfiles.toml) to notcore::config
- Add `Config::load_from(path)` for explicit config path; keep `Config::load(dir)`
  as the legacy/library variant (config inside dotfiles dir)
- CLI resolves config via XDG default or --config override; library functions
  stay legacy so the public API is unaffected
- `notfiles init` creates ~/.notfiles and ~/.config/notfiles/notfiles.toml
- Add --config global flag to override the config path
- Update integration tests to pass --config explicitly so they are isolated
  from any real ~/.config/notfiles/notfiles.toml on the test machine
This commit is contained in:
Joseph OBrien
2026-07-14 10:42:35 -04:00
parent d34a4f9363
commit df7df3d65c
4 changed files with 78 additions and 18 deletions

View File

@@ -1,7 +1,7 @@
use serde::de::DeserializeOwned; use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
use std::path::Path; use std::path::{Path, PathBuf};
use crate::NotfilesError; use crate::NotfilesError;
@@ -102,13 +102,28 @@ where
toml::from_str(&content).map_err(|err| parse_error(path, err)) 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 { impl Config {
pub fn load(dotfiles_dir: &Path) -> Result<Self, NotfilesError> { /// Load config from an explicit path. Returns `Config::default()` if the file does not exist.
let config_path = dotfiles_dir.join("notfiles.toml"); pub fn load_from(config_path: &Path) -> Result<Self, NotfilesError> {
if !config_path.exists() { if !config_path.exists() {
return Ok(Config::default()); 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())) NotfilesError::Config(format!("reading {}: {e}", config_path.display()))
})?; })?;
let config: Config = toml::from_str(&content).map_err(|e| { let config: Config = toml::from_str(&content).map_err(|e| {
@@ -117,6 +132,12 @@ impl Config {
Ok(config) Ok(config)
} }
/// Load config from `<dotfiles_dir>/notfiles.toml` (legacy location).
/// Prefer [`Config::load_from`] with an explicit path in new call sites.
pub fn load(dotfiles_dir: &Path) -> Result<Self, NotfilesError> {
Self::load_from(&dotfiles_dir.join("notfiles.toml"))
}
pub fn method_for(&self, package: &str) -> Method { pub fn method_for(&self, package: &str) -> Method {
self.packages self.packages
.get(package) .get(package)

View File

@@ -4,10 +4,14 @@ use std::path::PathBuf;
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
#[command(name = "notfiles", about = "A modern dotfiles manager")] #[command(name = "notfiles", about = "A modern dotfiles manager")]
pub struct Cli { pub struct Cli {
/// Path to the dotfiles directory (default: current directory) /// Path to the dotfiles directory (default: ~/.notfiles)
#[arg(long, global = true)] #[arg(long, global = true)]
pub dir: Option<PathBuf>, pub dir: Option<PathBuf>,
/// Path to the config file (default: ~/.config/notfiles/notfiles.toml)
#[arg(long, global = true)]
pub config: Option<PathBuf>,
/// Show what would be done without making changes /// Show what would be done without making changes
#[arg(long, global = true)] #[arg(long, global = true)]
pub dry_run: bool, pub dry_run: bool,

View File

@@ -15,10 +15,26 @@ fn main() -> Result<()> {
let cli = Cli::parse(); let cli = Cli::parse();
let dotfiles_dir = cli let dotfiles_dir = cli
.dir .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) let dotfiles_dir = fs::canonicalize(&dotfiles_dir)
.with_context(|| format!("dotfiles directory not found: {}", dotfiles_dir.display()))?; .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<dyn Reporter> = if cli.json { let reporter: Box<dyn Reporter> = if cli.json {
Box::new(JsonReporter) Box::new(JsonReporter)
} else { } else {
@@ -37,14 +53,14 @@ fn main() -> Result<()> {
detect::print_detected(&managers); detect::print_detected(&managers);
} }
} }
Command::Init => cmd_init(&dotfiles_dir)?, Command::Init => unreachable!("handled above"),
Command::Link { Command::Link {
force, force,
no_backup, no_backup,
packages, packages,
} => { } => {
let fs = &adapters::FileStoreImpl; let fs = &adapters::FileStoreImpl;
let config = Config::load(&dotfiles_dir)?; let config = Config::load_from(&config_path)?;
config.validate()?; config.validate()?;
let mut state = State::load(&dotfiles_dir, fs)?; let mut state = State::load(&dotfiles_dir, fs)?;
let pkgs = resolve_packages_filtered_with_store(&dotfiles_dir, &packages, &config, fs)?; let pkgs = resolve_packages_filtered_with_store(&dotfiles_dir, &packages, &config, fs)?;
@@ -89,7 +105,7 @@ fn main() -> Result<()> {
} }
Command::Unlink { packages } => { Command::Unlink { packages } => {
let fs = &adapters::FileStoreImpl; 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 _ = &config; // loaded but not needed for unlink
let mut state = State::load(&dotfiles_dir, fs)?; let mut state = State::load(&dotfiles_dir, fs)?;
let pkgs = if packages.is_empty() { let pkgs = if packages.is_empty() {
@@ -145,7 +161,7 @@ fn main() -> Result<()> {
} }
Command::Check => { Command::Check => {
let fs = &adapters::FileStoreImpl; let fs = &adapters::FileStoreImpl;
let config = Config::load(&dotfiles_dir)?; let config = Config::load_from(&config_path)?;
config.validate()?; config.validate()?;
let all = resolve_packages_filtered_with_store(&dotfiles_dir, &[], &config, fs)?; let all = resolve_packages_filtered_with_store(&dotfiles_dir, &[], &config, fs)?;
@@ -171,7 +187,7 @@ fn main() -> Result<()> {
} }
Command::Diff { packages } => { Command::Diff { packages } => {
let fs = &adapters::FileStoreImpl; let fs = &adapters::FileStoreImpl;
let config = Config::load(&dotfiles_dir)?; let config = Config::load_from(&config_path)?;
config.validate()?; config.validate()?;
let state = State::load(&dotfiles_dir, fs)?; let state = State::load(&dotfiles_dir, fs)?;
let pkgs = resolve_packages_filtered_with_store(&dotfiles_dir, &packages, &config, fs)?; let pkgs = resolve_packages_filtered_with_store(&dotfiles_dir, &packages, &config, fs)?;
@@ -183,7 +199,7 @@ fn main() -> Result<()> {
} }
Command::Adopt { package, files } => { Command::Adopt { package, files } => {
let fs = &adapters::FileStoreImpl; 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 mut state = State::load(&dotfiles_dir, fs)?;
let opts = LinkOptions { let opts = LinkOptions {
force: false, force: false,
@@ -217,7 +233,7 @@ fn main() -> Result<()> {
} }
Command::Status { packages } => { Command::Status { packages } => {
let fs = &adapters::FileStoreImpl; let fs = &adapters::FileStoreImpl;
let config = Config::load(&dotfiles_dir)?; let config = Config::load_from(&config_path)?;
config.validate()?; config.validate()?;
let state = State::load(&dotfiles_dir, fs)?; let state = State::load(&dotfiles_dir, fs)?;
let pkgs = resolve_packages_filtered_with_store(&dotfiles_dir, &packages, &config, fs)?; let pkgs = resolve_packages_filtered_with_store(&dotfiles_dir, &packages, &config, fs)?;
@@ -236,13 +252,29 @@ fn main() -> Result<()> {
Ok(()) Ok(())
} }
fn cmd_init(dotfiles_dir: &std::path::Path) -> Result<()> { fn cmd_init(
let config_path = dotfiles_dir.join("notfiles.toml"); 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() { if config_path.exists() {
println!("notfiles.toml already exists."); println!("{} already exists.", config_path.display());
return Ok(()); 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())?; fs::write(&config_path, notcore::config::starter_toml())?;
println!("Created notfiles.toml"); println!("Created {}", config_path.display());
Ok(()) Ok(())
} }

View File

@@ -14,9 +14,12 @@ fn notfiles_bin() -> std::path::PathBuf {
} }
fn run(dotfiles: &Path, args: &[&str]) -> (String, String, bool) { fn run(dotfiles: &Path, args: &[&str]) -> (String, String, bool) {
let config = dotfiles.join("notfiles.toml");
let output = Command::new(notfiles_bin()) let output = Command::new(notfiles_bin())
.arg("--dir") .arg("--dir")
.arg(dotfiles) .arg(dotfiles)
.arg("--config")
.arg(&config)
.args(args) .args(args)
.output() .output()
.expect("failed to run notfiles"); .expect("failed to run notfiles");
@@ -61,7 +64,7 @@ fn test_init_creates_config() {
let (stdout, _, ok) = run(&dotfiles, &["init"]); let (stdout, _, ok) = run(&dotfiles, &["init"]);
assert!(ok); assert!(ok);
assert!(stdout.contains("Created notfiles.toml")); assert!(stdout.contains("Created"), "expected Created in: {stdout}");
assert!(dotfiles.join("notfiles.toml").exists()); assert!(dotfiles.join("notfiles.toml").exists());
} }