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")]
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 #[serde(default)]
108 pub strategy: CacheStrategy,
109 #[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 #[default]
130 Local,
131 AristoCloud,
133}
134
135#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
138#[serde(deny_unknown_fields)]
139pub struct StampConfig {
140 #[serde(default)]
142 pub hooks: HooksMode,
143 #[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 #[default]
156 PreCommit,
157 None,
160}
161
162#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
165#[serde(deny_unknown_fields)]
166pub struct TelemetryConfig {
167 #[serde(default)]
170 pub enabled: bool,
171}
172
173#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
176#[serde(deny_unknown_fields)]
177pub struct LintConfig {
178 #[serde(default)]
182 pub pre_commit: LintPreCommit,
183 #[serde(default)]
186 pub strict: bool,
187 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
210pub enum LintPreCommit {
211 Off,
214 #[default]
217 Check,
218 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 #[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 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#[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#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
332#[serde(deny_unknown_fields)]
333pub struct CorpusConfig {
334 #[serde(default)]
339 pub contribute: bool,
340}
341
342#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
345#[serde(deny_unknown_fields)]
346pub struct DocConfig {
347 #[serde(default = "default_true")]
351 pub commit_artifacts: bool,
352 #[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#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
384#[serde(deny_unknown_fields)]
385pub struct IndexConfig {
386 #[serde(default, skip_serializing_if = "Vec::is_empty")]
390 pub exclude: Vec<String>,
391}
392
393#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
414#[serde(deny_unknown_fields)]
415pub struct CanonConfig {
416 #[serde(default = "default_true")]
421 pub enabled: bool,
422 #[serde(default = "default_threshold_stamp")]
425 pub threshold_stamp: f64,
426 #[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#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
459#[serde(deny_unknown_fields)]
460pub struct NudgesConfig {
461 #[serde(default)]
465 pub aggressiveness: Aggressiveness,
466}
467
468#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
473#[serde(rename_all = "lowercase")]
474pub enum Aggressiveness {
475 Off,
477 Low,
479 #[default]
481 Medium,
482 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 pub fn is_off(self) -> bool {
510 matches!(self, Aggressiveness::Off)
511 }
512}
513
514fn default_true() -> bool {
517 true
518}
519
520pub 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 let toml_text = "[canon]\nenabled = false\n";
573 let config: ConfigFile = toml::from_str(toml_text).unwrap();
574 assert!(!config.canon.enabled);
575 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 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 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 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 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 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 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 let empty: ConfigFile = toml::from_str("").unwrap();
725 assert!(empty.instance.url.is_none());
726 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}