Skip to main content

marque_config/
lib.rs

1// SPDX-FileCopyrightText: 2026 Knitli Inc.
2//
3// SPDX-License-Identifier: LicenseRef-MarqueLicense-1.0
4
5#![forbid(unsafe_code)]
6#![cfg_attr(coverage_nightly, feature(coverage_attribute))]
7
8//! marque-config — layered configuration loading.
9//!
10//! Precedence (highest wins): CLI flags → env vars → `.marque.local.toml` → `.marque.toml`
11//!
12//! # Hard-fail validators (T023)
13//!
14//! The loader refuses to produce a `Config` if any of these conditions hold:
15//! - `.marque.toml` contains a `[user]` section (FR-010, SC-006) → exit 65
16//! - `[capco] version` mismatches `marque_ism::SCHEMA_VERSION` (FR-011) → exit 65
17//! - `confidence_threshold` outside `[0.0, 1.0]` → exit 65
18
19use marque_ism::UtcOffset;
20use marque_rules::Severity;
21use serde::{Deserialize, Serialize};
22use std::collections::HashMap;
23use std::path::PathBuf;
24use thiserror::Error;
25
26#[cfg(feature = "corpus-override")]
27pub mod corpus_override;
28
29/// Exit code 65 (`EX_DATAERR`) per `contracts/cli.md`.
30pub const EX_DATAERR: i32 = 65;
31
32#[derive(Debug, Error)]
33pub enum ConfigError {
34    #[error("failed to read config file {path}: {source}")]
35    ReadError {
36        path: PathBuf,
37        source: std::io::Error,
38    },
39
40    #[error("failed to parse config: {0}")]
41    ParseError(#[from] toml::de::Error),
42
43    /// `.marque.toml` contains a `[user]` section (FR-010, SC-006).
44    #[error(
45        "committed config file {path} contains a [user] section — classifier identity \
46         must live only in .marque.local.toml or env vars (FR-010)"
47    )]
48    UserSectionInCommitted { path: PathBuf },
49
50    /// Schema version in config doesn't match compiled schema.
51    #[error(
52        "schema version mismatch: config says {config_version:?} but marque was compiled \
53         against {compiled_version:?} (FR-011). Update [capco] version in .marque.toml."
54    )]
55    SchemaVersionMismatch {
56        config_version: String,
57        compiled_version: &'static str,
58    },
59
60    /// Confidence threshold out of range.
61    #[error("confidence_threshold {value} is outside [0.0, 1.0]")]
62    ThresholdOutOfRange { value: f32 },
63
64    /// Environment variable could not be parsed into the expected type.
65    #[error("environment variable {var} has invalid value {raw:?}: {reason}")]
66    InvalidEnvVar {
67        var: &'static str,
68        raw: String,
69        reason: &'static str,
70    },
71
72    /// Rule severity string in config is not one of the recognized values.
73    #[error(
74        "rule {rule:?} has unrecognized severity {value:?} — expected one of \
75         \"off\", \"suggest\", \"info\", \"warn\", \"error\", \"fix\""
76    )]
77    UnknownSeverity { rule: String, value: String },
78
79    /// Timezone offset string is not a recognized ISO 8601 UTC offset form.
80    #[error("invalid timezone offset {value:?} — expected \"Z\", \"+HH:MM\", or \"-HH:MM\"")]
81    InvalidTimezone { value: String },
82
83    /// Corpus-override file did not parse as JSON, or violated the
84    /// `deny_unknown_fields` contract on any wire-format struct.
85    #[error("failed to parse corpus override {path}: {reason}")]
86    CorpusOverrideParse { path: PathBuf, reason: String },
87
88    /// Corpus-override file's `schema_version` is not the value
89    /// supported by this build of marque.
90    #[error(
91        "corpus override {path} has schema_version {file_version:?} but this build of marque \
92         supports {expected:?}"
93    )]
94    CorpusOverrideSchemaMismatch {
95        path: PathBuf,
96        file_version: String,
97        expected: &'static str,
98    },
99
100    /// Corpus-override file contained a value that failed range /
101    /// finiteness validation. `section` and `key` localize the
102    /// violation so an operator can find and correct the offending
103    /// entry without grepping the whole file.
104    #[error("corpus override {path}: invalid {section}.{key}: {reason}")]
105    CorpusOverrideInvalidValue {
106        path: PathBuf,
107        section: &'static str,
108        key: String,
109        reason: &'static str,
110    },
111}
112
113impl ConfigError {
114    /// Returns the exit code for this error per `contracts/cli.md`.
115    pub fn exit_code(&self) -> i32 {
116        match self {
117            Self::ReadError { .. } => 74, // EX_IOERR
118            Self::ParseError(_) => EX_DATAERR,
119            Self::UserSectionInCommitted { .. } => EX_DATAERR,
120            Self::SchemaVersionMismatch { .. } => EX_DATAERR,
121            Self::ThresholdOutOfRange { .. } => EX_DATAERR,
122            Self::InvalidEnvVar { .. } => EX_DATAERR,
123            Self::UnknownSeverity { .. } => EX_DATAERR,
124            Self::InvalidTimezone { .. } => EX_DATAERR,
125            Self::CorpusOverrideParse { .. } => EX_DATAERR,
126            Self::CorpusOverrideSchemaMismatch { .. } => EX_DATAERR,
127            Self::CorpusOverrideInvalidValue { .. } => EX_DATAERR,
128        }
129    }
130}
131
132/// Resolved, merged configuration ready for engine use.
133#[derive(Debug, Clone)]
134pub struct Config {
135    pub user: UserConfig,
136    pub rules: RuleConfig,
137    /// Organization-specific typo corrections from `[corrections]` in `.marque.toml`.
138    ///
139    /// **Do not mutate after passing to `Engine::new`** — the engine caches
140    /// this as an `Arc<HashMap>` at construction time. Post-construction
141    /// mutation leaves the cached copy stale.
142    pub corrections: HashMap<String, String>,
143    pub capco: CapcoConfig,
144    /// Fix confidence threshold. Fixes with confidence >= this value are auto-applied.
145    /// Default: 0.95 per spec.
146    confidence_threshold: f32,
147}
148
149impl Default for Config {
150    fn default() -> Self {
151        Self {
152            user: UserConfig::default(),
153            rules: RuleConfig::default(),
154            corrections: HashMap::new(),
155            capco: CapcoConfig::default(),
156            confidence_threshold: 0.95,
157        }
158    }
159}
160
161impl Config {
162    /// Returns the confidence threshold for auto-applying fixes.
163    pub fn confidence_threshold(&self) -> f32 {
164        self.confidence_threshold
165    }
166
167    /// Set confidence threshold (validated at load time).
168    pub fn set_confidence_threshold(&mut self, value: f32) -> Result<(), ConfigError> {
169        if !(0.0..=1.0).contains(&value) || value.is_nan() {
170            return Err(ConfigError::ThresholdOutOfRange { value });
171        }
172        self.confidence_threshold = value;
173        Ok(())
174    }
175}
176
177/// User identity — always from local config, never committed.
178#[derive(Debug, Clone, Default)]
179pub struct UserConfig {
180    pub classifier_id: Option<String>,
181    pub classification_authority: Option<String>,
182    pub default_reason: Option<String>,
183    pub derived_from_default: Option<String>,
184}
185
186/// Per-rule severity overrides.
187#[derive(Debug, Clone, Default)]
188pub struct RuleConfig {
189    /// Map of rule ID → configured severity string ("fix", "warn", "error", "off").
190    pub overrides: HashMap<String, String>,
191}
192
193/// CAPCO-specific configuration.
194#[derive(Debug, Clone)]
195pub struct CapcoConfig {
196    /// Pinned ISM schema version. Must match the compiled marque-ism version.
197    pub version: String,
198
199    /// Default UTC offset applied to floating (offset-naive) `DateHourMin` and
200    /// `DateTime` values encountered during document processing.
201    ///
202    /// In national-security documents, times without an explicit offset are
203    /// conventionally Zulu (UTC). Set this to a different offset only when
204    /// processing documents from an organization that consistently marks times
205    /// with a local civil offset without recording it explicitly in the marking.
206    ///
207    /// Configurable via `[capco] default_timezone = "Z"` in `.marque.toml` or
208    /// the `MARQUE_DEFAULT_TIMEZONE` environment variable.
209    /// Accepted forms: `"Z"`, `"+HH:MM"`, `"-HH:MM"`. Defaults to UTC.
210    pub default_timezone: UtcOffset,
211}
212
213impl Default for CapcoConfig {
214    fn default() -> Self {
215        Self {
216            version: marque_ism::generated::values::SCHEMA_VERSION.to_owned(),
217            default_timezone: UtcOffset::UTC,
218        }
219    }
220}
221
222// ---------------------------------------------------------------------------
223// TOML-deserialisable file format
224// ---------------------------------------------------------------------------
225
226#[derive(Debug, Deserialize, Serialize, Default)]
227struct ConfigFile {
228    #[serde(default)]
229    user: Option<UserConfigFile>,
230    #[serde(default)]
231    rules: HashMap<String, String>,
232    #[serde(default)]
233    corrections: HashMap<String, String>,
234    #[serde(default)]
235    capco: CapcoConfigFile,
236    #[serde(default)]
237    confidence_threshold: Option<f32>,
238}
239
240#[derive(Debug, Deserialize, Serialize, Default)]
241struct UserConfigFile {
242    classifier_id: Option<String>,
243    classification_authority: Option<String>,
244    default_reason: Option<String>,
245    derived_from_default: Option<String>,
246}
247
248#[derive(Debug, Deserialize, Serialize, Default)]
249struct CapcoConfigFile {
250    version: Option<String>,
251    /// UTC offset string for floating times. Accepted: `"Z"`, `"+HH:MM"`, `"-HH:MM"`.
252    default_timezone: Option<String>,
253}
254
255// ---------------------------------------------------------------------------
256// Config loading
257// ---------------------------------------------------------------------------
258
259/// Load and merge configuration from standard locations.
260///
261/// Search order (first found wins for each layer):
262/// 1. `.marque.toml` discovered by walking upward from `start` per
263///    `contracts/cli.md`. The walk stops at the **first** of:
264///    - a directory containing `.marque.toml`
265///    - a directory containing `.git/` (git repository root)
266///    - the filesystem root
267///
268///    If the walk finds a `.marque.toml`, that directory is the project root
269///    for both Layer 1 (committed) and Layer 2 (local). If the walk finds a
270///    git root or filesystem root first, no project config is loaded —
271///    Layer 3 (env vars) still runs.
272/// 2. `.marque.local.toml` **only in the same directory** as the discovered
273///    `.marque.toml`. The local-config search is never independently walked,
274///    so a stray `.marque.local.toml` in a parent directory cannot silently
275///    attach to a child project's config.
276/// 3. Environment variables (`MARQUE_CLASSIFIER_ID`, `MARQUE_CONFIDENCE_THRESHOLD`,
277///    `MARQUE_LOG`).
278///
279/// Hard-fail validators run after merging all layers.
280pub fn load(start: &std::path::Path) -> Result<Config, ConfigError> {
281    let mut config = Config::default();
282
283    // Layer 1+2: walk upward for the project config.
284    if let Some(project_dir) = discover_project_dir(start) {
285        // Layer 1: project config
286        let project_config = project_dir.join(".marque.toml");
287        let raw = std::fs::read_to_string(&project_config).map_err(|e| ConfigError::ReadError {
288            path: project_config.clone(),
289            source: e,
290        })?;
291        let file: ConfigFile = toml::from_str(&raw)?;
292
293        // T023: refuse [user] section in committed config (FR-010, SC-006)
294        if file.user.is_some() {
295            return Err(ConfigError::UserSectionInCommitted {
296                path: project_config,
297            });
298        }
299
300        merge_project_into(&mut config, file)?;
301
302        // Layer 2: user-local config in the SAME directory only.
303        let local_config = project_dir.join(".marque.local.toml");
304        if local_config.exists() {
305            let raw =
306                std::fs::read_to_string(&local_config).map_err(|e| ConfigError::ReadError {
307                    path: local_config.clone(),
308                    source: e,
309                })?;
310            let file: ConfigFile = toml::from_str(&raw)?;
311            merge_user_into(&mut config, file);
312        }
313    }
314
315    // Layer 3: environment variables
316    apply_env(&mut config)?;
317
318    // T023: validate schema version (FR-011)
319    validate_schema_version(&config)?;
320
321    Ok(config)
322}
323
324/// Load configuration from an explicit `.marque.toml` path, bypassing the
325/// upward walk. Used by `--config <PATH>` per `contracts/cli.md`:
326/// "short-circuits the walk and uses the specified path as the project
327/// config; the local-config search still applies, only in the directory
328/// containing the supplied path."
329pub fn load_with_explicit_config(project_config: &std::path::Path) -> Result<Config, ConfigError> {
330    let mut config = Config::default();
331
332    // Layer 1: explicit project config — required to exist.
333    let raw = std::fs::read_to_string(project_config).map_err(|e| ConfigError::ReadError {
334        path: project_config.to_path_buf(),
335        source: e,
336    })?;
337    let file: ConfigFile = toml::from_str(&raw)?;
338
339    if file.user.is_some() {
340        return Err(ConfigError::UserSectionInCommitted {
341            path: project_config.to_path_buf(),
342        });
343    }
344
345    merge_project_into(&mut config, file)?;
346
347    // Layer 2: local config in the same directory as the explicit path.
348    if let Some(parent) = project_config.parent() {
349        let local_config = parent.join(".marque.local.toml");
350        if local_config.exists() {
351            let raw =
352                std::fs::read_to_string(&local_config).map_err(|e| ConfigError::ReadError {
353                    path: local_config.clone(),
354                    source: e,
355                })?;
356            let file: ConfigFile = toml::from_str(&raw)?;
357            merge_user_into(&mut config, file);
358        }
359    }
360
361    apply_env(&mut config)?;
362    validate_schema_version(&config)?;
363    Ok(config)
364}
365
366/// Walk upward from `start` looking for a directory containing `.marque.toml`.
367///
368/// Returns `Some(dir)` if a `.marque.toml` is found before hitting either a
369/// git repository root (a directory containing `.git/`) or the filesystem
370/// root. Returns `None` otherwise — falling back to built-in defaults is the
371/// caller's responsibility.
372///
373/// The walk treats `.git` as a hard stop *only when* the directory does not
374/// also contain `.marque.toml`. A repo with `.marque.toml` at its root is
375/// the common case and must succeed.
376fn discover_project_dir(start: &std::path::Path) -> Option<std::path::PathBuf> {
377    let mut current = start.to_path_buf();
378    loop {
379        if current.join(".marque.toml").is_file() {
380            return Some(current);
381        }
382        // Hit a git repo root that did not contain .marque.toml — stop.
383        // The check is for `.git` as either a file (git worktree pointer)
384        // or a directory (normal repo).
385        if current.join(".git").exists() {
386            return None;
387        }
388        if !current.pop() {
389            // Filesystem root — nothing more to walk.
390            return None;
391        }
392    }
393}
394
395fn merge_project_into(config: &mut Config, file: ConfigFile) -> Result<(), ConfigError> {
396    // H-6: validate every severity override at load time. A typo like
397    // `E001 = "err"` must fail loudly, not silently fall back to the rule
398    // default.
399    for (rule, value) in &file.rules {
400        if Severity::parse_config(value).is_none() {
401            return Err(ConfigError::UnknownSeverity {
402                rule: rule.clone(),
403                value: value.clone(),
404            });
405        }
406    }
407    config.rules.overrides.extend(file.rules);
408    config.corrections.extend(file.corrections);
409    if let Some(v) = file.capco.version {
410        config.capco.version = v;
411    }
412    if let Some(ref tz) = file.capco.default_timezone {
413        config.capco.default_timezone = tz
414            .parse::<UtcOffset>()
415            .map_err(|_| ConfigError::InvalidTimezone { value: tz.clone() })?;
416    }
417    if let Some(threshold) = file.confidence_threshold {
418        config.set_confidence_threshold(threshold)?;
419    }
420    Ok(())
421}
422
423fn merge_user_into(config: &mut Config, file: ConfigFile) {
424    // L-2: an empty string is semantically equivalent to "not set". Without
425    // this guard, a .marque.local.toml entry of `classifier_id = ""` would
426    // silently overwrite a populated value from another layer with an empty
427    // string. For a security tool where classifier identity ends up in the
428    // audit record, that is a meaningful correctness hole.
429    fn non_empty(s: Option<String>) -> Option<String> {
430        s.filter(|v| !v.trim().is_empty())
431    }
432
433    if let Some(user) = file.user {
434        if let Some(v) = non_empty(user.classifier_id) {
435            config.user.classifier_id = Some(v);
436        }
437        if let Some(v) = non_empty(user.classification_authority) {
438            config.user.classification_authority = Some(v);
439        }
440        if let Some(v) = non_empty(user.default_reason) {
441            config.user.default_reason = Some(v);
442        }
443        if let Some(v) = non_empty(user.derived_from_default) {
444            config.user.derived_from_default = Some(v);
445        }
446    }
447}
448
449fn apply_env(config: &mut Config) -> Result<(), ConfigError> {
450    // L-2 parity: apply the same non-empty guard as merge_user_into so that
451    // `MARQUE_CLASSIFIER_ID=""` does not silently overwrite a populated
452    // local-config value with an empty string.
453    if let Ok(id) = std::env::var("MARQUE_CLASSIFIER_ID") {
454        if !id.trim().is_empty() {
455            config.user.classifier_id = Some(id);
456        }
457    }
458    // C-2: propagate parse failures. `MARQUE_CONFIDENCE_THRESHOLD=0.9o` must
459    // hard-fail, not silently apply the default.
460    if let Ok(raw) = std::env::var("MARQUE_CONFIDENCE_THRESHOLD") {
461        let threshold = raw.parse::<f32>().map_err(|_| ConfigError::InvalidEnvVar {
462            var: "MARQUE_CONFIDENCE_THRESHOLD",
463            raw: raw.clone(),
464            reason: "expected a floating-point number in [0.0, 1.0]",
465        })?;
466        config.set_confidence_threshold(threshold)?;
467    }
468    // MARQUE_LOG is handled by the tracing subscriber, not by config loading.
469    // C-3: parse MARQUE_DEFAULT_TIMEZONE as an ISO 8601 UTC offset.
470    if let Ok(raw) = std::env::var("MARQUE_DEFAULT_TIMEZONE") {
471        if !raw.trim().is_empty() {
472            config.capco.default_timezone =
473                raw.parse::<UtcOffset>()
474                    .map_err(|_| ConfigError::InvalidEnvVar {
475                        var: "MARQUE_DEFAULT_TIMEZONE",
476                        raw: raw.clone(),
477                        reason: "expected \"Z\", \"+HH:MM\", or \"-HH:MM\"",
478                    })?;
479        }
480    }
481    Ok(())
482}
483
484/// T023: validate schema version matches compiled marque-ism (FR-011).
485///
486/// Exact match required — the config must use the canonical form (e.g., "ISM-v2022-DEC").
487fn validate_schema_version(config: &Config) -> Result<(), ConfigError> {
488    let compiled = marque_ism::generated::values::SCHEMA_VERSION;
489    let config_ver = &config.capco.version;
490
491    if config_ver != compiled {
492        return Err(ConfigError::SchemaVersionMismatch {
493            config_version: config_ver.clone(),
494            compiled_version: compiled,
495        });
496    }
497    Ok(())
498}
499
500// ---------------------------------------------------------------------------
501// Tests
502// ---------------------------------------------------------------------------
503
504#[cfg(test)]
505#[cfg_attr(coverage_nightly, coverage(off))]
506mod tests {
507    use super::*;
508
509    fn config_file_with_rules(rules: &[(&str, &str)]) -> ConfigFile {
510        let mut file = ConfigFile::default();
511        for (k, v) in rules {
512            file.rules.insert((*k).to_owned(), (*v).to_owned());
513        }
514        file
515    }
516
517    #[test]
518    fn set_confidence_threshold_accepts_boundaries() {
519        let mut c = Config::default();
520        assert!(c.set_confidence_threshold(0.0).is_ok());
521        assert!(c.set_confidence_threshold(1.0).is_ok());
522        assert!(c.set_confidence_threshold(0.5).is_ok());
523    }
524
525    #[test]
526    fn set_confidence_threshold_rejects_out_of_range() {
527        let mut c = Config::default();
528        assert!(matches!(
529            c.set_confidence_threshold(-0.1),
530            Err(ConfigError::ThresholdOutOfRange { .. })
531        ));
532        assert!(matches!(
533            c.set_confidence_threshold(1.1),
534            Err(ConfigError::ThresholdOutOfRange { .. })
535        ));
536    }
537
538    #[test]
539    fn set_confidence_threshold_rejects_nan() {
540        let mut c = Config::default();
541        assert!(matches!(
542            c.set_confidence_threshold(f32::NAN),
543            Err(ConfigError::ThresholdOutOfRange { .. })
544        ));
545    }
546
547    #[test]
548    fn merge_project_accepts_valid_severity_strings() {
549        let mut c = Config::default();
550        let file = config_file_with_rules(&[
551            ("E001", "fix"),
552            ("E002", "warn"),
553            ("E003", "error"),
554            ("E004", "off"),
555            ("E005", "info"),
556            ("S004", "suggest"),
557        ]);
558        assert!(merge_project_into(&mut c, file).is_ok());
559        assert_eq!(c.rules.overrides.len(), 6);
560    }
561
562    #[test]
563    fn merge_project_accepts_suggest_severity() {
564        // Issue #235 / #186 PR-3: the suggest-don't-fix channel must be
565        // a config-valid severity string. Validates the loader pipes
566        // through `Severity::parse_config("suggest")`.
567        let mut c = Config::default();
568        let file = config_file_with_rules(&[("S004", "suggest")]);
569        assert!(merge_project_into(&mut c, file).is_ok());
570        assert_eq!(
571            c.rules.overrides.get("S004").map(String::as_str),
572            Some("suggest")
573        );
574    }
575
576    #[test]
577    fn merge_project_rejects_unknown_severity() {
578        let mut c = Config::default();
579        let file = config_file_with_rules(&[("E001", "err")]);
580        let err = merge_project_into(&mut c, file).unwrap_err();
581        match err {
582            ConfigError::UnknownSeverity { rule, value } => {
583                assert_eq!(rule, "E001");
584                assert_eq!(value, "err");
585            }
586            other => panic!("expected UnknownSeverity, got {other:?}"),
587        }
588    }
589
590    #[test]
591    fn merge_project_rejects_severity_is_case_sensitive() {
592        // Severity::parse_config is case-sensitive by design — uppercase must fail.
593        let mut c = Config::default();
594        let file = config_file_with_rules(&[("E001", "FIX")]);
595        assert!(matches!(
596            merge_project_into(&mut c, file),
597            Err(ConfigError::UnknownSeverity { .. })
598        ));
599    }
600
601    #[test]
602    fn merge_project_rejects_empty_severity() {
603        let mut c = Config::default();
604        let file = config_file_with_rules(&[("E001", "")]);
605        assert!(matches!(
606            merge_project_into(&mut c, file),
607            Err(ConfigError::UnknownSeverity { .. })
608        ));
609    }
610
611    #[test]
612    fn exit_code_matches_contract() {
613        assert_eq!(
614            ConfigError::ThresholdOutOfRange { value: 2.0 }.exit_code(),
615            EX_DATAERR
616        );
617        assert_eq!(
618            ConfigError::UnknownSeverity {
619                rule: "E001".into(),
620                value: "err".into(),
621            }
622            .exit_code(),
623            EX_DATAERR
624        );
625        assert_eq!(
626            ConfigError::InvalidEnvVar {
627                var: "MARQUE_CONFIDENCE_THRESHOLD",
628                raw: "bananas".into(),
629                reason: "not a float",
630            }
631            .exit_code(),
632            EX_DATAERR
633        );
634    }
635
636    // ---------------------------------------------------------------------
637    // D.1: discover_project_dir upward-walk semantics
638    // ---------------------------------------------------------------------
639
640    use std::fs;
641    use std::path::PathBuf;
642
643    fn make_tmpdir(name: &str) -> PathBuf {
644        let dir =
645            std::env::temp_dir().join(format!("marque-config-test-{name}-{}", std::process::id()));
646        let _ = fs::remove_dir_all(&dir);
647        fs::create_dir_all(&dir).expect("create tmpdir");
648        dir
649    }
650
651    #[test]
652    fn discover_finds_marque_toml_in_start_dir() {
653        let dir = make_tmpdir("discover-here");
654        fs::write(dir.join(".marque.toml"), b"").unwrap();
655        assert_eq!(super::discover_project_dir(&dir), Some(dir.clone()));
656        let _ = fs::remove_dir_all(&dir);
657    }
658
659    #[test]
660    fn discover_walks_upward_for_marque_toml() {
661        // tmp/root/.marque.toml; start from tmp/root/sub/deeper.
662        let root = make_tmpdir("discover-walk");
663        fs::write(root.join(".marque.toml"), b"").unwrap();
664        let sub = root.join("sub").join("deeper");
665        fs::create_dir_all(&sub).unwrap();
666        assert_eq!(super::discover_project_dir(&sub), Some(root.clone()));
667        let _ = fs::remove_dir_all(&root);
668    }
669
670    #[test]
671    fn discover_stops_at_git_root_without_marque_toml() {
672        // tmp/root/.git/ + tmp/root/sub/ — start from sub, walk should hit
673        // .git in root and return None (no project config above this point).
674        let root = make_tmpdir("discover-git-stop");
675        fs::create_dir_all(root.join(".git")).unwrap();
676        let sub = root.join("sub");
677        fs::create_dir_all(&sub).unwrap();
678        assert_eq!(super::discover_project_dir(&sub), None);
679        let _ = fs::remove_dir_all(&root);
680    }
681
682    #[test]
683    fn discover_returns_marque_toml_at_git_root_when_both_present() {
684        // The common case: a repo whose root has both .git and .marque.toml.
685        // The walk must NOT stop at .git before checking .marque.toml.
686        let root = make_tmpdir("discover-both");
687        fs::create_dir_all(root.join(".git")).unwrap();
688        fs::write(root.join(".marque.toml"), b"").unwrap();
689        let sub = root.join("crates").join("foo");
690        fs::create_dir_all(&sub).unwrap();
691        assert_eq!(super::discover_project_dir(&sub), Some(root.clone()));
692        let _ = fs::remove_dir_all(&root);
693    }
694
695    #[test]
696    fn load_walks_upward_to_find_project_config() {
697        // tmp/root/.marque.toml + tmp/root/sub/, load from sub.
698        let root = make_tmpdir("load-walk");
699        fs::write(
700            root.join(".marque.toml"),
701            br#"
702[rules]
703E001 = "warn"
704"#,
705        )
706        .unwrap();
707        let sub = root.join("sub");
708        fs::create_dir_all(&sub).unwrap();
709        let config = super::load(&sub).expect("load should succeed");
710        assert_eq!(config.rules.overrides.get("E001"), Some(&"warn".to_owned()));
711        let _ = fs::remove_dir_all(&root);
712    }
713
714    #[test]
715    fn load_returns_defaults_when_walk_finds_no_marque_toml() {
716        // tmp/root/.git but no .marque.toml — load returns defaults.
717        let root = make_tmpdir("load-defaults");
718        fs::create_dir_all(root.join(".git")).unwrap();
719        let sub = root.join("sub");
720        fs::create_dir_all(&sub).unwrap();
721        let config = super::load(&sub).expect("load should succeed with defaults");
722        assert!(config.rules.overrides.is_empty());
723        let _ = fs::remove_dir_all(&root);
724    }
725
726    #[test]
727    fn load_local_config_only_in_same_dir_as_marque_toml() {
728        // tmp/root/.marque.toml + tmp/root/.marque.local.toml
729        // tmp/root/sub/.marque.local.toml (should NOT be loaded)
730        let root = make_tmpdir("load-local-same-dir");
731        fs::write(
732            root.join(".marque.toml"),
733            br#"
734[capco]
735"#,
736        )
737        .unwrap();
738        fs::write(
739            root.join(".marque.local.toml"),
740            br#"
741[user]
742classifier_id = "from-root"
743"#,
744        )
745        .unwrap();
746        let sub = root.join("sub");
747        fs::create_dir_all(&sub).unwrap();
748        // A stray local config in `sub` should NOT be loaded — the local
749        // search is anchored to the directory of the project config.
750        fs::write(
751            sub.join(".marque.local.toml"),
752            br#"
753[user]
754classifier_id = "from-sub"
755"#,
756        )
757        .unwrap();
758        let config = super::load(&sub).expect("load should succeed");
759        assert_eq!(
760            config.user.classifier_id.as_deref(),
761            Some("from-root"),
762            "local config must be the one alongside .marque.toml, not in sub"
763        );
764        let _ = fs::remove_dir_all(&root);
765    }
766
767    #[test]
768    #[cfg(unix)]
769    fn load_returns_read_error_for_unreadable_project_config() {
770        use std::os::unix::fs::PermissionsExt;
771        let root = make_tmpdir("load-err-proj");
772        let project_config = root.join(".marque.toml");
773        fs::write(&project_config, b"").unwrap();
774
775        let mut perms = fs::metadata(&project_config).unwrap().permissions();
776        perms.set_mode(0o000); // remove read permission
777        fs::set_permissions(&project_config, perms).unwrap();
778
779        let err = super::load(&root).unwrap_err();
780        assert!(matches!(err, ConfigError::ReadError { .. }));
781
782        let _ = fs::remove_dir_all(&root);
783    }
784
785    #[test]
786    #[cfg(unix)]
787    fn load_returns_read_error_for_unreadable_local_config() {
788        use std::os::unix::fs::PermissionsExt;
789        let root = make_tmpdir("load-err-local");
790        fs::write(root.join(".marque.toml"), b"").unwrap();
791
792        let local_config = root.join(".marque.local.toml");
793        fs::write(&local_config, b"").unwrap();
794
795        let mut perms = fs::metadata(&local_config).unwrap().permissions();
796        perms.set_mode(0o000); // remove read permission
797        fs::set_permissions(&local_config, perms).unwrap();
798
799        let err = super::load(&root).unwrap_err();
800        assert!(matches!(err, ConfigError::ReadError { .. }));
801
802        let _ = fs::remove_dir_all(&root);
803    }
804
805    // ---------------------------------------------------------------------
806    // TZ-1: timezone config
807    // ---------------------------------------------------------------------
808
809    #[test]
810    fn capco_default_timezone_defaults_to_utc() {
811        let c = Config::default();
812        assert_eq!(c.capco.default_timezone, UtcOffset::UTC);
813    }
814
815    #[test]
816    fn merge_project_accepts_valid_timezone_offsets() {
817        for tz in ["Z", "+05:30", "-05:00", "+00:00", "+23:59"] {
818            let mut c = Config::default();
819            let mut file = ConfigFile::default();
820            file.capco.default_timezone = Some(tz.to_owned());
821            assert!(
822                merge_project_into(&mut c, file).is_ok(),
823                "should accept timezone {tz:?}"
824            );
825        }
826    }
827
828    #[test]
829    fn merge_project_timezone_sets_correct_offset() {
830        let mut c = Config::default();
831        let mut file = ConfigFile::default();
832        file.capco.default_timezone = Some("+05:30".to_owned());
833        merge_project_into(&mut c, file).unwrap();
834        assert_eq!(
835            c.capco.default_timezone,
836            UtcOffset::from_hhmm(1, 5, 30).unwrap()
837        );
838    }
839
840    #[test]
841    fn merge_project_rejects_invalid_timezone() {
842        for bad in ["EST", "UTC", "utc", "+0530", "+05-30", "05:30"] {
843            let mut c = Config::default();
844            let mut file = ConfigFile::default();
845            file.capco.default_timezone = Some(bad.to_owned());
846            assert!(
847                matches!(
848                    merge_project_into(&mut c, file),
849                    Err(ConfigError::InvalidTimezone { .. })
850                ),
851                "should reject timezone {bad:?}"
852            );
853        }
854    }
855
856    #[test]
857    fn utc_offset_from_str_z_is_utc() {
858        // Exercising UtcOffset::from_str through the config-layer parse path.
859        assert_eq!("Z".parse::<UtcOffset>().unwrap(), UtcOffset::UTC);
860    }
861
862    #[test]
863    fn utc_offset_from_str_wrong_separator_is_err() {
864        // `+05-30` has `-` instead of `:` at index 3.
865        assert!("+05-30".parse::<UtcOffset>().is_err());
866    }
867
868    #[test]
869    fn utc_offset_from_str_out_of_range_is_err() {
870        // Hours > 23 must be rejected.
871        assert!("+24:00".parse::<UtcOffset>().is_err());
872    }
873}