majordomus_cli/release/version.rs
1//! The version: the one place it is authored, what is derived from that place, what the
2//! contract requires next and what the commits imply as evidence, and the one writer that
3//! raises it.
4//!
5//! The product version is authored in exactly one place, the `[package] version` line of
6//! the crate manifest ([`MANIFEST`]). Every other statement of it is derived from that line
7//! and checked, or is a record of a version that was released:
8//!
9//! ```text
10//! apps/majordomus-cli/Cargo.toml authored the one place; `release bump` writes it
11//! crate::VERSION compiled cargo's CARGO_PKG_VERSION
12//! share/version.txt projected `majordomus generate`; bin/majordomus reads it
13//! apps/majordomus-cli/Cargo.lock derived cargo's record; the writer keeps it in step
14//! generator stamps, the changelog generated `majordomus generate`
15//! .ai/repo/releases/*.yaml records the release pipeline, after publication
16//! ```
17//!
18//! The shell tool used to state the version a second time, by hand, because an installed
19//! tree has no `Cargo.toml` and the tool cannot read one at run time. It reads
20//! [`PROJECTION`] instead: a typed generated artifact that ships in every archive beside the
21//! tool, that `generate --check` refuses when it falls behind the manifest, and that nobody
22//! edits. [`diagnose`] is what refuses a version anybody writes down by hand where the
23//! tool's own files live.
24//!
25//! One reader of the authority per language: [`declared`] here, `scripts/release-version`
26//! in shell. Nothing else parses the manifest for its version.
27//!
28//! ```
29//! use majordomus_cli::release::version::{declared_in, parse_projection, render_projection};
30//! // the authority, and the one line the shell tool reads back from its projection of it
31//! let authority = declared_in("[package]\nversion = \"0.9.0\"\n").unwrap();
32//! assert_eq!(parse_projection(&render_projection(&authority)).as_deref(), Some("0.9.0"));
33//! ```
34
35use std::collections::BTreeSet;
36use std::path::Path;
37
38use super::compat::{Diagnostic, Severity};
39use super::model::{Change, ChangeKind, DecidedBy, VersionReport};
40use crate::model::Object;
41
42/// The crate manifest: the one place the version is authored.
43pub const MANIFEST: &str = "apps/majordomus-cli/Cargo.toml";
44/// The projection of the version the shell tool reads at start-up, from beside itself.
45///
46/// Generated by `majordomus generate` (target `distribution`) from [`MANIFEST`] and never
47/// edited: [`render_projection`] writes its body and [`parse_projection`] reads it back.
48pub const PROJECTION: &str = "share/version.txt";
49/// The lock file, which records the crate's own version alongside every dependency's.
50///
51/// Not a declaration — cargo derives it from the manifest, and nobody chooses the value.
52/// It is a tracked *record*, though, and the build runs `--locked`: a bump that raised the
53/// manifest and left the lock behind made the next build fail with `cannot update the lock
54/// file ... because --locked was passed`, which reads as a toolchain fault rather than as a
55/// half-applied bump, and it fails every launcher call — the git hooks, the MCP server —
56/// until something rebuilds without `--locked`. So the writer keeps the crate's own entry
57/// in step, by cargo's rule, in the same command that raises the manifest.
58pub const LOCK: &str = "apps/majordomus-cli/Cargo.lock";
59
60/// Where the tool's own files live, and so where a version written by hand is refused.
61///
62/// The shell tool, the libraries it sources, the scripts that build and ship it, and the
63/// distribution's data. A version stated in one of these by hand is a second declaration of
64/// the fact [`MANIFEST`] owns; [`carriers`] finds it and [`diagnose`] refuses it.
65pub const CARRIER_ROOTS: &[&str] = &["bin", "lib", "scripts", "share"];
66
67/// The diagnostic id of a version written down by hand where the tool's files live.
68pub const STATED_BY_HAND: &str = "version-stated-by-hand";
69/// The diagnostic id of a projection that is behind the manifest, or missing: the state
70/// between a bump and the derivation after it. A warning — `generate --check` refuses it.
71pub const PROJECTION_STALE: &str = "projection-stale";
72/// The diagnostic id of a projection ahead of the manifest, or unrelated to it: a version
73/// no derivation produces, so somebody wrote it.
74pub const WRITERS_DISAGREE: &str = "writers-disagree";
75
76/// The diagnostic id of a version label — `v0.1`, a major and a minor with no patch —
77/// typed into hand-written prose, where it states which version the text describes.
78pub const LABEL_IN_PROSE: &str = "version-label-in-prose";
79/// The diagnostic id of a [`LABEL_HISTORY`] entry that cannot be honoured: no reason, a
80/// path that is not tracked prose, or a document that no longer carries a label.
81pub const LABEL_HISTORY_INVALID: &str = "version-label-history-invalid";
82
83/// The documents that describe a past version and are allowed to name it by label, one
84/// `<path> <reason>` per line. A record of the design phase says "v0.1" because it is about
85/// v0.1; a page describing the tool as it stands says it because nobody changed it.
86pub const LABEL_HISTORY: &str = ".ai/repo/version-label-history.txt";
87
88/// Where hand-written prose lives, as `git ls-files` pathspecs. [`prose_files`] narrows
89/// these: git's `*` crosses directories, and `site/content/` is mostly generated.
90const PROSE_ROOTS: &[&str] = &[
91 "README.md",
92 "AGENTS.md",
93 "CLAUDE.md",
94 "docs",
95 "site/content-src",
96 "site/content",
97 "site/data",
98 "site/templates",
99 ".ai/repo",
100 "share/providers",
101];
102
103/// Dated records under `.ai/repo/`: a decision or a session names the version it was taken
104/// in, and stays true about that moment.
105const PROSE_RECORDS: &[&str] = &[".ai/repo/adrs/", ".ai/repo/sessions/"];
106
107/// The generated-artifact manifest: every path in it is written by `majordomus generate`,
108/// so a version stamped into one is a projection and never a statement by hand.
109const GENERATED_MANIFEST: &str = "docs/generated/artifacts.json";
110
111/// A file larger than this is not a script or a declaration anybody writes by hand.
112const LARGEST_READ: u64 = 2 * 1024 * 1024;
113
114/// How much a set of changes raises a version.
115#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
116pub enum Bump {
117 /// Nothing since the last release.
118 None,
119 /// A fix, or anything that is not a feature.
120 Patch,
121 /// At least one feature.
122 Minor,
123 /// At least one breaking change.
124 Major,
125}
126
127impl Bump {
128 /// The word the reports use.
129 pub fn as_str(self) -> &'static str {
130 match self {
131 Bump::None => "none",
132 Bump::Patch => "patch",
133 Bump::Minor => "minor",
134 Bump::Major => "major",
135 }
136 }
137
138 /// The bump named by a word, for `release bump --minor` and friends.
139 pub fn parse(word: &str) -> Option<Bump> {
140 match word {
141 "major" => Some(Bump::Major),
142 "minor" => Some(Bump::Minor),
143 "patch" => Some(Bump::Patch),
144 "none" => Some(Bump::None),
145 _ => None,
146 }
147 }
148}
149
150/// What a set of conventional commits *says about itself*.
151///
152/// The rule is the conventional-commit rule and nothing more: a breaking change is major, a
153/// feature is minor, anything else is patch, and no commits at all is none.
154///
155/// # This is evidence, not authority
156///
157/// It used to decide what `release bump` wrote, and that was the defect this subsystem
158/// exists to remove. A commit message is a label a person chose, and a capability deleted
159/// under a `refactor:` heading was a patch to this function and a broken caller to everyone
160/// else. The compatibility level is now measured from the public contract by
161/// [`crate::release::compat::analyze`]; this answer is carried beside it as
162/// [`crate::release::compat::CommitEvidence`], and when the two disagree the plan says which
163/// one understated the change. Nothing decides a version from this alone.
164pub fn bump_of(changes: &[Change]) -> Bump {
165 if changes.is_empty() {
166 return Bump::None;
167 }
168 if changes.iter().any(|c| c.breaking) {
169 return Bump::Major;
170 }
171 if changes.iter().any(|c| c.kind == ChangeKind::Feat) {
172 return Bump::Minor;
173 }
174 Bump::Patch
175}
176
177/// A semantic version, only as much as this needs.
178///
179/// Ordered by the three numbers, which is what lets the writer refuse a version smaller than
180/// the one the contract requires rather than only a *bump* smaller than required.
181#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
182pub struct Version {
183 /// Major.
184 pub major: u64,
185 /// Minor.
186 pub minor: u64,
187 /// Patch.
188 pub patch: u64,
189}
190
191impl Version {
192 /// Read `1.2.3`; anything else is `None`.
193 pub fn parse(text: &str) -> Option<Version> {
194 let mut parts = text.trim().split('.');
195 let major = parts.next()?.parse().ok()?;
196 let minor = parts.next()?.parse().ok()?;
197 let patch = parts.next()?.parse().ok()?;
198 if parts.next().is_some() {
199 return None;
200 }
201 Some(Version {
202 major,
203 minor,
204 patch,
205 })
206 }
207
208 /// This version raised by `bump`.
209 ///
210 /// Arithmetic and nothing else: zero-major is not special-cased here, because whether a
211 /// breaking change below 1.0 costs a major or a minor is a *policy* question and this
212 /// repository answers it in exactly one place, [`crate::release::compat::Policy`]. A
213 /// second answer here is the shape this subsystem was built to remove.
214 pub fn raised(self, bump: Bump) -> Version {
215 match bump {
216 Bump::None => self,
217 Bump::Patch => Version {
218 patch: self.patch + 1,
219 ..self
220 },
221 Bump::Minor => Version {
222 minor: self.minor + 1,
223 patch: 0,
224 ..self
225 },
226 Bump::Major => Version {
227 major: self.major + 1,
228 minor: 0,
229 patch: 0,
230 },
231 }
232 }
233}
234
235impl Version {
236 /// This version raised by a measured compatibility impact.
237 ///
238 /// The bridge between the engine's verdict and the arithmetic: the policy has already
239 /// decided what a `0.x` breaking change costs, so this only has to apply it.
240 ///
241 /// ```
242 /// use majordomus_cli::release::compat::Impact;
243 /// use majordomus_cli::release::version::Version;
244 /// let v = Version::parse("0.5.0").unwrap();
245 /// assert_eq!(v.raised_to(Impact::Minor).to_string(), "0.6.0");
246 /// assert_eq!(v.raised_to(Impact::Major).to_string(), "1.0.0");
247 /// assert_eq!(v.raised_to(Impact::None).to_string(), "0.5.0");
248 /// ```
249 pub fn raised_to(self, impact: crate::release::compat::Impact) -> Version {
250 use crate::release::compat::Impact;
251 self.raised(match impact {
252 Impact::None => Bump::None,
253 Impact::Patch => Bump::Patch,
254 Impact::Minor => Bump::Minor,
255 Impact::Major => Bump::Major,
256 })
257 }
258}
259
260impl std::fmt::Display for Version {
261 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
262 write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
263 }
264}
265
266/// The version the crate manifest declares: the authority.
267///
268/// The one Rust reader of it. `majordomus generate` asks the same function when it decides
269/// whether the executable running is the one the tree declares.
270///
271/// ```
272/// use majordomus_cli::release::version::{declared, MANIFEST};
273/// let dir = tempfile::tempdir().unwrap();
274/// // No manifest is no version, never a guess.
275/// assert_eq!(declared(dir.path()), None);
276/// std::fs::create_dir_all(dir.path().join("apps/majordomus-cli")).unwrap();
277/// std::fs::write(dir.path().join(MANIFEST), "[package]\nversion = \"0.9.0\"\n").unwrap();
278/// assert_eq!(declared(dir.path()).as_deref(), Some("0.9.0"));
279/// ```
280pub fn declared(root: &Path) -> Option<String> {
281 declared_in(&std::fs::read_to_string(root.join(MANIFEST)).ok()?)
282}
283
284/// The `[package] version` of a manifest's text.
285///
286/// The first `version = "..."` inside `[package]`, which is where the manifest puts it: a
287/// dependency pinned at a version further down is a different package, a key that merely
288/// begins with `version` is a different key, and a trailing comment is not part of the value.
289/// Read without a TOML parser, because the value wanted is one quoted scalar in a table whose
290/// header is unambiguous, and a dependency to read it would be one this crate argues against.
291///
292/// ```
293/// use majordomus_cli::release::version::declared_in;
294/// let manifest = "[package]\nname = \"x\"\nversion = \"1.2.3\" # bumped\n\n\
295/// [dependencies]\nserde = { version = \"1.2.4\" }\n";
296/// assert_eq!(declared_in(manifest).as_deref(), Some("1.2.3"));
297/// // outside [package] there is no package version at all
298/// assert_eq!(declared_in("[dependencies]\nversion = \"1.0.0\"\n"), None);
299/// // a key that only starts with the word is another key
300/// assert_eq!(declared_in("[package]\nversioned = \"1.0.0\"\n"), None);
301/// ```
302pub fn declared_in(manifest: &str) -> Option<String> {
303 let mut in_package = false;
304 for line in manifest.lines() {
305 let line = line.trim();
306 if line.starts_with('[') {
307 in_package = line == "[package]";
308 continue;
309 }
310 if !in_package {
311 continue;
312 }
313 let Some(rest) = line.strip_prefix("version") else {
314 continue;
315 };
316 let Some(rest) = rest.trim_start().strip_prefix('=') else {
317 continue;
318 };
319 let rest = rest.trim();
320 let value = match rest.strip_prefix('"') {
321 Some(quoted) => quoted.split('"').next().unwrap_or_default(),
322 None => rest.split('#').next().unwrap_or_default().trim(),
323 };
324 return (!value.is_empty()).then(|| value.to_string());
325 }
326 None
327}
328
329/// The version the lock file records for this crate.
330///
331/// Only the entry whose `name` is this crate's: a dependency that happens to be at the same
332/// version is a different package and is never read or written here.
333///
334/// ```
335/// use majordomus_cli::release::version::locked;
336/// let dir = tempfile::tempdir().unwrap();
337/// // No lock file at all is `None`, never a guess.
338/// assert_eq!(locked(dir.path()), None);
339/// ```
340pub fn locked(root: &Path) -> Option<String> {
341 let text = std::fs::read_to_string(root.join(LOCK)).ok()?;
342 let mut in_package = false;
343 for line in text.lines() {
344 let line = line.trim();
345 if line == "[[package]]" {
346 in_package = false;
347 continue;
348 }
349 if line == "name = \"majordomus-cli\"" {
350 in_package = true;
351 continue;
352 }
353 if in_package {
354 if let Some(rest) = line.strip_prefix("version") {
355 return rest.split('"').nth(1).map(|v| v.to_string());
356 }
357 }
358 }
359 None
360}
361
362/// The body of [`PROJECTION`]: one `version=` line, which `bin/majordomus` reads with shell
363/// builtins alone. `majordomus generate` puts its provenance banner above it.
364///
365/// ```
366/// use majordomus_cli::release::version::{parse_projection, render_projection};
367/// assert_eq!(render_projection("0.9.0"), "version=0.9.0\n");
368/// assert_eq!(parse_projection(&render_projection("0.9.0")).as_deref(), Some("0.9.0"));
369/// ```
370pub fn render_projection(version: &str) -> String {
371 format!("version={version}\n")
372}
373
374/// The version [`PROJECTION`] states: its first `version=` line, past the `#` banner.
375///
376/// Exactly what `bin/majordomus` reads at start-up, so the executable and the shell tool
377/// cannot disagree about what the file says.
378///
379/// ```
380/// use majordomus_cli::release::version::parse_projection;
381/// let file = "# GENERATED FILE — DO NOT EDIT DIRECTLY\n# Generator: majordomus-cli 0.9.0\nversion=0.9.0\n";
382/// assert_eq!(parse_projection(file).as_deref(), Some("0.9.0"));
383/// // a file that states nothing states no version, and an empty value is not one
384/// assert_eq!(parse_projection("# nothing here\n"), None);
385/// assert_eq!(parse_projection("version=\n"), None);
386/// ```
387pub fn parse_projection(text: &str) -> Option<String> {
388 text.lines()
389 .filter(|l| !l.trim_start().starts_with('#'))
390 .find_map(|l| l.strip_prefix("version="))
391 .map(|v| v.trim().to_string())
392 .filter(|v| !v.is_empty())
393}
394
395/// The version the shell tool prints: what [`PROJECTION`] states.
396///
397/// ```
398/// use majordomus_cli::release::version::{tool, PROJECTION};
399/// let dir = tempfile::tempdir().unwrap();
400/// assert_eq!(tool(dir.path()), None, "no projection, no version");
401/// std::fs::create_dir_all(dir.path().join("share")).unwrap();
402/// std::fs::write(dir.path().join(PROJECTION), "version=0.9.0\n").unwrap();
403/// assert_eq!(tool(dir.path()).as_deref(), Some("0.9.0"));
404/// ```
405pub fn tool(root: &Path) -> Option<String> {
406 parse_projection(&std::fs::read_to_string(root.join(PROJECTION)).ok()?)
407}
408
409/// How [`PROJECTION`] stands against the authority.
410///
411/// Three answers that lead to three different actions: nothing to do, run the derivation,
412/// or find out who wrote a version nobody authored.
413///
414/// ```
415/// use majordomus_cli::release::version::ProjectionState;
416/// let stale = ProjectionState::Stale { projected: Some("0.8.0".into()), declared: "0.9.0".into() };
417/// // the state every bump leaves behind is not the state nothing could have produced
418/// assert_ne!(stale, ProjectionState::Disagrees { projected: "0.8.0".into(), declared: Some("0.9.0".into()) });
419/// ```
420#[derive(Debug, Clone, PartialEq, Eq)]
421pub enum ProjectionState {
422 /// Neither the manifest nor the projection is here: this repository does not carry the
423 /// product, so there is nothing to project and nothing to compare.
424 NotHere,
425 /// The projection states the version the manifest declares.
426 Current,
427 /// The projection is missing, or states an older version than the manifest: the state
428 /// between `release bump` and the derivation that follows it. `generate` fixes it.
429 Stale {
430 /// What the projection states, when it states anything.
431 projected: Option<String>,
432 /// What the manifest declares.
433 declared: String,
434 },
435 /// The projection states a version newer than the manifest's, or one that cannot be
436 /// compared with it, or there is a projection and no manifest. No derivation writes
437 /// that, so somebody did.
438 Disagrees {
439 /// What the projection states.
440 projected: String,
441 /// What the manifest declares, when there is one.
442 declared: Option<String>,
443 },
444}
445
446/// How [`PROJECTION`] stands against [`MANIFEST`]: compared as versions, so that a projection
447/// behind the manifest — what a bump leaves until the derivation runs — is told apart from
448/// one ahead of it or unrelated to it, which no derivation ever writes.
449///
450/// ```
451/// use majordomus_cli::release::version::{projection_state, ProjectionState, MANIFEST, PROJECTION};
452/// let dir = tempfile::tempdir().unwrap();
453/// let root = dir.path();
454/// assert_eq!(projection_state(root), ProjectionState::NotHere);
455/// std::fs::create_dir_all(root.join("apps/majordomus-cli")).unwrap();
456/// std::fs::create_dir_all(root.join("share")).unwrap();
457/// std::fs::write(root.join(MANIFEST), "[package]\nversion = \"0.9.0\"\n").unwrap();
458/// // raised and not yet derived: stale, which a derivation fixes
459/// std::fs::write(root.join(PROJECTION), "version=0.8.0\n").unwrap();
460/// assert!(matches!(projection_state(root), ProjectionState::Stale { .. }));
461/// // a version the manifest never declared: nothing derives that
462/// std::fs::write(root.join(PROJECTION), "version=1.0.0\n").unwrap();
463/// assert!(matches!(projection_state(root), ProjectionState::Disagrees { .. }));
464/// std::fs::write(root.join(PROJECTION), "version=0.9.0\n").unwrap();
465/// assert_eq!(projection_state(root), ProjectionState::Current);
466/// ```
467pub fn projection_state(root: &Path) -> ProjectionState {
468 match (declared(root), tool(root)) {
469 (None, None) => ProjectionState::NotHere,
470 (None, Some(projected)) => ProjectionState::Disagrees {
471 projected,
472 declared: None,
473 },
474 (Some(declared), None) => ProjectionState::Stale {
475 projected: None,
476 declared,
477 },
478 (Some(declared), Some(projected)) if declared == projected => ProjectionState::Current,
479 (Some(declared), Some(projected)) => {
480 match (Version::parse(&projected), Version::parse(&declared)) {
481 (Some(p), Some(d)) if p < d => ProjectionState::Stale {
482 projected: Some(projected),
483 declared,
484 },
485 _ => ProjectionState::Disagrees {
486 projected,
487 declared: Some(declared),
488 },
489 }
490 }
491 }
492}
493
494/// Whether [`PROJECTION`] states what [`MANIFEST`] declares — the one comparison every
495/// surface reports as `agree`.
496///
497/// ```
498/// use majordomus_cli::release::version::projection_current;
499/// let dir = tempfile::tempdir().unwrap();
500/// // a repository that carries neither has nothing out of step
501/// assert!(projection_current(dir.path()));
502/// ```
503pub fn projection_current(root: &Path) -> bool {
504 matches!(
505 projection_state(root),
506 ProjectionState::Current | ProjectionState::NotHere
507 )
508}
509
510/// One version written down by hand where the tool's own files live.
511///
512/// Where it is, to the line, and the shape it was written in — enough for the diagnostic to
513/// name the edit that removes it.
514///
515/// ```
516/// use majordomus_cli::release::version::{carrier_shape, Carrier};
517/// let line = "FOO_VERSION=\"1.2.3\"";
518/// let found = Carrier { path: "lib/x.sh".into(), line: 2, shape: carrier_shape(line).unwrap(), text: line.into() };
519/// assert_eq!(found.shape, "an assignment to a version name");
520/// ```
521#[derive(Debug, Clone, PartialEq, Eq)]
522pub struct Carrier {
523 /// Repository-relative path.
524 pub path: String,
525 /// One-based line number.
526 pub line: usize,
527 /// The shape the version was written in.
528 pub shape: &'static str,
529 /// The line, trimmed.
530 pub text: String,
531}
532
533/// The shape a line states a version in, when it states one.
534///
535/// Three shapes, each a place a version is *declared* rather than mentioned:
536///
537/// - an assignment to a name ending in `version`, in any case and in the languages the
538/// tool's files are written in: shell `MJ_VERSION="0.8.0"` and `export FOO_VERSION=1.2.3`,
539/// TOML `version = "1.2.3"`, JavaScript `const VERSION = '1.2.3'`, Python
540/// `__version__ = "1.2.3"`;
541/// - a `version` member of YAML or JSON at the start of a line: `version: 1.2.3`,
542/// `"version": "1.2.3"`;
543/// - the product's own answer to `version`: `majordomus 0.8.0`.
544///
545/// A version is three numbers, optionally after a `v`; a schema's `version: 1` is not one.
546///
547/// ```
548/// use majordomus_cli::release::version::carrier_shape;
549/// assert!(carrier_shape("MJ_VERSION=\"0.8.0\"").is_some());
550/// assert!(carrier_shape(" export FOO_VERSION=v1.2.3").is_some());
551/// assert!(carrier_shape("declare -r MJ_VERSION=\"1.2.3\"").is_some());
552/// assert!(carrier_shape("version = \"1.2.3\"").is_some());
553/// assert!(carrier_shape("export const VERSION = '1.2.3';").is_some());
554/// assert!(carrier_shape("let appVersion = `1.2.3`;").is_some());
555/// assert!(carrier_shape("__version__ = \"1.2.3\"").is_some());
556/// assert!(carrier_shape("version: 1.2.3").is_some());
557/// assert!(carrier_shape(" \"version\": \"1.2.3\",").is_some());
558/// assert!(carrier_shape("echo \"majordomus 0.8.0\"").is_some());
559/// // read, not stated: a variable, a schema version, a generator stamp, a sentinel, a
560/// // comparison, a property of something else
561/// assert!(carrier_shape("MJ_VERSION=\"${line#version=}\"").is_none());
562/// assert!(carrier_shape("version: 1").is_none());
563/// assert!(carrier_shape("# Generator: majordomus-cli 0.8.0").is_none());
564/// assert!(carrier_shape("MJ_VERSION=\"site\"").is_none());
565/// assert!(carrier_shape("version == \"1.2.3\"").is_none());
566/// assert!(carrier_shape("pkg.version = \"1.2.3\"").is_none());
567/// assert!(carrier_shape("versioned = \"1.2.3\"").is_none());
568/// ```
569pub fn carrier_shape(line: &str) -> Option<&'static str> {
570 let t = line.trim_start();
571
572 // an assignment to a name ending in `version`, once the words that declare it are set aside
573 if let Some((name, value)) = declared_name(t).split_once('=') {
574 let name = name.trim_end();
575 let is_version_name = !name.is_empty()
576 && name
577 .chars()
578 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$')
579 && name
580 .trim_matches('_')
581 .to_ascii_lowercase()
582 .ends_with("version");
583 let value = value.trim_start().trim_start_matches(['"', '\'', '`']);
584 if is_version_name && leads_with_version(value) {
585 return Some("an assignment to a version name");
586 }
587 }
588
589 // a `version` member of YAML or JSON, at the start of a line or of a list item
590 let member = t.strip_prefix("- ").unwrap_or(t);
591 let after_key = member
592 .strip_prefix("\"version\"")
593 .or_else(|| member.strip_prefix("'version'"))
594 .or_else(|| member.strip_prefix("version"));
595 if let Some(rest) = after_key {
596 if let Some(value) = rest.trim_start().strip_prefix(':') {
597 if leads_with_version(value.trim_start().trim_start_matches(['"', '\''])) {
598 return Some("a version member");
599 }
600 }
601 }
602
603 // the product's own answer to `majordomus version`
604 const PRODUCT: &str = "majordomus ";
605 for (at, _) in t.match_indices(PRODUCT) {
606 let before = t[..at].chars().next_back();
607 let joined = before.is_some_and(|c| c.is_ascii_alphanumeric() || "-_./".contains(c));
608 if !joined && leads_with_version(&t[at + PRODUCT.len()..]) {
609 return Some("the product's version line");
610 }
611 }
612 None
613}
614
615/// A line with the words that declare a name set aside — `export`, `readonly`, `local`,
616/// `declare` and `typeset` with their flags, `const`, `let`, `var` — so that what is left
617/// begins with the name being assigned.
618fn declared_name(mut t: &str) -> &str {
619 const DECLARING: &[&str] = &[
620 "export", "readonly", "local", "declare", "typeset", "const", "let", "var",
621 ];
622 const TAKES_FLAGS: &[&str] = &["export", "readonly", "local", "declare", "typeset"];
623 while let Some((word, rest)) = t.split_once(char::is_whitespace) {
624 if !DECLARING.contains(&word) {
625 break;
626 }
627 t = rest.trim_start();
628 if TAKES_FLAGS.contains(&word) {
629 while let Some((flag, rest)) = t.split_once(char::is_whitespace) {
630 if flag.len() > 1 && flag.starts_with('-') && !flag.contains('=') {
631 t = rest.trim_start();
632 } else {
633 break;
634 }
635 }
636 }
637 }
638 t
639}
640
641/// Whether `text` begins with a version: three dot-separated numbers, optionally after a
642/// `v`, and not followed by a fourth.
643fn leads_with_version(text: &str) -> bool {
644 let bytes = text.strip_prefix('v').unwrap_or(text).as_bytes();
645 let mut at = 0;
646 for group in 0..3 {
647 let start = at;
648 while at < bytes.len() && bytes[at].is_ascii_digit() {
649 at += 1;
650 }
651 if at == start {
652 return false;
653 }
654 if group < 2 {
655 if bytes.get(at) != Some(&b'.') {
656 return false;
657 }
658 at += 1;
659 }
660 }
661 !matches!(bytes.get(at), Some(b) if *b == b'.' || b.is_ascii_digit())
662}
663
664/// Every version written down by hand under [`CARRIER_ROOTS`].
665///
666/// The files git would commit there — tracked, or new and not ignored, so a file is refused
667/// before it is staged rather than after — or, where this is not a git checkout, every file
668/// under those directories, so that a tree nobody committed is still read rather than
669/// passed. [`PROJECTION`] is not a statement by hand, and neither is any path the generated
670/// artifact manifest names: `majordomus generate` wrote those, and `generate --check` is
671/// what holds them.
672///
673/// ```
674/// use majordomus_cli::release::version::carriers;
675/// let dir = tempfile::tempdir().unwrap();
676/// std::fs::create_dir_all(dir.path().join("lib")).unwrap();
677/// std::fs::write(dir.path().join("lib/x.sh"), "FOO_VERSION=\"1.2.3\"\n").unwrap();
678/// let found = carriers(dir.path());
679/// assert_eq!(found.len(), 1);
680/// assert_eq!((found[0].path.as_str(), found[0].line), ("lib/x.sh", 1));
681/// ```
682pub fn carriers(root: &Path) -> Vec<Carrier> {
683 let files = committable(root).unwrap_or_else(|| walk(root, CARRIER_ROOTS));
684 let generated = generated_paths(root);
685 let mut out = Vec::new();
686 for path in files {
687 if path == PROJECTION || generated.contains(&path) {
688 continue;
689 }
690 let full = root.join(&path);
691 let small =
692 std::fs::symlink_metadata(&full).is_ok_and(|m| m.is_file() && m.len() <= LARGEST_READ);
693 if !small {
694 continue;
695 }
696 let Ok(text) = std::fs::read_to_string(&full) else {
697 continue;
698 };
699 for (i, line) in text.lines().enumerate() {
700 if let Some(shape) = carrier_shape(line) {
701 out.push(Carrier {
702 path: path.clone(),
703 line: i + 1,
704 shape,
705 text: line.trim().to_string(),
706 });
707 }
708 }
709 }
710 out
711}
712
713/// The files under [`CARRIER_ROOTS`] git would commit: tracked, or untracked and not
714/// ignored. `None` when git cannot answer, which is not the same as "no files".
715fn committable(root: &Path) -> Option<Vec<String>> {
716 let out = crate::git::read_only(root)
717 .args([
718 "ls-files",
719 "-z",
720 "--cached",
721 "--others",
722 "--exclude-standard",
723 "--",
724 ])
725 .args(CARRIER_ROOTS)
726 .output()
727 .ok()?;
728 if !out.status.success() {
729 return None;
730 }
731 let mut files: Vec<String> = out
732 .stdout
733 .split(|b| *b == 0)
734 .filter(|s| !s.is_empty())
735 .map(|s| String::from_utf8_lossy(s).into_owned())
736 .collect();
737 crate::order::canonical_strings(&mut files);
738 files.dedup();
739 Some(files)
740}
741
742/// Every file under `roots` — directories walked, files taken as they are —
743/// repository-relative, in a stable order: the answer when git cannot list them.
744fn walk(root: &Path, roots: &[&str]) -> Vec<String> {
745 fn visit(dir: &Path, root: &Path, out: &mut Vec<String>) {
746 let Ok(entries) = std::fs::read_dir(dir) else {
747 return;
748 };
749 for entry in entries.flatten() {
750 let path = entry.path();
751 match entry.file_type() {
752 Ok(t) if t.is_dir() => visit(&path, root, out),
753 Ok(t) if t.is_file() => {
754 if let Ok(rel) = path.strip_prefix(root) {
755 out.push(rel.to_string_lossy().replace('\\', "/"));
756 }
757 }
758 _ => {}
759 }
760 }
761 }
762 let mut out = Vec::new();
763 for dir in roots {
764 let full = root.join(dir);
765 if full.is_file() {
766 out.push((*dir).to_string());
767 } else {
768 visit(&full, root, &mut out);
769 }
770 }
771 crate::order::canonical_strings(&mut out);
772 out
773}
774
775/// The paths the committed generated-artifact manifest names.
776fn generated_paths(root: &Path) -> BTreeSet<String> {
777 std::fs::read_to_string(root.join(GENERATED_MANIFEST))
778 .ok()
779 .and_then(|t| serde_json::from_str::<serde_json::Value>(&t).ok())
780 .and_then(|v| v.get("artifacts").and_then(|a| a.as_array()).cloned())
781 .unwrap_or_default()
782 .iter()
783 .filter_map(|a| a.get("path").and_then(|p| p.as_str()).map(str::to_string))
784 .collect()
785}
786
787/// Every version label on a line: `v`, a major and a minor, and no patch — `v0.1`, `v1.2`.
788///
789/// A release is always three numbers (`v0.3.1`), so a label of two names no release: it
790/// names the version a text was written about, and it stays where it was typed while the
791/// version moves on. A label joined to a word, a path or a URL is not one, and neither is a
792/// three-part version or a schema's `version: 1`.
793///
794/// ```
795/// use majordomus_cli::release::version::version_labels;
796/// assert_eq!(version_labels("What v0.1 does not do"), ["v0.1"]);
797/// assert_eq!(version_labels("(v1.12), and v2.0."), ["v1.12", "v2.0"]);
798/// assert!(version_labels("Release `v0.3.1` was published").is_empty());
799/// assert!(version_labels("see /releases/v0.1.json and api-v0.1").is_empty());
800/// assert!(version_labels("rule v2, schema version: 1, dev0.1").is_empty());
801/// assert!(version_labels("v0.1.0 and v0.1x").is_empty());
802/// ```
803pub fn version_labels(line: &str) -> Vec<String> {
804 let bytes = line.as_bytes();
805 let joined = |b: u8| b.is_ascii_alphanumeric() || b"./_-".contains(&b);
806 let digits = |mut at: usize| {
807 while at < bytes.len() && bytes[at].is_ascii_digit() {
808 at += 1;
809 }
810 at
811 };
812 let mut out = Vec::new();
813 for (at, _) in line.match_indices('v') {
814 if at > 0 && joined(bytes[at - 1]) {
815 continue;
816 }
817 let major = digits(at + 1);
818 if major == at + 1 || bytes.get(major) != Some(&b'.') {
819 continue;
820 }
821 let minor = digits(major + 1);
822 if minor == major + 1 {
823 continue;
824 }
825 let patch =
826 bytes.get(minor) == Some(&b'.') && bytes.get(minor + 1).is_some_and(u8::is_ascii_digit);
827 let glued = bytes
828 .get(minor)
829 .is_some_and(|b| b.is_ascii_alphanumeric() || b"_-".contains(b));
830 if !patch && !glued {
831 out.push(line[at..minor].to_string());
832 }
833 }
834 out
835}
836
837/// The tracked hand-written prose: documents, the site's authored pages and data, its
838/// templates, the layer's own documents and the provider templates.
839///
840/// Generated files are not prose anybody wrote — a label in one comes from its source, and
841/// the source is where it is found — so a path the generated-artifact manifest names, a
842/// file stamped by its generator, and a page under `site/content/` that the site generator
843/// projects from `site/content-src/` or writes into a subdirectory are all left out. The
844/// layer's dated records ([`PROSE_RECORDS`]) are left out too. Where git cannot list the
845/// tree — not a checkout — every file under the roots is read instead, as [`carriers`]
846/// does, so a tree nobody committed is still read rather than passed.
847fn prose_files(root: &Path) -> Vec<String> {
848 let listed =
849 crate::git::ls_files_any(root, PROSE_ROOTS).unwrap_or_else(|_| walk(root, PROSE_ROOTS));
850 let generated = generated_paths(root);
851 let prose = |p: &str| match p {
852 "README.md" | "AGENTS.md" | "CLAUDE.md" => true,
853 _ if PROSE_RECORDS.iter().any(|r| p.starts_with(r)) => false,
854 _ if p.starts_with("site/content/") => {
855 let name = &p["site/content/".len()..];
856 p.ends_with(".md")
857 && !name.contains('/')
858 && !root.join("site/content-src").join(name).exists()
859 }
860 _ if p.starts_with("site/data/") => {
861 p.ends_with(".toml") && !p["site/data/".len()..].contains('/')
862 }
863 _ if p.starts_with("share/providers/") || p.starts_with(".ai/repo/providers/") => {
864 p.ends_with(".tmpl")
865 }
866 _ if p.starts_with("site/templates/") => true,
867 _ => p.ends_with(".md"),
868 };
869 listed
870 .into_iter()
871 .filter(|p| prose(p) && !generated.contains(p))
872 .collect()
873}
874
875/// A file's text when it is hand-written and small enough to be prose; `None` for one that
876/// is missing, binary, oversized or stamped by its generator on its first line.
877fn authored_text(root: &Path, path: &str) -> Option<String> {
878 const STAMPS: &[&str] = &[
879 "<!-- generated by",
880 "<!-- GENERATED FILE",
881 "# GENERATED FILE",
882 "// GENERATED FILE",
883 ];
884 let full = root.join(path);
885 let small =
886 std::fs::symlink_metadata(&full).is_ok_and(|m| m.is_file() && m.len() <= LARGEST_READ);
887 let text = std::fs::read_to_string(&full).ok().filter(|_| small)?;
888 let first = text
889 .lines()
890 .next()
891 .unwrap_or("")
892 .trim_start_matches('\u{feff}');
893 (!STAMPS.iter().any(|s| first.trim_start().starts_with(s))).then_some(text)
894}
895
896/// The entries of [`LABEL_HISTORY`] as `(line, path, reason)`; an absent file declares none.
897fn label_history(root: &Path) -> Vec<(usize, String, String)> {
898 let text = std::fs::read_to_string(root.join(LABEL_HISTORY)).unwrap_or_default();
899 text.lines()
900 .enumerate()
901 .filter(|(_, l)| !l.trim().is_empty() && !l.trim_start().starts_with('#'))
902 .map(|(i, l)| {
903 let l = l.trim();
904 let (path, reason) = l.split_once(char::is_whitespace).unwrap_or((l, ""));
905 (i + 1, path.to_string(), reason.trim().to_string())
906 })
907 .collect()
908}
909
910/// Every version label typed into hand-written prose outside a declared history, and every
911/// history entry that cannot be honoured.
912///
913/// The label is the finding, not the version: `v0.1` on a page that describes the tool as
914/// it stands was true the day it was typed and is wrong the day after the next release,
915/// while nothing that derives the version can reach it. A document that is *about* a past
916/// version names it in [`LABEL_HISTORY`] with a reason. That list is held from both sides:
917/// an entry with no reason, one naming a path that is not tracked prose, and one whose
918/// document no longer carries a label are each refused, so the list cannot outlive what it
919/// exempts.
920///
921/// ```
922/// use majordomus_cli::release::version::{label_diagnostics, LABEL_HISTORY};
923/// use std::process::Command;
924/// let dir = tempfile::tempdir().unwrap();
925/// let root = dir.path();
926/// std::fs::create_dir_all(root.join("docs")).unwrap();
927/// std::fs::create_dir_all(root.join(".ai/repo")).unwrap();
928/// std::fs::write(root.join("README.md"), "What v0.1 does not do.\nRelease v0.3.1 shipped.\n").unwrap();
929/// std::fs::write(root.join("docs/DESIGN.md"), "The v0.1 specification.\n").unwrap();
930/// std::fs::write(root.join(LABEL_HISTORY), "docs/DESIGN.md the design phase, dated\n").unwrap();
931/// let git = |a: &[&str]| {
932/// Command::new("git")
933/// .env_remove("GIT_DIR").env_remove("GIT_WORK_TREE").env_remove("GIT_INDEX_FILE")
934/// .arg("-C").arg(root).args(a).output().unwrap()
935/// };
936/// git(&["init", "-q"]);
937/// git(&["add", "-A"]);
938/// let found = label_diagnostics(root);
939/// assert_eq!(found.len(), 1, "{found:?}");
940/// assert!(found[0].message.starts_with("README.md:1 "), "{}", found[0].message);
941///
942/// std::fs::write(root.join("docs/DESIGN.md"), "The founding specification.\n").unwrap();
943/// let found = label_diagnostics(root);
944/// assert_eq!(found[1].id, "version-label-history-invalid", "an exemption outliving its label");
945/// ```
946pub fn label_diagnostics(root: &Path) -> Vec<Diagnostic> {
947 let error = |id: &str, message: String| Diagnostic {
948 id: id.into(),
949 severity: Severity::Error,
950 message,
951 };
952 let files = prose_files(root);
953 let history = label_history(root);
954 let exempt: BTreeSet<&str> = history
955 .iter()
956 .filter(|(_, _, reason)| !reason.is_empty())
957 .map(|(_, path, _)| path.as_str())
958 .collect();
959 let mut out = Vec::new();
960 let mut labelled = BTreeSet::new();
961 for path in &files {
962 let Some(text) = authored_text(root, path) else {
963 continue;
964 };
965 for (i, line) in text.lines().enumerate() {
966 let labels = version_labels(line);
967 if labels.is_empty() {
968 continue;
969 }
970 labelled.insert(path.as_str());
971 if exempt.contains(path.as_str()) {
972 continue;
973 }
974 for label in labels {
975 out.push(error(
976 LABEL_IN_PROSE,
977 format!(
978 "{path}:{} names the version it describes as `{label}`: the label \
979 stays while the version moves. Say what is true of the tool as it \
980 stands, without a version; a document about a past version is \
981 declared in {LABEL_HISTORY} with its reason",
982 i + 1
983 ),
984 ));
985 }
986 }
987 }
988 for (line, path, reason) in &history {
989 let at = format!("{LABEL_HISTORY}:{line}");
990 if reason.is_empty() {
991 out.push(error(
992 LABEL_HISTORY_INVALID,
993 format!("{at} exempts {path} and says nothing about why: an exemption states its reason"),
994 ));
995 } else if !files.iter().any(|f| f == path) {
996 out.push(error(
997 LABEL_HISTORY_INVALID,
998 format!("{at} exempts {path}, which is not tracked hand-written prose: remove the entry"),
999 ));
1000 } else if !labelled.contains(path.as_str()) {
1001 out.push(error(
1002 LABEL_HISTORY_INVALID,
1003 format!(
1004 "{at} exempts {path}, which names no version label any more: remove the entry"
1005 ),
1006 ));
1007 }
1008 }
1009 out
1010}
1011
1012/// Everything wrong with where the version is stated: the projection against the
1013/// authority, and every version written down by hand where the tool's own files live.
1014///
1015/// A stale projection is a [`Severity::Warning`] — it is the state every bump leaves until
1016/// `scripts/derive` runs, `generate --check` is what refuses it, and a writer that refused
1017/// its own un-derived state could not correct a bump it had just made. A projection nothing
1018/// could have derived, and a version written by hand, are [`Severity::Error`]s. Asked only
1019/// where the product lives: a repository without the crate has its own `scripts/` and its
1020/// own versions, and they are none of this module's business.
1021///
1022/// ```
1023/// use majordomus_cli::release::compat::Severity;
1024/// use majordomus_cli::release::version::{diagnose, MANIFEST, PROJECTION};
1025/// let dir = tempfile::tempdir().unwrap();
1026/// let root = dir.path();
1027/// std::fs::create_dir_all(root.join("apps/majordomus-cli")).unwrap();
1028/// std::fs::create_dir_all(root.join("share")).unwrap();
1029/// std::fs::create_dir_all(root.join("lib")).unwrap();
1030/// std::fs::write(root.join(MANIFEST), "[package]\nversion = \"0.9.0\"\n").unwrap();
1031/// std::fs::write(root.join(PROJECTION), "version=0.9.0\n").unwrap();
1032/// assert!(diagnose(root).is_empty(), "authored once, projected once");
1033///
1034/// std::fs::write(root.join("lib/x.sh"), "FOO_VERSION=\"1.2.3\"\n").unwrap();
1035/// let found = diagnose(root);
1036/// assert_eq!(found.len(), 1);
1037/// assert_eq!(found[0].id, "version-stated-by-hand");
1038/// assert_eq!(found[0].severity, Severity::Error);
1039/// ```
1040pub fn diagnose(root: &Path) -> Vec<Diagnostic> {
1041 let mut out = Vec::new();
1042 match projection_state(root) {
1043 ProjectionState::NotHere | ProjectionState::Current => {}
1044 ProjectionState::Stale {
1045 projected,
1046 declared,
1047 } => out.push(Diagnostic {
1048 id: PROJECTION_STALE.into(),
1049 severity: Severity::Warning,
1050 message: format!(
1051 "{PROJECTION} states '{}' and {MANIFEST} declares '{declared}': the projection \
1052 is stale — run scripts/derive, which builds and then generates it; never edit \
1053 it or bin/majordomus",
1054 projected.as_deref().unwrap_or("nothing")
1055 ),
1056 }),
1057 ProjectionState::Disagrees {
1058 projected,
1059 declared,
1060 } => out.push(Diagnostic {
1061 id: WRITERS_DISAGREE.into(),
1062 severity: Severity::Error,
1063 message: format!(
1064 "{PROJECTION} states '{projected}' and {MANIFEST} declares '{}': no derivation \
1065 writes a version the manifest does not declare, so it was written by hand — \
1066 run scripts/derive, and raise the version only with `majordomus release bump`",
1067 declared.as_deref().unwrap_or("nothing")
1068 ),
1069 }),
1070 }
1071 if root.join(MANIFEST).is_file() {
1072 for c in carriers(root) {
1073 out.push(Diagnostic {
1074 id: STATED_BY_HAND.into(),
1075 severity: Severity::Error,
1076 message: format!(
1077 "{}:{} states a version by hand, as {}: `{}`. The version is authored \
1078 only in {MANIFEST}; read it with `scripts/release-version` in shell, \
1079 `release::version::declared` in Rust, or `bin/majordomus version`",
1080 c.path, c.line, c.shape, c.text
1081 ),
1082 });
1083 }
1084 out.extend(label_diagnostics(root));
1085 }
1086 out
1087}
1088
1089/// Write `to` into the one place the version is authored, keep the lock's record of it in
1090/// step, and say which files changed.
1091///
1092/// Byte-exact and narrow: the manifest's `version` line inside `[package]`, and the `version`
1093/// line of the lock's own `majordomus-cli` entry — the one line cargo would rewrite. Nothing
1094/// else in either file is read or rewritten, so a dependency at the same version is
1095/// untouched. [`PROJECTION`], the generator stamps and the changelog are not written here:
1096/// they are derived, and `scripts/derive` derives them.
1097///
1098/// ```
1099/// use majordomus_cli::release::version::{declared, write, MANIFEST};
1100/// let dir = tempfile::tempdir().unwrap();
1101/// std::fs::create_dir_all(dir.path().join("apps/majordomus-cli")).unwrap();
1102/// std::fs::write(dir.path().join(MANIFEST), "[package]\nversion = \"0.8.0\"\n").unwrap();
1103/// assert_eq!(write(dir.path(), "0.9.0").unwrap(), vec![MANIFEST.to_string()]);
1104/// assert_eq!(declared(dir.path()).as_deref(), Some("0.9.0"));
1105/// ```
1106pub fn write(root: &Path, to: &str) -> std::io::Result<Vec<String>> {
1107 let mut written = Vec::new();
1108
1109 let manifest = root.join(MANIFEST);
1110 let text = std::fs::read_to_string(&manifest)?;
1111 let mut out = String::with_capacity(text.len());
1112 let mut in_package = false;
1113 let mut done = false;
1114 for line in text.lines() {
1115 let trimmed = line.trim();
1116 if trimmed.starts_with('[') {
1117 in_package = trimmed == "[package]";
1118 }
1119 if in_package && !done && trimmed.starts_with("version") && trimmed.contains('"') {
1120 out.push_str(&format!("version = \"{to}\"\n"));
1121 done = true;
1122 continue;
1123 }
1124 out.push_str(line);
1125 out.push('\n');
1126 }
1127 if out != text {
1128 std::fs::write(&manifest, out)?;
1129 written.push(MANIFEST.to_string());
1130 }
1131
1132 // The lock's own entry, and only it: the `version` line that follows
1133 // `name = "majordomus-cli"`. Every other `version` in the file belongs to a dependency.
1134 let lock = root.join(LOCK);
1135 if let Ok(text) = std::fs::read_to_string(&lock) {
1136 let mut out = String::with_capacity(text.len());
1137 let mut here = false;
1138 let mut done = false;
1139 for line in text.lines() {
1140 let trimmed = line.trim();
1141 if trimmed == "[[package]]" {
1142 here = false;
1143 } else if trimmed == "name = \"majordomus-cli\"" {
1144 here = true;
1145 } else if here && !done && trimmed.starts_with("version") && trimmed.contains('"') {
1146 out.push_str(&format!("version = \"{to}\"\n"));
1147 here = false;
1148 done = true;
1149 continue;
1150 }
1151 out.push_str(line);
1152 out.push('\n');
1153 }
1154 if out != text {
1155 std::fs::write(&lock, out)?;
1156 written.push(LOCK.to_string());
1157 }
1158 }
1159
1160 Ok(written)
1161}
1162
1163/// The version the commit subjects imply: the last release raised by their bump — from the
1164/// declared version only when nothing was released. Evidence, never the answer on its own.
1165///
1166/// It raises the last release and not the declared version, because a declared version
1167/// already raised for the window must not be raised again by the same commits.
1168///
1169/// ```
1170/// use majordomus_cli::release::version::{commits_imply, Bump};
1171/// // released 0.10.0, already declared 0.11.0, only features since: 0.11.0, not 0.12.0
1172/// assert_eq!(commits_imply(Some("0.10.0"), "0.11.0", Bump::Minor).as_deref(), Some("0.11.0"));
1173/// // nothing released: the declared version is the only base there is
1174/// assert_eq!(commits_imply(None, "0.3.0", Bump::Patch).as_deref(), Some("0.3.1"));
1175/// ```
1176pub fn commits_imply(last: Option<&str>, declared: &str, bump: Bump) -> Option<String> {
1177 last.and_then(Version::parse)
1178 .or_else(|| Version::parse(declared))
1179 .map(|v| v.raised(bump).to_string())
1180}
1181
1182/// Who answers `next`, and with what: the contract when it could be measured, and no one
1183/// when it could not.
1184///
1185/// `contract` is the analysis' measured version ([`crate::release::compat::VersionPlan::
1186/// measured_version`]), or why none could be measured. ADR 0051 refuses an unmeasurable
1187/// baseline rather than guessing one, so an `Err` leaves `next` absent, [`DecidedBy::
1188/// Undecided`] and the reason carried — the commit inference stays evidence beside it and
1189/// never stands in for the measurement. The contract's answer is a minimum: when it is no
1190/// greater than `last` — only behaviour behind the public boundary changed, so the contract
1191/// requires no release — it is the release already made, never the next one. Then the
1192/// commits decide whether anything is released at all, and the contract decides how much:
1193/// the smallest release above the last, a patch, labelled [`DecidedBy::ContractAndCommits`].
1194/// The commits never raise `next` beyond what the contract requires.
1195///
1196/// ```
1197/// use majordomus_cli::release::model::DecidedBy;
1198/// use majordomus_cli::release::version::{decide, Bump};
1199/// // the commits say BREAKING, the contract only grew: below 1.0 that owes a minor
1200/// let (next, by, why) = decide(Some("0.10.0"), Bump::Major, Ok("0.11.0".into()));
1201/// assert_eq!((next.as_deref(), by, why), (Some("0.11.0"), DecidedBy::Contract, None));
1202/// // only fixes, the surface unchanged: the contract's minimum is the release already made
1203/// let (next, by, _) = decide(Some("0.10.0"), Bump::Patch, Ok("0.10.0".into()));
1204/// assert_eq!((next.as_deref(), by), (Some("0.10.1"), DecidedBy::ContractAndCommits));
1205/// // no published baseline: no one decides, and the reason is carried
1206/// let (next, by, why) = decide(None, Bump::Minor, Err("no release".into()));
1207/// assert_eq!((next, by), (None, DecidedBy::Undecided));
1208/// assert_eq!(why.as_deref(), Some("no release"));
1209/// ```
1210pub fn decide(
1211 last: Option<&str>,
1212 bump: Bump,
1213 contract: Result<String, String>,
1214) -> (Option<String>, DecidedBy, Option<String>) {
1215 let required = match contract {
1216 Ok(required) => required,
1217 Err(why) => return (None, DecidedBy::Undecided, Some(why)),
1218 };
1219 match (Version::parse(&required), last.and_then(Version::parse)) {
1220 (Some(r), Some(l)) if r <= l => match bump {
1221 Bump::None => (None, DecidedBy::Contract, None),
1222 _ => (
1223 Some(l.raised(Bump::Patch).to_string()),
1224 DecidedBy::ContractAndCommits,
1225 None,
1226 ),
1227 },
1228 _ => (Some(required), DecidedBy::Contract, None),
1229 }
1230}
1231
1232/// The whole version answer: what is declared, whether the projection the shell tool reads
1233/// states it, what the public contract requires next, and what the commits since the last
1234/// release imply — as evidence.
1235///
1236/// `contract` is the analysis' answer ([`decide`] says how it is used); [`select`] is the
1237/// caller that asks it, and the one every surface goes through. The last release is
1238/// [`crate::release::compat::last_release`] — the same baseline `release analyze` measures
1239/// from, the highest version the layer records — so the contract and the commit evidence
1240/// always measure one window. The commit inference raises that release ([`commits_imply`]).
1241pub fn report(root: &Path, objects: &[Object], contract: Result<String, String>) -> VersionReport {
1242 let declared = declared(root).unwrap_or_else(|| "unknown".into());
1243 let tool_version = tool(root).unwrap_or_else(|| "unknown".into());
1244
1245 let last = super::compat::last_release(root, objects);
1246 let changes = match &last {
1247 Some(l) => super::commits::in_range(root, &format!("{}..HEAD", l.read_at), objects),
1248 None => super::commits::in_range(root, "HEAD", objects),
1249 };
1250 let bump = bump_of(&changes);
1251 let last_version = last.map(|l| l.version);
1252 let commits_imply = commits_imply(last_version.as_deref(), &declared, bump);
1253 let (next, decided_by, contract_unreadable) = decide(last_version.as_deref(), bump, contract);
1254
1255 VersionReport {
1256 agree: projection_current(root),
1257 declared,
1258 tool: tool_version,
1259 last_release: last_version,
1260 bump: bump.as_str().to_string(),
1261 commits_imply,
1262 next,
1263 decided_by,
1264 contract_unreadable,
1265 changes,
1266 }
1267}
1268
1269/// The release selection: the contract's analysis against the last release, and the version
1270/// report decided from it — made once, by one function.
1271///
1272/// `release version` (the capability, and every surface that renders it) and `release bump`
1273/// without an explicit target both take their answer from here, so the report and the writer
1274/// cannot disagree about what the next version is: the report's `next` is the version the
1275/// writer raises to ([`default_target`]).
1276///
1277/// ```
1278/// use majordomus_cli::capability::registry::CapabilityRegistry;
1279/// use majordomus_cli::release::model::DecidedBy;
1280/// use majordomus_cli::release::version::{select, Selection};
1281/// let dir = tempfile::tempdir().unwrap();
1282/// let registry = CapabilityRegistry::builder().build().unwrap();
1283/// let selection: Selection = select(dir.path(), ®istry, &[]);
1284/// // nothing published: no plan, and so a report that decides nothing — with the same reason
1285/// let why = selection.plan.as_ref().unwrap_err();
1286/// assert_eq!(selection.report.decided_by, DecidedBy::Undecided);
1287/// assert_eq!(selection.report.contract_unreadable.as_ref(), Some(why));
1288/// assert_eq!(selection.report.next, None);
1289/// ```
1290#[derive(Debug)]
1291pub struct Selection {
1292 /// The analysis `release analyze` makes against the last release, or why it could not be
1293 /// made at all (no release to compare with, a baseline whose registry is not committed).
1294 pub plan: Result<super::compat::VersionPlan, String>,
1295 /// The version report decided from that analysis.
1296 pub report: VersionReport,
1297}
1298
1299/// Make the release selection ([`Selection`]): analyse the contract against the last
1300/// release, refuse a guess ([`crate::release::compat::VersionPlan::measured_version`]), and
1301/// decide the report from what is left ([`report`]).
1302///
1303/// ```
1304/// use majordomus_cli::capability::registry::CapabilityRegistry;
1305/// use majordomus_cli::release::version::{default_target, select, Version};
1306/// let dir = tempfile::tempdir().unwrap();
1307/// let registry = CapabilityRegistry::builder().build().unwrap();
1308/// let selection = select(dir.path(), ®istry, &[]);
1309/// // the writer's default target is read from this report, so it refuses with its reason
1310/// let current = Version::parse("0.1.0").unwrap();
1311/// assert_eq!(
1312/// default_target(&selection.report, current).unwrap_err(),
1313/// selection.report.contract_unreadable.clone().unwrap()
1314/// );
1315/// ```
1316pub fn select(
1317 root: &Path,
1318 registry: &crate::capability::registry::CapabilityRegistry,
1319 objects: &[Object],
1320) -> Selection {
1321 let plan = super::compat::analyze(root, registry, objects, None).map_err(|e| e.to_string());
1322 let contract = match &plan {
1323 Ok(p) => p.measured_version(),
1324 Err(why) => Err(why.clone()),
1325 };
1326 Selection {
1327 report: report(root, objects, contract),
1328 plan,
1329 }
1330}
1331
1332/// The version `release bump` raises to when no target is named: the report's `next`, the
1333/// declared version when nothing would be released — and a refusal, with the report's
1334/// reason, when the contract could not be measured and so no one decided.
1335///
1336/// ```
1337/// use majordomus_cli::release::model::{DecidedBy, VersionReport};
1338/// use majordomus_cli::release::version::{default_target, Version};
1339/// let report = |next: Option<&str>, decided_by, why: Option<&str>| VersionReport {
1340/// declared: "0.11.0".into(), tool: "0.11.0".into(), agree: true,
1341/// last_release: Some("0.11.0".into()), bump: "patch".into(),
1342/// commits_imply: Some("0.11.1".into()), next: next.map(Into::into), decided_by,
1343/// contract_unreadable: why.map(Into::into), changes: Vec::new() };
1344/// let current = Version::parse("0.11.0").unwrap();
1345/// let fix = report(Some("0.11.1"), DecidedBy::ContractAndCommits, None);
1346/// assert_eq!(default_target(&fix, current).unwrap().to_string(), "0.11.1");
1347/// let nothing = report(None, DecidedBy::Contract, None);
1348/// assert_eq!(default_target(¬hing, current), Ok(current));
1349/// let unread = report(None, DecidedBy::Undecided, Some("no release"));
1350/// assert_eq!(default_target(&unread, current), Err("no release".into()));
1351/// ```
1352pub fn default_target(report: &VersionReport, current: Version) -> Result<Version, String> {
1353 match (&report.next, report.decided_by) {
1354 (_, DecidedBy::Undecided) => Err(report
1355 .contract_unreadable
1356 .clone()
1357 .unwrap_or_else(|| "the public contract could not be measured".into())),
1358 (Some(next), _) => Version::parse(next)
1359 .ok_or_else(|| format!("the selected version '{next}' is not three numbers")),
1360 (None, _) => Ok(current),
1361 }
1362}
1363
1364#[cfg(test)]
1365mod tests {
1366 use super::*;
1367
1368 fn change(kind: ChangeKind, breaking: bool) -> Change {
1369 Change {
1370 kind,
1371 scope: None,
1372 subject: "x".into(),
1373 breaking,
1374 commit: "abc1234".into(),
1375 url: None,
1376 references: Vec::new(),
1377 }
1378 }
1379
1380 #[test]
1381 fn the_bump_is_a_total_function_of_the_changes() {
1382 assert_eq!(bump_of(&[]), Bump::None);
1383 assert_eq!(bump_of(&[change(ChangeKind::Chore, false)]), Bump::Patch);
1384 assert_eq!(bump_of(&[change(ChangeKind::Fix, false)]), Bump::Patch);
1385 assert_eq!(
1386 bump_of(&[
1387 change(ChangeKind::Fix, false),
1388 change(ChangeKind::Feat, false)
1389 ]),
1390 Bump::Minor
1391 );
1392 assert_eq!(
1393 bump_of(&[
1394 change(ChangeKind::Feat, false),
1395 change(ChangeKind::Fix, true)
1396 ]),
1397 Bump::Major
1398 );
1399 }
1400
1401 /// ADR 0051: the contract decides `next`, and an unmeasurable contract is refused, not
1402 /// guessed — `next` is absent, `undecided`, with the reason, whatever the commits say. The
1403 /// case this was written for: commits saying BREAKING (1.0.0 from 0.10.0) against a
1404 /// contract that only grew (0.11.0).
1405 #[test]
1406 fn the_contract_decides_next_and_an_unreadable_one_decides_nothing() {
1407 let last = Some("0.10.0");
1408 assert_eq!(
1409 decide(last, Bump::Major, Ok("0.11.0".into())),
1410 (Some("0.11.0".into()), DecidedBy::Contract, None)
1411 );
1412 assert_eq!(
1413 decide(last, Bump::Major, Err("no baseline".into())),
1414 (None, DecidedBy::Undecided, Some("no baseline".into()))
1415 );
1416 assert_eq!(
1417 decide(None, Bump::None, Err("no baseline".into())),
1418 (None, DecidedBy::Undecided, Some("no baseline".into()))
1419 );
1420 }
1421
1422 /// The contract's answer is a minimum, and the minimum of an unchanged surface is the
1423 /// release already made: `next` must never be the last release.
1424 #[test]
1425 fn a_contract_that_requires_no_release_never_answers_the_last_one() {
1426 let last = Some("0.10.0");
1427 // only fixes: the smallest release above the last, a patch
1428 assert_eq!(
1429 decide(last, Bump::Patch, Ok("0.10.0".into())),
1430 (Some("0.10.1".into()), DecidedBy::ContractAndCommits, None)
1431 );
1432 // a feature the contract cannot see is behind the boundary: a patch, not a minor
1433 assert_eq!(
1434 decide(last, Bump::Minor, Ok("0.10.0".into())),
1435 (Some("0.10.1".into()), DecidedBy::ContractAndCommits, None)
1436 );
1437 // nothing at all since the last release: nothing to release
1438 assert_eq!(
1439 decide(last, Bump::None, Ok("0.10.0".into())),
1440 (None, DecidedBy::Contract, None)
1441 );
1442 // a declared version already above the minimum is the contract's answer as it stands
1443 assert_eq!(
1444 decide(last, Bump::Patch, Ok("0.12.0".into())),
1445 (Some("0.12.0".into()), DecidedBy::Contract, None)
1446 );
1447 }
1448
1449 /// The report and the writer answer one question with one function: whatever `next` the
1450 /// selection reports, `release bump` with no target raises to exactly that — the two
1451 /// Codex found apart (report 0.11.1, writer 0.11.0) on an unchanged contract and a fix.
1452 #[test]
1453 fn the_report_and_the_writer_select_the_same_version() {
1454 let current = Version::parse("0.11.0").unwrap();
1455 let report_of = |last: &str, declared: &str, bump: Bump, contract| {
1456 let (next, decided_by, contract_unreadable) = decide(Some(last), bump, contract);
1457 VersionReport {
1458 declared: declared.into(),
1459 tool: declared.into(),
1460 agree: true,
1461 last_release: Some(last.into()),
1462 bump: bump.as_str().into(),
1463 commits_imply: commits_imply(Some(last), declared, bump),
1464 next,
1465 decided_by,
1466 contract_unreadable,
1467 changes: Vec::new(),
1468 }
1469 };
1470 // released and declared 0.11.0, the contract unchanged, a fix since: 0.11.1 for both
1471 let fix = report_of("0.11.0", "0.11.0", Bump::Patch, Ok("0.11.0".into()));
1472 assert_eq!(fix.next.as_deref(), Some("0.11.1"));
1473 assert_eq!(
1474 default_target(&fix, current).map(|v| v.to_string()),
1475 Ok("0.11.1".to_string())
1476 );
1477 // released 0.10.0, declared 0.10.0, the contract requires a minor: 0.11.0 for both
1478 let minor = report_of("0.10.0", "0.10.0", Bump::Major, Ok("0.11.0".into()));
1479 assert_eq!(minor.next.as_deref(), Some("0.11.0"));
1480 assert_eq!(
1481 default_target(&minor, Version::parse("0.10.0").unwrap()).map(|v| v.to_string()),
1482 Ok("0.11.0".to_string())
1483 );
1484 // the contract unreadable: the report decides nothing and the writer refuses, with
1485 // the report's reason — the commit inference answers neither
1486 let unread = report_of("0.10.0", "0.10.0", Bump::Major, Err("no baseline".into()));
1487 assert_eq!(unread.next, None);
1488 assert_eq!(unread.commits_imply.as_deref(), Some("1.0.0"));
1489 assert_eq!(
1490 default_target(&unread, current),
1491 Err("no baseline".to_string())
1492 );
1493 }
1494
1495 /// The commit inference raises the last release, not the declared version — a minor
1496 /// bump tells the two apart, where a major (which resets) would not.
1497 #[test]
1498 fn the_commit_inference_raises_the_last_release_not_the_declared_version() {
1499 assert_eq!(
1500 commits_imply(Some("0.10.0"), "0.11.0", Bump::Minor).as_deref(),
1501 Some("0.11.0")
1502 );
1503 assert_eq!(
1504 commits_imply(Some("0.10.0"), "0.11.0", Bump::Patch).as_deref(),
1505 Some("0.10.1")
1506 );
1507 assert_eq!(
1508 commits_imply(None, "0.11.0", Bump::Minor).as_deref(),
1509 Some("0.12.0")
1510 );
1511 assert_eq!(commits_imply(None, "unknown", Bump::Minor), None);
1512 }
1513
1514 #[test]
1515 fn raising_zeroes_what_the_bump_supersedes() {
1516 let v = Version::parse("0.3.1").unwrap();
1517 assert_eq!(v.raised(Bump::Patch).to_string(), "0.3.2");
1518 assert_eq!(v.raised(Bump::Minor).to_string(), "0.4.0");
1519 assert_eq!(v.raised(Bump::Major).to_string(), "1.0.0");
1520 assert_eq!(v.raised(Bump::None).to_string(), "0.3.1");
1521 }
1522
1523 #[test]
1524 fn a_version_that_is_not_three_numbers_is_refused() {
1525 assert!(Version::parse("0.3").is_none());
1526 assert!(Version::parse("0.3.1.4").is_none());
1527 assert!(Version::parse("v0.3.1").is_none());
1528 }
1529
1530 /// A tree laid out the way the writer finds this repository: the manifest, the lock, the
1531 /// projection and the shell tool, each carrying the version, and each carrying it a
1532 /// second time in a place nothing may touch.
1533 fn tree(dir: &Path, version: &str) {
1534 std::fs::create_dir_all(dir.join("apps/majordomus-cli")).unwrap();
1535 std::fs::create_dir_all(dir.join("bin")).unwrap();
1536 std::fs::create_dir_all(dir.join("share")).unwrap();
1537 std::fs::write(
1538 dir.join(MANIFEST),
1539 format!("[package]\nname = \"majordomus-cli\"\nversion = \"{version}\"\n\n[dependencies]\nserde = {{ version = \"{version}\" }}\n"),
1540 )
1541 .unwrap();
1542 std::fs::write(
1543 dir.join(LOCK),
1544 format!("[[package]]\nname = \"serde\"\nversion = \"{version}\"\n\n[[package]]\nname = \"majordomus-cli\"\nversion = \"{version}\"\ndependencies = [\n \"serde\",\n]\n"),
1545 )
1546 .unwrap();
1547 std::fs::write(
1548 dir.join(PROJECTION),
1549 format!(
1550 "# Generator: majordomus-cli {version}\n{}",
1551 render_projection(version)
1552 ),
1553 )
1554 .unwrap();
1555 std::fs::write(
1556 dir.join("bin/majordomus"),
1557 "#!/usr/bin/env bash\nMJ_VERSION=\"\"\necho hi\n",
1558 )
1559 .unwrap();
1560 }
1561
1562 #[test]
1563 fn writing_touches_the_one_authored_line_and_the_lock_record_of_it() {
1564 let dir = tempfile::tempdir().unwrap();
1565 let root = dir.path();
1566 tree(root, "0.3.1");
1567 let projection = std::fs::read(root.join(PROJECTION)).unwrap();
1568 let entry = std::fs::read(root.join("bin/majordomus")).unwrap();
1569
1570 let written = write(root, "0.4.0").unwrap();
1571 assert_eq!(
1572 written,
1573 vec![MANIFEST.to_string(), LOCK.to_string()],
1574 "the authored line and cargo's record of it, and nothing else"
1575 );
1576 assert_eq!(declared(root).as_deref(), Some("0.4.0"));
1577 assert_eq!(locked(root).as_deref(), Some("0.4.0"));
1578
1579 // derived and generated files are derivation's, never the writer's
1580 assert_eq!(std::fs::read(root.join(PROJECTION)).unwrap(), projection);
1581 assert_eq!(std::fs::read(root.join("bin/majordomus")).unwrap(), entry);
1582 assert!(
1583 matches!(projection_state(root), ProjectionState::Stale { .. }),
1584 "a bump leaves the projection stale until scripts/derive runs"
1585 );
1586
1587 // a dependency at the same version is a different package, in both files
1588 let lock = std::fs::read_to_string(root.join(LOCK)).unwrap();
1589 assert!(
1590 lock.contains("name = \"serde\"\nversion = \"0.3.1\""),
1591 "a dependency at the same version was rewritten: {lock}"
1592 );
1593 let manifest = std::fs::read_to_string(root.join(MANIFEST)).unwrap();
1594 assert!(
1595 manifest.contains("serde = { version = \"0.3.1\" }"),
1596 "a dependency was rewritten: {manifest}"
1597 );
1598
1599 // writing the same version again changes nothing, so a bump is safe to repeat
1600 assert!(write(root, "0.4.0").unwrap().is_empty());
1601 }
1602
1603 #[test]
1604 fn the_projection_round_trips_through_the_generated_file() {
1605 // the body the writer of the file renders, under the banner `generate` puts above it
1606 let file = crate::generate::Artifact::text(
1607 PROJECTION,
1608 "version",
1609 MANIFEST,
1610 "0.9.0",
1611 &render_projection("0.9.0"),
1612 );
1613 assert!(
1614 file.content.starts_with("# GENERATED FILE"),
1615 "{}",
1616 file.content
1617 );
1618 assert_eq!(parse_projection(&file.content).as_deref(), Some("0.9.0"));
1619 // the stamp names the generator's version too, and is never read as the version
1620 let stamped = file.content.replace("version=0.9.0", "version=1.2.3");
1621 assert_eq!(parse_projection(&stamped).as_deref(), Some("1.2.3"));
1622 }
1623
1624 #[test]
1625 fn a_stale_projection_warns_and_one_nothing_derives_is_refused() {
1626 let dir = tempfile::tempdir().unwrap();
1627 let root = dir.path();
1628 tree(root, "0.9.0");
1629 assert!(diagnose(root).is_empty(), "{:?}", diagnose(root));
1630
1631 std::fs::write(root.join(PROJECTION), render_projection("0.8.0")).unwrap();
1632 let found = diagnose(root);
1633 assert_eq!(found.len(), 1, "{found:?}");
1634 assert_eq!(found[0].id, PROJECTION_STALE);
1635 assert_eq!(found[0].severity, Severity::Warning);
1636 assert!(
1637 found[0].message.contains("scripts/derive"),
1638 "{}",
1639 found[0].message
1640 );
1641
1642 std::fs::remove_file(root.join(PROJECTION)).unwrap();
1643 assert_eq!(
1644 diagnose(root)[0].id,
1645 PROJECTION_STALE,
1646 "missing is stale too"
1647 );
1648
1649 for unrelated in ["1.0.0", "0.9.0-rc1", "banana"] {
1650 std::fs::write(root.join(PROJECTION), render_projection(unrelated)).unwrap();
1651 let found = diagnose(root);
1652 assert_eq!(found[0].id, WRITERS_DISAGREE, "{unrelated}");
1653 assert_eq!(found[0].severity, Severity::Error, "{unrelated}");
1654 }
1655 }
1656
1657 #[test]
1658 fn a_version_written_by_hand_is_found_and_a_generated_one_is_not() {
1659 let dir = tempfile::tempdir().unwrap();
1660 let root = dir.path();
1661 tree(root, "0.9.0");
1662 std::fs::create_dir_all(root.join("lib")).unwrap();
1663 std::fs::create_dir_all(root.join("scripts")).unwrap();
1664 std::fs::create_dir_all(root.join("docs/generated")).unwrap();
1665 std::fs::write(
1666 root.join("lib/a.sh"),
1667 "# 0.9.0 is fine in prose\nFOO_VERSION=\"1.2.3\"\n",
1668 )
1669 .unwrap();
1670 std::fs::write(root.join("scripts/b"), "echo \"majordomus 0.9.0\"\n").unwrap();
1671 // the same declaration in the other languages the tool's files are written in
1672 std::fs::write(
1673 root.join("scripts/c.mjs"),
1674 "// 0.9.0\nexport const VERSION = '0.9.0';\n",
1675 )
1676 .unwrap();
1677 std::fs::write(root.join("share/c.yaml"), "name: x\nversion: 0.9.0\n").unwrap();
1678 std::fs::write(root.join("share/d.toml"), "[tool]\nversion = \"0.9.0\"\n").unwrap();
1679 std::fs::write(root.join("share/gen.yaml"), "version: 0.9.0\n").unwrap();
1680 // outside the tool's own files a version is somebody else's business
1681 std::fs::write(root.join("docs/d.md"), "version: 0.9.0\n").unwrap();
1682 std::fs::write(
1683 root.join(GENERATED_MANIFEST),
1684 r#"{"artifacts":[{"path":"share/gen.yaml"}]}"#,
1685 )
1686 .unwrap();
1687
1688 let found: Vec<(String, usize)> = carriers(root)
1689 .into_iter()
1690 .map(|c| (c.path, c.line))
1691 .collect();
1692 assert_eq!(
1693 found,
1694 vec![
1695 ("lib/a.sh".to_string(), 2),
1696 ("scripts/b".to_string(), 1),
1697 ("scripts/c.mjs".to_string(), 2),
1698 ("share/c.yaml".to_string(), 2),
1699 ("share/d.toml".to_string(), 2),
1700 ],
1701 "every hand-written statement once, the projection and the generated file never"
1702 );
1703 assert!(diagnose(root)
1704 .iter()
1705 .all(|d| d.id == STATED_BY_HAND && d.severity == Severity::Error));
1706
1707 // a repository that does not carry the product is not asked
1708 std::fs::remove_file(root.join(MANIFEST)).unwrap();
1709 assert!(diagnose(root).iter().all(|d| d.id != STATED_BY_HAND));
1710 }
1711
1712 #[test]
1713 fn a_version_is_three_numbers_and_no_more() {
1714 assert!(leads_with_version("1.2.3"));
1715 assert!(leads_with_version("v1.2.3\""));
1716 assert!(leads_with_version("1.2.3-rc1"));
1717 assert!(!leads_with_version("1.2"));
1718 assert!(!leads_with_version("1.2.3.4"));
1719 assert!(!leads_with_version("x1.2.3"));
1720 assert!(!leads_with_version(""));
1721 }
1722
1723 /// One selection, made against a real release: a tree tagged `v0.1.0` whose surface has
1724 /// not moved, with a fix committed since. The analysis is sound, so the contract decides
1725 /// — it requires no release over the last one — and the commits make it a patch; the
1726 /// report's commit window is the one since that release, and the writer's default
1727 /// target is exactly the `next` the report states.
1728 #[test]
1729 fn a_sound_analysis_decides_the_report_and_the_writers_target_from_the_last_release() {
1730 let dir = tempfile::tempdir().unwrap();
1731 let root = dir.path();
1732 tree(root, "0.1.0");
1733 let registry_file = root.join(crate::release::surface::REGISTRY);
1734 std::fs::create_dir_all(registry_file.parent().unwrap()).unwrap();
1735 std::fs::write(
1736 ®istry_file,
1737 r#"{"schema": "majordomus/capability-registry/v1", "capabilities": []}"#,
1738 )
1739 .unwrap();
1740 let git = |args: &[&str]| {
1741 let out = std::process::Command::new("git")
1742 .current_dir(root)
1743 .args(args)
1744 .output()
1745 .expect("git runs");
1746 assert!(out.status.success(), "git {args:?}: {out:?}");
1747 };
1748 git(&["init", "-q"]);
1749 git(&["config", "user.email", "t@example.com"]);
1750 git(&["config", "user.name", "t"]);
1751 git(&["add", "-A"]);
1752 git(&["commit", "-q", "-m", "chore: release 0.1.0"]);
1753 git(&["tag", "v0.1.0"]);
1754 std::fs::write(root.join("notes.txt"), "repaired\n").unwrap();
1755 git(&["add", "-A"]);
1756 git(&["commit", "-q", "-m", "fix: repair the thing"]);
1757
1758 let registry = crate::capability::registry::CapabilityRegistry::builder()
1759 .build()
1760 .unwrap();
1761 let selection = select(root, ®istry, &[]);
1762 let plan = selection.plan.as_ref().expect("a sound plan");
1763 assert_eq!(plan.baseline.reference, "v0.1.0");
1764 assert_eq!(plan.measured_version(), Ok("0.1.0".to_string()));
1765
1766 let report = &selection.report;
1767 assert_eq!(report.last_release.as_deref(), Some("0.1.0"));
1768 // the window is the one since the release: the fix, and not the release commit
1769 let subjects: Vec<&str> = report.changes.iter().map(|c| c.subject.as_str()).collect();
1770 assert_eq!(subjects, ["repair the thing"], "{:?}", report.changes);
1771 assert_eq!(report.bump, "patch");
1772 assert_eq!(report.next.as_deref(), Some("0.1.1"));
1773 assert_eq!(report.decided_by, DecidedBy::ContractAndCommits);
1774 assert_eq!(report.contract_unreadable, None);
1775 let current = Version::parse("0.1.0").unwrap();
1776 assert_eq!(
1777 default_target(report, current).map(|v| v.to_string()),
1778 Ok("0.1.1".to_string())
1779 );
1780 }
1781
1782 /// The writer's default target refuses what it cannot raise to, and says why: an
1783 /// undecided report with or without a reason, a selected version that is not three
1784 /// numbers — and with nothing to release it stays where it is.
1785 #[test]
1786 fn the_default_target_refuses_what_it_cannot_raise_to() {
1787 let current = Version::parse("0.4.0").unwrap();
1788 let report = |next: Option<&str>, decided_by, why: Option<&str>| VersionReport {
1789 declared: "0.4.0".into(),
1790 tool: "0.4.0".into(),
1791 agree: true,
1792 last_release: Some("0.4.0".into()),
1793 bump: "none".into(),
1794 commits_imply: None,
1795 next: next.map(Into::into),
1796 decided_by,
1797 contract_unreadable: why.map(Into::into),
1798 changes: Vec::new(),
1799 };
1800 // undecided and no reason given: the refusal still says what was not measured
1801 assert_eq!(
1802 default_target(&report(None, DecidedBy::Undecided, None), current),
1803 Err("the public contract could not be measured".to_string())
1804 );
1805 // undecided wins over a stray `next`: no one decided it, so nothing is raised to it
1806 assert_eq!(
1807 default_target(
1808 &report(Some("0.5.0"), DecidedBy::Undecided, Some("why")),
1809 current
1810 ),
1811 Err("why".to_string())
1812 );
1813 assert_eq!(
1814 default_target(&report(Some("0.5"), DecidedBy::Contract, None), current),
1815 Err("the selected version '0.5' is not three numbers".to_string())
1816 );
1817 assert_eq!(
1818 default_target(&report(None, DecidedBy::Contract, None), current),
1819 Ok(current)
1820 );
1821 }
1822
1823 /// A tree of files, each written with its parent directories.
1824 fn prose_tree(root: &Path, files: &[(&str, &str)]) {
1825 for (path, text) in files {
1826 let full = root.join(path);
1827 std::fs::create_dir_all(full.parent().unwrap()).unwrap();
1828 std::fs::write(full, text).unwrap();
1829 }
1830 }
1831
1832 /// `git init` and `git add -A` in `root`, isolated from any repository the test runs in.
1833 fn tracked(root: &Path) {
1834 for args in [&["init", "-q"][..], &["add", "-A"][..]] {
1835 let out = std::process::Command::new("git")
1836 .env_remove("GIT_DIR")
1837 .env_remove("GIT_WORK_TREE")
1838 .env_remove("GIT_INDEX_FILE")
1839 .arg("-C")
1840 .arg(root)
1841 .args(args)
1842 .output()
1843 .unwrap();
1844 assert!(out.status.success(), "git {args:?}: {out:?}");
1845 }
1846 }
1847
1848 /// Every shape the label scanner decides: a label, and each way a `v` followed by
1849 /// numbers is not one.
1850 #[test]
1851 fn a_label_is_two_numbers_standing_alone() {
1852 assert_eq!(version_labels("v0.1"), ["v0.1"]);
1853 assert_eq!(version_labels("in v10.20, not v3."), ["v10.20"]);
1854 assert_eq!(version_labels("(v0.1) and \"v2.0\""), ["v0.1", "v2.0"]);
1855 // joined to what comes before it
1856 assert!(version_labels("dev0.1 api-v0.1 /v0.1 x.v0.1 _v0.1").is_empty());
1857 // not a version after the v
1858 assert!(version_labels("via vx.1 v.1 v1 v1. v1.x").is_empty());
1859 // a release, or glued to what follows
1860 assert!(version_labels("v0.3.1 v0.1a v0.1_x v0.1-rc").is_empty());
1861 // a dot that ends a sentence is not a patch
1862 assert_eq!(version_labels("It is v0.1."), ["v0.1"]);
1863 }
1864
1865 /// The prose population: what is hand-written and read, and every way a file is not.
1866 #[test]
1867 fn the_prose_is_what_a_person_wrote() {
1868 let dir = tempfile::tempdir().unwrap();
1869 let root = dir.path();
1870 prose_tree(
1871 root,
1872 &[
1873 ("README.md", "x"),
1874 ("AGENTS.md", "x"),
1875 ("CLAUDE.md", "x"),
1876 ("docs/A.md", "x"),
1877 ("docs/notes.txt", "x"),
1878 ("docs/generated/listed.md", "x"),
1879 ("site/content/limitations.md", "x"),
1880 ("site/content/architecture.md", "x"),
1881 ("site/content-src/architecture.md", "x"),
1882 ("site/content/docs/design.md", "x"),
1883 ("site/data/nav.toml", "x"),
1884 ("site/data/generated/x.toml", "x"),
1885 ("site/data/x.json", "x"),
1886 ("site/templates/page.html", "x"),
1887 (".ai/repo/README.md", "x"),
1888 (".ai/repo/adrs/0001-x.md", "x"),
1889 (".ai/repo/sessions/s.md", "x"),
1890 (".ai/repo/policy.yaml", "x"),
1891 (".ai/repo/providers/claude.tmpl", "x"),
1892 ("share/providers/claude.tmpl", "x"),
1893 ("share/providers/notes.md", "x"),
1894 ("share/providers/x.yaml", "x"),
1895 (
1896 GENERATED_MANIFEST,
1897 r#"{"artifacts":[{"path":"docs/generated/listed.md"}]}"#,
1898 ),
1899 ],
1900 );
1901 tracked(root);
1902 assert_eq!(
1903 prose_files(root),
1904 [
1905 ".ai/repo/README.md",
1906 ".ai/repo/providers/claude.tmpl",
1907 "AGENTS.md",
1908 "CLAUDE.md",
1909 "README.md",
1910 "docs/A.md",
1911 "share/providers/claude.tmpl",
1912 "site/content-src/architecture.md",
1913 "site/content/limitations.md",
1914 "site/data/nav.toml",
1915 "site/templates/page.html",
1916 ]
1917 );
1918 }
1919
1920 /// Where git cannot list the tree, the roots are walked: a file root is taken as it is
1921 /// and a directory root is read whole, so nothing goes unread for want of a checkout.
1922 #[test]
1923 fn a_tree_nobody_committed_is_still_read() {
1924 let dir = tempfile::tempdir().unwrap();
1925 let root = dir.path();
1926 prose_tree(root, &[("README.md", "x"), ("docs/sub/B.md", "x")]);
1927 assert_eq!(
1928 walk(root, &["README.md", "docs", "missing"]),
1929 ["docs/sub/B.md", "README.md"]
1930 );
1931 assert_eq!(prose_files(root), ["docs/sub/B.md", "README.md"]);
1932 }
1933
1934 /// Only text somebody wrote is read: not a missing file, an oversized one, or one its
1935 /// generator stamped.
1936 #[test]
1937 fn a_stamped_or_oversized_file_is_not_authored() {
1938 let dir = tempfile::tempdir().unwrap();
1939 let root = dir.path();
1940 let big = "x".repeat(LARGEST_READ as usize + 1);
1941 prose_tree(
1942 root,
1943 &[
1944 ("a.md", "v0.1\n"),
1945 ("b.md", "<!-- generated by majordomus -->\nv0.1\n"),
1946 ("c.md", "\u{feff} # GENERATED FILE\nv0.1\n"),
1947 ("d.md", &big),
1948 ],
1949 );
1950 assert_eq!(authored_text(root, "a.md").as_deref(), Some("v0.1\n"));
1951 assert_eq!(authored_text(root, "b.md"), None);
1952 assert_eq!(authored_text(root, "c.md"), None);
1953 assert_eq!(authored_text(root, "d.md"), None);
1954 assert_eq!(authored_text(root, "gone.md"), None);
1955 }
1956
1957 /// The history file: comments and blank lines are not entries, an entry's reason is
1958 /// everything after its path, and an absent file declares nothing.
1959 #[test]
1960 fn the_history_reads_a_path_and_its_reason() {
1961 let dir = tempfile::tempdir().unwrap();
1962 let root = dir.path();
1963 assert!(label_history(root).is_empty());
1964 prose_tree(
1965 root,
1966 &[(
1967 LABEL_HISTORY,
1968 "# a comment\n\n docs/A.md the design phase, dated \ndocs/B.md\n",
1969 )],
1970 );
1971 assert_eq!(
1972 label_history(root),
1973 [
1974 (
1975 3,
1976 "docs/A.md".to_string(),
1977 "the design phase, dated".to_string()
1978 ),
1979 (4, "docs/B.md".to_string(), String::new()),
1980 ]
1981 );
1982 }
1983
1984 /// Each finding the label check makes, and the exemption that holds only while its
1985 /// document still carries a label.
1986 #[test]
1987 fn a_label_is_refused_unless_its_document_is_declared_history() {
1988 let dir = tempfile::tempdir().unwrap();
1989 let root = dir.path();
1990 prose_tree(
1991 root,
1992 &[
1993 (
1994 "README.md",
1995 "# Tool\nWhat v0.1 does not do, in v0.2.\nRelease v0.3.1.\n",
1996 ),
1997 ("docs/DESIGN.md", "The v0.1 specification.\n"),
1998 ("docs/PLAIN.md", "No label here.\n"),
1999 // stamped by its generator: the label is its source's, not found here
2000 (
2001 "docs/STAMPED.md",
2002 "<!-- generated by x -->\nThe v0.1 notes.\n",
2003 ),
2004 (
2005 LABEL_HISTORY,
2006 "docs/DESIGN.md dated\ndocs/PLAIN.md it once had one\n\
2007 docs/GONE.md removed\nREADME.md\n",
2008 ),
2009 ],
2010 );
2011 tracked(root);
2012 let found = label_diagnostics(root);
2013 let messages: Vec<&str> = found.iter().map(|d| d.message.as_str()).collect();
2014 assert!(found.iter().all(|d| d.severity == Severity::Error));
2015 assert_eq!(found.len(), 5, "{messages:#?}");
2016 assert_eq!(found[0].id, LABEL_IN_PROSE);
2017 assert!(messages[0].starts_with("README.md:2 names the version it describes as `v0.1`"));
2018 assert!(messages[1].starts_with("README.md:2 names the version it describes as `v0.2`"));
2019 assert_eq!(found[2].id, LABEL_HISTORY_INVALID);
2020 assert!(messages[2].contains(":2 exempts docs/PLAIN.md, which names no version label"));
2021 assert!(messages[3].contains(":3 exempts docs/GONE.md, which is not tracked"));
2022 assert!(messages[4].contains(":4 exempts README.md and says nothing about why"));
2023 }
2024
2025 /// The label check is part of the one diagnosis the gate runs, and only where the
2026 /// product lives.
2027 #[test]
2028 fn the_diagnosis_carries_the_label_check_where_the_product_lives() {
2029 let dir = tempfile::tempdir().unwrap();
2030 let root = dir.path();
2031 prose_tree(root, &[("README.md", "What v0.1 does not do.\n")]);
2032 tracked(root);
2033 assert!(diagnose(root).is_empty(), "not the product's repository");
2034 prose_tree(
2035 root,
2036 &[
2037 (MANIFEST, "[package]\nversion = \"0.9.0\"\n"),
2038 (PROJECTION, "version=0.9.0\n"),
2039 ],
2040 );
2041 let found = diagnose(root);
2042 assert_eq!(found.len(), 1, "{found:?}");
2043 assert_eq!(found[0].id, LABEL_IN_PROSE);
2044 }
2045}