Skip to main content

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(), &registry, &[]);
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(), &registry, &[]);
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(&nothing, 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            &registry_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, &registry, &[]);
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}