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 #[serde(default)]
68 pub instance: InstanceConfig,
69}
70
71#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
79#[serde(deny_unknown_fields)]
80pub struct InstanceConfig {
81 #[serde(default, skip_serializing_if = "Option::is_none")]
83 pub url: Option<String>,
84}
85
86#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
89#[serde(deny_unknown_fields)]
90pub struct VerifyConfig {
91 #[serde(default, skip_serializing_if = "Option::is_none")]
94 pub default_method: Option<VerifyMethod>,
95 #[serde(default)]
96 pub cache: VerifyCacheConfig,
97}
98
99#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
100#[serde(deny_unknown_fields)]
101pub struct VerifyCacheConfig {
102 #[serde(default)]
107 pub strategy: CacheStrategy,
108 #[serde(default = "default_true")]
112 pub commit_specs: bool,
113}
114
115impl Default for VerifyCacheConfig {
116 fn default() -> Self {
117 Self {
118 strategy: CacheStrategy::default(),
119 commit_specs: true,
120 }
121 }
122}
123
124#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
125#[serde(rename_all = "kebab-case")]
126pub enum CacheStrategy {
127 #[default]
129 Local,
130 AristoCloud,
132}
133
134#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
137#[serde(deny_unknown_fields)]
138pub struct StampConfig {
139 #[serde(default)]
141 pub hooks: HooksMode,
142 #[serde(default)]
146 pub hash_crate_root: bool,
147}
148
149#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
150#[serde(rename_all = "kebab-case")]
151pub enum HooksMode {
152 #[default]
155 PreCommit,
156 None,
159}
160
161#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
164#[serde(deny_unknown_fields)]
165pub struct TelemetryConfig {
166 #[serde(default)]
169 pub enabled: bool,
170}
171
172#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
175#[serde(deny_unknown_fields)]
176pub struct LintConfig {
177 #[serde(default)]
181 pub pre_commit: LintPreCommit,
182 #[serde(default)]
185 pub strict: bool,
186 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
189 pub rules: BTreeMap<String, LintRuleConfig>,
190}
191
192impl Default for LintConfig {
193 fn default() -> Self {
194 Self {
195 pre_commit: LintPreCommit::Check,
196 strict: false,
197 rules: BTreeMap::new(),
198 }
199 }
200}
201
202#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
209pub enum LintPreCommit {
210 Off,
213 #[default]
216 Check,
217 Fix,
220}
221
222impl Serialize for LintPreCommit {
223 fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
224 s.serialize_str(self.as_str())
225 }
226}
227
228impl<'de> Deserialize<'de> for LintPreCommit {
229 fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
230 #[derive(Deserialize)]
232 #[serde(untagged)]
233 enum Wire {
234 Str(String),
235 Bool(bool),
236 }
237 match Wire::deserialize(d)? {
238 Wire::Str(s) => match s.as_str() {
239 "off" => Ok(Self::Off),
240 "check" => Ok(Self::Check),
241 "fix" => Ok(Self::Fix),
242 other => Err(serde::de::Error::unknown_variant(
243 other,
244 &["off", "check", "fix"],
245 )),
246 },
247 Wire::Bool(true) => Ok(Self::Check),
248 Wire::Bool(false) => Ok(Self::Off),
249 }
250 }
251}
252
253impl JsonSchema for LintPreCommit {
254 fn schema_name() -> String {
255 "LintPreCommit".to_owned()
256 }
257 fn json_schema(_gen: &mut schemars::gen::SchemaGenerator) -> schemars::schema::Schema {
258 use schemars::schema::*;
259 Schema::Object(SchemaObject {
261 subschemas: Some(Box::new(SubschemaValidation {
262 one_of: Some(vec![
263 Schema::Object(SchemaObject {
264 instance_type: Some(InstanceType::String.into()),
265 enum_values: Some(vec![
266 serde_json::json!("off"),
267 serde_json::json!("check"),
268 serde_json::json!("fix"),
269 ]),
270 ..Default::default()
271 }),
272 Schema::Object(SchemaObject {
273 instance_type: Some(InstanceType::Boolean.into()),
274 ..Default::default()
275 }),
276 ]),
277 ..Default::default()
278 })),
279 metadata: Some(Box::new(Metadata {
280 description: Some(
281 "`[lint] pre_commit` — string enum (\"off\" | \"check\" | \"fix\") \
282 or bool (true → \"check\", false → \"off\") for J6 back-compat."
283 .to_owned(),
284 ),
285 ..Default::default()
286 })),
287 ..Default::default()
288 })
289 }
290}
291
292impl LintPreCommit {
293 fn as_str(self) -> &'static str {
294 match self {
295 Self::Off => "off",
296 Self::Check => "check",
297 Self::Fix => "fix",
298 }
299 }
300}
301
302#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
306#[serde(deny_unknown_fields)]
307pub struct LintRuleConfig {
308 #[serde(default, skip_serializing_if = "Option::is_none")]
309 pub severity: Option<Severity>,
310 #[serde(default, skip_serializing_if = "Option::is_none")]
311 pub threshold: Option<u32>,
312 #[serde(default, skip_serializing_if = "Option::is_none")]
313 pub auto_fix: Option<bool>,
314 #[serde(default, skip_serializing_if = "Option::is_none")]
315 pub pattern: Option<String>,
316 #[serde(default, skip_serializing_if = "Option::is_none")]
317 pub message: Option<String>,
318}
319
320#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
321#[serde(rename_all = "lowercase")]
322pub enum Severity {
323 Info,
324 Warn,
325 Error,
326}
327
328#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
331#[serde(deny_unknown_fields)]
332pub struct CorpusConfig {
333 #[serde(default)]
338 pub contribute: bool,
339}
340
341#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
344#[serde(deny_unknown_fields)]
345pub struct DocConfig {
346 #[serde(default = "default_true")]
350 pub commit_artifacts: bool,
351 #[serde(default)]
355 pub position: DocPosition,
356}
357
358impl Default for DocConfig {
359 fn default() -> Self {
360 Self {
361 commit_artifacts: true,
362 position: DocPosition::default(),
363 }
364 }
365}
366
367#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
368#[serde(rename_all = "lowercase")]
369pub enum DocPosition {
370 #[default]
371 Before,
372 After,
373}
374
375#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
383#[serde(deny_unknown_fields)]
384pub struct IndexConfig {
385 #[serde(default, skip_serializing_if = "Vec::is_empty")]
389 pub exclude: Vec<String>,
390}
391
392#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
411#[serde(deny_unknown_fields)]
412pub struct CanonConfig {
413 #[serde(default = "default_true")]
418 pub enabled: bool,
419 #[serde(default = "default_threshold_stamp")]
422 pub threshold_stamp: f64,
423 #[serde(default = "default_threshold_critique")]
426 pub threshold_critique: f64,
427}
428
429impl Default for CanonConfig {
430 fn default() -> Self {
431 Self {
432 enabled: true,
433 threshold_stamp: default_threshold_stamp(),
434 threshold_critique: default_threshold_critique(),
435 }
436 }
437}
438
439impl Eq for CanonConfig {}
440
441fn default_threshold_stamp() -> f64 {
442 0.85
443}
444
445fn default_threshold_critique() -> f64 {
446 0.65
447}
448
449#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
456#[serde(deny_unknown_fields)]
457pub struct NudgesConfig {
458 #[serde(default)]
462 pub aggressiveness: Aggressiveness,
463}
464
465#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
470#[serde(rename_all = "lowercase")]
471pub enum Aggressiveness {
472 Off,
474 Low,
476 #[default]
478 Medium,
479 High,
481}
482
483impl Aggressiveness {
484 #[aristo::intent(
485 "Off MUST map to factor zero — it is the global opt-out. The scorer \
486 fires only when a signal's pressure scaled by its factor reaches the \
487 firing threshold, so an exact zero is the only value that guarantees \
488 nothing ever fires no matter how overdue a signal is. Assigning Off \
489 any small but non-zero factor would let extreme pressure leak through \
490 to a user who deliberately silenced nudges. The non-zero levels are \
491 tunable defaults (D8); this table is the single place to retune \
492 global nudge sensitivity.",
493 verify = "neural",
494 id = "aggressiveness_off_is_hard_silence"
495 )]
496 pub fn factor(self) -> f64 {
497 match self {
498 Aggressiveness::Off => 0.0,
499 Aggressiveness::Low => 0.6,
500 Aggressiveness::Medium => 1.0,
501 Aggressiveness::High => 1.6,
502 }
503 }
504
505 pub fn is_off(self) -> bool {
507 matches!(self, Aggressiveness::Off)
508 }
509}
510
511fn default_true() -> bool {
514 true
515}
516
517pub fn config_file_schema_json() -> String {
520 let schema = schemars::schema_for!(ConfigFile);
521 serde_json::to_string_pretty(&schema)
522 .expect("serializing a schemars-derived schema cannot fail")
523}
524
525#[cfg(test)]
526mod tests {
527 use super::*;
528
529 #[test]
530 fn empty_toml_yields_all_defaults() {
531 let config: ConfigFile = toml::from_str("").unwrap();
532 assert_eq!(config, ConfigFile::default());
533 assert_eq!(config.verify.cache.strategy, CacheStrategy::Local);
534 assert!(config.verify.cache.commit_specs);
535 assert_eq!(config.stamp.hooks, HooksMode::PreCommit);
536 assert!(!config.stamp.hash_crate_root);
537 assert!(!config.telemetry.enabled);
538 assert_eq!(config.lint.pre_commit, LintPreCommit::Check);
539 assert!(!config.lint.strict);
540 assert!(config.lint.rules.is_empty());
541 assert!(!config.corpus.contribute);
542 assert!(config.doc.commit_artifacts);
543 assert_eq!(config.doc.position, DocPosition::Before);
544 assert!(config.canon.enabled);
545 assert!((config.canon.threshold_stamp - 0.85).abs() < f64::EPSILON);
546 assert!((config.canon.threshold_critique - 0.65).abs() < f64::EPSILON);
547 assert_eq!(config.nudges.aggressiveness, Aggressiveness::Medium);
548 assert!(config.instance.url.is_none());
549 }
550
551 #[test]
552 fn canon_section_round_trips() {
553 let toml_text = "\
554 [canon]\n\
555 enabled = true\n\
556 threshold_stamp = 0.9\n\
557 threshold_critique = 0.7\n\
558 ";
559 let config: ConfigFile = toml::from_str(toml_text).unwrap();
560 assert!(config.canon.enabled);
561 assert!((config.canon.threshold_stamp - 0.9).abs() < f64::EPSILON);
562 assert!((config.canon.threshold_critique - 0.7).abs() < f64::EPSILON);
563 }
564
565 #[test]
566 fn canon_enabled_false_is_the_opt_out_for_regulated_buyers() {
567 let toml_text = "[canon]\nenabled = false\n";
570 let config: ConfigFile = toml::from_str(toml_text).unwrap();
571 assert!(!config.canon.enabled);
572 assert!((config.canon.threshold_stamp - 0.85).abs() < f64::EPSILON);
574 assert!((config.canon.threshold_critique - 0.65).abs() < f64::EPSILON);
575 }
576
577 #[test]
578 fn canon_section_rejects_flavor_field() {
579 let toml_text = "[canon]\nflavor = \"turso\"\n";
584 let result: Result<ConfigFile, _> = toml::from_str(toml_text);
585 assert!(result.is_err(), "expected deny_unknown_fields rejection");
586 }
587
588 #[test]
589 fn canon_partial_section_keeps_other_defaults() {
590 let toml_text = "[canon]\nenabled = false\n";
592 let config: ConfigFile = toml::from_str(toml_text).unwrap();
593 assert!(!config.canon.enabled);
594 assert_eq!(
595 config.canon.threshold_stamp,
596 CanonConfig::default().threshold_stamp
597 );
598 assert_eq!(
599 config.canon.threshold_critique,
600 CanonConfig::default().threshold_critique
601 );
602 }
603
604 #[test]
605 fn lint_pre_commit_accepts_string_form() {
606 for (s, expected) in [
607 ("off", LintPreCommit::Off),
608 ("check", LintPreCommit::Check),
609 ("fix", LintPreCommit::Fix),
610 ] {
611 let toml_text = format!("[lint]\npre_commit = \"{s}\"\n");
612 let config: ConfigFile = toml::from_str(&toml_text).unwrap();
613 assert_eq!(config.lint.pre_commit, expected);
614 }
615 }
616
617 #[test]
618 fn lint_pre_commit_bool_back_compat() {
619 for (b, expected) in [(true, LintPreCommit::Check), (false, LintPreCommit::Off)] {
621 let toml_text = format!("[lint]\npre_commit = {b}\n");
622 let config: ConfigFile = toml::from_str(&toml_text).unwrap();
623 assert_eq!(config.lint.pre_commit, expected);
624 }
625 }
626
627 #[test]
628 fn lint_pre_commit_unknown_string_rejected() {
629 let toml_text = "[lint]\npre_commit = \"sometimes\"\n";
630 let result: Result<ConfigFile, _> = toml::from_str(toml_text);
631 assert!(result.is_err());
632 }
633
634 #[test]
635 fn lint_pre_commit_serializes_as_string() {
636 let mut config = ConfigFile::default();
637 config.lint.pre_commit = LintPreCommit::Fix;
638 let toml_text = toml::to_string(&config).unwrap();
639 assert!(toml_text.contains("pre_commit = \"fix\""));
640 }
641
642 #[test]
643 fn lint_pre_commit_bool_form_normalizes_on_round_trip() {
644 let config: ConfigFile = toml::from_str("[lint]\npre_commit = true\n").unwrap();
646 let serialized = toml::to_string(&config).unwrap();
647 let reparsed: ConfigFile = toml::from_str(&serialized).unwrap();
648 assert_eq!(reparsed.lint.pre_commit, LintPreCommit::Check);
649 }
650
651 #[test]
652 fn cache_strategy_uses_kebab_case() {
653 let v = serde_json::to_value(CacheStrategy::AristoCloud).unwrap();
654 assert_eq!(v, serde_json::json!("aristo-cloud"));
655 }
656
657 #[test]
658 fn hooks_mode_uses_kebab_case() {
659 let v = serde_json::to_value(HooksMode::PreCommit).unwrap();
660 assert_eq!(v, serde_json::json!("pre-commit"));
661 }
662
663 #[test]
664 fn doc_position_uses_lowercase() {
665 for variant in [DocPosition::Before, DocPosition::After] {
666 let v = serde_json::to_value(variant).unwrap();
667 assert!(v.is_string());
669 assert_eq!(
670 v.as_str().unwrap(),
671 match variant {
672 DocPosition::Before => "before",
673 DocPosition::After => "after",
674 }
675 );
676 }
677 }
678
679 #[test]
680 fn lint_rules_map_round_trips() {
681 let toml_text = r#"
682[lint.rules.empty_text]
683severity = "error"
684
685[lint.rules.long_text]
686severity = "warn"
687threshold = 200
688"#;
689 let config: ConfigFile = toml::from_str(toml_text).unwrap();
690 assert_eq!(config.lint.rules.len(), 2);
691 let empty_text = config.lint.rules.get("empty_text").unwrap();
692 assert_eq!(empty_text.severity, Some(Severity::Error));
693 let long_text = config.lint.rules.get("long_text").unwrap();
694 assert_eq!(long_text.threshold, Some(200));
695 }
696
697 #[test]
698 fn unknown_top_level_field_rejected() {
699 let toml_text = "totally_unknown = 42\n";
700 let result: Result<ConfigFile, _> = toml::from_str(toml_text);
701 assert!(result.is_err());
702 }
703
704 #[test]
705 fn unknown_section_field_rejected() {
706 let toml_text = "[verify]\nunknown_field = \"x\"\n";
707 let result: Result<ConfigFile, _> = toml::from_str(toml_text);
708 assert!(result.is_err());
709 }
710
711 #[test]
712 fn ignored_instance_section_still_parses() {
713 let config: ConfigFile =
715 toml::from_str("[instance]\nurl = \"https://turso.aretta.ai\"\n").unwrap();
716 assert_eq!(
717 config.instance.url.as_deref(),
718 Some("https://turso.aretta.ai")
719 );
720 let empty: ConfigFile = toml::from_str("").unwrap();
722 assert!(empty.instance.url.is_none());
723 assert!(toml::from_str::<ConfigFile>("[instance]\nhost = \"x\"\n").is_err());
725 }
726
727 #[test]
728 fn full_config_round_trips() {
729 let mut config = ConfigFile::default();
730 config.verify.default_method = Some(VerifyMethod::Full);
731 config.verify.cache.strategy = CacheStrategy::AristoCloud;
732 config.verify.cache.commit_specs = false;
733 config.stamp.hooks = HooksMode::None;
734 config.stamp.hash_crate_root = true;
735 config.telemetry.enabled = true;
736 config.lint.pre_commit = LintPreCommit::Fix;
737 config.lint.strict = true;
738 config.lint.rules.insert(
739 "empty_text".into(),
740 LintRuleConfig {
741 severity: Some(Severity::Error),
742 ..Default::default()
743 },
744 );
745 config.corpus.contribute = true;
746 config.doc.commit_artifacts = false;
747 config.doc.position = DocPosition::After;
748 config.instance.url = Some("https://turso.aretta.ai".into());
749
750 let toml_text = toml::to_string(&config).unwrap();
751 let back: ConfigFile = toml::from_str(&toml_text).unwrap();
752 assert_eq!(back, config);
753 }
754}