comindly_api/
conventions.rs1use anyhow::{bail, Result};
2use serde::{Deserialize, Serialize};
3
4#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
5pub struct Convention {
6 pub repo: String,
8 pub dimension: String,
11 pub key: String,
13 pub rule: String,
14 pub generic: String,
16 pub support: usize,
17 pub total: usize,
18 #[serde(default)]
20 pub unit: String,
21 #[serde(default, skip_serializing_if = "Vec::is_empty")]
23 pub repos: Vec<String>,
24 pub exemplars: Vec<String>,
26 pub counterexamples: Vec<String>,
28 #[serde(default)]
29 pub origin: Origin,
30}
31
32#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(rename_all = "snake_case")]
34pub enum Origin {
35 #[default]
36 Measured,
37 Edited,
39 Manual,
41}
42
43impl Convention {
44 pub fn id(&self) -> String {
46 match self.key.strip_prefix(MANUAL) {
47 Some(id) => id.to_string(),
48 None => measured_id(&self.repo, &self.key),
49 }
50 }
51}
52
53#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
55pub struct Manual {
56 pub id: String,
57 pub repo: String,
59 pub dimension: String,
60 pub rule: String,
62 #[serde(default)]
64 pub replaces: Option<String>,
65 #[serde(default)]
66 pub hidden: bool,
67 pub actor: String,
68 pub updated: String,
69}
70
71impl Manual {
72 pub fn new(repo: &str, dimension: &str, rule: &str, actor: &str) -> Manual {
73 let id = uuid::Uuid::now_v7().simple().to_string();
74 Manual {
75 id: format!("c{}", &id[id.len() - 10..]),
76 repo: repo.into(),
77 dimension: dimension.trim().to_lowercase(),
78 rule: rule.trim().into(),
79 replaces: None,
80 hidden: false,
81 actor: actor.into(),
82 updated: chrono::Utc::now().format("%Y-%m-%d").to_string(),
83 }
84 }
85
86 pub fn validate(&self) -> Result<()> {
87 if self.rule.trim().is_empty() || self.rule.chars().count() > 500 {
88 bail!("rule must be 1–500 characters");
89 }
90 if self.dimension.is_empty() || self.dimension.len() > 40 {
91 bail!("dimension must be 1–40 characters");
92 }
93 if let Some(why) = crate::ledger::suspicious(&self.rule) {
94 bail!("rule rejected: {why}");
95 }
96 Ok(())
97 }
98}
99
100impl Convention {
101 pub fn strength(&self) -> Option<&'static str> {
104 if self.total == 0 || self.counts_only() {
105 return None;
106 }
107 let p = self.support as f64 / self.total as f64;
108 Some(match p {
109 p if p >= 0.95 => "Always",
110 p if p >= 0.8 => "Usually",
111 p if p >= 0.6 => "Often",
112 _ => "Sometimes",
113 })
114 }
115
116 pub fn few(&self) -> bool {
118 self.repos.is_empty() && self.total > 0 && self.total < FEW
119 }
120
121 pub fn evidence(&self) -> Option<String> {
123 if !self.repos.is_empty() {
124 return Some(format!("Shared by {}.", and_list(&self.repos)));
125 }
126 (self.total > 0).then(|| {
127 let unit = if self.unit.is_empty() {
128 "places"
129 } else {
130 &self.unit
131 };
132 let (n, of) = (thousands(self.support), thousands(self.total));
133 match (self.counts_only(), self.support == self.total) {
134 (true, _) => format!("Extended by {n} {unit}."),
135 (false, true) => format!("Followed by all {of} {unit}."),
136 (false, false) => format!("Followed by {n} of the {of} {unit}."),
137 }
138 })
139 }
140
141 fn counts_only(&self) -> bool {
143 self.key.starts_with("models:base:")
144 }
145}
146
147pub const MANUAL: &str = "manual:";
148
149pub fn measured_id(repo: &str, key: &str) -> String {
150 let h = blake3::hash(format!("{repo}\n{key}").as_bytes()).to_hex();
151 format!("m{}", &h[..10])
152}
153
154pub fn thousands(n: usize) -> String {
156 let digits = n.to_string();
157 let mut out = String::new();
158 for (i, c) in digits.chars().enumerate() {
159 if i > 0 && (digits.len() - i).is_multiple_of(3) {
160 out.push(',');
161 }
162 out.push(c);
163 }
164 out
165}
166
167pub const FEW: usize = 10;
169
170pub fn and_list(items: &[String]) -> String {
171 match items {
172 [] => String::new(),
173 [one] => one.clone(),
174 [rest @ .., last] => format!("{} and {last}", rest.join(", ")),
175 }
176}
177
178pub const ORG: &str = "*";