Skip to main content

majordomus_cli/capability/builtin/
artifacts.rs

1//! The `artifacts` module: what `majordomus generate` writes, read back.
2//!
3//! The generator declares every artifact it produces — the document it projects, the
4//! encoding it is written in, the JSON Schema its content satisfies, and where it came
5//! from — and commits that declaration as `docs/generated/artifacts.json`. This module is
6//! the reading half: it takes the committed manifest and reconciles it with the working
7//! tree, so that a person, an MCP client, an HTTP caller and the Cockpit all learn from
8//! one answer which generated files exist, which encodings a document is committed in,
9//! and which are stale.
10//!
11//! It does not regenerate anything and it holds no list of its own: a document added to
12//! the generator appears here on the next `majordomus generate`, and a file this module
13//! reports as stale is a file `majordomus generate --check` refuses.
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::{Exposure, McpExposure, McpResource, Stability};
21use crate::capability::module::ModuleDescriptor;
22use crate::capability::CachePolicy;
23use crate::generate::{ArtifactFormat, MANIFEST_ID, MANIFEST_SCHEMA, OUT_DIR};
24use crate::policy::sha256_hex;
25use crate::{capability, module};
26
27use super::get;
28
29/// The URI under which the manifest is read as an MCP resource.
30pub const ARTIFACTS_URI: &str = "majordomus://artifacts";
31
32/// Where one generated file stands against the tree it is committed in.
33#[derive(
34    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
35)]
36#[serde(rename_all = "snake_case")]
37pub enum ArtifactState {
38    /// The file is there and its bytes hash to what the manifest recorded.
39    Current,
40    /// The file is there and its bytes differ: it was edited, or the generator moved on.
41    Stale,
42    /// The manifest names it and the tree does not have it.
43    Missing,
44    /// The file is there and the manifest records no hash for it: the manifest's own
45    /// encodings, which cannot hash themselves. `generate --check` compares them.
46    Present,
47}
48
49impl ArtifactState {
50    /// The word as serialised.
51    ///
52    /// ```
53    /// use majordomus_cli::capability::builtin::artifacts::ArtifactState;
54    /// assert_eq!(ArtifactState::Stale.as_str(), "stale");
55    /// ```
56    pub fn as_str(self) -> &'static str {
57        match self {
58            ArtifactState::Current => "current",
59            ArtifactState::Stale => "stale",
60            ArtifactState::Missing => "missing",
61            ArtifactState::Present => "present",
62        }
63    }
64}
65
66/// Where the generated tree stands as a whole: the one verdict every surface renders,
67/// decided here so that no page decides it again from a subset of the tallies.
68///
69/// ```
70/// use majordomus_cli::capability::builtin::artifacts::ArtifactVerdict;
71/// let v: ArtifactVerdict = serde_json::from_str("\"unverified\"").unwrap();
72/// assert_eq!(v, ArtifactVerdict::Unverified);
73/// assert!(ArtifactVerdict::Current < ArtifactVerdict::Unverified);
74/// ```
75#[derive(
76    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
77)]
78#[serde(rename_all = "snake_case")]
79pub enum ArtifactVerdict {
80    /// Every file is there and hashes to what the manifest recorded.
81    Current,
82    /// Every file is there, none is stale, and some carry no recorded hash: their bytes
83    /// were not compared here, so the tree is not known to be current.
84    /// `majordomus generate --check` is what decides them.
85    Unverified,
86    /// A file differs from what the manifest recorded.
87    Stale,
88    /// A file the manifest names is not in the tree.
89    Missing,
90    /// No manifest is committed: the repository has never run `majordomus generate`.
91    NotGenerated,
92}
93
94impl ArtifactVerdict {
95    /// The verdict over the tallies of a manifest that exists, or `NotGenerated` when none
96    /// does. Missing outranks stale, stale outranks unhashed, and a set is current only when
97    /// every file in it was hashed and matched.
98    ///
99    /// ```
100    /// use majordomus_cli::capability::builtin::artifacts::{ArtifactTallies, ArtifactVerdict};
101    /// let t = ArtifactTallies { documents: 1, artifacts: 3, current: 2, stale: 0, missing: 0, present: 1 };
102    /// assert_eq!(ArtifactVerdict::of(true, &t), ArtifactVerdict::Unverified);
103    /// ```
104    pub fn of(generated: bool, t: &ArtifactTallies) -> Self {
105        if !generated {
106            ArtifactVerdict::NotGenerated
107        } else if t.missing > 0 {
108            ArtifactVerdict::Missing
109        } else if t.stale > 0 {
110            ArtifactVerdict::Stale
111        } else if t.present > 0 {
112            ArtifactVerdict::Unverified
113        } else {
114            ArtifactVerdict::Current
115        }
116    }
117
118    /// The word the verdict is serialised as, which is also the word every surface prints
119    /// for it: the Cockpit, the command line and the HTTP answer never spell it differently.
120    ///
121    /// ```
122    /// use majordomus_cli::capability::builtin::artifacts::ArtifactVerdict;
123    /// assert_eq!(ArtifactVerdict::NotGenerated.as_str(), "not_generated");
124    /// ```
125    pub fn as_str(self) -> &'static str {
126        match self {
127            ArtifactVerdict::Current => "current",
128            ArtifactVerdict::Unverified => "unverified",
129            ArtifactVerdict::Stale => "stale",
130            ArtifactVerdict::Missing => "missing",
131            ArtifactVerdict::NotGenerated => "not_generated",
132        }
133    }
134}
135
136#[derive(Debug, Serialize, Deserialize, JsonSchema)]
137#[serde(deny_unknown_fields)]
138/// The input of `artifacts.list`: the whole manifest, or one slice of it.
139pub struct ArtifactsInput {
140    #[serde(default, skip_serializing_if = "Option::is_none")]
141    /// Only the artifacts of this document (`registry`, `cli`, `openapi`, ...).
142    pub document: Option<String>,
143    #[serde(default, skip_serializing_if = "Option::is_none")]
144    /// Only the artifacts written in this encoding.
145    pub format: Option<ArtifactFormat>,
146}
147
148impl BenchmarkCases for ArtifactsInput {
149    fn benchmark_cases(_: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
150        vec![
151            NamedCase::new(
152                "all",
153                ArtifactsInput {
154                    document: None,
155                    format: None,
156                },
157            ),
158            NamedCase::new(
159                "one-encoding",
160                ArtifactsInput {
161                    document: None,
162                    format: Some(ArtifactFormat::Yaml),
163                },
164            ),
165        ]
166    }
167}
168
169#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
170/// One document, and the encodings it is committed in.
171pub struct DocumentView {
172    /// The document id, shared by every encoding of it.
173    pub id: String,
174    #[serde(default, skip_serializing_if = "Option::is_none")]
175    /// The schema id its structured encodings carry.
176    pub schema: Option<String>,
177    /// One line: what it was derived from.
178    pub source: String,
179    /// The encodings, in the manifest's order.
180    pub formats: Vec<ArtifactFormat>,
181}
182
183#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
184/// One generated file, as the manifest declares it and as the tree has it.
185pub struct ArtifactView {
186    /// Repository-relative path.
187    pub path: String,
188    /// The document it projects.
189    pub document: String,
190    /// The encoding.
191    pub format: ArtifactFormat,
192    #[serde(default, skip_serializing_if = "Option::is_none")]
193    /// The JSON Schema its content satisfies, when it declares one.
194    pub schema: Option<String>,
195    /// One line: what it was derived from.
196    pub source: String,
197    #[serde(default, skip_serializing_if = "Option::is_none")]
198    /// The size the manifest recorded.
199    pub bytes: Option<u64>,
200    #[serde(default, skip_serializing_if = "Option::is_none")]
201    /// The hash the manifest recorded.
202    pub sha256: Option<String>,
203    /// Where the file stands against it.
204    pub state: ArtifactState,
205}
206
207#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
208/// How many of each, so a caller needs no arithmetic of its own.
209pub struct ArtifactTallies {
210    /// Documents in the manifest.
211    pub documents: usize,
212    /// Files in the manifest, after any filter.
213    pub artifacts: usize,
214    /// Hashes that match.
215    pub current: usize,
216    /// Hashes that do not.
217    pub stale: usize,
218    /// Files the manifest names and the tree lacks.
219    pub missing: usize,
220    /// Files the manifest records no hash for.
221    pub present: usize,
222}
223
224#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
225/// The answer of `artifacts.list`.
226pub struct ArtifactReport {
227    /// The manifest this was read from, repository-relative.
228    pub manifest: String,
229    /// Whether the manifest is committed in this repository at all. A repository that has
230    /// never run `majordomus generate` has no generated tree, which is a fact about it and
231    /// not a failure of this call: every list below is then empty and every tally zero.
232    pub present: bool,
233    /// The schema the manifest carries, empty when there is none to read.
234    pub schema: String,
235    /// The documents, in the manifest's order.
236    pub documents: Vec<DocumentView>,
237    /// The files, in the manifest's order, after any filter.
238    pub artifacts: Vec<ArtifactView>,
239    /// The counts.
240    pub tallies: ArtifactTallies,
241    /// Where the tree stands as a whole, decided from every tally.
242    pub verdict: ArtifactVerdict,
243    /// The command that rewrites every one of them.
244    pub regenerate: String,
245    /// The command that decides staleness byte for byte, which is stronger than the hash.
246    pub verify: String,
247}
248
249/// The manifest as committed, deserialised into the shape the generator wrote.
250#[derive(Debug, Deserialize)]
251struct Manifest {
252    schema: String,
253    documents: Vec<DocumentView>,
254    artifacts: Vec<ManifestEntry>,
255}
256
257#[derive(Debug, Deserialize)]
258struct ManifestEntry {
259    path: String,
260    document: String,
261    format: ArtifactFormat,
262    #[serde(default)]
263    schema: Option<String>,
264    source: String,
265    #[serde(default)]
266    bytes: Option<u64>,
267    #[serde(default)]
268    sha256: Option<String>,
269}
270
271fn artifacts_list(ctx: &Context, input: ArtifactsInput) -> Result<ArtifactReport, CapabilityError> {
272    let rel = format!("{OUT_DIR}/{MANIFEST_ID}.json");
273    let root = std::path::Path::new(&ctx.index.repository.root);
274    let Ok(text) = std::fs::read_to_string(root.join(&rel)) else {
275        return Ok(ArtifactReport {
276            manifest: rel,
277            present: false,
278            schema: String::new(),
279            documents: Vec::new(),
280            artifacts: Vec::new(),
281            tallies: ArtifactTallies {
282                documents: 0,
283                artifacts: 0,
284                current: 0,
285                stale: 0,
286                missing: 0,
287                present: 0,
288            },
289            verdict: ArtifactVerdict::NotGenerated,
290            regenerate: "majordomus generate".into(),
291            verify: "majordomus generate --check".into(),
292        });
293    };
294    let manifest: Manifest = serde_json::from_str(&text).map_err(|e| {
295        CapabilityError::Internal(format!(
296            "{rel} is not the manifest this executable writes: {e}"
297        ))
298    })?;
299    if manifest.schema != MANIFEST_SCHEMA {
300        return Err(CapabilityError::Internal(format!(
301            "{rel} carries schema '{}', and this executable reads {MANIFEST_SCHEMA}",
302            manifest.schema
303        )));
304    }
305
306    let mut artifacts = Vec::new();
307    let mut tallies = ArtifactTallies {
308        documents: manifest.documents.len(),
309        artifacts: 0,
310        current: 0,
311        stale: 0,
312        missing: 0,
313        present: 0,
314    };
315    for e in manifest.artifacts {
316        if input.document.as_deref().is_some_and(|d| d != e.document) {
317            continue;
318        }
319        if input.format.is_some_and(|f| f != e.format) {
320            continue;
321        }
322        let state = match std::fs::read_to_string(root.join(&e.path)) {
323            Err(_) => ArtifactState::Missing,
324            Ok(on_disk) => match &e.sha256 {
325                None => ArtifactState::Present,
326                Some(hash) if *hash == sha256_hex(&on_disk) => ArtifactState::Current,
327                Some(_) => ArtifactState::Stale,
328            },
329        };
330        match state {
331            ArtifactState::Current => tallies.current += 1,
332            ArtifactState::Stale => tallies.stale += 1,
333            ArtifactState::Missing => tallies.missing += 1,
334            ArtifactState::Present => tallies.present += 1,
335        }
336        tallies.artifacts += 1;
337        artifacts.push(ArtifactView {
338            path: e.path,
339            document: e.document,
340            format: e.format,
341            schema: e.schema,
342            source: e.source,
343            bytes: e.bytes,
344            sha256: e.sha256,
345            state,
346        });
347    }
348
349    let documents = match &input.document {
350        Some(id) => manifest
351            .documents
352            .into_iter()
353            .filter(|d| &d.id == id)
354            .collect(),
355        None => manifest.documents,
356    };
357    tallies.documents = documents.len();
358    let verdict = ArtifactVerdict::of(true, &tallies);
359
360    Ok(ArtifactReport {
361        manifest: rel,
362        present: true,
363        schema: manifest.schema,
364        documents,
365        artifacts,
366        tallies,
367        verdict,
368        regenerate: "majordomus generate".into(),
369        verify: "majordomus generate --check".into(),
370    })
371}
372
373/// The module.
374pub fn module() -> ModuleDescriptor {
375    module! {
376        id: "artifacts",
377        title: "Generated artifacts",
378        description: "What `majordomus generate` writes: every document with the encodings it is committed in — JSON for a program, YAML beside it, Markdown for a reader — each with its schema, its source and its hash, reconciled with the working tree. The declaration is the generator's own manifest; nothing here keeps a list.",
379        stability: Stability::BehaviorallyVerified,
380        capabilities: [
381            capability! {
382                id: "artifacts.list",
383                title: "List the generated artifacts",
384                description: "The manifest `majordomus generate` commits as docs/generated/artifacts.json, reconciled with the working tree: every document with the encodings it is written in, and every file with its format, schema, source, size, hash and whether the file on disk still matches. Optionally narrowed to one document or one encoding. Reads only; `majordomus generate` writes and `majordomus generate --check` is the byte-for-byte verdict.",
385                input: ArtifactsInput,
386                output: ArtifactReport,
387                stability: Stability::BehaviorallyVerified,
388                exposure: Exposure {
389                    mcp: Some(McpExposure {
390                        tool: Some("majordomus_artifacts".into()),
391                        resource: Some(McpResource { uri: ARTIFACTS_URI.into(), name: "artifacts".into() }),
392                    }),
393                    http: get("/api/v1/artifacts"),
394                    cli: None,
395                },
396                tags: ["artifacts", "generation", "introspection"],
397                cache: CachePolicy::Process { max_entries: 8, ttl_seconds: Some(5) },
398                handler: artifacts_list,
399            },
400        ],
401    }
402}
403
404#[cfg(test)]
405mod tests {
406    use super::*;
407
408    fn tallies(current: usize, stale: usize, missing: usize, present: usize) -> ArtifactTallies {
409        ArtifactTallies {
410            documents: 1,
411            artifacts: current + stale + missing + present,
412            current,
413            stale,
414            missing,
415            present,
416        }
417    }
418
419    /// A file with no recorded hash was not compared, so a set holding one is not current:
420    /// the verdict the Cockpit used to decide in the page, from missing and stale alone,
421    /// called 146 hashed and 3 unhashed files "current".
422    #[test]
423    fn an_unhashed_file_makes_the_tree_unverified_and_never_current() {
424        assert_eq!(
425            ArtifactVerdict::of(true, &tallies(146, 0, 0, 3)),
426            ArtifactVerdict::Unverified
427        );
428        assert_eq!(
429            ArtifactVerdict::of(true, &tallies(146, 0, 0, 0)),
430            ArtifactVerdict::Current
431        );
432    }
433
434    /// Missing outranks stale, stale outranks unhashed, and no manifest is its own word.
435    #[test]
436    fn the_verdict_ranks_what_is_worst() {
437        assert_eq!(
438            ArtifactVerdict::of(true, &tallies(1, 1, 1, 1)),
439            ArtifactVerdict::Missing
440        );
441        assert_eq!(
442            ArtifactVerdict::of(true, &tallies(1, 1, 0, 1)),
443            ArtifactVerdict::Stale
444        );
445        assert_eq!(
446            ArtifactVerdict::of(false, &tallies(0, 0, 0, 0)),
447            ArtifactVerdict::NotGenerated
448        );
449    }
450
451    /// The declaration is the only place the id, the tool name, the resource URI and the
452    /// route exist. A refactor that dropped one of them would still compile, and every
453    /// suite that tests the report itself would still pass; this is the assertion that
454    /// would not — which is what `project.rust-command-tested-in-file` asks of a command.
455    #[test]
456    fn the_declaration_yields_the_identity_and_the_projections_it_claims() {
457        let m = module();
458        assert_eq!(m.id.as_str(), "artifacts");
459        let ids: Vec<&str> = m
460            .capabilities
461            .iter()
462            .map(|e| e.capability.id.as_str())
463            .collect();
464        assert_eq!(ids, ["artifacts.list"]);
465
466        let c = &m.capabilities[0].capability;
467        let mcp = c.exposure.mcp.as_ref().expect("an MCP projection");
468        assert_eq!(mcp.tool.as_deref(), Some("majordomus_artifacts"));
469        assert_eq!(
470            mcp.resource.as_ref().map(|r| r.uri.as_str()),
471            Some(ARTIFACTS_URI)
472        );
473        assert_eq!(
474            c.exposure.http.as_ref().map(|h| h.path.as_str()),
475            Some("/api/v1/artifacts")
476        );
477        // read-only, and the registry refuses an executable capability that is not
478        assert!(c.kind.is_read_only() && c.kind.is_executable());
479        // and the module stamps its own namespace on what it composes
480        assert!(ids.iter().all(|id| id.starts_with("artifacts.")));
481    }
482}