Skip to main content

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}