majordomus_cli/commit/scopes.rs
1//! The scope vocabulary, derived from the repository rather than declared beside it.
2//!
3//! # The table this replaces
4//!
5//! The usual way to answer *"what scope does this change belong to?"* is a table: a list of
6//! path patterns and the scope each one maps to, written once, kept in a document a worker
7//! is told to read. It works on the day it is written. Then a directory is added, or
8//! renamed, or a subsystem is split in two, and the table says a scope that the history
9//! stopped using months ago — with nothing to notice, because a table has no way to be
10//! wrong.
11//!
12//! The repository already knows the answer. Every commit in the history is a worked example
13//! of which scope a set of paths belongs to, decided by whoever made the change and kept by
14//! whoever reviewed it. [`fn@derive`] reads those examples: the scopes the history uses, how
15//! often, and which directories each one is written about. A scope is not configured. It is
16//! observed, with the observation attached, so that the answer can be checked rather than
17//! believed.
18//!
19//! The consequence a person feels is that the vocabulary is never stale and never has to be
20//! maintained: a subsystem that gets its first commit today is in the vocabulary tomorrow,
21//! and one that nobody has touched for a year falls to the bottom of the list on its own.
22//!
23//! # What it costs and where it is allowed to
24//!
25//! Reading names out of the log is not free, and this is deliberately not on any hot path:
26//! nothing about entering the repository, completing a word or answering `--help` reads it.
27//! It is read when a commit is being planned or judged, which is a moment that already
28//! involves a person waiting for git.
29
30//! ```
31//! use majordomus_cli::commit::ScopeVocabulary;
32//!
33//! // A vocabulary is normally learned with `derive`; stated directly it reads the same.
34//! let mut v = ScopeVocabulary::default();
35//! v.learn("commit", "apps/majordomus-cli/src/commit", 40);
36//! v.learn("site", "site/content", 90);
37//!
38//! let s = v.suggest(&["apps/majordomus-cli/src/commit/plan.rs".into()]).expect("an association");
39//! assert_eq!(s.scope, "commit");
40//! assert_eq!(s.commits, 40, "the association, not the vote weight");
41//! assert!(!s.ambiguous);
42//!
43//! // and a path nothing in the history scopes is answered with nothing, never with the
44//! // repository's most popular scope
45//! assert!(v.suggest(&["brand/new/thing.rs".into()]).is_none());
46//! ```
47
48use std::collections::BTreeMap;
49use std::path::Path;
50use std::process::Command;
51
52use schemars::JsonSchema;
53use serde::{Deserialize, Serialize};
54
55use super::CommitHeader;
56
57/// How many commits back the vocabulary is learned from.
58///
59/// Bounded because the cost is linear in it and the value is not: a scope used ten times
60/// last month is in the vocabulary either way, and one last used two thousand commits ago
61/// is history rather than vocabulary.
62pub const SAMPLE: usize = 1500;
63
64/// How many leading path segments an association is recorded at.
65///
66/// Four, measured rather than chosen: this repository's source lives at
67/// `apps/majordomus-cli/src/<subsystem>/`, and three segments stop one level above the
68/// subsystem — where a dozen scopes all have commits and no path can be told from another.
69/// Four reaches the directory that actually determines the scope, and a path with fewer
70/// directories than that simply associates at every depth it has.
71const PREFIX_DEPTH: usize = 4;
72
73/// One scope the history uses, and how much.
74#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
75/// ```
76/// use majordomus_cli::commit::scopes::ScopeUse;
77/// let u = ScopeUse { scope: "commit".into(), commits: 40, directories: vec!["src/commit".into()] };
78/// assert_eq!(u.commits, 40);
79/// ```
80pub struct ScopeUse {
81 /// The scope word, as commits spell it.
82 pub scope: String,
83 /// How many of the sampled commits used it.
84 pub commits: usize,
85 /// The directory prefixes those commits touched, most often first, at most five.
86 pub directories: Vec<String>,
87}
88
89/// The vocabulary as observed, with what was observed.
90#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
91/// ```
92/// use majordomus_cli::commit::ScopeVocabulary;
93/// // an empty vocabulary knows nothing and says so, rather than guessing
94/// let v = ScopeVocabulary::default();
95/// assert!(!v.knows("anything"));
96/// assert!(v.words().is_empty());
97/// ```
98pub struct ScopeVocabulary {
99 /// Every scope, by how often it is used, then by name. The order is the answer to
100 /// "which scopes does this repository use", so it is the order a person reads.
101 pub scopes: Vec<ScopeUse>,
102 /// How many commits were read to learn it.
103 pub sampled: usize,
104 /// Set when git could not be asked; the vocabulary is then empty rather than wrong.
105 #[serde(skip_serializing_if = "Option::is_none")]
106 pub unavailable: Option<String>,
107 /// scope × directory prefix, counted. Not rendered for a person; this is what
108 /// [`ScopeVocabulary::suggest`] reads.
109 #[serde(skip)]
110 association: BTreeMap<(String, String), usize>,
111}
112
113/// Which scope a set of paths belongs to, and the evidence for it.
114#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
115/// ```
116/// use majordomus_cli::commit::{ScopeSuggestion, ScopeVocabulary};
117/// let mut v = ScopeVocabulary::default();
118/// // two scopes with equal claim to one directory: reported, never broken silently
119/// v.learn("a", "src", 7);
120/// v.learn("b", "src", 7);
121/// let s: ScopeSuggestion = v.suggest(&["src/x.rs".into()]).expect("a tie");
122/// assert!(s.ambiguous);
123/// ```
124pub struct ScopeSuggestion {
125 /// The scope.
126 pub scope: String,
127 /// How many prior commits associate this scope with directories these paths are in.
128 pub commits: usize,
129 /// The directory prefix the association was strongest at.
130 pub directory: String,
131 /// Whether another scope was as strongly associated. A tie is reported rather than
132 /// broken silently: a planner that guessed would be inventing the one field of a commit
133 /// message that a reader uses to navigate.
134 pub ambiguous: bool,
135}
136
137/// The directory prefixes a path is in, deepest first, at most [`PREFIX_DEPTH`] segments.
138fn prefixes(path: &str) -> Vec<String> {
139 let segments: Vec<&str> = path.split('/').collect();
140 // The file name itself is never a prefix; a directory is.
141 let dirs = segments.len().saturating_sub(1);
142 let mut out = Vec::new();
143 for depth in (1..=dirs.min(PREFIX_DEPTH)).rev() {
144 out.push(segments[..depth].join("/"));
145 }
146 out
147}
148
149/// Learn the vocabulary from the repository's own history.
150///
151/// Never fails: a directory that is not a work tree, a repository with no commits and a
152/// missing `git` all yield an empty vocabulary that says why.
153/// ```
154/// use majordomus_cli::commit::scopes::derive;
155/// // a directory that is not a work tree yields an empty vocabulary that says why,
156/// // never an empty one that looks like a repository with no conventions
157/// let plain = tempfile::tempdir().expect("a temporary directory");
158/// let v = derive(plain.path());
159/// assert!(v.scopes.is_empty());
160/// assert!(v.unavailable.is_some());
161/// ```
162pub fn derive(root: &Path) -> ScopeVocabulary {
163 // One `git log`, formatted so that a subject and the names it touched arrive together.
164 // `%x00` separates the two, and a record separator git will not produce delimits the
165 // commits, because a file name may contain anything a byte can be.
166 let out = Command::new("git")
167 .arg("-C")
168 .arg(root)
169 .args([
170 "log",
171 "--no-merges",
172 "-n",
173 &SAMPLE.to_string(),
174 "--name-only",
175 "--format=%x02%s",
176 ])
177 .output();
178 let out = match out {
179 Ok(o) if o.status.success() => o,
180 Ok(o) => {
181 return ScopeVocabulary {
182 unavailable: Some(String::from_utf8_lossy(&o.stderr).trim().to_string()),
183 ..ScopeVocabulary::default()
184 }
185 }
186 Err(e) => {
187 return ScopeVocabulary {
188 unavailable: Some(e.to_string()),
189 ..ScopeVocabulary::default()
190 }
191 }
192 };
193 let text = String::from_utf8_lossy(&out.stdout);
194 let mut counts: BTreeMap<String, usize> = BTreeMap::new();
195 let mut association: BTreeMap<(String, String), usize> = BTreeMap::new();
196 let mut sampled = 0usize;
197 for record in text.split('\u{2}') {
198 let mut lines = record.lines();
199 let Some(subject) = lines.next() else {
200 continue;
201 };
202 if subject.trim().is_empty() {
203 continue;
204 }
205 sampled += 1;
206 let header = CommitHeader::parse(subject);
207 if !header.is_conventional() {
208 continue;
209 }
210 let Some(scope) = header.scope.filter(|s| !s.trim().is_empty()) else {
211 continue;
212 };
213 *counts.entry(scope.clone()).or_default() += 1;
214 let mut seen: Vec<String> = Vec::new();
215 for path in lines.filter(|l| !l.trim().is_empty()) {
216 for prefix in prefixes(path) {
217 if seen.contains(&prefix) {
218 continue;
219 }
220 seen.push(prefix.clone());
221 *association.entry((scope.clone(), prefix)).or_default() += 1;
222 }
223 }
224 }
225 let mut scopes: Vec<ScopeUse> = counts
226 .into_iter()
227 .map(|(scope, commits)| {
228 let dirs: Vec<(String, usize)> = association
229 .iter()
230 .filter(|((s, _), _)| *s == scope)
231 .map(|((_, d), n)| (d.clone(), *n))
232 .collect();
233 let mut weighted: Vec<Weighted> = dirs
234 .into_iter()
235 .map(|(name, weight)| Weighted { name, weight })
236 .collect();
237 crate::order::canonical(&mut weighted);
238 let mut dirs: Vec<(String, usize)> =
239 weighted.into_iter().map(|w| (w.name, w.weight)).collect();
240 dirs.truncate(5);
241 ScopeUse {
242 scope,
243 commits,
244 directories: dirs.into_iter().map(|(d, _)| d).collect(),
245 }
246 })
247 .collect();
248 crate::order::canonical(&mut scopes);
249 ScopeVocabulary {
250 scopes,
251 sampled,
252 unavailable: None,
253 association,
254 }
255}
256
257impl ScopeVocabulary {
258 /// Record that `commits` commits scoped changes under `prefix` as `scope`.
259 ///
260 /// [`fn@derive`] is how a vocabulary is normally built, from the history. This is the same
261 /// association stated directly, for a caller that has the evidence by another route —
262 /// and for tests, which must be able to state a history rather than construct one.
263 /// ```
264 /// use majordomus_cli::commit::ScopeVocabulary;
265 /// let mut v = ScopeVocabulary::default();
266 /// v.learn("commit", "src/commit", 12);
267 /// v.learn("commit", "src/commit", 5);
268 /// assert_eq!(v.suggest(&["src/commit/a.rs".into()]).expect("known").commits, 17);
269 /// ```
270 pub fn learn(&mut self, scope: &str, prefix: &str, commits: usize) {
271 *self
272 .association
273 .entry((scope.to_string(), prefix.to_string()))
274 .or_default() += commits;
275 }
276
277 /// Whether the history has used this scope.
278 /// ```
279 /// use majordomus_cli::commit::{scopes::ScopeUse, ScopeVocabulary};
280 /// let mut v = ScopeVocabulary::default();
281 /// v.scopes.push(ScopeUse { scope: "commit".into(), commits: 1, directories: vec![] });
282 /// assert!(v.knows("commit"));
283 /// assert!(!v.knows("nonesuch"));
284 /// ```
285 pub fn knows(&self, scope: &str) -> bool {
286 self.scopes.iter().any(|s| s.scope == scope)
287 }
288
289 /// The scope words alone, in vocabulary order.
290 /// ```
291 /// use majordomus_cli::commit::{scopes::ScopeUse, ScopeVocabulary};
292 /// let mut v = ScopeVocabulary::default();
293 /// v.scopes.push(ScopeUse { scope: "site".into(), commits: 9, directories: vec![] });
294 /// assert_eq!(v.words(), vec!["site".to_string()]);
295 /// ```
296 pub fn words(&self) -> Vec<String> {
297 self.scopes.iter().map(|s| s.scope.clone()).collect()
298 }
299
300 /// Which scope these paths belong to, judged by what prior commits did with the same
301 /// directories.
302 ///
303 /// `None` when nothing in the history associates any scope with any of these paths —
304 /// which is the honest answer for the first commit of a new subsystem, and is better
305 /// than the most popular scope in the repository, which is what a tie-break to
306 /// frequency would produce.
307 /// ```
308 /// use majordomus_cli::commit::ScopeVocabulary;
309 /// let mut v = ScopeVocabulary::default();
310 /// // the deepest association wins over the busiest directory two levels up
311 /// v.learn("commit", "apps/cli/src/commit", 12);
312 /// v.learn("everything", "apps", 900);
313 /// let s = v.suggest(&["apps/cli/src/commit/a.rs".into()]).expect("an association");
314 /// assert_eq!(s.scope, "commit");
315 /// ```
316 pub fn suggest(&self, paths: &[String]) -> Option<ScopeSuggestion> {
317 // Each path votes once, at the deepest prefix that anything is associated with, so
318 // that a change inside one subsystem is not outvoted by the repository's busiest
319 // directory two levels up.
320 let mut votes: BTreeMap<String, (usize, String)> = BTreeMap::new();
321 for path in paths {
322 for prefix in prefixes(path) {
323 let here: Vec<(&String, usize)> = self
324 .association
325 .iter()
326 .filter(|((_, d), _)| *d == prefix)
327 .map(|((s, _), n)| (s, *n))
328 .collect();
329 if here.is_empty() {
330 continue;
331 }
332 for (scope, n) in here {
333 let entry = votes.entry(scope.clone()).or_insert((0, prefix.clone()));
334 entry.0 += n;
335 entry.1 = prefix.clone();
336 }
337 break;
338 }
339 }
340 let ranked: Vec<(String, usize, String)> =
341 votes.into_iter().map(|(s, (n, d))| (s, n, d)).collect();
342 let mut candidates: Vec<Candidate> = ranked
343 .into_iter()
344 .map(|(scope, weight, directory)| Candidate {
345 scope,
346 weight,
347 directory,
348 })
349 .collect();
350 crate::order::canonical(&mut candidates);
351 let ranked: Vec<(String, usize, String)> = candidates
352 .into_iter()
353 .map(|c| (c.scope, c.weight, c.directory))
354 .collect();
355 let (scope, weight, directory) = ranked.first()?.clone();
356 let ambiguous = ranked.get(1).is_some_and(|(_, n, _)| *n == weight);
357 // The weight is what ranked the candidates — every path votes, so it grows with the
358 // number of paths. What a person is told is the association itself: how many commits
359 // scoped *that directory* that way. Reporting the weight would say "80 prior commits"
360 // for two files under a directory forty commits have touched, which is a number
361 // nobody could reconcile with the history.
362 let commits = self
363 .association
364 .get(&(scope.clone(), directory.clone()))
365 .copied()
366 .unwrap_or(weight);
367 Some(ScopeSuggestion {
368 scope,
369 commits,
370 directory,
371 ambiguous,
372 })
373 }
374}
375
376/// A scope is shown most-used first; between equally used scopes, by name.
377impl crate::order::Ordered for ScopeUse {
378 fn order_key(&self) -> crate::order::OrderKey<'_> {
379 crate::order::OrderKey::plain(&self.scope, &self.scope).ranked(-(self.commits as i64))
380 }
381}
382
383/// A directory weighted by how many commits tie it to a scope: heaviest first, then by name.
384struct Weighted {
385 name: String,
386 weight: usize,
387}
388
389impl crate::order::Ordered for Weighted {
390 fn order_key(&self) -> crate::order::OrderKey<'_> {
391 crate::order::OrderKey::plain(&self.name, &self.name).ranked(-(self.weight as i64))
392 }
393}
394
395/// A candidate scope for a set of paths: most votes first, then by name.
396struct Candidate {
397 scope: String,
398 weight: usize,
399 directory: String,
400}
401
402impl crate::order::Ordered for Candidate {
403 fn order_key(&self) -> crate::order::OrderKey<'_> {
404 crate::order::OrderKey::plain(&self.scope, &self.scope).ranked(-(self.weight as i64))
405 }
406}
407
408#[cfg(test)]
409mod tests {
410 use super::*;
411
412 #[test]
413 fn a_prefix_is_a_directory_and_never_the_file() {
414 assert_eq!(
415 prefixes("apps/majordomus-cli/src/commit/plan.rs"),
416 vec![
417 "apps/majordomus-cli/src/commit",
418 "apps/majordomus-cli/src",
419 "apps/majordomus-cli",
420 "apps"
421 ],
422 "deepest first, at most PREFIX_DEPTH segments"
423 );
424 assert_eq!(prefixes("docs/COMMIT.md"), vec!["docs"]);
425 // a file at the root is in no directory, so it associates with nothing
426 assert!(prefixes("README.md").is_empty());
427 }
428
429 fn vocabulary(rows: &[(&str, &str, usize)]) -> ScopeVocabulary {
430 let mut v = ScopeVocabulary::default();
431 for (scope, prefix, n) in rows {
432 v.learn(scope, prefix, *n);
433 if !v.knows(scope) {
434 v.scopes.push(ScopeUse {
435 scope: scope.to_string(),
436 commits: *n,
437 directories: vec![prefix.to_string()],
438 });
439 }
440 }
441 v
442 }
443
444 #[test]
445 fn a_path_takes_the_scope_the_history_gave_its_directory() {
446 let v = vocabulary(&[
447 ("commit", "apps/majordomus-cli/src", 40),
448 ("site", "site", 90),
449 ]);
450 let s = v
451 .suggest(&["apps/majordomus-cli/src/commit/plan.rs".into()])
452 .expect("an association");
453 assert_eq!(s.scope, "commit");
454 assert_eq!(s.directory, "apps/majordomus-cli/src");
455 assert!(!s.ambiguous);
456 }
457
458 #[test]
459 fn the_deepest_association_wins_over_the_busiest_directory() {
460 // `apps` is where everything is; `apps/majordomus-cli/src` is where this is. A vote
461 // at the shallow prefix would make every change in the crate take the scope of
462 // whatever the crate's busiest subsystem happens to be.
463 let v = vocabulary(&[
464 ("commit", "apps/majordomus-cli/src", 12),
465 ("everything", "apps", 900),
466 ]);
467 let s = v
468 .suggest(&["apps/majordomus-cli/src/commit/plan.rs".into()])
469 .expect("an association");
470 assert_eq!(s.scope, "commit");
471 }
472
473 #[test]
474 fn a_tie_is_reported_rather_than_broken() {
475 let v = vocabulary(&[("a", "src", 7), ("b", "src", 7)]);
476 let s = v.suggest(&["src/x.rs".into()]).expect("an association");
477 assert!(s.ambiguous, "two scopes are equally associated");
478 // and it is still deterministic: the same input yields the same answer every time
479 assert_eq!(s.scope, v.suggest(&["src/x.rs".into()]).unwrap().scope);
480 }
481
482 #[test]
483 fn nothing_known_is_answered_with_nothing_and_not_with_the_most_popular_scope() {
484 let v = vocabulary(&[("site", "site", 900)]);
485 assert!(v.suggest(&["brand/new/thing.rs".into()]).is_none());
486 assert!(v.suggest(&[]).is_none());
487 }
488
489 #[test]
490 fn a_directory_that_is_not_a_work_tree_yields_an_empty_vocabulary_that_says_why() {
491 let plain = tempfile::tempdir().expect("a temporary directory");
492 let v = derive(plain.path());
493 assert!(v.scopes.is_empty());
494 assert!(
495 v.unavailable.is_some(),
496 "it says why rather than pretending"
497 );
498 assert!(!v.knows("anything"));
499 }
500}