1use std::collections::BTreeMap;
38
39use schemars::JsonSchema;
40use serde::{Deserialize, Deserializer, Serialize, Serializer};
41
42use crate::index::VerifyMethod;
43
44#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
47#[serde(deny_unknown_fields)]
48pub struct ConfigFile {
49 #[serde(default)]
50 pub verify: VerifyConfig,
51 #[serde(default)]
52 pub stamp: StampConfig,
53 #[serde(default)]
54 pub telemetry: TelemetryConfig,
55 #[serde(default)]
56 pub lint: LintConfig,
57 #[serde(default)]
58 pub corpus: CorpusConfig,
59 #[serde(default)]
60 pub doc: DocConfig,
61 #[serde(default)]
62 pub index: IndexConfig,
63 #[serde(default)]
64 pub canon: CanonConfig,
65 #[serde(default)]
66 pub nudges: NudgesConfig,
67}
68
69#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
72#[serde(deny_unknown_fields)]
73pub struct VerifyConfig {
74 #[serde(default, skip_serializing_if = "Option::is_none")]
78 pub default_method: Option<VerifyMethod>,
79 #[serde(default)]
80 pub cache: VerifyCacheConfig,
81}
82
83#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
84#[serde(deny_unknown_fields)]
85pub struct VerifyCacheConfig {
86 #[serde(default)]
91 pub strategy: CacheStrategy,
92 #[serde(default = "default_true")]
96 pub commit_specs: bool,
97}
98
99impl Default for VerifyCacheConfig {
100 fn default() -> Self {
101 Self {
102 strategy: CacheStrategy::default(),
103 commit_specs: true,
104 }
105 }
106}
107
108#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
109#[serde(rename_all = "kebab-case")]
110pub enum CacheStrategy {
111 #[default]
113 Local,
114 AristoCloud,
116}
117
118#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
121#[serde(deny_unknown_fields)]
122pub struct StampConfig {
123 #[serde(default)]
125 pub hooks: HooksMode,
126 #[serde(default)]
130 pub hash_crate_root: bool,
131}
132
133#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
134#[serde(rename_all = "kebab-case")]
135pub enum HooksMode {
136 #[default]
139 PreCommit,
140 None,
143}
144
145#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
148#[serde(deny_unknown_fields)]
149pub struct TelemetryConfig {
150 #[serde(default)]
153 pub enabled: bool,
154}
155
156#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
159#[serde(deny_unknown_fields)]
160pub struct LintConfig {
161 #[serde(default)]
165 pub pre_commit: LintPreCommit,
166 #[serde(default)]
169 pub strict: bool,
170 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
173 pub rules: BTreeMap<String, LintRuleConfig>,
174}
175
176impl Default for LintConfig {
177 fn default() -> Self {
178 Self {
179 pre_commit: LintPreCommit::Check,
180 strict: false,
181 rules: BTreeMap::new(),
182 }
183 }
184}
185
186#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
193pub enum LintPreCommit {
194 Off,
197 #[default]
200 Check,
201 Fix,
204}
205
206impl Serialize for LintPreCommit {
207 fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
208 s.serialize_str(self.as_str())
209 }
210}
211
212impl<'de> Deserialize<'de> for LintPreCommit {
213 fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
214 #[derive(Deserialize)]
216 #[serde(untagged)]
217 enum Wire {
218 Str(String),
219 Bool(bool),
220 }
221 match Wire::deserialize(d)? {
222 Wire::Str(s) => match s.as_str() {
223 "off" => Ok(Self::Off),
224 "check" => Ok(Self::Check),
225 "fix" => Ok(Self::Fix),
226 other => Err(serde::de::Error::unknown_variant(
227 other,
228 &["off", "check", "fix"],
229 )),
230 },
231 Wire::Bool(true) => Ok(Self::Check),
232 Wire::Bool(false) => Ok(Self::Off),
233 }
234 }
235}
236
237impl JsonSchema for LintPreCommit {
238 fn schema_name() -> String {
239 "LintPreCommit".to_owned()
240 }
241 fn json_schema(_gen: &mut schemars::gen::SchemaGenerator) -> schemars::schema::Schema {
242 use schemars::schema::*;
243 Schema::Object(SchemaObject {
245 subschemas: Some(Box::new(SubschemaValidation {
246 one_of: Some(vec![
247 Schema::Object(SchemaObject {
248 instance_type: Some(InstanceType::String.into()),
249 enum_values: Some(vec![
250 serde_json::json!("off"),
251 serde_json::json!("check"),
252 serde_json::json!("fix"),
253 ]),
254 ..Default::default()
255 }),
256 Schema::Object(SchemaObject {
257 instance_type: Some(InstanceType::Boolean.into()),
258 ..Default::default()
259 }),
260 ]),
261 ..Default::default()
262 })),
263 metadata: Some(Box::new(Metadata {
264 description: Some(
265 "`[lint] pre_commit` — string enum (\"off\" | \"check\" | \"fix\") \
266 or bool (true → \"check\", false → \"off\") for J6 back-compat."
267 .to_owned(),
268 ),
269 ..Default::default()
270 })),
271 ..Default::default()
272 })
273 }
274}
275
276impl LintPreCommit {
277 fn as_str(self) -> &'static str {
278 match self {
279 Self::Off => "off",
280 Self::Check => "check",
281 Self::Fix => "fix",
282 }
283 }
284}
285
286#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
290#[serde(deny_unknown_fields)]
291pub struct LintRuleConfig {
292 #[serde(default, skip_serializing_if = "Option::is_none")]
293 pub severity: Option<Severity>,
294 #[serde(default, skip_serializing_if = "Option::is_none")]
295 pub threshold: Option<u32>,
296 #[serde(default, skip_serializing_if = "Option::is_none")]
297 pub auto_fix: Option<bool>,
298 #[serde(default, skip_serializing_if = "Option::is_none")]
299 pub pattern: Option<String>,
300 #[serde(default, skip_serializing_if = "Option::is_none")]
301 pub message: Option<String>,
302}
303
304#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
305#[serde(rename_all = "lowercase")]
306pub enum Severity {
307 Info,
308 Warn,
309 Error,
310}
311
312#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
315#[serde(deny_unknown_fields)]
316pub struct CorpusConfig {
317 #[serde(default)]
322 pub contribute: bool,
323}
324
325#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
328#[serde(deny_unknown_fields)]
329pub struct DocConfig {
330 #[serde(default = "default_true")]
334 pub commit_artifacts: bool,
335 #[serde(default)]
339 pub position: DocPosition,
340}
341
342impl Default for DocConfig {
343 fn default() -> Self {
344 Self {
345 commit_artifacts: true,
346 position: DocPosition::default(),
347 }
348 }
349}
350
351#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
352#[serde(rename_all = "lowercase")]
353pub enum DocPosition {
354 #[default]
355 Before,
356 After,
357}
358
359#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
367#[serde(deny_unknown_fields)]
368pub struct IndexConfig {
369 #[serde(default, skip_serializing_if = "Vec::is_empty")]
373 pub exclude: Vec<String>,
374}
375
376#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
397#[serde(deny_unknown_fields)]
398pub struct CanonConfig {
399 #[serde(default = "default_true")]
404 pub enabled: bool,
405 #[serde(default = "default_threshold_stamp")]
408 pub threshold_stamp: f64,
409 #[serde(default = "default_threshold_critique")]
412 pub threshold_critique: f64,
413}
414
415impl Default for CanonConfig {
416 fn default() -> Self {
417 Self {
418 enabled: true,
419 threshold_stamp: default_threshold_stamp(),
420 threshold_critique: default_threshold_critique(),
421 }
422 }
423}
424
425impl Eq for CanonConfig {}
426
427fn default_threshold_stamp() -> f64 {
428 0.85
429}
430
431fn default_threshold_critique() -> f64 {
432 0.65
433}
434
435#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
442#[serde(deny_unknown_fields)]
443pub struct NudgesConfig {
444 #[serde(default)]
448 pub aggressiveness: Aggressiveness,
449}
450
451#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
456#[serde(rename_all = "lowercase")]
457pub enum Aggressiveness {
458 Off,
460 Low,
462 #[default]
464 Medium,
465 High,
467}
468
469impl Aggressiveness {
470 #[aristo::intent(
471 "Off MUST map to factor 0.0 — it is the global opt-out, and the \
472 scorer's fire test is `pressure * factor >= 1`, so only an exact \
473 0.0 guarantees NOTHING ever fires regardless of how overdue a \
474 signal is. A non-zero `low` would let extreme pressure leak \
475 through a user who explicitly silenced nudges. The non-zero rungs \
476 are tunable defaults (D8); this table is the single place to \
477 retune global nudge sensitivity.",
478 verify = "neural",
479 id = "aggressiveness_off_is_hard_silence"
480 )]
481 pub fn factor(self) -> f64 {
482 match self {
483 Aggressiveness::Off => 0.0,
484 Aggressiveness::Low => 0.6,
485 Aggressiveness::Medium => 1.0,
486 Aggressiveness::High => 1.6,
487 }
488 }
489
490 pub fn is_off(self) -> bool {
492 matches!(self, Aggressiveness::Off)
493 }
494}
495
496fn default_true() -> bool {
499 true
500}
501
502pub fn config_file_schema_json() -> String {
505 let schema = schemars::schema_for!(ConfigFile);
506 serde_json::to_string_pretty(&schema)
507 .expect("serializing a schemars-derived schema cannot fail")
508}
509
510#[cfg(test)]
511mod tests {
512 use super::*;
513
514 #[test]
515 fn empty_toml_yields_all_defaults() {
516 let config: ConfigFile = toml::from_str("").unwrap();
517 assert_eq!(config, ConfigFile::default());
518 assert_eq!(config.verify.cache.strategy, CacheStrategy::Local);
519 assert!(config.verify.cache.commit_specs);
520 assert_eq!(config.stamp.hooks, HooksMode::PreCommit);
521 assert!(!config.stamp.hash_crate_root);
522 assert!(!config.telemetry.enabled);
523 assert_eq!(config.lint.pre_commit, LintPreCommit::Check);
524 assert!(!config.lint.strict);
525 assert!(config.lint.rules.is_empty());
526 assert!(!config.corpus.contribute);
527 assert!(config.doc.commit_artifacts);
528 assert_eq!(config.doc.position, DocPosition::Before);
529 assert!(config.canon.enabled);
530 assert!((config.canon.threshold_stamp - 0.85).abs() < f64::EPSILON);
531 assert!((config.canon.threshold_critique - 0.65).abs() < f64::EPSILON);
532 assert_eq!(config.nudges.aggressiveness, Aggressiveness::Medium);
533 }
534
535 #[test]
536 fn canon_section_round_trips() {
537 let toml_text = "\
538 [canon]\n\
539 enabled = true\n\
540 threshold_stamp = 0.9\n\
541 threshold_critique = 0.7\n\
542 ";
543 let config: ConfigFile = toml::from_str(toml_text).unwrap();
544 assert!(config.canon.enabled);
545 assert!((config.canon.threshold_stamp - 0.9).abs() < f64::EPSILON);
546 assert!((config.canon.threshold_critique - 0.7).abs() < f64::EPSILON);
547 }
548
549 #[test]
550 fn canon_enabled_false_is_the_opt_out_for_regulated_buyers() {
551 let toml_text = "[canon]\nenabled = false\n";
554 let config: ConfigFile = toml::from_str(toml_text).unwrap();
555 assert!(!config.canon.enabled);
556 assert!((config.canon.threshold_stamp - 0.85).abs() < f64::EPSILON);
558 assert!((config.canon.threshold_critique - 0.65).abs() < f64::EPSILON);
559 }
560
561 #[test]
562 fn canon_section_rejects_flavor_field() {
563 let toml_text = "[canon]\nflavor = \"turso\"\n";
568 let result: Result<ConfigFile, _> = toml::from_str(toml_text);
569 assert!(result.is_err(), "expected deny_unknown_fields rejection");
570 }
571
572 #[test]
573 fn canon_partial_section_keeps_other_defaults() {
574 let toml_text = "[canon]\nenabled = false\n";
576 let config: ConfigFile = toml::from_str(toml_text).unwrap();
577 assert!(!config.canon.enabled);
578 assert_eq!(
579 config.canon.threshold_stamp,
580 CanonConfig::default().threshold_stamp
581 );
582 assert_eq!(
583 config.canon.threshold_critique,
584 CanonConfig::default().threshold_critique
585 );
586 }
587
588 #[test]
589 fn lint_pre_commit_accepts_string_form() {
590 for (s, expected) in [
591 ("off", LintPreCommit::Off),
592 ("check", LintPreCommit::Check),
593 ("fix", LintPreCommit::Fix),
594 ] {
595 let toml_text = format!("[lint]\npre_commit = \"{s}\"\n");
596 let config: ConfigFile = toml::from_str(&toml_text).unwrap();
597 assert_eq!(config.lint.pre_commit, expected);
598 }
599 }
600
601 #[test]
602 fn lint_pre_commit_bool_back_compat() {
603 for (b, expected) in [(true, LintPreCommit::Check), (false, LintPreCommit::Off)] {
605 let toml_text = format!("[lint]\npre_commit = {b}\n");
606 let config: ConfigFile = toml::from_str(&toml_text).unwrap();
607 assert_eq!(config.lint.pre_commit, expected);
608 }
609 }
610
611 #[test]
612 fn lint_pre_commit_unknown_string_rejected() {
613 let toml_text = "[lint]\npre_commit = \"sometimes\"\n";
614 let result: Result<ConfigFile, _> = toml::from_str(toml_text);
615 assert!(result.is_err());
616 }
617
618 #[test]
619 fn lint_pre_commit_serializes_as_string() {
620 let mut config = ConfigFile::default();
621 config.lint.pre_commit = LintPreCommit::Fix;
622 let toml_text = toml::to_string(&config).unwrap();
623 assert!(toml_text.contains("pre_commit = \"fix\""));
624 }
625
626 #[test]
627 fn lint_pre_commit_bool_form_normalizes_on_round_trip() {
628 let config: ConfigFile = toml::from_str("[lint]\npre_commit = true\n").unwrap();
630 let serialized = toml::to_string(&config).unwrap();
631 let reparsed: ConfigFile = toml::from_str(&serialized).unwrap();
632 assert_eq!(reparsed.lint.pre_commit, LintPreCommit::Check);
633 }
634
635 #[test]
636 fn cache_strategy_uses_kebab_case() {
637 let v = serde_json::to_value(CacheStrategy::AristoCloud).unwrap();
638 assert_eq!(v, serde_json::json!("aristo-cloud"));
639 }
640
641 #[test]
642 fn hooks_mode_uses_kebab_case() {
643 let v = serde_json::to_value(HooksMode::PreCommit).unwrap();
644 assert_eq!(v, serde_json::json!("pre-commit"));
645 }
646
647 #[test]
648 fn doc_position_uses_lowercase() {
649 for variant in [DocPosition::Before, DocPosition::After] {
650 let v = serde_json::to_value(variant).unwrap();
651 assert!(v.is_string());
653 assert_eq!(
654 v.as_str().unwrap(),
655 match variant {
656 DocPosition::Before => "before",
657 DocPosition::After => "after",
658 }
659 );
660 }
661 }
662
663 #[test]
664 fn lint_rules_map_round_trips() {
665 let toml_text = r#"
666[lint.rules.empty_text]
667severity = "error"
668
669[lint.rules.long_text]
670severity = "warn"
671threshold = 200
672"#;
673 let config: ConfigFile = toml::from_str(toml_text).unwrap();
674 assert_eq!(config.lint.rules.len(), 2);
675 let empty_text = config.lint.rules.get("empty_text").unwrap();
676 assert_eq!(empty_text.severity, Some(Severity::Error));
677 let long_text = config.lint.rules.get("long_text").unwrap();
678 assert_eq!(long_text.threshold, Some(200));
679 }
680
681 #[test]
682 fn unknown_top_level_field_rejected() {
683 let toml_text = "totally_unknown = 42\n";
684 let result: Result<ConfigFile, _> = toml::from_str(toml_text);
685 assert!(result.is_err());
686 }
687
688 #[test]
689 fn unknown_section_field_rejected() {
690 let toml_text = "[verify]\nunknown_field = \"x\"\n";
691 let result: Result<ConfigFile, _> = toml::from_str(toml_text);
692 assert!(result.is_err());
693 }
694
695 #[test]
696 fn full_config_round_trips() {
697 let mut config = ConfigFile::default();
698 config.verify.default_method = Some(VerifyMethod::Full);
699 config.verify.cache.strategy = CacheStrategy::AristoCloud;
700 config.verify.cache.commit_specs = false;
701 config.stamp.hooks = HooksMode::None;
702 config.stamp.hash_crate_root = true;
703 config.telemetry.enabled = true;
704 config.lint.pre_commit = LintPreCommit::Fix;
705 config.lint.strict = true;
706 config.lint.rules.insert(
707 "empty_text".into(),
708 LintRuleConfig {
709 severity: Some(Severity::Error),
710 ..Default::default()
711 },
712 );
713 config.corpus.contribute = true;
714 config.doc.commit_artifacts = false;
715 config.doc.position = DocPosition::After;
716
717 let toml_text = toml::to_string(&config).unwrap();
718 let back: ConfigFile = toml::from_str(&toml_text).unwrap();
719 assert_eq!(back, config);
720 }
721}