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}
66
67#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
70#[serde(deny_unknown_fields)]
71pub struct VerifyConfig {
72 #[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 #[serde(default)]
89 pub strategy: CacheStrategy,
90 #[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 #[default]
111 Local,
112 AristoCloud,
114}
115
116#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
119#[serde(deny_unknown_fields)]
120pub struct StampConfig {
121 #[serde(default)]
123 pub hooks: HooksMode,
124 #[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 #[default]
137 PreCommit,
138 None,
141}
142
143#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
146#[serde(deny_unknown_fields)]
147pub struct TelemetryConfig {
148 #[serde(default)]
151 pub enabled: bool,
152}
153
154#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
157#[serde(deny_unknown_fields)]
158pub struct LintConfig {
159 #[serde(default)]
163 pub pre_commit: LintPreCommit,
164 #[serde(default)]
167 pub strict: bool,
168 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
191pub enum LintPreCommit {
192 Off,
195 #[default]
198 Check,
199 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 #[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 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#[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#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
313#[serde(deny_unknown_fields)]
314pub struct CorpusConfig {
315 #[serde(default)]
320 pub contribute: bool,
321}
322
323#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
326#[serde(deny_unknown_fields)]
327pub struct DocConfig {
328 #[serde(default = "default_true")]
332 pub commit_artifacts: bool,
333 #[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#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
365#[serde(deny_unknown_fields)]
366pub struct IndexConfig {
367 #[serde(default, skip_serializing_if = "Vec::is_empty")]
371 pub exclude: Vec<String>,
372}
373
374#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
395#[serde(deny_unknown_fields)]
396pub struct CanonConfig {
397 #[serde(default = "default_true")]
402 pub enabled: bool,
403 #[serde(default = "default_threshold_stamp")]
406 pub threshold_stamp: f64,
407 #[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
433fn default_true() -> bool {
436 true
437}
438
439pub 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 let toml_text = "[canon]\nenabled = false\n";
490 let config: ConfigFile = toml::from_str(toml_text).unwrap();
491 assert!(!config.canon.enabled);
492 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 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 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 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 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 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}