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