majordomus_cli/share.rs
1//! The tool distribution's data directory: `share/kinds.yaml` and
2//! `share/schemas/<kind>.schema.json`, read at run time. Nothing about kinds or keys is
3//! compiled into the executable; the directory is located per invocation, explicitly or
4//! by convention, and named in every error when it is not.
5//!
6//! # Why nothing is compiled in
7//!
8//! A kind is a fact about a repository's layer, not about the executable that reads it. If
9//! the kinds were compiled in, adding one would mean shipping a binary, and a repository
10//! that added its own would be adding it to somebody else's program. Reading them at run
11//! time is what makes `.ai/repo/knowledge/` able to declare a kind that the same binary
12//! then serves.
13//!
14//! ```
15//! use majordomus_cli::share::Share;
16//!
17//! // located explicitly, and the paths under it are derived rather than joined by callers
18//! let dir = tempfile::tempdir().unwrap();
19//! std::fs::write(dir.path().join("kinds.yaml"), "version: 1\nkinds: []\n").unwrap();
20//! std::fs::create_dir(dir.path().join("schemas")).unwrap();
21//!
22//! let share = Share::locate(Some(dir.path()), dir.path()).unwrap();
23//! assert_eq!(share.origin, "--share", "it says how it was found");
24//! assert!(share.kinds_path().ends_with("kinds.yaml"));
25//! assert!(share.schemas_dir().ends_with("schemas"));
26//! assert_eq!(share.kinds_path().parent(), Some(share.dir()));
27//!
28//! // and when there is none, the error names where it looked
29//! let nowhere = tempfile::tempdir().unwrap();
30//! let err = Share::locate(None, nowhere.path()).unwrap_err();
31//! assert_eq!(err.exit_code(), 12);
32//! ```
33
34use std::path::{Path, PathBuf};
35
36use crate::error::{Error, Result};
37
38/// The environment variable that names the share directory.
39pub const SHARE_ENV: &str = "MAJORDOMUS_SHARE";
40
41/// The kinds file inside the share directory.
42pub const KINDS_FILE: &str = "kinds.yaml";
43
44/// The provider declarations inside the share directory: what a provider is beyond its
45/// template — its title, the client configuration it reads, the scratch roots it creates.
46pub const PROVIDERS_FILE: &str = "providers.yaml";
47
48/// The JSON Schema directory inside the share directory.
49pub const SCHEMAS_DIR: &str = "schemas";
50
51/// The directory of the shell tool's allow-lists, which `generate allow` derives from the
52/// schemas.
53pub const ALLOW_DIR: &str = "allow";
54
55/// The directory of the shell tool's body-section lists, which `generate documents`
56/// derives from the `.proto` document schemas. The allow-list is the header half of a
57/// contract and this is the body half; a shell cannot read either out of a schema.
58pub const SECTIONS_DIR: &str = "sections";
59
60/// The subdirectory of [`SCHEMAS_DIR`] holding one JSON Schema per generated document.
61/// A kind's schema is projected from a `.proto` under `<vendor>/<name>/`, so nothing here
62/// is ever read as a kind's schema.
63pub const GENERATED_SCHEMAS_DIR: &str = "generated";
64
65/// The suffix of a schema file: `<name>.schema.json`.
66pub const SCHEMA_SUFFIX: &str = ".schema.json";
67
68/// A located distribution directory.
69#[derive(Debug, Clone, PartialEq, Eq)]
70pub struct Share {
71 dir: PathBuf,
72 /// How it was found, for diagnostics: `--share`, the environment, the repository, or
73 /// the executable's own installation.
74 pub origin: &'static str,
75}
76
77impl Share {
78 /// Locate the share directory. In order: `explicit` (from `--share`), `MAJORDOMUS_SHARE`,
79 /// `<repository root>/share` when it holds a kinds file (this repository supervising
80 /// itself), and `<directory of the executable>/../share` (an installation laid out as
81 /// `bin/` beside `share/`). A candidate that exists but holds no kinds file is skipped
82 /// only when it came from a convention; an explicit path that lacks one is an error.
83 pub fn locate(explicit: Option<&Path>, repo_root: &Path) -> Result<Self> {
84 let mut tried = Vec::new();
85 if let Some(p) = explicit {
86 return Self::open(p, "--share");
87 }
88 if let Some(p) = std::env::var_os(SHARE_ENV) {
89 return Self::open(Path::new(&p), SHARE_ENV);
90 }
91 let in_repo = repo_root.join("share");
92 if in_repo.join(KINDS_FILE).is_file() {
93 return Self::open(&in_repo, "repository");
94 }
95 tried.push(in_repo);
96 if let Ok(exe) = std::env::current_exe() {
97 if let Some(dir) = exe.parent() {
98 let beside = dir.join("../share");
99 if beside.join(KINDS_FILE).is_file() {
100 return Self::open(&beside, "installation");
101 }
102 tried.push(beside);
103 }
104 }
105 Err(Error::ShareNotFound { tried })
106 }
107
108 fn open(dir: &Path, origin: &'static str) -> Result<Self> {
109 let kinds = dir.join(KINDS_FILE);
110 if !kinds.is_file() {
111 return Err(Error::ShareNotFound {
112 tried: vec![dir.to_path_buf()],
113 });
114 }
115 let dir = dir.canonicalize().map_err(|e| Error::io(dir, e))?;
116 Ok(Share { dir, origin })
117 }
118
119 /// The directory, canonical.
120 pub fn dir(&self) -> &Path {
121 &self.dir
122 }
123
124 /// The distribution's default scope declaration, the file `majordomus init` seeds.
125 pub fn skeleton_scope(&self) -> PathBuf {
126 self.dir.join(crate::scope::SKELETON_SCOPE)
127 }
128
129 /// `<share>/kinds.yaml`.
130 pub fn kinds_path(&self) -> PathBuf {
131 self.dir.join(KINDS_FILE)
132 }
133
134 /// `<share>/schemas`.
135 pub fn schemas_dir(&self) -> PathBuf {
136 self.dir.join(SCHEMAS_DIR)
137 }
138
139 /// `<share>/allow`.
140 pub fn allow_dir(&self) -> PathBuf {
141 self.dir.join(ALLOW_DIR)
142 }
143
144 /// `<share>/sections`.
145 pub fn sections_dir(&self) -> PathBuf {
146 self.dir.join(SECTIONS_DIR)
147 }
148
149 /// `<share>/schemas/generated`: the contracts of the generated documents, matched to a
150 /// document by the `const` of its `schema` member.
151 pub fn generated_schemas_dir(&self) -> PathBuf {
152 self.dir.join(SCHEMAS_DIR).join(GENERATED_SCHEMAS_DIR)
153 }
154
155 /// `<share>/providers`: the provider templates the tool ships, `<provider>.tmpl` each.
156 pub fn providers_dir(&self) -> PathBuf {
157 self.dir.join("providers")
158 }
159
160 /// The providers the tool has a template for, by id (the file stem), sorted. A
161 /// distribution without the directory has none; that is not an error, it is a
162 /// distribution that ships no adapter.
163 pub fn provider_templates(&self) -> Vec<String> {
164 let Ok(entries) = std::fs::read_dir(self.providers_dir()) else {
165 return Vec::new();
166 };
167 let mut out: Vec<String> = entries
168 .filter_map(|e| e.ok())
169 .filter_map(|e| e.file_name().to_str().map(str::to_string))
170 .filter_map(|n| n.strip_suffix(".tmpl").map(str::to_string))
171 .collect();
172 crate::order::canonical(&mut out);
173 out
174 }
175
176 /// `<share>/providers.yaml`.
177 pub fn providers_path(&self) -> PathBuf {
178 self.dir.join(PROVIDERS_FILE)
179 }
180
181 /// The provider declarations the distribution ships, joined with its templates: one
182 /// [`ProviderDeclaration`] per id that has either, sorted by id. A distribution without
183 /// the file declares nothing and every template is a provider named by its file; a
184 /// file that does not parse is an error naming it, because a declaration half-read is
185 /// worse than none.
186 pub fn providers(&self) -> Result<ProviderDeclarations> {
187 let path = self.providers_path();
188 let file: ProvidersFile = match std::fs::read_to_string(&path) {
189 Ok(text) => {
190 crate::metadata::yaml::parse_into(&text).map_err(|reason| Error::KindSchema {
191 reason: format!("{}: {reason}", path.display()),
192 })?
193 }
194 Err(e) if e.kind() == std::io::ErrorKind::NotFound => ProvidersFile::default(),
195 Err(e) => return Err(Error::io(&path, e)),
196 };
197 let templates = self.provider_templates();
198 let mut ids: Vec<String> = templates.clone();
199 ids.extend(file.providers.keys().cloned());
200 crate::order::canonical(&mut ids);
201 ids.dedup();
202 let providers = ids
203 .into_iter()
204 .map(|id| {
205 let decl = file.providers.get(&id);
206 ProviderDeclaration {
207 title: decl
208 .and_then(|d| d.title.clone())
209 .unwrap_or_else(|| id.clone()),
210 client_config: decl.and_then(|d| d.client_config.clone()),
211 scratch_roots: decl.map(|d| d.scratch_roots.clone()).unwrap_or_default(),
212 lifecycle: decl.map(|d| d.lifecycle.clone()).unwrap_or_default(),
213 prompt_capture: decl.map(|d| d.prompt_capture).unwrap_or(false),
214 offers: decl
215 .and_then(|d| d.offers.as_ref())
216 .map(offers_of)
217 .unwrap_or_default(),
218 template: templates.contains(&id),
219 declared: decl.is_some(),
220 id,
221 }
222 })
223 .collect();
224 Ok(ProviderDeclarations {
225 scratch_roots: file.scratch_roots,
226 providers,
227 })
228 }
229}
230
231/// `share/providers.yaml` as written.
232#[derive(Debug, Default, serde::Deserialize)]
233#[serde(deny_unknown_fields)]
234struct ProvidersFile {
235 #[serde(default)]
236 #[allow(dead_code)]
237 version: u64,
238 #[serde(default)]
239 scratch_roots: Vec<String>,
240 #[serde(default)]
241 providers: std::collections::BTreeMap<String, ProviderEntry>,
242}
243
244#[derive(Debug, Default, serde::Deserialize)]
245#[serde(deny_unknown_fields)]
246struct ProviderEntry {
247 #[serde(default)]
248 title: Option<String>,
249 #[serde(default)]
250 client_config: Option<String>,
251 /// The file the provider reads for one person's instructions beside the project's.
252 /// Read by `init --personal` in the shell tool; accepted here so the declaration parses.
253 #[serde(default)]
254 #[allow(dead_code)]
255 personal_target: Option<String>,
256 #[serde(default)]
257 scratch_roots: Vec<String>,
258 #[serde(default)]
259 lifecycle: Vec<String>,
260 #[serde(default)]
261 prompt_capture: bool,
262 #[serde(default)]
263 offers: Option<ProviderOffersEntry>,
264}
265
266/// The `offers:` block of one provider entry, as written.
267///
268/// The two capability keys deserialise straight into their enums, so a word outside the
269/// vocabulary fails the read of the whole file with the path named rather than degrading
270/// to `none`. A typo that quietly means "this provider can do nothing" is the exact
271/// failure this file was written to end.
272#[derive(Debug, Default, Clone, serde::Deserialize)]
273#[serde(deny_unknown_fields)]
274struct ProviderOffersEntry {
275 #[serde(default)]
276 episode: Option<EpisodeSource>,
277 #[serde(default)]
278 episode_evidence: Option<String>,
279 #[serde(default)]
280 prompts: Option<PromptSource>,
281 #[serde(default)]
282 prompts_evidence: Option<String>,
283}
284
285fn offers_of(e: &ProviderOffersEntry) -> ProviderOffers {
286 ProviderOffers {
287 episode: e.episode.unwrap_or_default(),
288 episode_evidence: e.episode_evidence.clone().unwrap_or_default(),
289 prompts: e.prompts.unwrap_or_default(),
290 prompts_evidence: e.prompts_evidence.clone().unwrap_or_default(),
291 }
292}
293
294/// How an episode boundary can be drawn for one provider.
295///
296/// Three words, because three things are true of different providers and collapsing them
297/// into "supported / not supported" is what let this tool report one provider and stay
298/// silent about five others. A provider that fires its own events is not the same as one
299/// that fires none but speaks MCP, and neither is the same as one that can do nothing.
300///
301/// The words are the declaration's own, and a word outside them is refused rather than read
302/// as `none`:
303///
304/// ```
305/// use majordomus_cli::share::EpisodeSource;
306/// let read: EpisodeSource = serde_json::from_str("\"connection\"").unwrap();
307/// assert_eq!(read, EpisodeSource::Connection);
308/// assert_eq!(EpisodeSource::default(), EpisodeSource::None, "undeclared reads as none");
309/// assert!(serde_json::from_str::<EpisodeSource>("\"hook\"").is_err(), "hooks, plural");
310/// ```
311#[derive(
312 Debug,
313 Clone,
314 Copy,
315 Default,
316 PartialEq,
317 Eq,
318 serde::Serialize,
319 serde::Deserialize,
320 schemars::JsonSchema,
321)]
322#[serde(rename_all = "snake_case")]
323pub enum EpisodeSource {
324 /// The provider fires its own session events and a hook this tool installs draws the
325 /// boundary from them. The strongest form: it sees a window somebody closed.
326 Hooks,
327 /// The provider fires no session event, but its client attaches to this repository's
328 /// shared MCP server, and the connection is the boundary (ADR 0103).
329 Connection,
330 /// Neither. Nothing can say when this provider's episode began or ended, and the tool
331 /// reports that rather than implying a capability nobody has.
332 #[default]
333 None,
334}
335
336impl EpisodeSource {
337 /// The word as the declaration writes it, which is also how it serialises; `capture
338 /// status` prints it beside the evidence that backs it.
339 ///
340 /// ```
341 /// use majordomus_cli::share::EpisodeSource;
342 /// for s in [EpisodeSource::Hooks, EpisodeSource::Connection, EpisodeSource::None] {
343 /// assert_eq!(serde_json::to_string(&s).unwrap(), format!("\"{}\"", s.as_str()));
344 /// }
345 /// ```
346 pub fn as_str(self) -> &'static str {
347 match self {
348 EpisodeSource::Hooks => "hooks",
349 EpisodeSource::Connection => "connection",
350 EpisodeSource::None => "none",
351 }
352 }
353}
354
355/// Whether the person's raw prompt can be captured below the model for one provider.
356///
357/// There is no `connection` here and there must not be: an MCP server is handed tool calls,
358/// never the prompt that produced them. Prompt capture is provider-specific and the
359/// declaration says so rather than pretending the connection can stand in for it.
360///
361/// ```
362/// use majordomus_cli::share::PromptSource;
363/// assert_eq!(serde_json::from_str::<PromptSource>("\"hook\"").unwrap(), PromptSource::Hook);
364/// assert!(serde_json::from_str::<PromptSource>("\"connection\"").is_err(),
365/// "no connection can observe a prompt");
366/// assert_eq!(PromptSource::default(), PromptSource::None);
367/// ```
368#[derive(
369 Debug,
370 Clone,
371 Copy,
372 Default,
373 PartialEq,
374 Eq,
375 serde::Serialize,
376 serde::Deserialize,
377 schemars::JsonSchema,
378)]
379#[serde(rename_all = "snake_case")]
380pub enum PromptSource {
381 /// The provider hands the raw prompt to a command before the model runs.
382 Hook,
383 /// It does not. Nothing this tool can do makes the prompt observable.
384 #[default]
385 None,
386}
387
388impl PromptSource {
389 /// The word as the declaration writes it, which is also how it serialises.
390 ///
391 /// ```
392 /// use majordomus_cli::share::PromptSource;
393 /// assert_eq!(PromptSource::Hook.as_str(), "hook");
394 /// assert_eq!(serde_json::to_string(&PromptSource::None).unwrap(), "\"none\"");
395 /// ```
396 pub fn as_str(self) -> &'static str {
397 match self {
398 PromptSource::Hook => "hook",
399 PromptSource::None => "none",
400 }
401 }
402}
403
404/// What one provider can do about the session lifecycle, and where that was verified.
405///
406/// **Every cell carries its evidence.** A capability asserted with no citation is the defect
407/// this type exists to make impossible: before it, a provider absent from a shell table was
408/// silently unsupported and a provider present in one was silently capable, and no surface
409/// could tell a reader which vendor page either answer came from — `capture status`
410/// enumerated the one provider that had an adapter and said nothing whatever about the
411/// other five this distribution declares. The rule
412/// `project.a-provider-capability-cites-its-evidence` refuses a declaration without one.
413///
414/// A provider that declares nothing is `none` on both axes with empty evidence — the state
415/// that rule refuses, so the gap is a finding rather than a silent "unsupported":
416///
417/// ```
418/// use majordomus_cli::share::{EpisodeSource, PromptSource, ProviderOffers};
419/// let undeclared = ProviderOffers::default();
420/// assert_eq!((undeclared.episode, undeclared.prompts), (EpisodeSource::None, PromptSource::None));
421/// assert!(undeclared.episode_evidence.is_empty() && undeclared.prompts_evidence.is_empty());
422/// let generic: ProviderOffers = serde_json::from_value(serde_json::json!({
423/// "episode": "connection", "episode_evidence": "ADR 0103",
424/// "prompts": "none", "prompts_evidence": "MCP carries no prompt",
425/// })).unwrap();
426/// assert_eq!(generic.episode, EpisodeSource::Connection);
427/// ```
428#[derive(
429 Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize, schemars::JsonSchema,
430)]
431pub struct ProviderOffers {
432 /// How the episode boundary is drawn for this provider.
433 pub episode: EpisodeSource,
434 /// Where `episode` was verified: a vendor documentation URL, or the statement that no
435 /// such documentation exists and what was searched for it.
436 pub episode_evidence: String,
437 /// Whether the raw prompt can be captured below the model.
438 pub prompts: PromptSource,
439 /// Where `prompts` was verified, on the same terms as `episode_evidence`.
440 pub prompts_evidence: String,
441}
442
443/// What the distribution declares about its providers, joined with the templates it ships.
444#[derive(Debug, Clone, Default, PartialEq, Eq)]
445pub struct ProviderDeclarations {
446 /// The tool's own scratch roots, unexpanded (see the file's header for the grammar).
447 pub scratch_roots: Vec<String>,
448 /// Every provider, sorted by id.
449 pub providers: Vec<ProviderDeclaration>,
450}
451
452/// One provider: the template's file stem, and what the declaration adds to it.
453#[derive(
454 Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize, schemars::JsonSchema,
455)]
456pub struct ProviderDeclaration {
457 /// The id: the template's file stem, and the key in the declarations.
458 pub id: String,
459 /// The name a person knows it by; the id when nothing is declared.
460 pub title: String,
461 /// The file it reads a project-scoped MCP client configuration from, when it has one.
462 #[serde(default, skip_serializing_if = "Option::is_none")]
463 pub client_config: Option<String>,
464 /// The scratch roots it creates checkouts under, unexpanded.
465 #[serde(default)]
466 pub scratch_roots: Vec<String>,
467 /// The lifecycle events the tool's adapter for it declares, in the provider's own
468 /// vocabulary (`SessionStart`, `SessionEnd`, `PreCompact`). Empty means the tool ships
469 /// no lifecycle adapter for it, which costs a worker the automation and none of the
470 /// model. Declared, because the two other ways to answer this question are both wrong:
471 /// reading `.claude/hooks/` reports an installation as a capability, and a list written
472 /// into a page is a second source of truth that goes stale the day an adapter changes.
473 #[serde(default)]
474 pub lifecycle: Vec<String>,
475 /// Whether the tool's adapter for it can archive the worker's prompts.
476 #[serde(default)]
477 pub prompt_capture: bool,
478 /// What the provider itself can do about the session lifecycle, and where each answer
479 /// was verified: the vendor's capability, where `lifecycle` is this tool's adapter. A
480 /// provider with no `offers:` block declares nothing, which reads as `none` with no
481 /// evidence — the state the capability rule refuses, so that the gap is a finding
482 /// rather than a silent "unsupported".
483 #[serde(default)]
484 pub offers: ProviderOffers,
485 /// The distribution ships a template for it.
486 pub template: bool,
487 /// The distribution declares it.
488 pub declared: bool,
489}
490
491impl ProviderDeclarations {
492 /// Every scratch root — the tool's own and every provider's — expanded against
493 /// `primary` (the repository's primary checkout) and the environment, in declaration
494 /// order, with the provider that declared each. A root whose expansion needs a variable
495 /// the environment does not hold is skipped, not an error.
496 pub fn scratch_roots(&self, primary: &Path) -> Vec<(Option<String>, PathBuf)> {
497 let mut out = Vec::new();
498 for r in &self.scratch_roots {
499 if let Some(p) = expand_root(r, primary) {
500 out.push((None, p));
501 }
502 }
503 for p in &self.providers {
504 for r in &p.scratch_roots {
505 if let Some(path) = expand_root(r, primary) {
506 out.push((Some(p.id.clone()), path));
507 }
508 }
509 }
510 out
511 }
512}
513
514/// Expand one declared root: `<primary>/`, `~/`, `${NAME:-default}` and `$NAME`, nothing
515/// else. `None` when a variable without a default is unset.
516///
517/// ```
518/// # use std::path::Path;
519/// use majordomus_cli::share::expand_root;
520/// std::env::set_var("MJ_DOCTEST_ROOT", "/srv/x");
521/// std::env::remove_var("MJ_DOCTEST_UNSET");
522/// let primary = Path::new("/repo");
523/// assert_eq!(expand_root("<primary>/.claude/worktrees", primary).unwrap(), Path::new("/repo/.claude/worktrees"));
524/// assert_eq!(expand_root("$MJ_DOCTEST_ROOT/wt", primary).unwrap(), Path::new("/srv/x/wt"));
525/// assert_eq!(expand_root("${MJ_DOCTEST_UNSET:-/d}/wt", primary).unwrap(), Path::new("/d/wt"));
526/// assert!(expand_root("$MJ_DOCTEST_UNSET/wt", primary).is_none());
527/// assert_eq!(expand_root("/tmp", primary).unwrap(), Path::new("/tmp"));
528/// ```
529pub fn expand_root(root: &str, primary: &Path) -> Option<PathBuf> {
530 let mut out = String::new();
531 let mut rest = root;
532 while !rest.is_empty() {
533 if let Some(r) = rest.strip_prefix("<primary>") {
534 out.push_str(&primary.to_string_lossy());
535 rest = r;
536 } else if let Some(r) = rest.strip_prefix('~') {
537 let home = std::env::var_os("HOME")?;
538 out.push_str(&home.to_string_lossy());
539 rest = r;
540 } else if let Some(r) = rest.strip_prefix("${") {
541 let end = r.find('}')?;
542 let (name, default) = match r[..end].split_once(":-") {
543 Some((n, d)) => (n, Some(d)),
544 None => (&r[..end], None),
545 };
546 match std::env::var(name) {
547 Ok(v) if !v.is_empty() => {
548 out.push_str(&expand_root(&v, primary)?.to_string_lossy())
549 }
550 _ => out.push_str(&expand_root(default?, primary)?.to_string_lossy()),
551 }
552 rest = &r[end + 1..];
553 } else if let Some(r) = rest.strip_prefix('$') {
554 let end = r
555 .find(|c: char| !(c.is_ascii_alphanumeric() || c == '_'))
556 .unwrap_or(r.len());
557 let v = std::env::var(&r[..end]).ok().filter(|v| !v.is_empty())?;
558 out.push_str(&v);
559 rest = &r[end..];
560 } else {
561 let end = rest.find(['<', '~', '$']).unwrap_or(rest.len());
562 let end = if end == 0 { 1 } else { end };
563 out.push_str(&rest[..end]);
564 rest = &rest[end..];
565 }
566 }
567 Some(PathBuf::from(out))
568}
569
570/// Every `<vendor>/<name>/<name>.v<n>.schema.json` under a schema root, parsed, keyed by
571/// the schema identity its path derives (`majordomus.adr/v1`) and sorted by it. A root that
572/// does not exist yields nothing; that is the repository's case when it adds no schema of
573/// its own.
574///
575/// The identity and the path fix each other, so a schema can be found from a `kinds.yaml`
576/// entry without a directory scan and a file cannot quietly answer to a name it does not
577/// declare. A file whose path does not have the shape is refused by name rather than
578/// skipped, because a schema nobody loads validates nothing and says so nowhere.
579pub fn read_schema_dir(dir: &Path) -> Result<Vec<(String, serde_json::Value)>> {
580 let mut schemas = Vec::new();
581 read_schema_tree(dir, dir, &mut schemas)?;
582 schemas.sort_by(|a, b| a.0.cmp(&b.0));
583 Ok(schemas)
584}
585
586fn read_schema_tree(
587 root: &Path,
588 dir: &Path,
589 out: &mut Vec<(String, serde_json::Value)>,
590) -> Result<()> {
591 if !dir.is_dir() {
592 return Ok(());
593 }
594 for entry in std::fs::read_dir(dir).map_err(|e| Error::io(dir, e))? {
595 let path = entry.map_err(|e| Error::io(dir, e))?.path();
596 if path.is_dir() {
597 // `<schemas>/generated/` holds the contracts of the *generated documents* —
598 // the manifest, the registry, the benchmark matrix. Those are not kinds of the
599 // layer, carry no `<vendor>/<name>/` identity, and are read by
600 // [`read_generated_schema_dir`] instead.
601 if dir == root
602 && path.file_name().and_then(|f| f.to_str()) == Some(GENERATED_SCHEMAS_DIR)
603 {
604 continue;
605 }
606 read_schema_tree(root, &path, out)?;
607 continue;
608 }
609 let Some(file) = path.file_name().and_then(|f| f.to_str()) else {
610 continue;
611 };
612 if !file.ends_with(SCHEMA_SUFFIX) {
613 continue;
614 }
615 let relative = path
616 .strip_prefix(root)
617 .map(|p| p.to_string_lossy().replace('\\', "/"))
618 .unwrap_or_else(|_| file.to_string());
619 let identity = schema_identity(&relative).ok_or_else(|| Error::KindSchema {
620 reason: format!(
621 "{}: a schema's path is <vendor>/<name>/<name>.v<n>{SCHEMA_SUFFIX}, which this is not",
622 path.display()
623 ),
624 })?;
625 let text = std::fs::read_to_string(&path).map_err(|e| Error::io(&path, e))?;
626 let json: serde_json::Value =
627 serde_json::from_str(&text).map_err(|e| Error::KindSchema {
628 reason: format!("{}: not JSON: {e}", path.display()),
629 })?;
630 out.push((identity, json));
631 }
632 Ok(())
633}
634
635/// Every `<name>.schema.json` directly under a directory, parsed, keyed by that `<name>`
636/// and sorted by it. A directory that does not exist yields nothing.
637///
638/// This is the reader for `<share>/schemas/generated/`, whose files are the contracts of
639/// the *generated documents* rather than of the layer's kinds: they answer to the document
640/// id they pin in `properties.schema.const`, not to a `<vendor>/<name>` path, so the shape
641/// [`read_schema_dir`] insists on does not apply to them.
642pub fn read_generated_schema_dir(dir: &Path) -> Result<Vec<(String, serde_json::Value)>> {
643 let mut out = Vec::new();
644 if !dir.is_dir() {
645 return Ok(out);
646 }
647 for entry in std::fs::read_dir(dir).map_err(|e| Error::io(dir, e))? {
648 let path = entry.map_err(|e| Error::io(dir, e))?.path();
649 let Some(file) = path.file_name().and_then(|f| f.to_str()) else {
650 continue;
651 };
652 if path.is_dir() || !file.ends_with(SCHEMA_SUFFIX) {
653 continue;
654 }
655 let text = std::fs::read_to_string(&path).map_err(|e| Error::io(&path, e))?;
656 let json: serde_json::Value =
657 serde_json::from_str(&text).map_err(|e| Error::KindSchema {
658 reason: format!("{}: not JSON: {e}", path.display()),
659 })?;
660 out.push((file[..file.len() - SCHEMA_SUFFIX.len()].to_string(), json));
661 }
662 out.sort_by(|a, b| a.0.cmp(&b.0));
663 Ok(out)
664}
665
666/// The identity a schema path carries: `majordomus/adr/adr.v1.schema.json` is
667/// `majordomus.adr/v1`. A path of another shape yields nothing.
668///
669/// ```
670/// use majordomus_cli::share::schema_identity;
671/// assert_eq!(
672/// schema_identity("majordomus/adr/adr.v1.schema.json").as_deref(),
673/// Some("majordomus.adr/v1")
674/// );
675/// assert_eq!(schema_identity("adr.schema.json"), None);
676/// assert_eq!(schema_identity("majordomus/adr/other.v1.schema.json"), None);
677/// ```
678pub fn schema_identity(relative: &str) -> Option<String> {
679 let stem = relative.strip_suffix(SCHEMA_SUFFIX)?;
680 let mut parts = stem.split('/');
681 let vendor = parts.next()?;
682 let name = parts.next()?;
683 let file = parts.next()?;
684 if parts.next().is_some() || vendor.is_empty() || name.is_empty() {
685 return None;
686 }
687 let version = file.strip_prefix(&format!("{name}."))?;
688 if !version.starts_with('v') || version.len() < 2 {
689 return None;
690 }
691 Some(format!("{vendor}.{name}/{version}"))
692}
693
694#[cfg(test)]
695mod tests {
696 use super::*;
697
698 /// The word each offer is printed as is the word it is declared and serialised as, so a
699 /// reader of the table and a reader of the JSON read one vocabulary.
700 #[test]
701 fn every_offer_prints_as_the_word_it_is_declared_as() {
702 for (source, word) in [
703 (EpisodeSource::Hooks, "hooks"),
704 (EpisodeSource::Connection, "connection"),
705 (EpisodeSource::None, "none"),
706 ] {
707 assert_eq!(source.as_str(), word);
708 assert_eq!(
709 serde_json::to_string(&source).unwrap(),
710 format!("\"{word}\"")
711 );
712 }
713 for (source, word) in [(PromptSource::Hook, "hook"), (PromptSource::None, "none")] {
714 assert_eq!(source.as_str(), word);
715 assert_eq!(
716 serde_json::to_string(&source).unwrap(),
717 format!("\"{word}\"")
718 );
719 }
720 }
721}