use std::collections::BTreeMap;
use schemars::JsonSchema;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use crate::index::VerifyMethod;
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ConfigFile {
#[serde(default)]
pub verify: VerifyConfig,
#[serde(default)]
pub stamp: StampConfig,
#[serde(default)]
pub telemetry: TelemetryConfig,
#[serde(default)]
pub lint: LintConfig,
#[serde(default)]
pub corpus: CorpusConfig,
#[serde(default)]
pub doc: DocConfig,
#[serde(default)]
pub index: IndexConfig,
#[serde(default)]
pub canon: CanonConfig,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct VerifyConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default_method: Option<VerifyMethod>,
#[serde(default)]
pub cache: VerifyCacheConfig,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct VerifyCacheConfig {
#[serde(default)]
pub strategy: CacheStrategy,
#[serde(default = "default_true")]
pub commit_specs: bool,
}
impl Default for VerifyCacheConfig {
fn default() -> Self {
Self {
strategy: CacheStrategy::default(),
commit_specs: true,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum CacheStrategy {
#[default]
Local,
AristoCloud,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct StampConfig {
#[serde(default)]
pub hooks: HooksMode,
#[serde(default)]
pub hash_crate_root: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum HooksMode {
#[default]
PreCommit,
None,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct TelemetryConfig {
#[serde(default)]
pub enabled: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct LintConfig {
#[serde(default)]
pub pre_commit: LintPreCommit,
#[serde(default)]
pub strict: bool,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub rules: BTreeMap<String, LintRuleConfig>,
}
impl Default for LintConfig {
fn default() -> Self {
Self {
pre_commit: LintPreCommit::Check,
strict: false,
rules: BTreeMap::new(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum LintPreCommit {
Off,
#[default]
Check,
Fix,
}
impl Serialize for LintPreCommit {
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(self.as_str())
}
}
impl<'de> Deserialize<'de> for LintPreCommit {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
#[derive(Deserialize)]
#[serde(untagged)]
enum Wire {
Str(String),
Bool(bool),
}
match Wire::deserialize(d)? {
Wire::Str(s) => match s.as_str() {
"off" => Ok(Self::Off),
"check" => Ok(Self::Check),
"fix" => Ok(Self::Fix),
other => Err(serde::de::Error::unknown_variant(
other,
&["off", "check", "fix"],
)),
},
Wire::Bool(true) => Ok(Self::Check),
Wire::Bool(false) => Ok(Self::Off),
}
}
}
impl JsonSchema for LintPreCommit {
fn schema_name() -> String {
"LintPreCommit".to_owned()
}
fn json_schema(_gen: &mut schemars::gen::SchemaGenerator) -> schemars::schema::Schema {
use schemars::schema::*;
Schema::Object(SchemaObject {
subschemas: Some(Box::new(SubschemaValidation {
one_of: Some(vec![
Schema::Object(SchemaObject {
instance_type: Some(InstanceType::String.into()),
enum_values: Some(vec![
serde_json::json!("off"),
serde_json::json!("check"),
serde_json::json!("fix"),
]),
..Default::default()
}),
Schema::Object(SchemaObject {
instance_type: Some(InstanceType::Boolean.into()),
..Default::default()
}),
]),
..Default::default()
})),
metadata: Some(Box::new(Metadata {
description: Some(
"`[lint] pre_commit` — string enum (\"off\" | \"check\" | \"fix\") \
or bool (true → \"check\", false → \"off\") for J6 back-compat."
.to_owned(),
),
..Default::default()
})),
..Default::default()
})
}
}
impl LintPreCommit {
fn as_str(self) -> &'static str {
match self {
Self::Off => "off",
Self::Check => "check",
Self::Fix => "fix",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct LintRuleConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub severity: Option<Severity>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub threshold: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auto_fix: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pattern: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum Severity {
Info,
Warn,
Error,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct CorpusConfig {
#[serde(default)]
pub contribute: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct DocConfig {
#[serde(default = "default_true")]
pub commit_artifacts: bool,
#[serde(default)]
pub position: DocPosition,
}
impl Default for DocConfig {
fn default() -> Self {
Self {
commit_artifacts: true,
position: DocPosition::default(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum DocPosition {
#[default]
Before,
After,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct IndexConfig {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub exclude: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct CanonConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default = "default_threshold_stamp")]
pub threshold_stamp: f64,
#[serde(default = "default_threshold_critique")]
pub threshold_critique: f64,
}
impl Default for CanonConfig {
fn default() -> Self {
Self {
enabled: true,
threshold_stamp: default_threshold_stamp(),
threshold_critique: default_threshold_critique(),
}
}
}
impl Eq for CanonConfig {}
fn default_threshold_stamp() -> f64 {
0.85
}
fn default_threshold_critique() -> f64 {
0.65
}
fn default_true() -> bool {
true
}
pub fn config_file_schema_json() -> String {
let schema = schemars::schema_for!(ConfigFile);
serde_json::to_string_pretty(&schema)
.expect("serializing a schemars-derived schema cannot fail")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_toml_yields_all_defaults() {
let config: ConfigFile = toml::from_str("").unwrap();
assert_eq!(config, ConfigFile::default());
assert_eq!(config.verify.cache.strategy, CacheStrategy::Local);
assert!(config.verify.cache.commit_specs);
assert_eq!(config.stamp.hooks, HooksMode::PreCommit);
assert!(!config.stamp.hash_crate_root);
assert!(!config.telemetry.enabled);
assert_eq!(config.lint.pre_commit, LintPreCommit::Check);
assert!(!config.lint.strict);
assert!(config.lint.rules.is_empty());
assert!(!config.corpus.contribute);
assert!(config.doc.commit_artifacts);
assert_eq!(config.doc.position, DocPosition::Before);
assert!(config.canon.enabled);
assert!((config.canon.threshold_stamp - 0.85).abs() < f64::EPSILON);
assert!((config.canon.threshold_critique - 0.65).abs() < f64::EPSILON);
}
#[test]
fn canon_section_round_trips() {
let toml_text = "\
[canon]\n\
enabled = true\n\
threshold_stamp = 0.9\n\
threshold_critique = 0.7\n\
";
let config: ConfigFile = toml::from_str(toml_text).unwrap();
assert!(config.canon.enabled);
assert!((config.canon.threshold_stamp - 0.9).abs() < f64::EPSILON);
assert!((config.canon.threshold_critique - 0.7).abs() < f64::EPSILON);
}
#[test]
fn canon_enabled_false_is_the_opt_out_for_regulated_buyers() {
let toml_text = "[canon]\nenabled = false\n";
let config: ConfigFile = toml::from_str(toml_text).unwrap();
assert!(!config.canon.enabled);
assert!((config.canon.threshold_stamp - 0.85).abs() < f64::EPSILON);
assert!((config.canon.threshold_critique - 0.65).abs() < f64::EPSILON);
}
#[test]
fn canon_section_rejects_flavor_field() {
let toml_text = "[canon]\nflavor = \"turso\"\n";
let result: Result<ConfigFile, _> = toml::from_str(toml_text);
assert!(result.is_err(), "expected deny_unknown_fields rejection");
}
#[test]
fn canon_partial_section_keeps_other_defaults() {
let toml_text = "[canon]\nenabled = false\n";
let config: ConfigFile = toml::from_str(toml_text).unwrap();
assert!(!config.canon.enabled);
assert_eq!(
config.canon.threshold_stamp,
CanonConfig::default().threshold_stamp
);
assert_eq!(
config.canon.threshold_critique,
CanonConfig::default().threshold_critique
);
}
#[test]
fn lint_pre_commit_accepts_string_form() {
for (s, expected) in [
("off", LintPreCommit::Off),
("check", LintPreCommit::Check),
("fix", LintPreCommit::Fix),
] {
let toml_text = format!("[lint]\npre_commit = \"{s}\"\n");
let config: ConfigFile = toml::from_str(&toml_text).unwrap();
assert_eq!(config.lint.pre_commit, expected);
}
}
#[test]
fn lint_pre_commit_bool_back_compat() {
for (b, expected) in [(true, LintPreCommit::Check), (false, LintPreCommit::Off)] {
let toml_text = format!("[lint]\npre_commit = {b}\n");
let config: ConfigFile = toml::from_str(&toml_text).unwrap();
assert_eq!(config.lint.pre_commit, expected);
}
}
#[test]
fn lint_pre_commit_unknown_string_rejected() {
let toml_text = "[lint]\npre_commit = \"sometimes\"\n";
let result: Result<ConfigFile, _> = toml::from_str(toml_text);
assert!(result.is_err());
}
#[test]
fn lint_pre_commit_serializes_as_string() {
let mut config = ConfigFile::default();
config.lint.pre_commit = LintPreCommit::Fix;
let toml_text = toml::to_string(&config).unwrap();
assert!(toml_text.contains("pre_commit = \"fix\""));
}
#[test]
fn lint_pre_commit_bool_form_normalizes_on_round_trip() {
let config: ConfigFile = toml::from_str("[lint]\npre_commit = true\n").unwrap();
let serialized = toml::to_string(&config).unwrap();
let reparsed: ConfigFile = toml::from_str(&serialized).unwrap();
assert_eq!(reparsed.lint.pre_commit, LintPreCommit::Check);
}
#[test]
fn cache_strategy_uses_kebab_case() {
let v = serde_json::to_value(CacheStrategy::AristoCloud).unwrap();
assert_eq!(v, serde_json::json!("aristo-cloud"));
}
#[test]
fn hooks_mode_uses_kebab_case() {
let v = serde_json::to_value(HooksMode::PreCommit).unwrap();
assert_eq!(v, serde_json::json!("pre-commit"));
}
#[test]
fn doc_position_uses_lowercase() {
for variant in [DocPosition::Before, DocPosition::After] {
let v = serde_json::to_value(variant).unwrap();
assert!(v.is_string());
assert_eq!(
v.as_str().unwrap(),
match variant {
DocPosition::Before => "before",
DocPosition::After => "after",
}
);
}
}
#[test]
fn lint_rules_map_round_trips() {
let toml_text = r#"
[lint.rules.empty_text]
severity = "error"
[lint.rules.long_text]
severity = "warn"
threshold = 200
"#;
let config: ConfigFile = toml::from_str(toml_text).unwrap();
assert_eq!(config.lint.rules.len(), 2);
let empty_text = config.lint.rules.get("empty_text").unwrap();
assert_eq!(empty_text.severity, Some(Severity::Error));
let long_text = config.lint.rules.get("long_text").unwrap();
assert_eq!(long_text.threshold, Some(200));
}
#[test]
fn unknown_top_level_field_rejected() {
let toml_text = "totally_unknown = 42\n";
let result: Result<ConfigFile, _> = toml::from_str(toml_text);
assert!(result.is_err());
}
#[test]
fn unknown_section_field_rejected() {
let toml_text = "[verify]\nunknown_field = \"x\"\n";
let result: Result<ConfigFile, _> = toml::from_str(toml_text);
assert!(result.is_err());
}
#[test]
fn full_config_round_trips() {
let mut config = ConfigFile::default();
config.verify.default_method = Some(VerifyMethod::Full);
config.verify.cache.strategy = CacheStrategy::AristoCloud;
config.verify.cache.commit_specs = false;
config.stamp.hooks = HooksMode::None;
config.stamp.hash_crate_root = true;
config.telemetry.enabled = true;
config.lint.pre_commit = LintPreCommit::Fix;
config.lint.strict = true;
config.lint.rules.insert(
"empty_text".into(),
LintRuleConfig {
severity: Some(Severity::Error),
..Default::default()
},
);
config.corpus.contribute = true;
config.doc.commit_artifacts = false;
config.doc.position = DocPosition::After;
let toml_text = toml::to_string(&config).unwrap();
let back: ConfigFile = toml::from_str(&toml_text).unwrap();
assert_eq!(back, config);
}
}