Skip to main content

comindly_api/
conventions.rs

1use anyhow::{bail, Result};
2use serde::{Deserialize, Serialize};
3
4#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
5pub struct Convention {
6    /// Repo the convention holds in, or [`ORG`] for the org baseline.
7    pub repo: String,
8    /// `naming`, `signatures`, `models`, `errors`, `layout`, `logging`, `config`, `libraries`,
9    /// `testing`.
10    pub dimension: String,
11    /// Stable identity of the measured choice, shared across repos (`naming:functions:snake_case`).
12    pub key: String,
13    pub rule: String,
14    /// The rule without repo-specific counts, names or paths (used for the org baseline).
15    pub generic: String,
16    pub support: usize,
17    pub total: usize,
18    /// What `support` and `total` count, e.g. `functions with parameters`.
19    #[serde(default)]
20    pub unit: String,
21    /// Org baseline only: the repositories that share it.
22    #[serde(default, skip_serializing_if = "Vec::is_empty")]
23    pub repos: Vec<String>,
24    /// `path:line` of representative, central instances.
25    pub exemplars: Vec<String>,
26    /// `path:line` of instances that deviate.
27    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    /// Measured, with the rule rewritten by a person.
38    Edited,
39    /// Written by a person.
40    Manual,
41}
42
43impl Convention {
44    /// Stable handle for edits: a manual entry's id, or a hash of repo and key.
45    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/// A person's change: a new convention, or an edit or hiding (`replaces`) of a measured one.
54#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
55pub struct Manual {
56    pub id: String,
57    /// Repo, or [`ORG`] for the whole org.
58    pub repo: String,
59    pub dimension: String,
60    /// The rule; for a hidden convention, the rule that was hidden.
61    pub rule: String,
62    /// Key of the measured convention this overrides.
63    #[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    /// How strictly the code follows it: `Always`, `Usually`, `Often` or `Sometimes`; `None` when
102    /// nothing was counted (the team's own).
103    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    /// Too few instances to call it settled.
117    pub fn few(&self) -> bool {
118        self.repos.is_empty() && self.total > 0 && self.total < FEW
119    }
120
121    /// What it was measured from, e.g. `Followed by 412 of the 530 functions with parameters.`
122    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    /// A pattern counted where it occurs, with nothing that could deviate from it.
142    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
154/// `1689` as `1,689`.
155pub 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
167/// Below this many instances a convention is marked as having few examples.
168pub 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
178/// Repo name used for org-wide conventions.
179pub const ORG: &str = "*";