Skip to main content

majordomus_cli/capability/builtin/
release.rs

1//! The `release` module: the changelog, the version, and the compatibility analysis behind
2//! it — all three derived.
3//!
4//! Three capabilities, all read-only, all answered by [`crate::release`] — the same code the
5//! command line renders, the generated document is written from, and the site page shows.
6//! Neither reads a file somebody maintains: the changelog composes the layer's own release
7//! records, decisions and the repository's commits, and the version report reads the one
8//! place the version is authored and says whether the projection the shell tool prints is
9//! current.
10//!
11//! Raising the version is not here. It writes tracked files, which makes it a repository
12//! mutation, and the exposure policy keeps repository mutations off every machine surface —
13//! so `release bump` is a command and nothing more, and this module is the read half.
14
15use schemars::JsonSchema;
16use serde::{Deserialize, Serialize};
17
18use crate::capability::benchmark::{BenchmarkCases, CaseContext, NamedCase};
19use crate::capability::handler::{CapabilityError, Context};
20use crate::capability::model::{
21    BenchmarkPolicy, Exposure, McpExposure, McpResource, Stability, WaiverReason,
22};
23use crate::capability::module::ModuleDescriptor;
24use crate::capability::registry::CapabilityRegistry;
25use crate::release::compat::VersionPlan;
26use crate::release::{self, model::ProducedBy, model::VersionReport, Changelog};
27use crate::{capability, module};
28
29use super::{get, Empty};
30
31/// The URI under which the changelog is read as an MCP resource.
32pub const CHANGELOG_URI: &str = "majordomus://changelog";
33
34/// Which part of the changelog to answer with.
35#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
36#[serde(deny_unknown_fields)]
37#[schemars(rename = "ReleaseChangelogInput")]
38pub struct ChangelogInput {
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    /// One version, or `unreleased`; every section when absent.
41    pub version: Option<String>,
42}
43
44impl BenchmarkCases for ChangelogInput {
45    /// One real version from the layer's own records, so the narrow case is timed against
46    /// something that exists rather than against a filter that matches nothing. A
47    /// repository with no records has a narrow case all the same — the coverage gate counts
48    /// targets, not repositories, and the OpenAPI operation's example of `version` *is* this
49    /// case, which case 92 refuses to see missing — so it falls back to `unreleased`, the
50    /// one section a repository has whether it has released anything or not.
51    fn benchmark_cases(ctx: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
52        let version = ctx
53            .index
54            .objects
55            .iter()
56            .find(|o| o.kind == crate::release::changelog::RELEASE_KIND)
57            .and_then(|o| o.metadata.get("version"))
58            .and_then(|v| v.as_str())
59            .map_or_else(|| "unreleased".to_string(), str::to_string);
60        vec![
61            NamedCase::new("all", ChangelogInput::default()),
62            NamedCase::new(
63                "one-version",
64                ChangelogInput {
65                    version: Some(version),
66                },
67            ),
68        ]
69    }
70}
71
72/// Which release to measure this tree against.
73///
74/// ```
75/// use majordomus_cli::capability::builtin::release::AnalysisInput;
76/// // Absent means the last release the layer records, which is the canonical baseline.
77/// let input = AnalysisInput::default();
78/// assert!(input.since.is_none());
79/// let explicit = AnalysisInput { since: Some("v0.4.0".into()) };
80/// assert_eq!(explicit.since.as_deref(), Some("v0.4.0"));
81/// ```
82#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
83#[serde(deny_unknown_fields)]
84#[schemars(rename = "ReleaseAnalysisInput")]
85pub struct AnalysisInput {
86    #[serde(default, skip_serializing_if = "Option::is_none")]
87    /// A ref to compare with instead of the last release: `v0.4.0`, or any commit that
88    /// carries a committed registry.
89    pub since: Option<String>,
90}
91
92impl BenchmarkCases for AnalysisInput {
93    /// None. The capability is waived from the benchmark
94    /// ([`WaiverReason::PublishedHistory`]) because its input is a release this repository
95    /// has published, and a benchmark fixture has published nothing — so every case here
96    /// would time a refusal and be counted as coverage. The trait is still implemented
97    /// because the registry asks every input type for its cases; answering "none" is the
98    /// honest answer rather than a case that measures an error path.
99    fn benchmark_cases(_: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
100        Vec::new()
101    }
102}
103
104/// The input of `release.next`: the landing it is asked about.
105///
106/// ```
107/// use majordomus_cli::capability::builtin::release::NextInput;
108/// // nothing named is the landing the executor performs: this branch onto the trunk
109/// let input = NextInput::default();
110/// assert_eq!((input.base(), input.head()), ("origin/master", "HEAD"));
111/// ```
112#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
113#[serde(deny_unknown_fields)]
114#[schemars(rename = "ReleaseNextInput")]
115pub struct NextInput {
116    #[serde(default, skip_serializing_if = "Option::is_none")]
117    /// The trunk the landing raises from; `origin/master` when absent.
118    pub base: Option<String>,
119    #[serde(default, skip_serializing_if = "Option::is_none")]
120    /// What lands; `HEAD` when absent.
121    pub head: Option<String>,
122}
123
124impl NextInput {
125    /// The trunk the landing raises from: the ref named, or `origin/master`, which is the
126    /// trunk the integration executor lands on.
127    ///
128    /// ```
129    /// use majordomus_cli::capability::builtin::release::NextInput;
130    /// assert_eq!(NextInput { base: Some("main".into()), head: None }.base(), "main");
131    /// ```
132    pub fn base(&self) -> &str {
133        self.base.as_deref().unwrap_or("origin/master")
134    }
135
136    /// What lands: the ref named, or `HEAD`, which is the branch this checkout is on.
137    ///
138    /// ```
139    /// use majordomus_cli::capability::builtin::release::NextInput;
140    /// assert_eq!(NextInput::default().head(), "HEAD");
141    /// ```
142    pub fn head(&self) -> &str {
143        self.head.as_deref().unwrap_or("HEAD")
144    }
145}
146
147impl BenchmarkCases for NextInput {
148    /// None, for the reason [`AnalysisInput`] gives: the answer reads committed registries
149    /// at two refs, and a benchmark fixture has no history to read them from.
150    fn benchmark_cases(_: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
151        Vec::new()
152    }
153}
154
155/// The module.
156pub fn module() -> ModuleDescriptor {
157    module! {
158        id: "release",
159        title: "Release",
160        description: "What this project has shipped and what it would ship next, derived rather than maintained: the changelog composes the layer's release records, the decisions dated inside each release's window and the conventional commits in its range; the version report reads the one place the version is authored and the projection the shell tool prints, and says what the public contract requires it to become — or that it cannot be measured, and so decides nothing — with what the commits since the last release imply beside it as evidence.",
161        stability: Stability::Implemented,
162        capabilities: [
163            capability! {
164                id: CHANGELOG_ID,
165                title: "The changelog",
166                description: "Every release the layer records, newest first, with the work that has not been released leading. A section's decisions are the ADRs dated inside that release's window, its changes the conventional commits in its range, its artifacts the record's own evidence. Nothing in it is authored, and a section that could not be read says so rather than appearing empty.",
167                input: ChangelogInput,
168                output: Changelog,
169                stability: Stability::Implemented,
170                exposure: Exposure {
171                    mcp: Some(McpExposure {
172                        tool: Some("majordomus_changelog".into()),
173                        resource: Some(McpResource { uri: CHANGELOG_URI.into(), name: "changelog".into() }),
174                    }),
175                    http: get("/api/v1/changelog"),
176                    // Not a CLI projection: `majordomus release changelog` is a local
177                    // command that *renders* this capability for a person at a terminal,
178                    // and cli::LOCAL says so once (`RendersCapability`). Declaring the
179                    // path here as well is the double accounting `tests/quality.rs`
180                    // refuses (OPERATION_CLASSIFICATION_CONFLICT).
181                    cli: None,
182                },
183                tags: ["release", "changelog"],
184                handler: changelog,
185            },
186            capability! {
187                id: "release.version",
188                title: "The version, and the one the contract requires next",
189                description: "The version the crate manifest declares — the one place it is authored — the version the shell tool prints from its projection `share/version.txt`, and whether that projection is current — the question `generate --check` refuses and `scripts/release-version --check` gates on. Then the next version, which is the public contract's answer: the version `release analyze` requires against the last release — the declared version when it already satisfies the contract, otherwise the smallest one it allows, and the smallest release above the last when the contract requires none over it — with `decided_by` naming who answered. It is the one release selection `release bump` also raises to when no target is named, so the report and the writer cannot disagree. When the contract cannot be measured — no published baseline, a version that is not three numbers, or any other error that makes the plan unsound, the plan `release bump` refuses to write from — the baseline is refused, not guessed: `next` is absent, `decided_by` is `undecided` and `contract_unreadable` carries every error, one per line. The bump the conventional commits since the last release imply, the version it would produce and the commits themselves are carried beside it as evidence, and never answer `next` in the contract's place.",
190                input: Empty,
191                output: VersionReport,
192                stability: Stability::Implemented,
193                exposure: Exposure {
194                    mcp: Some(McpExposure {
195                        tool: Some("majordomus_release_version".into()),
196                        resource: None,
197                    }),
198                    http: get("/api/v1/release/version"),
199                    cli: None,
200                },
201                tags: ["release", "version"],
202                cache: crate::capability::CachePolicy::Checkout { max_entries: 2 },
203                handler: version,
204            },
205            capability! {
206                id: ANALYSIS_ID,
207                title: "What the public contract did, and the smallest version it allows",
208                description: "The public capability surface of this tree against the one the last release published, every movement of it named with the reason it counts for what it does, and the smallest version this tree may therefore declare. The compatibility level is measured from the contract rather than read off the commit subjects: a capability, route, MCP tool, command, or a required field of either schema that a caller could hold and cannot any more is breaking whatever the commit that removed it called itself. What the commits said about themselves is carried beside the verdict as evidence, and the answer says when the two disagree.",
209                input: AnalysisInput,
210                output: VersionPlan,
211                stability: Stability::Implemented,
212                exposure: Exposure {
213                    mcp: Some(McpExposure {
214                        tool: Some("majordomus_release_analysis".into()),
215                        resource: None,
216                    }),
217                    http: get("/api/v1/release/analysis"),
218                    // `majordomus release analyze` renders this capability for a person at a
219                    // terminal; `cli::LOCAL` declares that once, and declaring the path here
220                    // as well is the double accounting `tests/quality.rs` refuses.
221                    cli: None,
222                },
223                tags: ["release", "version", "compatibility"],
224                // Timed nowhere, because there is nothing honest to time. The analysis reads
225                // what the last release published, and a benchmark fixture is a repository
226                // that has published nothing: every case would measure the refusal rather
227                // than the comparison. The behavioural case (112) is where it is exercised
228                // against a repository with a real publication history.
229                benchmark: BenchmarkPolicy::Waived { reason: WaiverReason::PublishedHistory },
230                handler: analysis,
231            },
232            capability! {
233                id: NEXT_ID,
234                title: "The version a landing allocates, and why",
235                description: "What the integration executor raises the trunk's version to when it lands a head (ADR 0134): the version the trunk declares, how far the head moves the public contract measured against the trunk's committed registry, what that movement requires under the version's mode, the repository's floor from `release.floor` in the policy, the exemption class that lowered the floor if every changed path falls in one, the larger of the requirement and the floor, and the trunk's version raised by it — with one sentence per step. Everything is read from git at the two refs, so the answer does not depend on which executable runs or on the branch's own version, which is why a retried landing cannot raise twice. It decides and writes nothing; an input that cannot be read is refused rather than guessed.",
236                input: NextInput,
237                output: release::landing::LandingDecision,
238                stability: Stability::Implemented,
239                exposure: Exposure {
240                    mcp: Some(McpExposure {
241                        tool: Some("majordomus_release_next".into()),
242                        resource: None,
243                    }),
244                    http: get("/api/v1/release/next"),
245                    // `majordomus release next` renders this capability; `cli::LOCAL`
246                    // declares that once, as it does for `release analyze`.
247                    cli: None,
248                },
249                tags: ["release", "version", "integration"],
250                benchmark: BenchmarkPolicy::Waived { reason: WaiverReason::PublishedHistory },
251                handler: next,
252            },
253        ],
254    }
255}
256
257/// The compatibility analysis, named beside its declaration.
258pub const ANALYSIS_ID: &str = "release.analysis";
259
260/// The landing decision, named beside its declaration.
261pub const NEXT_ID: &str = "release.next";
262
263/// The capability's own id. Beside its declaration, so the two cannot drift apart without
264/// the test below noticing; the document carries it so that no page has to enumerate where
265/// this value is served.
266pub const CHANGELOG_ID: &str = "release.changelog";
267
268/// Where else the changelog can be had, read off the declarations that already say so.
269///
270/// One answer for every writer of the document: the handler that serves it and the generator
271/// that commits it both ask here, so the committed artifact and the live answer name the same
272/// surfaces. The route, the tool and the resource are the capability's own declaration; the
273/// command line is the local command that renders this capability, which `cli::LOCAL`
274/// declares once beside the reason it is local. Nothing is spelled out a second time, and a
275/// page that lists these resolves each against the datasets the registry generates rather
276/// than typing a route of its own. `None` only when the registry does not carry the
277/// capability at all, which the module's own test refuses.
278///
279/// ```
280/// use majordomus_cli::capability::builtin::release::{module, produced_by};
281/// use majordomus_cli::capability::registry::CapabilityRegistry;
282///
283/// let registry = CapabilityRegistry::builder()
284///     .with_modules(vec![module()])
285///     .build()
286///     .expect("the release module composes on its own");
287/// let by = produced_by(&registry).expect("the registry carries release.changelog");
288/// assert_eq!(by.capability, "release.changelog");
289/// assert_eq!(by.cli.as_deref(), Some("majordomus release changelog"));
290/// assert_eq!(by.http.as_deref(), Some("/api/v1/changelog"));
291/// assert_eq!(by.mcp_tool.as_deref(), Some("majordomus_changelog"));
292/// assert_eq!(by.mcp_resource.as_deref(), Some("majordomus://changelog"));
293/// ```
294pub fn produced_by(registry: &CapabilityRegistry) -> Option<ProducedBy> {
295    let c = registry.get(CHANGELOG_ID)?;
296    let cli = crate::cli::local::LOCAL
297        .iter()
298        .find(|l| l.reason.renders() == Some(CHANGELOG_ID))
299        .map(|l| format!("majordomus {}", l.command));
300    Some(ProducedBy {
301        capability: c.id.to_string(),
302        cli,
303        http: c.exposure.http.as_ref().map(|h| h.path.clone()),
304        mcp_tool: c.exposure.mcp.as_ref().and_then(|m| m.tool.clone()),
305        mcp_resource: c
306            .exposure
307            .mcp
308            .as_ref()
309            .and_then(|m| m.resource.as_ref().map(|r| r.uri.clone())),
310    })
311}
312
313fn changelog(ctx: &Context, input: ChangelogInput) -> Result<Changelog, CapabilityError> {
314    let root = std::path::Path::new(&ctx.index.repository.root);
315    let mut log = release::compose(root, &ctx.index.objects);
316    log.produced_by = produced_by(&ctx.registry);
317    if let Some(wanted) = input.version.as_deref() {
318        log.sections.retain(|s| {
319            s.version == wanted
320                || s.tag.as_deref() == Some(wanted)
321                || (wanted == "unreleased" && s.unreleased)
322        });
323        if log.sections.is_empty() {
324            return Err(CapabilityError::NotFound(format!(
325                "no release section carries the version '{wanted}'"
326            )));
327        }
328    }
329    Ok(log)
330}
331
332/// The version report, whose `next` is the contract's answer (ADR 0051).
333///
334/// [`release::version::select`] — the same selection `release bump` takes its default target
335/// from, so the two never answer differently. When the analysis cannot be made — no published
336/// baseline carries a registry, or a version is not three numbers and the analysis could only
337/// guess — the report is still answered: `next` is absent, `decided_by` is `undecided` and the
338/// reason is carried, beside the commit evidence that does not depend on it.
339fn version(ctx: &Context, _: Empty) -> Result<VersionReport, CapabilityError> {
340    let root = std::path::Path::new(&ctx.index.repository.root);
341    Ok(release::version::select(root, &ctx.registry, &ctx.index.objects).report)
342}
343
344/// The one analysis, behind every surface that shows a version verdict.
345///
346/// A surface that cannot be read is a refusal and never an empty diff: an empty diff means
347/// "nothing changed", and answering that when the truth is "the baseline could not be read"
348/// is the false negative this whole subsystem exists to stop.
349fn analysis(ctx: &Context, input: AnalysisInput) -> Result<VersionPlan, CapabilityError> {
350    let root = std::path::Path::new(&ctx.index.repository.root);
351    release::compat::analyze(
352        root,
353        &ctx.registry,
354        &ctx.index.objects,
355        input.since.as_deref(),
356    )
357    .map_err(|e| CapabilityError::NotFound(e.to_string()))
358}
359
360/// What a landing of the input's head onto its base allocates, under this repository's
361/// policy. An unreadable policy is refused: a decision made without its floor would
362/// understate what the repository asks.
363fn next(
364    ctx: &Context,
365    input: NextInput,
366) -> Result<release::landing::LandingDecision, CapabilityError> {
367    let root = std::path::Path::new(&ctx.index.repository.root);
368    let repo = crate::repository::Repository::open(root).map_err(|e| {
369        CapabilityError::NotFound(format!("the repository could not be opened: {e}"))
370    })?;
371    let policy = crate::policy::LoadedPolicy::load(&repo)
372        .map_err(|e| CapabilityError::NotFound(format!("the policy could not be read: {e}")))?
373        .policy
374        .release;
375    release::landing::next_for(root, input.base(), input.head(), &policy)
376        .map_err(|e| CapabilityError::NotFound(e.to_string()))
377}
378
379#[cfg(test)]
380mod tests {
381    use super::*;
382
383    /// A repository whose manifest cannot be read is refused by name, never guessed at.
384    #[test]
385    fn a_landing_in_a_repository_that_cannot_be_opened_is_refused() {
386        let repo = crate::synthetic::SyntheticRepository::small().expect("a synthetic repository");
387        let ctx = repo.context().expect("a context");
388        std::fs::remove_file(repo.root().join(crate::repository::MANIFEST)).expect("the manifest");
389        match next(&ctx, NextInput::default()) {
390            Err(CapabilityError::NotFound(m)) => {
391                assert!(m.contains("could not be opened"), "{m}")
392            }
393            other => panic!("not refused as not found: {other:?}"),
394        }
395    }
396
397    /// A policy that cannot be read is refused by name, never taken as no allocation.
398    #[test]
399    fn a_landing_under_an_unreadable_policy_is_refused() {
400        let repo = crate::synthetic::SyntheticRepository::small().expect("a synthetic repository");
401        let ctx = repo.context().expect("a context");
402        let policy = repo.root().join(".ai/repo/policy.yaml");
403        let text = std::fs::read_to_string(&policy).expect("the policy");
404        std::fs::write(&policy, format!("{text}release:\n  allocation: sideways\n"))
405            .expect("the policy");
406        match next(&ctx, NextInput::default()) {
407            Err(CapabilityError::NotFound(m)) => {
408                assert!(m.contains("the policy could not be read"), "{m}")
409            }
410            other => panic!("not refused as not found: {other:?}"),
411        }
412    }
413
414    /// Two capabilities, and every surface that carries them is derived from this
415    /// declaration: the MCP tools, the changelog resource, the HTTP routes, the OpenAPI
416    /// operations. A refactor that renamed a route, dropped the resource or added a third
417    /// would still compile, and the suites that exercise the changelog behind it would
418    /// still pass. This is the assertion that would not.
419    #[test]
420    fn the_declaration_yields_the_projections_it_claims() {
421        let m = module();
422        assert_eq!(m.id.as_str(), "release");
423        assert_eq!(
424            ANALYSIS_ID, "release.analysis",
425            "the id beside the declaration is the declared one"
426        );
427        let expected: &[(&str, &str, &str)] = &[
428            (
429                "release.changelog",
430                "majordomus_changelog",
431                "/api/v1/changelog",
432            ),
433            (
434                "release.version",
435                "majordomus_release_version",
436                "/api/v1/release/version",
437            ),
438            (
439                "release.analysis",
440                "majordomus_release_analysis",
441                "/api/v1/release/analysis",
442            ),
443            (
444                "release.next",
445                "majordomus_release_next",
446                "/api/v1/release/next",
447            ),
448        ];
449        let ids: Vec<&str> = m
450            .capabilities
451            .iter()
452            .map(|e| e.capability.id.as_str())
453            .collect();
454        let want: Vec<&str> = expected.iter().map(|(id, _, _)| *id).collect();
455        assert_eq!(
456            ids, want,
457            "the module declares a different set of capabilities"
458        );
459        for (executable, (id, tool, path)) in m.capabilities.iter().zip(expected) {
460            let exposure = &executable.capability.exposure;
461            assert_eq!(
462                exposure.mcp.as_ref().and_then(|m| m.tool.as_deref()),
463                Some(*tool),
464                "{id} lost or renamed its MCP tool"
465            );
466            assert_eq!(
467                exposure.http.as_ref().map(|h| h.path.as_str()),
468                Some(*path),
469                "{id} lost or renamed its HTTP route"
470            );
471        }
472        // the changelog is also a resource, so a client may read it without calling a tool
473        let changelog = &m.capabilities[0].capability.exposure;
474        assert_eq!(
475            changelog
476                .mcp
477                .as_ref()
478                .and_then(|m| m.resource.as_ref())
479                .map(|r| r.uri.as_str()),
480            Some(CHANGELOG_URI),
481            "the changelog stopped being an MCP resource"
482        );
483    }
484}