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