Skip to main content

aristo_core/config/
mod.rs

1//! `aristo.toml` document schema (per TOOLS.md §4 field map).
2//!
3//! Every section is optional and has a sensible default — a project with
4//! an empty `aristo.toml` (just `[__meta__]`-less, since this format has
5//! no meta header) gets the same behavior as one with no config at all.
6//!
7//! ```toml
8//! [verify]
9//! default_method = "full"
10//!
11//! [verify.cache]
12//! strategy     = "local"
13//! commit_specs = true
14//!
15//! [stamp]
16//! hooks            = "pre-commit"
17//! hash_crate_root  = false
18//!
19//! [telemetry]
20//! enabled = false
21//!
22//! [lint]
23//! pre_commit = "check"     # also accepts a bool: true → "check", false → "off"
24//! strict     = false
25//!
26//! [lint.rules.empty_text]
27//! severity  = "error"
28//!
29//! [corpus]
30//! contribute = false
31//!
32//! [doc]
33//! commit_artifacts = true
34//! position         = "before"
35//! ```
36
37use std::collections::BTreeMap;
38
39use schemars::JsonSchema;
40use serde::{Deserialize, Deserializer, Serialize, Serializer};
41
42use crate::index::VerifyMethod;
43
44/// Top-level `aristo.toml` document. Every field defaults; an empty
45/// file produces a `ConfigFile` with each section at its default.
46#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
47#[serde(deny_unknown_fields)]
48pub struct ConfigFile {
49    #[serde(default)]
50    pub verify: VerifyConfig,
51    #[serde(default)]
52    pub stamp: StampConfig,
53    #[serde(default)]
54    pub telemetry: TelemetryConfig,
55    #[serde(default)]
56    pub lint: LintConfig,
57    #[serde(default)]
58    pub corpus: CorpusConfig,
59    #[serde(default)]
60    pub doc: DocConfig,
61    #[serde(default)]
62    pub index: IndexConfig,
63    #[serde(default)]
64    pub canon: CanonConfig,
65    #[serde(default)]
66    pub nudges: NudgesConfig,
67}
68
69// ─── [verify] ──────────────────────────────────────────────────────────────
70
71#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
72#[serde(deny_unknown_fields)]
73pub struct VerifyConfig {
74    /// Resolves `verify = true` on annotations to a concrete method.
75    /// `None` means "use the per-tier default" (free → `"test"`,
76    /// paid → `"full"` per G1).
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub default_method: Option<VerifyMethod>,
79    #[serde(default)]
80    pub cache: VerifyCacheConfig,
81}
82
83#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
84#[serde(deny_unknown_fields)]
85pub struct VerifyCacheConfig {
86    /// Where mined-assertion specs are cached. `Local` keeps them in
87    /// `.aristo/specs/` only; `AristoCloud` opts in to cross-machine
88    /// caching via the Aristo server (free users must explicitly
89    /// enable per G7).
90    #[serde(default)]
91    pub strategy: CacheStrategy,
92    /// Whether to commit `.aristo/specs/` to git. Default `true`
93    /// (matches the .gitignore precedent — fresh clones produce
94    /// reproducible verification runs).
95    #[serde(default = "default_true")]
96    pub commit_specs: bool,
97}
98
99impl Default for VerifyCacheConfig {
100    fn default() -> Self {
101        Self {
102            strategy: CacheStrategy::default(),
103            commit_specs: true,
104        }
105    }
106}
107
108#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
109#[serde(rename_all = "kebab-case")]
110pub enum CacheStrategy {
111    /// `.aristo/specs/` only — no server roundtrip.
112    #[default]
113    Local,
114    /// Opt-in cross-machine cache via the Aristo server.
115    AristoCloud,
116}
117
118// ─── [stamp] ───────────────────────────────────────────────────────────────
119
120#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
121#[serde(deny_unknown_fields)]
122pub struct StampConfig {
123    /// Which git hook to install. Default `PreCommit`.
124    #[serde(default)]
125    pub hooks: HooksMode,
126    /// Whether to hash the entire crate token-stream for crate-root
127    /// annotation staleness detection. Default `false` (expensive on
128    /// large crates per B3).
129    #[serde(default)]
130    pub hash_crate_root: bool,
131}
132
133#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
134#[serde(rename_all = "kebab-case")]
135pub enum HooksMode {
136    /// Install a `.git/hooks/pre-commit` script that runs `aristo stamp`
137    /// and (per `[lint] pre_commit`) `aristo lint`.
138    #[default]
139    PreCommit,
140    /// Don't install any git hooks. CI is expected to gate via
141    /// `aristo stamp --check` / `aristo lint --check`.
142    None,
143}
144
145// ─── [telemetry] ──────────────────────────────────────────────────────────
146
147#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
148#[serde(deny_unknown_fields)]
149pub struct TelemetryConfig {
150    /// Opt-in toggle for free-tier usage telemetry. Default `false`.
151    /// Per H8: never gated as required; fully off by default.
152    #[serde(default)]
153    pub enabled: bool,
154}
155
156// ─── [lint] ────────────────────────────────────────────────────────────────
157
158#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
159#[serde(deny_unknown_fields)]
160pub struct LintConfig {
161    /// Pre-commit-hook lint mode. Per J6: string enum
162    /// (`"off"` / `"check"` / `"fix"`) with bool back-compat
163    /// (`true` → `Check`, `false` → `Off`).
164    #[serde(default)]
165    pub pre_commit: LintPreCommit,
166    /// When `true`, `aristo lint --check` exits non-zero on `warn`
167    /// findings as well as `error`. Default `false`.
168    #[serde(default)]
169    pub strict: bool,
170    /// Per-rule configuration overrides. Map key is the rule name
171    /// (e.g., `"empty_text"`).
172    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
173    pub rules: BTreeMap<String, LintRuleConfig>,
174}
175
176impl Default for LintConfig {
177    fn default() -> Self {
178        Self {
179            pre_commit: LintPreCommit::Check,
180            strict: false,
181            rules: BTreeMap::new(),
182        }
183    }
184}
185
186/// `[lint] pre_commit` value. Wire form is a string (`"off"` / `"check"` /
187/// `"fix"`); deserialization additionally accepts a bool for back-compat
188/// per J6 — `true` → `Check`, `false` → `Off`.
189///
190/// Custom `Serialize` always emits the canonical string form so a
191/// round-trip normalizes the bool form.
192#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
193pub enum LintPreCommit {
194    /// Skip lint in the pre-commit hook entirely. CI still runs
195    /// `aristo lint --check` per the starter workflow.
196    Off,
197    /// Run `aristo lint --check` in the hook — fail-fast, never
198    /// silently modifies staged content. Standard devtool default.
199    #[default]
200    Check,
201    /// Run `aristo lint --fix` and re-stage modified files.
202    /// Opt-in for teams that want auto-fix-and-restage.
203    Fix,
204}
205
206impl Serialize for LintPreCommit {
207    fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
208        s.serialize_str(self.as_str())
209    }
210}
211
212impl<'de> Deserialize<'de> for LintPreCommit {
213    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
214        // Accepts string ("off" | "check" | "fix") OR bool (J6 back-compat).
215        #[derive(Deserialize)]
216        #[serde(untagged)]
217        enum Wire {
218            Str(String),
219            Bool(bool),
220        }
221        match Wire::deserialize(d)? {
222            Wire::Str(s) => match s.as_str() {
223                "off" => Ok(Self::Off),
224                "check" => Ok(Self::Check),
225                "fix" => Ok(Self::Fix),
226                other => Err(serde::de::Error::unknown_variant(
227                    other,
228                    &["off", "check", "fix"],
229                )),
230            },
231            Wire::Bool(true) => Ok(Self::Check),
232            Wire::Bool(false) => Ok(Self::Off),
233        }
234    }
235}
236
237impl JsonSchema for LintPreCommit {
238    fn schema_name() -> String {
239        "LintPreCommit".to_owned()
240    }
241    fn json_schema(_gen: &mut schemars::gen::SchemaGenerator) -> schemars::schema::Schema {
242        use schemars::schema::*;
243        // oneOf: string enum OR bool (back-compat).
244        Schema::Object(SchemaObject {
245            subschemas: Some(Box::new(SubschemaValidation {
246                one_of: Some(vec![
247                    Schema::Object(SchemaObject {
248                        instance_type: Some(InstanceType::String.into()),
249                        enum_values: Some(vec![
250                            serde_json::json!("off"),
251                            serde_json::json!("check"),
252                            serde_json::json!("fix"),
253                        ]),
254                        ..Default::default()
255                    }),
256                    Schema::Object(SchemaObject {
257                        instance_type: Some(InstanceType::Boolean.into()),
258                        ..Default::default()
259                    }),
260                ]),
261                ..Default::default()
262            })),
263            metadata: Some(Box::new(Metadata {
264                description: Some(
265                    "`[lint] pre_commit` — string enum (\"off\" | \"check\" | \"fix\") \
266                     or bool (true → \"check\", false → \"off\") for J6 back-compat."
267                        .to_owned(),
268                ),
269                ..Default::default()
270            })),
271            ..Default::default()
272        })
273    }
274}
275
276impl LintPreCommit {
277    fn as_str(self) -> &'static str {
278        match self {
279            Self::Off => "off",
280            Self::Check => "check",
281            Self::Fix => "fix",
282        }
283    }
284}
285
286/// Per-rule lint configuration. All fields are optional; each individual
287/// rule consumes the subset that applies to it (e.g., `pattern` +
288/// `message` are only meaningful for the custom-regex rule).
289#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
290#[serde(deny_unknown_fields)]
291pub struct LintRuleConfig {
292    #[serde(default, skip_serializing_if = "Option::is_none")]
293    pub severity: Option<Severity>,
294    #[serde(default, skip_serializing_if = "Option::is_none")]
295    pub threshold: Option<u32>,
296    #[serde(default, skip_serializing_if = "Option::is_none")]
297    pub auto_fix: Option<bool>,
298    #[serde(default, skip_serializing_if = "Option::is_none")]
299    pub pattern: Option<String>,
300    #[serde(default, skip_serializing_if = "Option::is_none")]
301    pub message: Option<String>,
302}
303
304#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
305#[serde(rename_all = "lowercase")]
306pub enum Severity {
307    Info,
308    Warn,
309    Error,
310}
311
312// ─── [corpus] ─────────────────────────────────────────────────────────────
313
314#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
315#[serde(deny_unknown_fields)]
316pub struct CorpusConfig {
317    /// Opt-in for paid users to contribute abstracted annotation
318    /// patterns to the server-side property-template library.
319    /// Default `false`. Default-on for design partners (set in
320    /// their contract, applied as user-visible `true`).
321    #[serde(default)]
322    pub contribute: bool,
323}
324
325// ─── [doc] ────────────────────────────────────────────────────────────────
326
327#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
328#[serde(deny_unknown_fields)]
329pub struct DocConfig {
330    /// Whether `.aristo/doc/*` markdown + graph artifacts are
331    /// committed. Default `true` so a fresh clone renders correct
332    /// docs without re-running anything.
333    #[serde(default = "default_true")]
334    pub commit_artifacts: bool,
335    /// Where the Aristo-injected `#[doc = ...]` block sits relative
336    /// to the user's hand-written `///` comments. Default `Before`
337    /// (verified-intent claims at the top of each item's rendered docs).
338    #[serde(default)]
339    pub position: DocPosition,
340}
341
342impl Default for DocConfig {
343    fn default() -> Self {
344        Self {
345            commit_artifacts: true,
346            position: DocPosition::default(),
347        }
348    }
349}
350
351#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
352#[serde(rename_all = "lowercase")]
353pub enum DocPosition {
354    #[default]
355    Before,
356    After,
357}
358
359// ─── [index] ──────────────────────────────────────────────────────────────
360
361/// Filters applied during the source walk. Always-skipped directory
362/// names (`target/`, `.git/`, `.aristo/`, `node_modules/`) are
363/// hardcoded in the walker; `exclude` adds project-specific globs on
364/// top of that floor (e.g., `"**/tests/ui/**"` to skip trybuild
365/// fixtures that contain intentional empty-text annotations).
366#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
367#[serde(deny_unknown_fields)]
368pub struct IndexConfig {
369    /// Glob patterns (matched against paths relative to the workspace
370    /// root) that the walker skips. Standard `**` / `*` / `?` syntax
371    /// per `globset`. Paths use forward slashes regardless of host OS.
372    #[serde(default, skip_serializing_if = "Vec::is_empty")]
373    pub exclude: Vec<String>,
374}
375
376// ─── [canon] ──────────────────────────────────────────────────────────────
377
378/// §13 canon-and-matching tunables (Pro/Enterprise tiers only — the
379/// free tier ignores this section and surfaces an upgrade nudge).
380///
381/// `enabled` is the project-level opt-out: regulated buyers and
382/// air-gapped CI set `enabled = false` to skip canon API calls
383/// unconditionally. Default is `true`; tier-gating is server-side
384/// (the API returns the upgrade nudge for free-tier tokens).
385///
386/// The two threshold knobs control which match candidates surface.
387/// Server enforces a floor of `0.5` (HTTP 400 below that). Defaults
388/// match `docs/mockups/13-canon-and-matching/README.md` §L3:
389///   - `threshold_stamp = 0.85` — stamp surfaces only high-confidence
390///     matches (the daily-loop default; minimizes noise).
391///   - `threshold_critique = 0.65` — critique surfaces broader
392///     candidates (the deliberate review pass; user is reviewing).
393///
394/// No `flavor` field: scope membership is server-resolved from
395/// repo identity per canon-strategy.md §CS8.
396#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
397#[serde(deny_unknown_fields)]
398pub struct CanonConfig {
399    /// Project-level opt-out. Default `true`. When `false`, canon
400    /// API calls are skipped unconditionally; cached matches remain
401    /// readable but no new matches are surfaced and no accept-path
402    /// runs.
403    #[serde(default = "default_true")]
404    pub enabled: bool,
405    /// Confidence threshold for matches surfaced by `aristo stamp`.
406    /// Honored above the server-enforced `0.5` floor. Default `0.85`.
407    #[serde(default = "default_threshold_stamp")]
408    pub threshold_stamp: f64,
409    /// Confidence threshold for matches surfaced by `aristo critique`.
410    /// Honored above the server-enforced `0.5` floor. Default `0.65`.
411    #[serde(default = "default_threshold_critique")]
412    pub threshold_critique: f64,
413}
414
415impl Default for CanonConfig {
416    fn default() -> Self {
417        Self {
418            enabled: true,
419            threshold_stamp: default_threshold_stamp(),
420            threshold_critique: default_threshold_critique(),
421        }
422    }
423}
424
425impl Eq for CanonConfig {}
426
427fn default_threshold_stamp() -> f64 {
428    0.85
429}
430
431fn default_threshold_critique() -> f64 {
432    0.65
433}
434
435// ─── [nudges] ─────────────────────────────────────────────────────────────
436
437/// `[nudges]` — the proactive nudge/progress engine (Phase 18). A single
438/// `aggressiveness` knob scales every nudge's fire threshold (and the
439/// human-prompt cooldown); `off` silences the engine entirely — the global
440/// opt-out, mirroring `[canon] enabled = false`.
441#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
442#[serde(deny_unknown_fields)]
443pub struct NudgesConfig {
444    /// How eagerly the engine surfaces nudges. Higher lowers every
445    /// signal's fire threshold and shortens the human cooldown; `off`
446    /// disables all nudges. Default `medium`.
447    #[serde(default)]
448    pub aggressiveness: Aggressiveness,
449}
450
451/// Nudge aggressiveness ladder. Maps to a numeric factor `f` the scorer
452/// multiplies into each signal's normalized pressure: a signal fires when
453/// `pressure * f >= 1`, so higher `f` fires sooner. `Off` yields `f = 0`,
454/// the structural global opt-out (nothing can fire).
455#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
456#[serde(rename_all = "lowercase")]
457pub enum Aggressiveness {
458    /// No nudges at all (global opt-out).
459    Off,
460    /// Quietest: only large backlogs / strong signals surface.
461    Low,
462    /// Balanced default.
463    #[default]
464    Medium,
465    /// Eager: surfaces sooner and re-arms faster.
466    High,
467}
468
469impl Aggressiveness {
470    #[aristo::intent(
471        "Off MUST map to factor 0.0 — it is the global opt-out, and the \
472         scorer's fire test is `pressure * factor >= 1`, so only an exact \
473         0.0 guarantees NOTHING ever fires regardless of how overdue a \
474         signal is. A non-zero `low` would let extreme pressure leak \
475         through a user who explicitly silenced nudges. The non-zero rungs \
476         are tunable defaults (D8); this table is the single place to \
477         retune global nudge sensitivity.",
478        verify = "neural",
479        id = "aggressiveness_off_is_hard_silence"
480    )]
481    pub fn factor(self) -> f64 {
482        match self {
483            Aggressiveness::Off => 0.0,
484            Aggressiveness::Low => 0.6,
485            Aggressiveness::Medium => 1.0,
486            Aggressiveness::High => 1.6,
487        }
488    }
489
490    /// True when nudges are entirely disabled (`aggressiveness = "off"`).
491    pub fn is_off(self) -> bool {
492        matches!(self, Aggressiveness::Off)
493    }
494}
495
496// ─── helpers ──────────────────────────────────────────────────────────────
497
498fn default_true() -> bool {
499    true
500}
501
502/// Produce the canonical JSON Schema (draft-07 via schemars 0.8) for the
503/// project-level `aristo.toml` config file.
504pub fn config_file_schema_json() -> String {
505    let schema = schemars::schema_for!(ConfigFile);
506    serde_json::to_string_pretty(&schema)
507        .expect("serializing a schemars-derived schema cannot fail")
508}
509
510#[cfg(test)]
511mod tests {
512    use super::*;
513
514    #[test]
515    fn empty_toml_yields_all_defaults() {
516        let config: ConfigFile = toml::from_str("").unwrap();
517        assert_eq!(config, ConfigFile::default());
518        assert_eq!(config.verify.cache.strategy, CacheStrategy::Local);
519        assert!(config.verify.cache.commit_specs);
520        assert_eq!(config.stamp.hooks, HooksMode::PreCommit);
521        assert!(!config.stamp.hash_crate_root);
522        assert!(!config.telemetry.enabled);
523        assert_eq!(config.lint.pre_commit, LintPreCommit::Check);
524        assert!(!config.lint.strict);
525        assert!(config.lint.rules.is_empty());
526        assert!(!config.corpus.contribute);
527        assert!(config.doc.commit_artifacts);
528        assert_eq!(config.doc.position, DocPosition::Before);
529        assert!(config.canon.enabled);
530        assert!((config.canon.threshold_stamp - 0.85).abs() < f64::EPSILON);
531        assert!((config.canon.threshold_critique - 0.65).abs() < f64::EPSILON);
532        assert_eq!(config.nudges.aggressiveness, Aggressiveness::Medium);
533    }
534
535    #[test]
536    fn canon_section_round_trips() {
537        let toml_text = "\
538            [canon]\n\
539            enabled = true\n\
540            threshold_stamp = 0.9\n\
541            threshold_critique = 0.7\n\
542        ";
543        let config: ConfigFile = toml::from_str(toml_text).unwrap();
544        assert!(config.canon.enabled);
545        assert!((config.canon.threshold_stamp - 0.9).abs() < f64::EPSILON);
546        assert!((config.canon.threshold_critique - 0.7).abs() < f64::EPSILON);
547    }
548
549    #[test]
550    fn canon_enabled_false_is_the_opt_out_for_regulated_buyers() {
551        // canon-strategy.md §CS5 + README L3: project-level opt-out
552        // via `[canon] enabled = false`.
553        let toml_text = "[canon]\nenabled = false\n";
554        let config: ConfigFile = toml::from_str(toml_text).unwrap();
555        assert!(!config.canon.enabled);
556        // Thresholds still default when unspecified.
557        assert!((config.canon.threshold_stamp - 0.85).abs() < f64::EPSILON);
558        assert!((config.canon.threshold_critique - 0.65).abs() < f64::EPSILON);
559    }
560
561    #[test]
562    fn canon_section_rejects_flavor_field() {
563        // Per canon-strategy.md §CS8: NO user-side flavor declaration
564        // anywhere. Scope membership is server-resolved from repo
565        // identity. A `flavor` field in [canon] must be rejected by
566        // serde's `deny_unknown_fields`.
567        let toml_text = "[canon]\nflavor = \"turso\"\n";
568        let result: Result<ConfigFile, _> = toml::from_str(toml_text);
569        assert!(result.is_err(), "expected deny_unknown_fields rejection");
570    }
571
572    #[test]
573    fn canon_partial_section_keeps_other_defaults() {
574        // Only enabled set; thresholds keep their defaults.
575        let toml_text = "[canon]\nenabled = false\n";
576        let config: ConfigFile = toml::from_str(toml_text).unwrap();
577        assert!(!config.canon.enabled);
578        assert_eq!(
579            config.canon.threshold_stamp,
580            CanonConfig::default().threshold_stamp
581        );
582        assert_eq!(
583            config.canon.threshold_critique,
584            CanonConfig::default().threshold_critique
585        );
586    }
587
588    #[test]
589    fn lint_pre_commit_accepts_string_form() {
590        for (s, expected) in [
591            ("off", LintPreCommit::Off),
592            ("check", LintPreCommit::Check),
593            ("fix", LintPreCommit::Fix),
594        ] {
595            let toml_text = format!("[lint]\npre_commit = \"{s}\"\n");
596            let config: ConfigFile = toml::from_str(&toml_text).unwrap();
597            assert_eq!(config.lint.pre_commit, expected);
598        }
599    }
600
601    #[test]
602    fn lint_pre_commit_bool_back_compat() {
603        // J6: true → Check, false → Off
604        for (b, expected) in [(true, LintPreCommit::Check), (false, LintPreCommit::Off)] {
605            let toml_text = format!("[lint]\npre_commit = {b}\n");
606            let config: ConfigFile = toml::from_str(&toml_text).unwrap();
607            assert_eq!(config.lint.pre_commit, expected);
608        }
609    }
610
611    #[test]
612    fn lint_pre_commit_unknown_string_rejected() {
613        let toml_text = "[lint]\npre_commit = \"sometimes\"\n";
614        let result: Result<ConfigFile, _> = toml::from_str(toml_text);
615        assert!(result.is_err());
616    }
617
618    #[test]
619    fn lint_pre_commit_serializes_as_string() {
620        let mut config = ConfigFile::default();
621        config.lint.pre_commit = LintPreCommit::Fix;
622        let toml_text = toml::to_string(&config).unwrap();
623        assert!(toml_text.contains("pre_commit = \"fix\""));
624    }
625
626    #[test]
627    fn lint_pre_commit_bool_form_normalizes_on_round_trip() {
628        // bool input → string output (canonical form)
629        let config: ConfigFile = toml::from_str("[lint]\npre_commit = true\n").unwrap();
630        let serialized = toml::to_string(&config).unwrap();
631        let reparsed: ConfigFile = toml::from_str(&serialized).unwrap();
632        assert_eq!(reparsed.lint.pre_commit, LintPreCommit::Check);
633    }
634
635    #[test]
636    fn cache_strategy_uses_kebab_case() {
637        let v = serde_json::to_value(CacheStrategy::AristoCloud).unwrap();
638        assert_eq!(v, serde_json::json!("aristo-cloud"));
639    }
640
641    #[test]
642    fn hooks_mode_uses_kebab_case() {
643        let v = serde_json::to_value(HooksMode::PreCommit).unwrap();
644        assert_eq!(v, serde_json::json!("pre-commit"));
645    }
646
647    #[test]
648    fn doc_position_uses_lowercase() {
649        for variant in [DocPosition::Before, DocPosition::After] {
650            let v = serde_json::to_value(variant).unwrap();
651            // "before" or "after"
652            assert!(v.is_string());
653            assert_eq!(
654                v.as_str().unwrap(),
655                match variant {
656                    DocPosition::Before => "before",
657                    DocPosition::After => "after",
658                }
659            );
660        }
661    }
662
663    #[test]
664    fn lint_rules_map_round_trips() {
665        let toml_text = r#"
666[lint.rules.empty_text]
667severity = "error"
668
669[lint.rules.long_text]
670severity = "warn"
671threshold = 200
672"#;
673        let config: ConfigFile = toml::from_str(toml_text).unwrap();
674        assert_eq!(config.lint.rules.len(), 2);
675        let empty_text = config.lint.rules.get("empty_text").unwrap();
676        assert_eq!(empty_text.severity, Some(Severity::Error));
677        let long_text = config.lint.rules.get("long_text").unwrap();
678        assert_eq!(long_text.threshold, Some(200));
679    }
680
681    #[test]
682    fn unknown_top_level_field_rejected() {
683        let toml_text = "totally_unknown = 42\n";
684        let result: Result<ConfigFile, _> = toml::from_str(toml_text);
685        assert!(result.is_err());
686    }
687
688    #[test]
689    fn unknown_section_field_rejected() {
690        let toml_text = "[verify]\nunknown_field = \"x\"\n";
691        let result: Result<ConfigFile, _> = toml::from_str(toml_text);
692        assert!(result.is_err());
693    }
694
695    #[test]
696    fn full_config_round_trips() {
697        let mut config = ConfigFile::default();
698        config.verify.default_method = Some(VerifyMethod::Full);
699        config.verify.cache.strategy = CacheStrategy::AristoCloud;
700        config.verify.cache.commit_specs = false;
701        config.stamp.hooks = HooksMode::None;
702        config.stamp.hash_crate_root = true;
703        config.telemetry.enabled = true;
704        config.lint.pre_commit = LintPreCommit::Fix;
705        config.lint.strict = true;
706        config.lint.rules.insert(
707            "empty_text".into(),
708            LintRuleConfig {
709                severity: Some(Severity::Error),
710                ..Default::default()
711            },
712        );
713        config.corpus.contribute = true;
714        config.doc.commit_artifacts = false;
715        config.doc.position = DocPosition::After;
716
717        let toml_text = toml::to_string(&config).unwrap();
718        let back: ConfigFile = toml::from_str(&toml_text).unwrap();
719        assert_eq!(back, config);
720    }
721}