Skip to main content

cargoless_core/
config.rs

1//! Model R fleet/daemon configuration — the **Stream A↔B seam**.
2//!
3//! ## Why this lives in `cargoless-core` (the ratified A↔B decision)
4//!
5//! cargoless v0 config is CLI-crate-local (`crates/cargoless/src/config.rs`:
6//! single-root project `Config` + `detect_from_cargo_toml`, the house
7//! tf.toml pattern). Model R adds a *fleet/daemon* dimension
8//! (`--cas-dir/--state-dir/--repo/--bind/--no-corun/--auth-token`) that
9//! `cargoless-core` itself consumes — `repo.rs` (Stream B), `cluster.rs`,
10//! `corun.rs`, `transport/` all need the resolved values, with **no CLI in
11//! the loop** for daemon-runtime re-resolution (per-worktree `tf.toml`
12//! `state_dir` overrides resolved while the daemon runs).
13//!
14//! The lead's recommendation (CLI parses, core consumes a resolved struct
15//! via injection) is **ratified on dependency direction** and **refined on
16//! resolver placement**:
17//!
18//! - **`cargoless-core` owns the resolved type [`FleetConfig`] AND the
19//!   clap-free precedence resolver** ([`FleetConfig::resolve`]). It takes a
20//!   plain [`FleetOverrides`] struct — **never a clap type**. Core gains no
21//!   arg-parsing dep and no dependency on the CLI crate; the
22//!   `core ← cli` direction is intact and core stays unit-testable without
23//!   clap. The bug-prone parts (precedence, the tolerant-overlay tf.toml
24//!   reader) live in ONE place, exhaustively unit-tested here, and are
25//!   reusable by the daemon-runtime re-resolution path that has no CLI.
26//! - **The CLI crate owns only the clap flag surface.** It maps flags into
27//!   [`FleetOverrides`] and calls [`FleetConfig::resolve`]. It does not
28//!   re-implement env/`tf.toml` parsing or the precedence rule.
29//!
30//! Net: the lead's three constraints (no circular dep, core clap-free, core
31//! unit-testable) are all satisfied, and precedence logic is singular +
32//! testable + reusable by the daemon. This struct shape is the **frozen
33//! contract** Stream B codes `repo.rs` against.
34//!
35//! ## Precedence
36//!
37//! `CLI flag  >  environment  >  tf.toml  >  built-in default`
38//!
39//! ## Backward-compat (every default is v0 behaviour, unchanged)
40//!
41//! | Field        | Default            | v0 meaning preserved                       |
42//! |--------------|--------------------|--------------------------------------------|
43//! | `cas_dir`    | `None`             | per-process PID-scoped CAS (no fleet share) |
44//! | `state_dir`  | `.cargoless`       | the existing v0 state directory             |
45//! | `repo`       | `None`             | single-worktree mode; no daemon             |
46//! | `bind`       | `None`             | no network transport bound                  |
47//! | `corun`      | `true`             | (only meaningful once `repo` is set)        |
48//! | `auth_token` | `None`             | (only meaningful once `bind` is non-loopback) |
49//!
50//! A v0 invocation with no flags / no `[fleet]` `tf.toml` resolves to
51//! exactly today's behaviour — verified by [`tests::defaults_are_v0`].
52//!
53//! ## Hand-rolled, serde-free
54//!
55//! No `toml`/`serde`/`clap` dep — matches the CLI `config.rs` precedent and
56//! `CLAUDE.md` (no new deps; keep the cold-build path AC#1/#2 measure
57//! lean). The `tf.toml` reader here is a **tolerant partial overlay**: it
58//! reads only the keys it owns and *ignores everything else*. This is the
59//! deliberate opposite of the CLI `Config` reader, which hard-errors on any
60//! unknown section/key. Both are correct for their ownership scope — the
61//! CLI reader owns `[project] root/target` + `[cache] dir` and must catch
62//! typos there; this reader is a partial view of the *same shared file* and
63//! must never reject keys it does not own (doing so would break every
64//! existing v0 `tf.toml`).
65
66use std::collections::BTreeMap;
67use std::fmt;
68use std::net::SocketAddr;
69use std::path::{Path, PathBuf};
70use std::str::FromStr;
71
72/// The v0 default state directory (relative to the project/repo root).
73pub const DEFAULT_STATE_DIR: &str = ".cargoless";
74
75/// Which precedence layer set a given field — surfaced for diagnostics,
76/// `--version`-style introspection, and three-layer-validation evidence.
77/// (Mirrors the intent of the CLI `Config`'s `Detection`: the codebase
78/// should always be able to say *why* it is configured the way it is.)
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80pub enum Source {
81    /// Built-in v0-compatible default (no override anywhere).
82    Default,
83    /// Set by a `[fleet]`/`[project]`/`[cache]` key in `tf.toml`.
84    TfToml,
85    /// Set by a `TF_*` / `CARGOLESS_*` environment variable.
86    Env,
87    /// Set by an explicit CLI flag.
88    Cli,
89}
90
91impl Source {
92    pub fn describe(self) -> &'static str {
93        match self {
94            Source::Default => "default (v0-compatible)",
95            Source::TfToml => "tf.toml",
96            Source::Env => "environment",
97            Source::Cli => "CLI flag",
98        }
99    }
100}
101
102/// Per-field provenance. Cheap, `Copy`, and load-bearing for the
103/// "codebase always knows what it is" vision cut.
104#[derive(Debug, Clone, Copy, PartialEq, Eq)]
105pub struct Provenance {
106    pub cas_dir: Source,
107    pub state_dir: Source,
108    pub repo: Source,
109    pub bind: Source,
110    pub corun: Source,
111    pub auth_token: Source,
112}
113
114impl Default for Provenance {
115    fn default() -> Self {
116        Self {
117            cas_dir: Source::Default,
118            state_dir: Source::Default,
119            repo: Source::Default,
120            bind: Source::Default,
121            corun: Source::Default,
122            auth_token: Source::Default,
123        }
124    }
125}
126
127/// CLI-supplied overrides — the **injection struct** the CLI crate fills
128/// from clap and hands to [`FleetConfig::resolve`]. Deliberately plain
129/// (`Option`-of-value, no clap types) so `cargoless-core` never gains an
130/// arg-parsing dependency.
131///
132/// `corun` is `Option<bool>`: `None` = flag absent (fall through to
133/// env/toml/default); `Some(false)` = `--no-corun` was passed;
134/// `Some(true)` is reserved for a future explicit `--corun`.
135#[derive(Debug, Clone, Default, PartialEq, Eq)]
136pub struct FleetOverrides {
137    pub cas_dir: Option<PathBuf>,
138    pub state_dir: Option<PathBuf>,
139    pub repo: Option<PathBuf>,
140    pub bind: Option<String>,
141    pub corun: Option<bool>,
142    pub auth_token: Option<String>,
143}
144
145/// Fully-resolved Model R fleet configuration. Every field is populated
146/// after [`FleetConfig::resolve`]; consumers in `cargoless-core`
147/// (`repo.rs`, `cluster.rs`, `corun.rs`, `transport/`) read it directly.
148#[derive(Debug, Clone, PartialEq, Eq)]
149pub struct FleetConfig {
150    /// Shared content-addressed CAS directory. `None` ⇒ v0 per-process
151    /// PID-scoped CAS (no fleet dedup) — the unchanged v0 behaviour.
152    pub cas_dir: Option<PathBuf>,
153    /// State/cache directory (cli-status, tree.cache, diagnostics). v0
154    /// default `.cargoless`; tf-multiverse sets `.triform/cargoless`.
155    pub state_dir: PathBuf,
156    /// Repo root for daemon mode (`serve --repo <path>`). `None` ⇒
157    /// single-worktree v0 mode; no daemon, no worktree discovery.
158    pub repo: Option<PathBuf>,
159    /// Network bind address for the HTTP+SSE transport. `None` ⇒ no
160    /// network transport (in-proc / Unix-socket only) — the safe default.
161    pub bind: Option<SocketAddr>,
162    /// Corun batching enabled (design §7). Default `true`; only takes
163    /// effect once `repo` is set (multi-worktree).
164    pub corun: bool,
165    /// Bearer token for authenticated HTTP mode (#14). `None` ⇒ no auth.
166    /// Prefer the `CARGOLESS_AUTH_TOKEN` env over `tf.toml` for secrets.
167    pub auth_token: Option<String>,
168    /// Per-field provenance (which layer won).
169    pub provenance: Provenance,
170}
171
172/// Configuration failure. Like the CLI `ConfigError`, every variant renders
173/// one actionable message — a daemon's config error is its onboarding UX.
174#[derive(Debug, Clone, PartialEq, Eq)]
175pub enum FleetConfigError {
176    BadTfToml {
177        line_no: usize,
178        line: String,
179        why: String,
180    },
181    BadBind {
182        value: String,
183        why: String,
184    },
185    BadBool {
186        origin: &'static str,
187        key: String,
188        value: String,
189    },
190}
191
192impl fmt::Display for FleetConfigError {
193    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
194        match self {
195            FleetConfigError::BadTfToml { line_no, line, why } => write!(
196                f,
197                "tf.toml: {why} (line {line_no}: `{line}`).\n  \
198                 [fleet] keys: repo, bind, corun, auth_token. \
199                 [project] state_dir. [cache] cas_dir."
200            ),
201            FleetConfigError::BadBind { value, why } => write!(
202                f,
203                "invalid bind address `{value}`: {why}.\n  \
204                 expected `HOST:PORT`, e.g. `127.0.0.1:8080` (loopback, \
205                 safe) or `0.0.0.0:8080` (network — requires --auth-token)."
206            ),
207            FleetConfigError::BadBool { origin, key, value } => write!(
208                f,
209                "{origin}: `{key}` expects a boolean (`true`/`false`), \
210                 got `{value}`."
211            ),
212        }
213    }
214}
215
216impl std::error::Error for FleetConfigError {}
217
218impl FleetConfig {
219    /// The all-defaults config = exact v0 behaviour.
220    pub fn defaults() -> Self {
221        Self {
222            cas_dir: None,
223            state_dir: PathBuf::from(DEFAULT_STATE_DIR),
224            repo: None,
225            bind: None,
226            corun: true,
227            auth_token: None,
228            provenance: Provenance::default(),
229        }
230    }
231
232    /// Resolve fleet config for `repo_root`, layering
233    /// `default < tf.toml < env < CLI`. Reads the process environment via
234    /// `std::env::var`; see [`FleetConfig::resolve_layered`] for the
235    /// env-injected (unit-testable) variant.
236    pub fn resolve(
237        repo_root: impl AsRef<Path>,
238        overrides: FleetOverrides,
239    ) -> Result<Self, FleetConfigError> {
240        let env = |k: &str| std::env::var(k).ok();
241        Self::resolve_layered(repo_root.as_ref(), overrides, &env)
242    }
243
244    /// Env-injected resolver core. `env` is the only IO seam (so the
245    /// precedence + tf.toml-overlay logic is pure and exhaustively
246    /// unit-testable without touching the process environment).
247    pub fn resolve_layered(
248        repo_root: &Path,
249        ov: FleetOverrides,
250        env: &dyn Fn(&str) -> Option<String>,
251    ) -> Result<Self, FleetConfigError> {
252        let mut cfg = Self::defaults();
253
254        // ---- layer 1: tf.toml (tolerant partial overlay) -------------
255        if let Ok(text) = std::fs::read_to_string(repo_root.join("tf.toml")) {
256            apply_tf_toml_overlay(&mut cfg, &text)?;
257        }
258
259        // ---- layer 2: environment ------------------------------------
260        if let Some(v) = env("TF_CAS_DIR").filter(|s| !s.is_empty()) {
261            cfg.cas_dir = Some(PathBuf::from(v));
262            cfg.provenance.cas_dir = Source::Env;
263        }
264        if let Some(v) = env("TF_STATE_DIR").filter(|s| !s.is_empty()) {
265            cfg.state_dir = PathBuf::from(v);
266            cfg.provenance.state_dir = Source::Env;
267        }
268        if let Some(v) = env("TF_REPO").filter(|s| !s.is_empty()) {
269            cfg.repo = Some(PathBuf::from(v));
270            cfg.provenance.repo = Source::Env;
271        }
272        if let Some(v) = env("TF_BIND").filter(|s| !s.is_empty()) {
273            cfg.bind = Some(parse_bind(&v)?);
274            cfg.provenance.bind = Source::Env;
275        }
276        if let Some(v) = env("TF_NO_CORUN").filter(|s| !s.is_empty()) {
277            // presence/truthy ⇒ disable corun.
278            if parse_bool("env TF_NO_CORUN", "TF_NO_CORUN", &v)? {
279                cfg.corun = false;
280                cfg.provenance.corun = Source::Env;
281            }
282        }
283        if let Some(v) = env("CARGOLESS_AUTH_TOKEN").filter(|s| !s.trim().is_empty()) {
284            cfg.auth_token = Some(v);
285            cfg.provenance.auth_token = Source::Env;
286        }
287
288        // ---- layer 3: explicit CLI flags (highest) -------------------
289        if let Some(v) = ov.cas_dir {
290            cfg.cas_dir = Some(v);
291            cfg.provenance.cas_dir = Source::Cli;
292        }
293        if let Some(v) = ov.state_dir {
294            cfg.state_dir = v;
295            cfg.provenance.state_dir = Source::Cli;
296        }
297        if let Some(v) = ov.repo {
298            cfg.repo = Some(v);
299            cfg.provenance.repo = Source::Cli;
300        }
301        if let Some(v) = ov.bind {
302            cfg.bind = Some(parse_bind(&v)?);
303            cfg.provenance.bind = Source::Cli;
304        }
305        if let Some(b) = ov.corun {
306            cfg.corun = b;
307            cfg.provenance.corun = Source::Cli;
308        }
309        if let Some(v) = ov.auth_token.filter(|s| !s.trim().is_empty()) {
310            cfg.auth_token = Some(v);
311            cfg.provenance.auth_token = Source::Cli;
312        }
313
314        Ok(cfg)
315    }
316
317    /// `true` once a repo root is set ⇒ run the repo-scoped daemon
318    /// (`serve --repo`). Stream B's `repo.rs` gates on this.
319    pub fn daemon_mode(&self) -> bool {
320        self.repo.is_some()
321    }
322
323    /// `true` if the bind address is non-loopback — i.e. reachable off-host
324    /// and therefore MUST carry auth. This is the **#14 enforcement hook**:
325    /// `parse` + this predicate land in #1; the daemon-side
326    /// reject-non-loopback-without-token enforcement lands in #14 (after
327    /// Stream E #10 transport). Defined here so the contract is frozen and
328    /// Stream E can depend on the predicate now.
329    pub fn requires_auth(&self) -> bool {
330        match self.bind {
331            Some(addr) => !addr.ip().is_loopback(),
332            None => false,
333        }
334    }
335
336    /// The auth token iff one is **effectively** present — `Some(secret)`
337    /// only when configured AND non-blank (not empty, not whitespace-only).
338    /// A blank token is treated as **absent**: the real invariant the
339    /// security policy models is "an effective shared secret exists", not
340    /// the `Option::is_some` proxy. CWDL #197 — `--auth-token ""` /
341    /// `[fleet] auth_token = ""` / `CARGOLESS_AUTH_TOKEN=" "` must NOT
342    /// yield an unauthenticated non-loopback socket. All three config
343    /// sources also reject a blank token at parse time; this is the single
344    /// consulted predicate (used by [`security_check`](Self::security_check)
345    /// and `transport::authorizer_for`) so no current/future path can
346    /// reintroduce a blank effective secret.
347    pub fn effective_auth_token(&self) -> Option<&str> {
348        self.auth_token.as_deref().filter(|t| !t.trim().is_empty())
349    }
350
351    /// #14 pre-flight: non-loopback bind without an auth token is an unsafe
352    /// network exposure. Inert until #14 wires it into the daemon startup
353    /// path; provided now so the contract + message are frozen.
354    pub fn security_check(&self) -> Result<(), FleetConfigError> {
355        if self.requires_auth() && self.effective_auth_token().is_none() {
356            let value = self.bind.map(|a| a.to_string()).unwrap_or_default();
357            return Err(FleetConfigError::BadBind {
358                value,
359                why: "non-loopback bind requires --auth-token / \
360                      CARGOLESS_AUTH_TOKEN (refusing unauthenticated \
361                      network exposure)"
362                    .to_string(),
363            });
364        }
365        Ok(())
366    }
367
368    /// State directory resolved against `repo_root` (absolute if the
369    /// configured `state_dir` is relative — the v0 default `.cargoless`
370    /// is relative by design).
371    pub fn state_dir_abs(&self, repo_root: &Path) -> PathBuf {
372        if self.state_dir.is_absolute() {
373            self.state_dir.clone()
374        } else {
375            repo_root.join(&self.state_dir)
376        }
377    }
378}
379
380/// Parse a `HOST:PORT` bind string into a `SocketAddr`.
381fn parse_bind(s: &str) -> Result<SocketAddr, FleetConfigError> {
382    SocketAddr::from_str(s.trim()).map_err(|e| FleetConfigError::BadBind {
383        value: s.to_string(),
384        why: e.to_string(),
385    })
386}
387
388/// Parse a permissive boolean (`true/false/1/0/yes/no/on/off`,
389/// case-insensitive). Used for `[fleet] corun` and `TF_NO_CORUN`.
390fn parse_bool(origin: &'static str, key: &str, v: &str) -> Result<bool, FleetConfigError> {
391    match v.trim().to_ascii_lowercase().as_str() {
392        "true" | "1" | "yes" | "on" => Ok(true),
393        "false" | "0" | "no" | "off" => Ok(false),
394        _ => Err(FleetConfigError::BadBool {
395            origin,
396            key: key.to_string(),
397            value: v.to_string(),
398        }),
399    }
400}
401
402/// Apply the **fleet-owned** keys from a (shared) `tf.toml` over `cfg`.
403///
404/// Tolerant by contract: unknown sections/keys are *ignored*, not rejected
405/// — the CLI `Config` reader owns strict validation of `[project]
406/// root/target` + `[cache] dir`; this is a partial view of the same file
407/// and must not reject keys outside its ownership. Only the *values it
408/// owns* are validated (bad bind / bad bool ⇒ hard error).
409///
410/// Owned keys:
411/// - `[project] state_dir = "<path>"`
412/// - `[cache]   cas_dir   = "<path>"`
413/// - `[fleet]   repo      = "<path>"`
414/// - `[fleet]   bind      = "HOST:PORT"`
415/// - `[fleet]   corun     = true|false`
416/// - `[fleet]   auth_token = "<secret>"` (discouraged; prefer env)
417fn apply_tf_toml_overlay(cfg: &mut FleetConfig, text: &str) -> Result<(), FleetConfigError> {
418    let mut section = String::new();
419    for (idx, raw) in text.lines().enumerate() {
420        let line_no = idx + 1;
421        let line = strip_comment(raw).trim();
422        if line.is_empty() {
423            continue;
424        }
425        if let Some(name) = line.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
426            section = name.trim().to_string();
427            continue; // tolerant: never reject an unknown section.
428        }
429        let Some((key, val)) = line.split_once('=') else {
430            continue; // tolerant: malformed non-owned line ⇒ ignore.
431        };
432        let key = key.trim();
433        let val = unquote(val.trim());
434        match (section.as_str(), key) {
435            ("project", "state_dir") => {
436                cfg.state_dir = PathBuf::from(&val);
437                cfg.provenance.state_dir = Source::TfToml;
438            }
439            ("cache", "cas_dir") => {
440                cfg.cas_dir = Some(PathBuf::from(&val));
441                cfg.provenance.cas_dir = Source::TfToml;
442            }
443            ("fleet", "repo") => {
444                cfg.repo = Some(PathBuf::from(&val));
445                cfg.provenance.repo = Source::TfToml;
446            }
447            ("fleet", "bind") => {
448                cfg.bind = Some(parse_bind(&val).map_err(|_| FleetConfigError::BadTfToml {
449                    line_no,
450                    line: raw.trim().to_string(),
451                    why: format!("invalid bind address `{val}`"),
452                })?);
453                cfg.provenance.bind = Source::TfToml;
454            }
455            ("fleet", "corun") => {
456                let b = parse_bool("tf.toml", "corun", &val).map_err(|_| {
457                    FleetConfigError::BadTfToml {
458                        line_no,
459                        line: raw.trim().to_string(),
460                        why: format!("`corun` expects true/false, got `{val}`"),
461                    }
462                })?;
463                cfg.corun = b;
464                cfg.provenance.corun = Source::TfToml;
465            }
466            // Blank (empty / whitespace-only) ⇒ NOT a token: falls to the
467            // tolerant `_` arm, uniform with the env + CLI paths (CWDL
468            // #197 — `[fleet] auth_token = ""` must not yield an
469            // unauthenticated non-loopback socket).
470            ("fleet", "auth_token") if !val.trim().is_empty() => {
471                cfg.auth_token = Some(val);
472                cfg.provenance.auth_token = Source::TfToml;
473            }
474            // Tolerant: any other (section,key) — incl. a blank
475            // `auth_token` (handled above) — belongs to the CLI `Config`
476            // reader or a future consumer; ignore silently.
477            _ => {}
478        }
479    }
480    Ok(())
481}
482
483/// Strip a `#` comment, respecting `#` inside a double-quoted string.
484/// (Same rule as the CLI `config.rs` — kept identical so the two readers
485/// over the same file never disagree on what a comment is.)
486fn strip_comment(line: &str) -> &str {
487    let mut in_str = false;
488    for (i, c) in line.char_indices() {
489        match c {
490            '"' => in_str = !in_str,
491            '#' if !in_str => return &line[..i],
492            _ => {}
493        }
494    }
495    line
496}
497
498fn unquote(s: &str) -> String {
499    s.strip_prefix('"')
500        .and_then(|s| s.strip_suffix('"'))
501        .unwrap_or(s)
502        .to_string()
503}
504
505/// Build a [`FleetOverrides`] from an already-collected string map — a
506/// convenience for the CLI crate / tests that have flag values as strings.
507/// Not used by core itself; keeps the string→typed boundary in one place.
508pub fn overrides_from_map(m: &BTreeMap<String, String>) -> FleetOverrides {
509    FleetOverrides {
510        cas_dir: m.get("cas-dir").map(PathBuf::from),
511        state_dir: m.get("state-dir").map(PathBuf::from),
512        repo: m.get("repo").map(PathBuf::from),
513        bind: m.get("bind").cloned(),
514        corun: if m.contains_key("no-corun") {
515            Some(false)
516        } else {
517            None
518        },
519        auth_token: m.get("auth-token").cloned(),
520    }
521}
522
523#[cfg(test)]
524mod tests {
525    use super::*;
526
527    fn no_env(_: &str) -> Option<String> {
528        None
529    }
530
531    #[test]
532    fn defaults_are_v0() {
533        // The whole backward-compat guarantee in one assertion: no flags,
534        // no env, no tf.toml ⇒ exactly today's v0 behaviour.
535        let c = FleetConfig::defaults();
536        assert_eq!(c.cas_dir, None, "v0: per-process PID-scoped CAS");
537        assert_eq!(c.state_dir, PathBuf::from(".cargoless"));
538        assert_eq!(c.repo, None, "v0: no daemon mode");
539        assert_eq!(c.bind, None, "v0: no network transport");
540        assert!(c.corun, "corun default-on (inert until repo set)");
541        assert_eq!(c.auth_token, None);
542        assert!(!c.daemon_mode());
543        assert!(!c.requires_auth());
544        assert!(c.security_check().is_ok());
545    }
546
547    #[test]
548    fn resolve_no_inputs_equals_defaults() {
549        let tmp = std::env::temp_dir().join("cl-cfg-empty-xyz");
550        let _ = std::fs::create_dir_all(&tmp);
551        let c = FleetConfig::resolve_layered(&tmp, FleetOverrides::default(), &no_env).unwrap();
552        assert_eq!(c, FleetConfig::defaults());
553    }
554
555    #[test]
556    fn precedence_cli_beats_env_beats_toml_beats_default() {
557        let dir = std::env::temp_dir().join(format!("cl-cfg-prec-{}", std::process::id()));
558        std::fs::create_dir_all(&dir).unwrap();
559        std::fs::write(
560            dir.join("tf.toml"),
561            "[project]\nstate_dir = \".triform/cargoless\"\n\
562             [fleet]\nrepo = \"/from/toml\"\ncorun = false\n",
563        )
564        .unwrap();
565
566        // toml-only: state_dir + repo + corun come from tf.toml.
567        let c = FleetConfig::resolve_layered(&dir, FleetOverrides::default(), &no_env).unwrap();
568        assert_eq!(c.state_dir, PathBuf::from(".triform/cargoless"));
569        assert_eq!(c.provenance.state_dir, Source::TfToml);
570        assert_eq!(c.repo, Some(PathBuf::from("/from/toml")));
571        assert!(!c.corun);
572        assert_eq!(c.provenance.corun, Source::TfToml);
573
574        // env overrides toml for repo.
575        let env = |k: &str| match k {
576            "TF_REPO" => Some("/from/env".to_string()),
577            _ => None,
578        };
579        let c = FleetConfig::resolve_layered(&dir, FleetOverrides::default(), &env).unwrap();
580        assert_eq!(c.repo, Some(PathBuf::from("/from/env")));
581        assert_eq!(c.provenance.repo, Source::Env);
582        // state_dir still from toml (env didn't touch it).
583        assert_eq!(c.state_dir, PathBuf::from(".triform/cargoless"));
584
585        // CLI overrides everything for repo.
586        let ov = FleetOverrides {
587            repo: Some(PathBuf::from("/from/cli")),
588            ..Default::default()
589        };
590        let c = FleetConfig::resolve_layered(&dir, ov, &env).unwrap();
591        assert_eq!(c.repo, Some(PathBuf::from("/from/cli")));
592        assert_eq!(c.provenance.repo, Source::Cli);
593
594        let _ = std::fs::remove_dir_all(&dir);
595    }
596
597    #[test]
598    fn tf_toml_overlay_is_tolerant_of_cli_owned_keys() {
599        // A realistic v0 tf.toml: [project] root/target + [cache] dir are
600        // owned by the CLI Config reader. The fleet overlay MUST ignore
601        // them (not hard-error) while still reading its own state_dir.
602        let dir = std::env::temp_dir().join(format!("cl-cfg-tol-{}", std::process::id()));
603        std::fs::create_dir_all(&dir).unwrap();
604        std::fs::write(
605            dir.join("tf.toml"),
606            "[project]\nroot = \"/proj\"\ntarget = \"wasm32-unknown-unknown\"\n\
607             state_dir = \".triform/cargoless\"\n\
608             [cache]\ndir = \"/tmp/cache\"\ncas_dir = \"/shared/cas\"\n\
609             [serve]\nport = 8080\n",
610        )
611        .unwrap();
612        let c = FleetConfig::resolve_layered(&dir, FleetOverrides::default(), &no_env).unwrap();
613        // owned keys read:
614        assert_eq!(c.state_dir, PathBuf::from(".triform/cargoless"));
615        assert_eq!(c.cas_dir, Some(PathBuf::from("/shared/cas")));
616        // non-owned keys ([project] root/target, [cache] dir, [serve])
617        // ignored — no error, defaults untouched:
618        assert_eq!(c.repo, None);
619        assert_eq!(c.bind, None);
620        let _ = std::fs::remove_dir_all(&dir);
621    }
622
623    #[test]
624    fn bind_parsing_and_auth_predicate() {
625        // loopback bind needs no auth.
626        let ov = FleetOverrides {
627            bind: Some("127.0.0.1:8080".to_string()),
628            ..Default::default()
629        };
630        let c = FleetConfig::resolve_layered(std::path::Path::new("/nonexistent"), ov, &no_env)
631            .unwrap();
632        assert_eq!(c.bind.unwrap().to_string(), "127.0.0.1:8080");
633        assert!(!c.requires_auth());
634        assert!(c.security_check().is_ok());
635
636        // non-loopback bind requires auth — security_check rejects.
637        let ov = FleetOverrides {
638            bind: Some("0.0.0.0:8080".to_string()),
639            ..Default::default()
640        };
641        let c = FleetConfig::resolve_layered(std::path::Path::new("/nonexistent"), ov, &no_env)
642            .unwrap();
643        assert!(c.requires_auth());
644        assert!(c.security_check().is_err());
645
646        // …with a token it passes.
647        let ov = FleetOverrides {
648            bind: Some("0.0.0.0:8080".to_string()),
649            auth_token: Some("s3cr3t".to_string()),
650            ..Default::default()
651        };
652        let c = FleetConfig::resolve_layered(std::path::Path::new("/nonexistent"), ov, &no_env)
653            .unwrap();
654        assert!(c.security_check().is_ok());
655    }
656
657    #[test]
658    fn bad_bind_is_actionable() {
659        let ov = FleetOverrides {
660            bind: Some("not-an-addr".to_string()),
661            ..Default::default()
662        };
663        let e = FleetConfig::resolve_layered(std::path::Path::new("/nonexistent"), ov, &no_env)
664            .unwrap_err();
665        assert!(matches!(e, FleetConfigError::BadBind { .. }));
666        assert!(e.to_string().contains("--auth-token"));
667    }
668
669    #[test]
670    fn no_corun_via_env_and_cli() {
671        // env TF_NO_CORUN=1 disables corun.
672        let env = |k: &str| (k == "TF_NO_CORUN").then(|| "1".to_string());
673        let c = FleetConfig::resolve_layered(
674            std::path::Path::new("/nonexistent"),
675            FleetOverrides::default(),
676            &env,
677        )
678        .unwrap();
679        assert!(!c.corun);
680        assert_eq!(c.provenance.corun, Source::Env);
681
682        // --no-corun (Some(false)) wins over env-unset default.
683        let ov = FleetOverrides {
684            corun: Some(false),
685            ..Default::default()
686        };
687        let c = FleetConfig::resolve_layered(std::path::Path::new("/nonexistent"), ov, &no_env)
688            .unwrap();
689        assert!(!c.corun);
690        assert_eq!(c.provenance.corun, Source::Cli);
691    }
692
693    #[test]
694    fn auth_token_prefers_env_over_toml() {
695        let dir = std::env::temp_dir().join(format!("cl-cfg-tok-{}", std::process::id()));
696        std::fs::create_dir_all(&dir).unwrap();
697        std::fs::write(dir.join("tf.toml"), "[fleet]\nauth_token = \"from-toml\"\n").unwrap();
698        let env = |k: &str| (k == "CARGOLESS_AUTH_TOKEN").then(|| "from-env".to_string());
699        let c = FleetConfig::resolve_layered(&dir, FleetOverrides::default(), &env).unwrap();
700        assert_eq!(c.auth_token.as_deref(), Some("from-env"));
701        assert_eq!(c.provenance.auth_token, Source::Env);
702        let _ = std::fs::remove_dir_all(&dir);
703    }
704
705    // ───────── CWDL #197: blank auth_token is NOT a token ─────────
706
707    #[test]
708    fn blank_auth_token_is_no_token_via_cli_env_toml() {
709        // Empty AND whitespace-only, every source ⇒ parsed as no token
710        // (uniform with the env path's long-standing empty-filter).
711        // Single-line whitespace only — a raw newline is not a valid
712        // single-line `tf.toml` basic-string value (would split the
713        // line-based reader); `\t`/spaces fully exercise the trim guard.
714        for blank in ["", "   ", "\t", " \t "] {
715            // CLI
716            let ov = FleetOverrides {
717                auth_token: Some(blank.to_string()),
718                ..FleetOverrides::default()
719            };
720            let c = FleetConfig::resolve_layered(std::path::Path::new("/nonexistent"), ov, &no_env)
721                .unwrap();
722            assert_eq!(c.auth_token, None, "CLI blank {blank:?} ⇒ no token");
723            assert_eq!(c.effective_auth_token(), None);
724
725            // env
726            let env = |k: &str| (k == "CARGOLESS_AUTH_TOKEN").then(|| blank.to_string());
727            let c = FleetConfig::resolve_layered(
728                std::path::Path::new("/nonexistent"),
729                FleetOverrides::default(),
730                &env,
731            )
732            .unwrap();
733            assert_eq!(c.auth_token, None, "env blank {blank:?} ⇒ no token");
734            assert_eq!(c.effective_auth_token(), None);
735
736            // tf.toml
737            let dir = std::env::temp_dir().join(format!(
738                "cl-cfg-blank-{}-{}",
739                std::process::id(),
740                blank.len()
741            ));
742            std::fs::create_dir_all(&dir).unwrap();
743            std::fs::write(
744                dir.join("tf.toml"),
745                format!("[fleet]\nauth_token = \"{blank}\"\n"),
746            )
747            .unwrap();
748            let c = FleetConfig::resolve_layered(&dir, FleetOverrides::default(), &no_env).unwrap();
749            assert_eq!(c.auth_token, None, "toml blank {blank:?} ⇒ no token");
750            assert_eq!(c.effective_auth_token(), None);
751            let _ = std::fs::remove_dir_all(&dir);
752        }
753    }
754
755    #[test]
756    fn blank_auth_token_nonloopback_refuses_security_check() {
757        // THE security property: a blank token on a non-loopback bind
758        // is REFUSED exactly like None — no unauthenticated public
759        // socket. (parse path AND a directly-blank FleetConfig — the
760        // effective_auth_token defense-in-depth seam.)
761        let ov = FleetOverrides {
762            bind: Some("0.0.0.0:8080".to_string()),
763            auth_token: Some("   ".to_string()),
764            ..FleetOverrides::default()
765        };
766        let c = FleetConfig::resolve_layered(std::path::Path::new("/nonexistent"), ov, &no_env)
767            .unwrap();
768        assert!(
769            matches!(c.security_check(), Err(FleetConfigError::BadBind { .. })),
770            "non-loopback + blank CLI token MUST refuse (no unauth socket)"
771        );
772
773        // Defense-in-depth: even a FleetConfig that already holds a
774        // blank auth_token (bypassing the parse-reject) is refused —
775        // security_check models "effective secret present", not is_none.
776        let mut c2 = FleetConfig::defaults();
777        c2.bind = Some("0.0.0.0:9090".parse().unwrap());
778        c2.auth_token = Some(" \t ".to_string());
779        assert_eq!(c2.effective_auth_token(), None);
780        assert!(
781            matches!(c2.security_check(), Err(FleetConfigError::BadBind { .. })),
782            "blank token in FleetConfig + non-loopback MUST still refuse"
783        );
784
785        // A real token on the same bind is accepted (no over-rejection).
786        let mut ok = FleetConfig::defaults();
787        ok.bind = Some("0.0.0.0:9090".parse().unwrap());
788        ok.auth_token = Some("s3cr3t".to_string());
789        assert!(ok.security_check().is_ok());
790        assert_eq!(ok.effective_auth_token(), Some("s3cr3t"));
791    }
792
793    #[test]
794    fn state_dir_abs_resolution() {
795        let c = FleetConfig::defaults();
796        assert_eq!(
797            c.state_dir_abs(std::path::Path::new("/repo")),
798            PathBuf::from("/repo/.cargoless")
799        );
800        let mut c = FleetConfig::defaults();
801        c.state_dir = PathBuf::from("/abs/state");
802        assert_eq!(
803            c.state_dir_abs(std::path::Path::new("/repo")),
804            PathBuf::from("/abs/state")
805        );
806    }
807
808    #[test]
809    fn overrides_from_map_helper() {
810        let mut m = BTreeMap::new();
811        m.insert("repo".to_string(), "/r".to_string());
812        m.insert("no-corun".to_string(), String::new());
813        let ov = overrides_from_map(&m);
814        assert_eq!(ov.repo, Some(PathBuf::from("/r")));
815        assert_eq!(ov.corun, Some(false));
816        assert_eq!(ov.cas_dir, None);
817    }
818}