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)]
85#[serde(deny_unknown_fields)]
86pub struct InstanceConfig {
87 #[serde(default, skip_serializing_if = "Option::is_none")]
91 pub url: Option<String>,
92}
93
94#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
97#[serde(deny_unknown_fields)]
98pub struct VerifyConfig {
99 #[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 #[serde(default)]
116 pub strategy: CacheStrategy,
117 #[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 #[default]
138 Local,
139 AristoCloud,
141}
142
143#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
146#[serde(deny_unknown_fields)]
147pub struct StampConfig {
148 #[serde(default)]
150 pub hooks: HooksMode,
151 #[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 #[default]
164 PreCommit,
165 None,
168}
169
170#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
173#[serde(deny_unknown_fields)]
174pub struct TelemetryConfig {
175 #[serde(default)]
178 pub enabled: bool,
179}
180
181#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
184#[serde(deny_unknown_fields)]
185pub struct LintConfig {
186 #[serde(default)]
190 pub pre_commit: LintPreCommit,
191 #[serde(default)]
194 pub strict: bool,
195 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
218pub enum LintPreCommit {
219 Off,
222 #[default]
225 Check,
226 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 #[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 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#[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#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
340#[serde(deny_unknown_fields)]
341pub struct CorpusConfig {
342 #[serde(default)]
347 pub contribute: bool,
348}
349
350#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
353#[serde(deny_unknown_fields)]
354pub struct DocConfig {
355 #[serde(default = "default_true")]
359 pub commit_artifacts: bool,
360 #[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#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
392#[serde(deny_unknown_fields)]
393pub struct IndexConfig {
394 #[serde(default, skip_serializing_if = "Vec::is_empty")]
398 pub exclude: Vec<String>,
399}
400
401#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
422#[serde(deny_unknown_fields)]
423pub struct CanonConfig {
424 #[serde(default = "default_true")]
429 pub enabled: bool,
430 #[serde(default = "default_threshold_stamp")]
433 pub threshold_stamp: f64,
434 #[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#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
467#[serde(deny_unknown_fields)]
468pub struct NudgesConfig {
469 #[serde(default)]
473 pub aggressiveness: Aggressiveness,
474}
475
476#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
481#[serde(rename_all = "lowercase")]
482pub enum Aggressiveness {
483 Off,
485 Low,
487 #[default]
489 Medium,
490 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 pub fn is_off(self) -> bool {
518 matches!(self, Aggressiveness::Off)
519 }
520}
521
522fn default_true() -> bool {
525 true
526}
527
528pub 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 let toml_text = "[canon]\nenabled = false\n";
581 let config: ConfigFile = toml::from_str(toml_text).unwrap();
582 assert!(!config.canon.enabled);
583 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 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 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 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 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 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 let empty: ConfigFile = toml::from_str("").unwrap();
732 assert!(empty.instance.url.is_none());
733 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}