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