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