1use 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
29pub const ARTIFACTS_URI: &str = "majordomus://artifacts";
31
32#[derive(
34 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
35)]
36#[serde(rename_all = "snake_case")]
37pub enum ArtifactState {
38 Current,
40 Stale,
42 Missing,
44 Present,
47}
48
49impl ArtifactState {
50 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#[derive(
76 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
77)]
78#[serde(rename_all = "snake_case")]
79pub enum ArtifactVerdict {
80 Current,
82 Unverified,
86 Stale,
88 Missing,
90 NotGenerated,
92}
93
94impl ArtifactVerdict {
95 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 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)]
138pub struct ArtifactsInput {
140 #[serde(default, skip_serializing_if = "Option::is_none")]
141 pub document: Option<String>,
143 #[serde(default, skip_serializing_if = "Option::is_none")]
144 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)]
170pub struct DocumentView {
172 pub id: String,
174 #[serde(default, skip_serializing_if = "Option::is_none")]
175 pub schema: Option<String>,
177 pub source: String,
179 pub formats: Vec<ArtifactFormat>,
181}
182
183#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
184pub struct ArtifactView {
186 pub path: String,
188 pub document: String,
190 pub format: ArtifactFormat,
192 #[serde(default, skip_serializing_if = "Option::is_none")]
193 pub schema: Option<String>,
195 pub source: String,
197 #[serde(default, skip_serializing_if = "Option::is_none")]
198 pub bytes: Option<u64>,
200 #[serde(default, skip_serializing_if = "Option::is_none")]
201 pub sha256: Option<String>,
203 pub state: ArtifactState,
205}
206
207#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
208pub struct ArtifactTallies {
210 pub documents: usize,
212 pub artifacts: usize,
214 pub current: usize,
216 pub stale: usize,
218 pub missing: usize,
220 pub present: usize,
222}
223
224#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
225pub struct ArtifactReport {
227 pub manifest: String,
229 pub present: bool,
233 pub schema: String,
235 pub documents: Vec<DocumentView>,
237 pub artifacts: Vec<ArtifactView>,
239 pub tallies: ArtifactTallies,
241 pub verdict: ArtifactVerdict,
243 pub regenerate: String,
245 pub verify: String,
247}
248
249#[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
373pub 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 #[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 #[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 #[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 assert!(c.kind.is_read_only() && c.kind.is_executable());
479 assert!(ids.iter().all(|id| id.starts_with("artifacts.")));
481 }
482}