use anyhow::{bail, Result};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Convention {
pub repo: String,
pub dimension: String,
pub key: String,
pub rule: String,
pub generic: String,
pub support: usize,
pub total: usize,
#[serde(default)]
pub unit: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub repos: Vec<String>,
pub exemplars: Vec<String>,
pub counterexamples: Vec<String>,
#[serde(default)]
pub origin: Origin,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Origin {
#[default]
Measured,
Edited,
Manual,
}
impl Convention {
pub fn id(&self) -> String {
match self.key.strip_prefix(MANUAL) {
Some(id) => id.to_string(),
None => measured_id(&self.repo, &self.key),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Manual {
pub id: String,
pub repo: String,
pub dimension: String,
pub rule: String,
#[serde(default)]
pub replaces: Option<String>,
#[serde(default)]
pub hidden: bool,
pub actor: String,
pub updated: String,
}
impl Manual {
pub fn new(repo: &str, dimension: &str, rule: &str, actor: &str) -> Manual {
let id = uuid::Uuid::now_v7().simple().to_string();
Manual {
id: format!("c{}", &id[id.len() - 10..]),
repo: repo.into(),
dimension: dimension.trim().to_lowercase(),
rule: rule.trim().into(),
replaces: None,
hidden: false,
actor: actor.into(),
updated: chrono::Utc::now().format("%Y-%m-%d").to_string(),
}
}
pub fn validate(&self) -> Result<()> {
if self.rule.trim().is_empty() || self.rule.chars().count() > 500 {
bail!("rule must be 1–500 characters");
}
if self.dimension.is_empty() || self.dimension.len() > 40 {
bail!("dimension must be 1–40 characters");
}
if let Some(why) = crate::ledger::suspicious(&self.rule) {
bail!("rule rejected: {why}");
}
Ok(())
}
}
impl Convention {
pub fn strength(&self) -> Option<&'static str> {
if self.total == 0 || self.counts_only() {
return None;
}
let p = self.support as f64 / self.total as f64;
Some(match p {
p if p >= 0.95 => "Always",
p if p >= 0.8 => "Usually",
p if p >= 0.6 => "Often",
_ => "Sometimes",
})
}
pub fn few(&self) -> bool {
self.repos.is_empty() && self.total > 0 && self.total < FEW
}
pub fn evidence(&self) -> Option<String> {
if !self.repos.is_empty() {
return Some(format!("Shared by {}.", and_list(&self.repos)));
}
(self.total > 0).then(|| {
let unit = if self.unit.is_empty() {
"places"
} else {
&self.unit
};
let (n, of) = (thousands(self.support), thousands(self.total));
match (self.counts_only(), self.support == self.total) {
(true, _) => format!("Extended by {n} {unit}."),
(false, true) => format!("Followed by all {of} {unit}."),
(false, false) => format!("Followed by {n} of the {of} {unit}."),
}
})
}
fn counts_only(&self) -> bool {
self.key.starts_with("models:base:")
}
}
pub const MANUAL: &str = "manual:";
pub fn measured_id(repo: &str, key: &str) -> String {
let h = blake3::hash(format!("{repo}\n{key}").as_bytes()).to_hex();
format!("m{}", &h[..10])
}
pub fn thousands(n: usize) -> String {
let digits = n.to_string();
let mut out = String::new();
for (i, c) in digits.chars().enumerate() {
if i > 0 && (digits.len() - i).is_multiple_of(3) {
out.push(',');
}
out.push(c);
}
out
}
pub const FEW: usize = 10;
pub fn and_list(items: &[String]) -> String {
match items {
[] => String::new(),
[one] => one.clone(),
[rest @ .., last] => format!("{} and {last}", rest.join(", ")),
}
}
pub const ORG: &str = "*";