From 0a608e4183d0b08a7192e4cb2fec7b1f495891a1 Mon Sep 17 00:00:00 2001 From: Peter Schuller Date: Sun, 22 Mar 2026 08:07:21 -0700 Subject: [PATCH] feat: support user-level config at ~/.relocal/config.toml --- Cargo.lock | 67 ++++++++- Cargo.toml | 1 + README.md | 6 +- SPEC.md | 32 ++++- lore/2026-03-22-user-config.md | 28 ++++ src/commands/session.rs | 8 +- src/config.rs | 245 +++++++++++++++++++++++++++++++-- src/daemon.rs | 16 ++- src/daemon_client.rs | 21 ++- src/error.rs | 4 +- src/main.rs | 23 ++-- 11 files changed, 413 insertions(+), 38 deletions(-) create mode 100644 lore/2026-03-22-user-config.md diff --git a/Cargo.lock b/Cargo.lock index f072dc8..99bb0ae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -148,6 +148,27 @@ dependencies = [ "zeroize", ] +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + [[package]] name = "encode_unicode" version = "1.0.0" @@ -182,6 +203,17 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + [[package]] name = "getrandom" version = "0.4.2" @@ -264,6 +296,15 @@ version = "0.2.183" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" +[[package]] +name = "libredox" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" +dependencies = [ + "libc", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -315,6 +356,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -355,12 +402,24 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", +] + [[package]] name = "relocal" version = "0.1.0" dependencies = [ "clap", "dialoguer", + "dirs", "libc", "nix", "serde", @@ -495,7 +554,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom", + "getrandom 0.4.2", "once_cell", "rustix", "windows-sys 0.61.2", @@ -678,6 +737,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + [[package]] name = "wasip2" version = "1.0.2+wasi-0.2.9" diff --git a/Cargo.toml b/Cargo.toml index f387d16..eee44e2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ edition = "2021" [dependencies] clap = { version = "4", features = ["derive"] } dialoguer = "0.11" +dirs = "6" libc = "0.2.183" nix = { version = "0.31.2", features = ["poll"] } serde = { version = "1", features = ["derive"] } diff --git a/README.md b/README.md index 483f1f4..c7daa24 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,11 @@ su - "$NEW_USER" -c 'sudo -n true && echo "passwordless sudo OK"' # Install relocal locally cargo install --path . -# In your project directory, create a config file +# Set your default remote (one-time, shared across all repos) +mkdir -p ~/.relocal +echo 'remote = "user@host"' > ~/.relocal/config.toml + +# In your project directory, create a per-repo config (optional if user config has remote) cd ~/my-project relocal init # Follow the prompts and set remote to user@host diff --git a/SPEC.md b/SPEC.md index 39b8345..e196fc4 100644 --- a/SPEC.md +++ b/SPEC.md @@ -22,13 +22,12 @@ scope of this spec. ## Configuration -A single `relocal.toml` file in the repo root. Created by `relocal init`. +Configuration comes from two layers, merged with per-field override semantics: -The repo root is discovered by checking the current working directory for a `relocal.toml` file. Unlike tools that walk -up the directory tree, relocal intentionally only checks the CWD. This prevents accidentally discovering a -`relocal.toml` high in the tree (e.g. in `$HOME`) which would cause the tool to sync an unexpectedly large directory -with `rsync --delete`. If not found, commands that require it fail with an error suggesting `relocal init` or running -from the project root. +- **User config**: `~/.relocal/config.toml` — user-wide defaults (e.g., a default remote host). +- **Project config**: `relocal.toml` in the repo root — per-repo overrides. Created by `relocal init`. + +Both files use the same schema: ```toml remote = "user@host" @@ -42,7 +41,26 @@ exclude = [".env", "secrets/"] apt_packages = ["libssl-dev", "pkg-config"] ``` -All fields except `remote` are optional. +All fields except `remote` are optional at each layer. The merged result must have `remote`. + +### Merge Semantics + +For each field, the project config wins if it specifies a value; otherwise the user config's value is used. List fields +(`exclude`, `apt_packages`) are replaced entirely, not concatenated — if a project config specifies `exclude`, it +completely overrides the user-level `exclude`. + +### User Config + +Located at `~/.relocal/config.toml`. Created manually by the user. If the file does not exist, it is silently skipped. +If it exists but cannot be parsed, relocal exits with an error. + +### Project Config Discovery + +The repo root is discovered by checking the current working directory for a `relocal.toml` file. Unlike tools that walk +up the directory tree, relocal intentionally only checks the CWD. This prevents accidentally discovering a +`relocal.toml` high in the tree (e.g. in `$HOME`) which would cause the tool to sync an unexpectedly large directory +with `rsync --delete`. If not found, commands that require it fail with an error suggesting `relocal init` or running +from the project root. ## Session Naming diff --git a/lore/2026-03-22-user-config.md b/lore/2026-03-22-user-config.md new file mode 100644 index 0000000..5e5ab9a --- /dev/null +++ b/lore/2026-03-22-user-config.md @@ -0,0 +1,28 @@ +# User-level configuration + +## Problem + +I have one or two disposable SSH boxes that I reuse across everything. Right now, every repo I want to use relocal in +needs its own `relocal.toml` with at least `remote = "user@host"`, and I need to make sure it's in `.gitignore`. That's +enough friction that I won't bother for a quick one-off session in a repo I haven't set up yet. + +The goal is to be able to `cd` into any git repo and just run `relocal claude` without touching any project files. + +## Design + +A user-level config at `~/.relocal/config.toml` with the same schema as the project-level `relocal.toml`. The project +file acts as a per-field override when present. + +Merge semantics are simple replacement, not union: if a project config specifies `exclude`, that list entirely replaces +the user-level one. I considered concatenation (user excludes + project excludes) but it makes the mental model harder — +you'd need to remember what your user config contributes to figure out what's actually excluded. With replacement, the +project config is "what I want for this repo" and the user config is "my defaults when I haven't said otherwise." + +## Phasing + +Two steps, each its own change: + +1. Add user config loading with project-level override. A `relocal.toml` in the project is still required for discovery + — without it, commands fail as before. But fields like `remote` can come from the user config. +2. Allow running without a project-level `relocal.toml` at all. If the user config provides `remote`, that's enough. + This is the step that actually achieves the "cd into any repo and go" goal. diff --git a/src/commands/session.rs b/src/commands/session.rs index 2912629..3f0403c 100644 --- a/src/commands/session.rs +++ b/src/commands/session.rs @@ -7,7 +7,7 @@ use std::path::Path; -use tracing::{error, info, warn}; +use tracing::{debug, error, info, warn}; use crate::config::Config; use crate::daemon_client; @@ -36,14 +36,20 @@ pub fn run( verbosity: u8, extra_args: &[String], ) -> Result<()> { + debug!("Connecting to session daemon for {session_name}..."); let daemon_conn = daemon_client::connect_or_spawn(session_name, &config.remote, repo_root, verbosity) .inspect_err(|_| { info!("Run `relocal log {session_name}` to see daemon logs."); })?; + debug!( + "Daemon connected, control master at {}", + daemon_conn.control_master_path().display() + ); let runner = ProcessRunner::with_control_path(daemon_conn.control_master_path()); check_tool_installed(tool, &runner, config)?; + debug!("{} installation verified", tool.display_name); let remote_cmd = (tool.start_session)(session_name, extra_args); info!("Launching {} on {}...", tool.display_name, config.remote); diff --git a/src/config.rs b/src/config.rs index e7a68d9..386e0fa 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,38 +1,115 @@ -//! Parsing and representation of `relocal.toml`, the per-repo configuration file. +//! Configuration loading and merging for relocal. //! -//! The config is intentionally minimal: only `remote` is required. Unknown keys -//! are silently ignored so that older binaries can read configs written for newer -//! versions (forward compatibility). +//! Config comes from two layers: a user-wide `~/.relocal/config.toml` and a +//! per-repo `relocal.toml`. Both use the same schema. The project config +//! overrides the user config on a per-field basis (no list merging). +//! +//! Unknown keys are silently ignored so that older binaries can read configs +//! written for newer versions (forward compatibility). + +use std::path::{Path, PathBuf}; use crate::error::{Error, Result}; use serde::Deserialize; -/// Deserialized contents of `relocal.toml`. +/// Resolved configuration with all required fields present. /// -/// All fields except `remote` have defaults, so a minimal config is just -/// `remote = "user@host"`. -#[derive(Debug, Clone, Deserialize)] +/// This is the type that the rest of the codebase uses. Produced by +/// [`PartialConfig::resolve`] after merging user and project layers. +#[derive(Debug, Clone)] pub struct Config { pub remote: String, - - #[serde(default)] pub exclude: Vec, - - #[serde(default)] pub apt_packages: Vec, } impl Config { + /// Parse a TOML string that must contain `remote`. Convenience for + /// call sites that have a single authoritative config source. pub fn parse(input: &str) -> Result { + PartialConfig::parse(input, "relocal.toml")?.resolve() + } +} + +/// A config layer where every field is optional. +/// +/// Used for deserialization of both user and project config files before +/// merging. +#[derive(Debug, Clone, Default, Deserialize)] +pub struct PartialConfig { + pub remote: Option, + pub exclude: Option>, + pub apt_packages: Option>, +} + +impl PartialConfig { + pub fn parse(input: &str, path: &str) -> Result { toml::from_str(input).map_err(|e| Error::ConfigParse { + path: path.to_string(), reason: e.to_string(), }) } + + /// Overlay `over` on top of `self`. For each field, `over` wins if present. + pub fn merge(self, over: PartialConfig) -> PartialConfig { + PartialConfig { + remote: over.remote.or(self.remote), + exclude: over.exclude.or(self.exclude), + apt_packages: over.apt_packages.or(self.apt_packages), + } + } + + /// Convert to a resolved [`Config`], failing if `remote` is missing. + pub fn resolve(self) -> Result { + let remote = self.remote.ok_or_else(|| Error::ConfigParse { + path: "config".to_string(), + reason: "missing field `remote` (not set in ~/.relocal/config.toml or relocal.toml)" + .to_string(), + })?; + Ok(Config { + remote, + exclude: self.exclude.unwrap_or_default(), + apt_packages: self.apt_packages.unwrap_or_default(), + }) + } +} + +/// Read and parse a config file. Returns `None` if the file does not exist. +/// Returns an error if the file exists but cannot be read or parsed. +fn load_optional_config(path: &Path) -> Result> { + let display = path.display().to_string(); + match std::fs::read_to_string(path) { + Ok(contents) => Ok(Some(PartialConfig::parse(&contents, &display)?)), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(e.into()), + } +} + +/// Path to the user-level config file. +fn user_config_path(home: &Path) -> PathBuf { + home.join(".relocal").join("config.toml") +} + +/// Load and merge config from user and project layers. +/// +/// The project config overrides the user config per-field. The merged result +/// must have `remote`. +pub fn load_merged_config(home: &Path, repo_root: &Path) -> Result { + let mut base = PartialConfig::default(); + if let Some(user) = load_optional_config(&user_config_path(home))? { + base = base.merge(user); + } + if let Some(project) = load_optional_config(&repo_root.join("relocal.toml"))? { + base = base.merge(project); + } + base.resolve() } #[cfg(test)] mod tests { use super::*; + use std::fs; + use tempfile::TempDir; #[test] fn minimal_config() { @@ -84,4 +161,148 @@ another = "value" let config = Config::parse(input).unwrap(); assert_eq!(config.remote, "user@host"); } + + // --- PartialConfig merge tests --- + + #[test] + fn merge_override_wins() { + let base = PartialConfig { + remote: Some("base@host".into()), + exclude: Some(vec!["base.txt".into()]), + apt_packages: Some(vec!["base-pkg".into()]), + }; + let over = PartialConfig { + remote: Some("over@host".into()), + exclude: Some(vec!["over.txt".into()]), + apt_packages: None, + }; + let merged = base.merge(over); + assert_eq!(merged.remote.as_deref(), Some("over@host")); + assert_eq!(merged.exclude, Some(vec!["over.txt".into()])); + assert_eq!(merged.apt_packages, Some(vec!["base-pkg".into()])); + } + + #[test] + fn merge_base_used_when_override_absent() { + let base = PartialConfig { + remote: Some("base@host".into()), + exclude: Some(vec![".env".into()]), + apt_packages: None, + }; + let over = PartialConfig::default(); + let merged = base.merge(over); + assert_eq!(merged.remote.as_deref(), Some("base@host")); + assert_eq!(merged.exclude, Some(vec![".env".into()])); + assert!(merged.apt_packages.is_none()); + } + + #[test] + fn resolve_missing_remote() { + let partial = PartialConfig::default(); + let err = partial.resolve().unwrap_err(); + assert!(err.to_string().contains("remote")); + } + + #[test] + fn resolve_fills_defaults() { + let partial = PartialConfig { + remote: Some("u@h".into()), + exclude: None, + apt_packages: None, + }; + let config = partial.resolve().unwrap(); + assert!(config.exclude.is_empty()); + assert!(config.apt_packages.is_empty()); + } + + // --- load_optional_config tests --- + + #[test] + fn load_optional_missing_returns_none() { + let tmp = TempDir::new().unwrap(); + let path = tmp.path().join("nonexistent.toml"); + assert!(load_optional_config(&path).unwrap().is_none()); + } + + #[test] + fn load_optional_valid() { + let tmp = TempDir::new().unwrap(); + let path = tmp.path().join("test.toml"); + fs::write(&path, "remote = \"u@h\"").unwrap(); + let partial = load_optional_config(&path).unwrap().unwrap(); + assert_eq!(partial.remote.as_deref(), Some("u@h")); + } + + #[test] + fn load_optional_parse_error_includes_path() { + let tmp = TempDir::new().unwrap(); + let path = tmp.path().join("bad.toml"); + fs::write(&path, "{{invalid}}").unwrap(); + let err = load_optional_config(&path).unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains("bad.toml"), + "error should name the file: {msg}" + ); + } + + // --- load_merged_config tests --- + + #[test] + fn merged_project_overrides_user() { + let home = TempDir::new().unwrap(); + let repo = TempDir::new().unwrap(); + + let user_dir = home.path().join(".relocal"); + fs::create_dir(&user_dir).unwrap(); + fs::write( + user_dir.join("config.toml"), + "remote = \"user@default\"\nexclude = [\".env\"]", + ) + .unwrap(); + + fs::write( + repo.path().join("relocal.toml"), + "remote = \"user@project\"", + ) + .unwrap(); + + let config = load_merged_config(home.path(), repo.path()).unwrap(); + assert_eq!(config.remote, "user@project"); + // Project didn't specify exclude, so user's value is used + assert_eq!(config.exclude, vec![".env"]); + } + + #[test] + fn merged_user_only() { + let home = TempDir::new().unwrap(); + let repo = TempDir::new().unwrap(); + + let user_dir = home.path().join(".relocal"); + fs::create_dir(&user_dir).unwrap(); + fs::write(user_dir.join("config.toml"), "remote = \"u@h\"").unwrap(); + + let config = load_merged_config(home.path(), repo.path()).unwrap(); + assert_eq!(config.remote, "u@h"); + } + + #[test] + fn merged_project_only() { + let home = TempDir::new().unwrap(); + let repo = TempDir::new().unwrap(); + + fs::write(repo.path().join("relocal.toml"), "remote = \"u@h\"").unwrap(); + + let config = load_merged_config(home.path(), repo.path()).unwrap(); + assert_eq!(config.remote, "u@h"); + } + + #[test] + fn merged_neither_has_remote() { + let home = TempDir::new().unwrap(); + let repo = TempDir::new().unwrap(); + + let err = load_merged_config(home.path(), repo.path()).unwrap_err(); + assert!(err.to_string().contains("remote")); + } } diff --git a/src/daemon.rs b/src/daemon.rs index 11cf28e..cbaf471 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -12,7 +12,7 @@ use std::path::Path; use std::time::Duration; use nix::poll::{poll, PollFd, PollFlags, PollTimeout}; -use tracing::{info, warn}; +use tracing::{debug, info, warn}; use crate::commands::sync::{sync_pull, sync_push}; use crate::config::Config; @@ -46,7 +46,12 @@ pub fn run_daemon( verbose: bool, ) -> Result<()> { info!("Connecting to {}...", config.remote); + debug!("Establishing SSH ControlMaster..."); let control_master = SshControlMaster::start_shared(&config.remote, session_name)?; + debug!( + "ControlMaster established at {}", + control_master.socket_path().display() + ); let runner = ProcessRunner::with_control_path(control_master.socket_path()); daemon_setup(&runner, config, session_name, repo_root, verbose)?; @@ -68,7 +73,10 @@ pub fn run_daemon( message: format!("failed to set socket nonblocking: {e}"), })?; + debug!("Daemon socket bound at {}", socket_path.display()); + // Signal readiness to the spawning client. + debug!("Signaling READY to client..."); let _ = std::io::stdout().write_all(b"READY\n"); let _ = std::io::stdout().flush(); // The spawning client blocks on read_line() waiting for READY. Closing @@ -151,16 +159,22 @@ pub fn daemon_setup( session: session_name.to_string(), }); } + debug!("No stale session found"); info!("Creating remote working directory..."); runner .run_ssh(&config.remote, &ssh::mkdir_work_dir(session_name))? .check("mkdir")?; + debug!("Remote directory created"); + runner .run_ssh(&config.remote, &ssh::create_lock_file(session_name))? .check("create lock file")?; + debug!("Lock file created"); + debug!("Starting initial rsync push..."); sync_push(runner, config, session_name, repo_root, verbose)?; + debug!("Initial rsync push complete"); Ok(()) } diff --git a/src/daemon_client.rs b/src/daemon_client.rs index 3bee02d..ba32bf7 100644 --- a/src/daemon_client.rs +++ b/src/daemon_client.rs @@ -11,7 +11,7 @@ use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use std::time::Duration; -use tracing::info; +use tracing::{debug, info}; use crate::error::{Error, Result}; use crate::ssh; @@ -54,12 +54,16 @@ pub fn connect_or_spawn( ) -> Result { let socket_path = ssh::daemon_socket_path(session_name, remote); + debug!("Checking for existing daemon at {}", socket_path.display()); + // Fast path: daemon is already running. if let Ok(conn) = try_connect(&socket_path) { info!("Connected to existing session daemon"); return Ok(conn); } + debug!("No existing daemon found, acquiring startup flock..."); + // Slow path: acquire flock and spawn daemon if needed. let flock_path = ssh::daemon_flock_path(session_name, remote); let flock_file = std::fs::OpenOptions::new() @@ -78,6 +82,8 @@ pub fn connect_or_spawn( // from spawning a replacement that would hit a stale remote lock. ssh::acquire_flock(&flock_file)?; + debug!("Flock acquired, double-checking for daemon..."); + // Double-check: another process may have started the daemon while we waited. if let Ok(conn) = try_connect(&socket_path) { info!("Connected to session daemon (started by another process)"); @@ -98,6 +104,12 @@ pub fn connect_or_spawn( let exe = std::env::current_exe().map_err(|e| Error::DaemonSpawnFailed { message: format!("failed to determine current executable: {e}"), })?; + debug!( + "Spawning daemon: {} _daemon {} {}", + exe.display(), + session_name, + repo_root_str + ); let mut cmd = Command::new(&exe); // Propagate the full verbosity level so the daemon gets the same @@ -115,6 +127,11 @@ pub fn connect_or_spawn( message: format!("failed to spawn daemon process: {e}"), })?; + debug!( + "Daemon spawned (pid={}), waiting for READY signal...", + child.id() + ); + // Wait for the daemon to signal readiness. let stdout = child .stdout @@ -151,6 +168,8 @@ pub fn connect_or_spawn( let _ = child.wait(); }); + debug!("Connecting to daemon socket..."); + // Connect to the now-running daemon. let conn = try_connect(&socket_path).map_err(|_| Error::DaemonSpawnFailed { message: "daemon signaled ready but socket connection failed".to_string(), diff --git a/src/error.rs b/src/error.rs index 1ff1b01..e46a2eb 100644 --- a/src/error.rs +++ b/src/error.rs @@ -19,8 +19,8 @@ pub enum Error { )] ConfigNotFound { start_dir: PathBuf }, - #[error("failed to parse relocal.toml: {reason}")] - ConfigParse { reason: String }, + #[error("failed to parse {path}: {reason}")] + ConfigParse { path: String, reason: String }, #[error("invalid session name {name:?}: {reason}")] InvalidSessionName { name: String, reason: String }, diff --git a/src/main.rs b/src/main.rs index 551240e..f669f3f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,17 +9,21 @@ use relocal::{commands, config, daemon, discovery, runner, session, ssh}; use tracing::error; use tracing_subscriber::FmtSubscriber; -/// Finds the repo root and loads `relocal.toml`. Exits on failure. +/// Returns the user's home directory, or exits with an error. +fn home_dir() -> PathBuf { + dirs::home_dir().unwrap_or_else(|| { + error!("could not determine home directory"); + std::process::exit(1); + }) +} + +/// Finds the repo root and loads merged config (user + project). Exits on failure. fn load_config() -> (PathBuf, config::Config) { let root = discovery::find_repo_root(&std::env::current_dir().unwrap()).unwrap_or_else(|e| { error!("{e}"); std::process::exit(1); }); - let toml_str = std::fs::read_to_string(root.join("relocal.toml")).unwrap_or_else(|e| { - error!("reading relocal.toml: {e}"); - std::process::exit(1); - }); - let cfg = config::Config::parse(&toml_str).unwrap_or_else(|e| { + let cfg = config::load_merged_config(&home_dir(), &root).unwrap_or_else(|e| { error!("{e}"); std::process::exit(1); }); @@ -59,12 +63,7 @@ fn init_daemon_tracing( session_name: &str, repo_root: &str, ) -> config::Config { - let toml_str = std::fs::read_to_string(Path::new(repo_root).join("relocal.toml")) - .unwrap_or_else(|e| { - eprintln!("Error reading relocal.toml: {e}"); - std::process::exit(1); - }); - let cfg = config::Config::parse(&toml_str).unwrap_or_else(|e| { + let cfg = config::load_merged_config(&home_dir(), Path::new(repo_root)).unwrap_or_else(|e| { eprintln!("Error: {e}"); std::process::exit(1); });