Skip to content
23 changes: 21 additions & 2 deletions apps/nexus42/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,25 @@ impl CliConfig {
Self::load_from_nexus_home(&nexus)
}

/// Load configuration from an explicit Nexus root (`~/.nexus42`).
///
/// Path-taking counterpart to [`Self::load`]: same legacy migration and
/// reader, without a platform home lookup. The CLI startup seam uses it so
/// the failure path stays testable without mutating the process home.
///
/// # Errors
///
/// Returns an error if migration, file reads, or serialization fail.
pub fn load_from_nexus_root(nexus: &Path) -> anyhow::Result<Self> {
Self::load_from_nexus_home(nexus)
}

/// Path to `config.toml` under an explicit Nexus root.
#[must_use]
pub fn path_in(nexus: &Path) -> PathBuf {
nexus.join("config.toml")
}

fn load_from_nexus_home(nexus: &Path) -> anyhow::Result<Self> {
// Migration writes `config.toml`, so it takes the writer lock. Probe
// first so a plain read does not contend with the other writers.
Expand All @@ -195,7 +214,7 @@ impl CliConfig {
/// Unknown keys are retained by the flattened [`Self::extra`] table, so a
/// later write round-trips the keys other writers own.
fn read_document(nexus: &Path) -> anyhow::Result<Self> {
let config_path = nexus.join("config.toml");
let config_path = Self::path_in(nexus);
let content = match std::fs::read_to_string(&config_path) {
Ok(content) => content,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
Expand Down Expand Up @@ -328,7 +347,7 @@ impl CliConfig {
///
/// Returns an error if the Nexus home directory cannot be resolved.
fn config_path() -> anyhow::Result<PathBuf> {
Ok(nexus_home()?.join("config.toml"))
Ok(Self::path_in(&nexus_home()?))
}

/// Public path to the configuration file (for `config path` command).
Expand Down
102 changes: 101 additions & 1 deletion apps/nexus42/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ use clap::Parser;
use nexus42::cli::{Cli, Commands};
use nexus42::config::CliConfig;
use nexus42::errors::Result;
use std::path::Path;

fn main() {
let cli = Cli::parse();
Expand Down Expand Up @@ -66,9 +67,49 @@ fn main() {
}
}

/// Loads the startup config from an explicit Nexus root (`~/.nexus42`),
/// degrading to defaults with a user-facing warning message when it cannot be
/// loaded.
///
/// Taking the root as an argument keeps the failure path testable without
/// mutating process state.
fn load_at(nexus: &Path) -> (CliConfig, Option<String>) {
match CliConfig::load_from_nexus_root(nexus) {
Ok(config) => (config, None),
Err(error) => {
// `{:#}` keeps the cause chain (e.g. the TOML parser's reason).
let mut message = format!("config unreadable — {error:#}");
// Non-parse failures (e.g. invalid UTF-8) carry no path.
let path = CliConfig::path_in(nexus).display().to_string();
if !message.contains(&path) {
message = format!("{message} ({path})");
}
message.push_str(" (continuing with defaults)");
(CliConfig::default(), Some(message))
}
}
}

/// Startup wrapper: resolves the Nexus root the loader uses, then loads
/// through [`load_at`]. A corrupt config must not fail startup.
fn load_startup_config() -> (CliConfig, Option<String>) {
match nexus42::config::nexus_home() {
Ok(nexus) => load_at(&nexus),
Err(error) => (
CliConfig::default(),
Some(format!(
"config unreadable — {error:#} (continuing with defaults)"
)),
),
}
}

async fn async_main(cli: Cli) -> Result<()> {
// Load configuration
let mut config = CliConfig::load().unwrap_or_default();
let (mut config, config_warning) = load_startup_config();
if let Some(warning) = config_warning {
eprintln!("nexus42: {warning}");
}

// Resolve persistent device ID (UUID v4) for platform HTTP requests.
if let (Ok(nexus_home), Some(raw_home)) = (nexus42::config::nexus_home(), dirs::home_dir()) {
Expand Down Expand Up @@ -142,3 +183,62 @@ fn init_logging(verbose: bool, stderr_only: bool) {
builder.init();
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn corrupt_config_yields_defaults_and_path_warning() {
let nexus = tempfile::TempDir::new().expect("tempdir");
let config_path = CliConfig::path_in(nexus.path());
std::fs::write(&config_path, "not = [valid toml").expect("write config");

let (config, warning) = load_at(nexus.path());
let warning = warning.expect("corrupt config must produce a warning");
assert!(
warning.contains(&config_path.display().to_string()),
"warning must name the config path: {warning}"
);
assert!(
warning.contains("unclosed array"),
"warning must carry the TOML reason: {warning}"
);
assert_eq!(config.platform_url, CliConfig::default().platform_url);
}

#[test]
fn unreadable_config_warning_still_names_the_path() {
let nexus = tempfile::TempDir::new().expect("tempdir");
let config_path = CliConfig::path_in(nexus.path());
// Invalid UTF-8 fails in `read_to_string` before any TOML context is
// attached, so this exercises the path-less error class.
std::fs::write(&config_path, [0xff, 0xfe]).expect("write config");

let (config, warning) = load_at(nexus.path());
let warning = warning.expect("unreadable config must produce a warning");
assert!(
warning.contains(&config_path.display().to_string()),
"warning must name the config path: {warning}"
);
assert!(
warning.contains("UTF-8"),
"warning must carry the read-failure reason: {warning}"
);
assert_eq!(config.platform_url, CliConfig::default().platform_url);
}

#[test]
fn valid_config_loads_without_warning() {
let nexus = tempfile::TempDir::new().expect("tempdir");
std::fs::write(
CliConfig::path_in(nexus.path()),
"platform_url = \"https://example.test\"\n",
)
.expect("write config");

let (config, warning) = load_at(nexus.path());
assert!(warning.is_none(), "valid config must not warn: {warning:?}");
assert_eq!(config.platform_url, "https://example.test");
}
}
40 changes: 35 additions & 5 deletions crates/nexus-home-layout/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ pub fn ensure_system_layout(nexus_home: &Path) -> std::io::Result<()> {
let toml_path = nexus_home.join("config.toml");
let json_path = nexus_home.join("config.json");
if !toml_path.exists() && !json_path.exists() {
#[cfg(test)]
tests::after_existence_check(nexus_home);
let _lock = config_lock::acquire(nexus_home)?;
if !toml_path.exists() && !json_path.exists() {
std::fs::write(&toml_path, "")?;
Expand Down Expand Up @@ -804,6 +806,28 @@ pub fn validate_reference_id_safe(id: &str) -> std::result::Result<(), String> {
mod tests {
use super::*;

/// Test-only rendezvous at the pre-lock existence check: the startup path
/// calls it before acquiring `config.toml.lock`, so a test can order a
/// concurrent document write strictly into the acquisition wait — the
/// window the under-lock re-check exists to cover — with no fixed wait.
static AFTER_EXISTENCE_CHECK: std::sync::Mutex<
Option<(std::path::PathBuf, std::sync::Arc<std::sync::Barrier>)>,
> = std::sync::Mutex::new(None);

/// Blocks the startup caller until the test that installed the rendezvous
/// for this home arrives.
pub fn after_existence_check(home: &std::path::Path) {
let barrier = AFTER_EXISTENCE_CHECK
.lock()
.expect("rendezvous")
.as_ref()
.filter(|(path, _)| path == home)
.map(|(_, barrier)| std::sync::Arc::clone(barrier));
if let Some(barrier) = barrier {
barrier.wait();
}
}

#[test]
fn operational_dir_layout() {
let home = PathBuf::from("/fake/home");
Expand Down Expand Up @@ -1546,24 +1570,29 @@ mod tests {

/// The existence check is repeated under the lock: a document committed
/// while the startup acquisition waited must not be overwritten with the
/// empty skeleton.
/// empty skeleton. The rendezvous orders the commit strictly after the
/// pre-lock check and before the lock acquisition, so the window is
/// exercised deterministically instead of raced against a sleep.
#[test]
fn ensure_system_layout_keeps_a_document_committed_while_it_waited() {
use std::time::Duration;

let tmp = tempfile::tempdir().expect("temp home");
let home = tmp.path().to_path_buf();
std::fs::create_dir_all(&home).expect("home");
let lock = home.join("config.toml.lock");
std::fs::write(&lock, format!("{} foreign\n", std::process::id())).expect("foreign lock");

let barrier = std::sync::Arc::new(std::sync::Barrier::new(2));
*AFTER_EXISTENCE_CHECK.lock().expect("rendezvous") =
Some((home.clone(), std::sync::Arc::clone(&barrier)));

let boot = {
let home = home.clone();
std::thread::spawn(move || ensure_system_layout(&home).expect("system layout"))
};

// The committed document lands while the startup write waits.
std::thread::sleep(Duration::from_millis(100));
// Startup is past its pre-lock existence check and cannot acquire
// until the foreign lock is released: no timing assumption.
barrier.wait();
std::fs::write(
home.join("config.toml"),
"active_creator_id = \"ctr_kept\"\n",
Expand All @@ -1572,6 +1601,7 @@ mod tests {
std::fs::remove_file(&lock).expect("release foreign lock");

boot.join().expect("boot thread");
*AFTER_EXISTENCE_CHECK.lock().expect("rendezvous") = None;
let stored = std::fs::read_to_string(home.join("config.toml")).expect("config");
assert!(
stored.contains("ctr_kept"),
Expand Down
Loading