Skip to main content

spec_driven_docs/domain/
docs_catalog.rs

1//! The one description of what this binary's corpus holds.
2//!
3//! Every document the binary carries is already one command away. What is
4//! missing is a route: a list of bare names tells a reader nothing about
5//! which name answers their question. This catalog carries the summary and
6//! the aliases an author owes that reader, and one resolver turns a
7//! question into exactly one topic.
8//!
9//! Size is never authored. It is read from the embedded bytes when asked,
10//! so a number here can never disagree with the document it describes.
11
12use std::sync::LazyLock;
13
14use serde::{Deserialize, Serialize};
15use thiserror::Error;
16
17/// Where the catalog sits inside a bundle.
18pub const CATALOG_PATH: &str = "instance/docs-catalog.toml";
19
20/// The schema this engine reads.
21pub const SCHEMA: u32 = 1;
22
23/// The machine schema `sdd docs --json` declares.
24pub const JSON_SCHEMA: &str = "sdd.docs/1";
25
26/// A catalog this engine cannot read.
27#[derive(Debug, Error, PartialEq, Eq)]
28pub enum CatalogError {
29    /// The bytes are not the catalog's shape.
30    #[error("{CATALOG_PATH} does not parse: {0}")]
31    Malformed(String),
32
33    /// The catalog is written in a schema this engine does not read.
34    #[error("{CATALOG_PATH} declares schema {0}, and this engine reads {SCHEMA}")]
35    UnknownSchema(u32),
36}
37
38/// Which shelf serves a document topic.
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
40#[serde(rename_all = "kebab-case")]
41pub enum ShelfId {
42    /// The method chapters.
43    Method,
44    /// The specification seeds and the canon-only specs.
45    Spec,
46    /// The document templates.
47    Template,
48}
49
50impl ShelfId {
51    /// The word the catalog spells.
52    #[must_use]
53    pub const fn as_str(self) -> &'static str {
54        match self {
55            Self::Method => "method",
56            Self::Spec => "spec",
57            Self::Template => "template",
58        }
59    }
60}
61
62/// What reading a topic gets the reader.
63///
64/// Two kinds and no third. A document target names something a shelf
65/// already serves. A command target names an argv of this binary, so a
66/// topic whose best answer is a help page routes there rather than
67/// acquiring a document nobody needs to write.
68#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
69#[serde(rename_all = "kebab-case", deny_unknown_fields)]
70pub enum Target {
71    /// One document on one shelf.
72    Document {
73        /// The shelf that serves it.
74        shelf: ShelfId,
75        /// The short name the shelf addresses it by.
76        name: String,
77    },
78    /// One argv of this binary, printed rather than run.
79    Command(Vec<String>),
80}
81
82/// What a topic is, for grouping the index.
83#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
84#[serde(rename_all = "kebab-case")]
85pub enum Kind {
86    /// A chapter of the method.
87    MethodChapter,
88    /// A specification, adopted or canon-only.
89    SpecSeed,
90    /// A stable authoring template.
91    Template,
92    /// Something an operator does, answered by a help page.
93    OperatorTask,
94}
95
96impl Kind {
97    /// The word the catalog spells.
98    #[must_use]
99    pub const fn as_str(self) -> &'static str {
100        match self {
101            Self::MethodChapter => "method-chapter",
102            Self::SpecSeed => "spec-seed",
103            Self::Template => "template",
104            Self::OperatorTask => "operator-task",
105        }
106    }
107
108    /// The index heading this kind sits under.
109    #[must_use]
110    pub const fn heading(self) -> &'static str {
111        match self {
112            Self::MethodChapter => "Method chapters",
113            Self::SpecSeed => "Specifications",
114            Self::Template => "Templates",
115            Self::OperatorTask => "Operator tasks",
116        }
117    }
118
119    /// Every kind, in the order the index prints them.
120    #[must_use]
121    pub const fn every() -> [Self; 4] {
122        [
123            Self::OperatorTask,
124            Self::MethodChapter,
125            Self::SpecSeed,
126            Self::Template,
127        ]
128    }
129}
130
131/// One thing a reader can ask for.
132#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
133#[serde(deny_unknown_fields)]
134pub struct Topic {
135    /// The stable identifier, unique case-folded across the catalog.
136    pub id: String,
137    /// The title a reader sees.
138    pub title: String,
139    /// One line saying what reading this answers.
140    pub summary: String,
141    /// What this topic is.
142    pub kind: Kind,
143    /// Other phrasings that resolve here.
144    #[serde(default)]
145    pub aliases: Vec<String>,
146    /// What reading it gets.
147    pub target: Target,
148}
149
150/// The whole catalog, parsed.
151#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
152#[serde(deny_unknown_fields)]
153pub struct Catalog {
154    /// Always [`SCHEMA`] once parsed.
155    pub catalog_schema: u32,
156    /// Every topic, in authored order.
157    #[serde(default, rename = "topic")]
158    pub topics: Vec<Topic>,
159}
160
161/// What this binary's own release declares its corpus to hold.
162///
163/// The bytes are embedded, so a catalog that does not parse is a defect in
164/// the build rather than a state a command can meet. The canon suite parses
165/// the same file, so the failure lands in the test run.
166#[expect(
167    clippy::expect_used,
168    reason = "the catalog is compiled in; a parse failure is a build defect the canon suite catches first"
169)]
170pub static CATALOG: LazyLock<Catalog> = LazyLock::new(|| {
171    let bytes = crate::embedded::asset(CATALOG_PATH)
172        .expect("the payload carries instance/docs-catalog.toml");
173    Catalog::parse(bytes).expect("the embedded documentation catalog parses")
174});
175
176/// Why a query resolved to no single topic.
177#[derive(Debug, Clone, PartialEq, Eq, Error)]
178pub enum Unresolved {
179    /// More than one topic matched, and the engine guesses none.
180    #[error("'{query}' matches {}; name one", .candidates.join(", "))]
181    Ambiguous {
182        /// What was asked for.
183        query: String,
184        /// Every topic that matched.
185        candidates: Vec<String>,
186    },
187
188    /// Nothing matched, with the nearest identifiers.
189    #[error("no topic matches '{query}'; nearest: {}", .nearest.join(", "))]
190    Missing {
191        /// What was asked for.
192        query: String,
193        /// The closest identifiers, or every identifier when none is close.
194        nearest: Vec<String>,
195    },
196}
197
198/// Normalize a query or a key the same way, so both sides compare alike.
199fn fold(value: &str) -> String {
200    value
201        .split_whitespace()
202        .collect::<Vec<_>>()
203        .join(" ")
204        .to_lowercase()
205}
206
207impl Catalog {
208    /// Read one catalog.
209    ///
210    /// # Errors
211    ///
212    /// [`CatalogError`] when the bytes do not parse or the schema is one
213    /// this engine does not read.
214    pub fn parse(bytes: &[u8]) -> Result<Self, CatalogError> {
215        let text = std::str::from_utf8(bytes)
216            .map_err(|source| CatalogError::Malformed(source.to_string()))?;
217        let held: Self =
218            toml::from_str(text).map_err(|source| CatalogError::Malformed(source.to_string()))?;
219        if held.catalog_schema != SCHEMA {
220            return Err(CatalogError::UnknownSchema(held.catalog_schema));
221        }
222        Ok(held)
223    }
224
225    /// The topic serving one shelf document, where the catalog has one.
226    #[must_use]
227    pub fn for_document(&self, on: ShelfId, wanted: &str) -> Option<&Topic> {
228        self.topics.iter().find(|topic| {
229            matches!(&topic.target, Target::Document { shelf: held, name: held_name }
230                if *held == on && held_name == wanted)
231        })
232    }
233
234    /// Turn one query into exactly one topic.
235    ///
236    /// The order is exact id, then exact alias, then a case-folded prefix
237    /// over both. An ambiguous query names every candidate rather than
238    /// picking one, because a reader who asked loosely gets a shorter list
239    /// and never a document they did not mean.
240    ///
241    /// # Errors
242    ///
243    /// [`Unresolved`] naming either the candidates or the nearest ids.
244    pub fn resolve(&self, query: &str) -> Result<&Topic, Unresolved> {
245        let wanted = fold(query);
246        if let Some(topic) = self.topics.iter().find(|topic| fold(&topic.id) == wanted) {
247            return Ok(topic);
248        }
249
250        let by_alias: Vec<&Topic> = self
251            .topics
252            .iter()
253            .filter(|topic| topic.aliases.iter().any(|alias| fold(alias) == wanted))
254            .collect();
255        if let [only] = by_alias.as_slice() {
256            return Ok(only);
257        }
258        if !by_alias.is_empty() {
259            return Err(Unresolved::Ambiguous {
260                query: wanted,
261                candidates: by_alias.iter().map(|topic| topic.id.clone()).collect(),
262            });
263        }
264
265        let by_prefix: Vec<&Topic> = self
266            .topics
267            .iter()
268            .filter(|topic| {
269                fold(&topic.id).starts_with(&wanted)
270                    || topic
271                        .aliases
272                        .iter()
273                        .any(|alias| fold(alias).starts_with(&wanted))
274            })
275            .collect();
276        match by_prefix.as_slice() {
277            [only] => Ok(only),
278            [] => Err(Unresolved::Missing {
279                query: wanted.clone(),
280                nearest: self.nearest(&wanted),
281            }),
282            many => Err(Unresolved::Ambiguous {
283                query: wanted,
284                candidates: many.iter().map(|topic| topic.id.clone()).collect(),
285            }),
286        }
287    }
288
289    /// The identifiers closest to a query that matched nothing.
290    ///
291    /// Substring containment in either direction, capped, and every id
292    /// where nothing is close, because a reader who missed needs a list
293    /// rather than an apology.
294    fn nearest(&self, wanted: &str) -> Vec<String> {
295        let mut near: Vec<String> = self
296            .topics
297            .iter()
298            .filter(|topic| {
299                let id = fold(&topic.id);
300                id.contains(wanted) || wanted.contains(&id)
301            })
302            .map(|topic| topic.id.clone())
303            .collect();
304        if near.is_empty() {
305            near = self.topics.iter().map(|topic| topic.id.clone()).collect();
306        }
307        near.truncate(8);
308        near
309    }
310}
311
312#[cfg(test)]
313mod tests {
314    #![allow(
315        clippy::unwrap_used,
316        reason = "a test panics as its failure signal, not as control flow"
317    )]
318
319    use super::*;
320
321    const SAMPLE: &str = r#"
322catalog_schema = 1
323
324[[topic]]
325id = "agent-context"
326title = "Agent context"
327summary = "What an agent loads and the budget an always-loaded file pays."
328kind = "method-chapter"
329aliases = ["context budget", "always loaded"]
330target = { document = { shelf = "method", name = "agent-context" } }
331
332[[topic]]
333id = "agents-digest"
334title = "The digest template"
335summary = "A stable starting point for an author-instructions file."
336kind = "template"
337aliases = ["digest template"]
338target = { document = { shelf = "template", name = "agents-digest" } }
339
340[[topic]]
341id = "upgrade"
342title = "Upgrading an instance"
343summary = "Take a landed instance to a chosen release through one plan."
344kind = "operator-task"
345aliases = ["new version"]
346target = { command = ["reconcile", "plan", "--help"] }
347"#;
348
349    fn sample() -> Catalog {
350        Catalog::parse(SAMPLE.as_bytes()).unwrap()
351    }
352
353    #[test]
354    fn a_topic_resolves_by_id_by_alias_and_by_unique_prefix() {
355        let catalog = sample();
356        assert_eq!(
357            catalog.resolve("agent-context").unwrap().id,
358            "agent-context"
359        );
360        assert_eq!(
361            catalog.resolve("Context Budget").unwrap().id,
362            "agent-context"
363        );
364        assert_eq!(catalog.resolve("upg").unwrap().id, "upgrade");
365        assert_eq!(
366            catalog.resolve("always  loaded").unwrap().id,
367            "agent-context"
368        );
369    }
370
371    #[test]
372    fn an_ambiguous_query_names_every_candidate_and_guesses_none() {
373        let catalog = sample();
374        let refused = catalog.resolve("agent").unwrap_err();
375        let Unresolved::Ambiguous { candidates, .. } = refused else {
376            panic!("an ambiguous prefix resolved to one topic");
377        };
378        assert_eq!(candidates, vec!["agent-context", "agents-digest"]);
379    }
380
381    #[test]
382    fn a_missing_topic_names_the_nearest_ids() {
383        let catalog = sample();
384        let refused = catalog.resolve("release process").unwrap_err();
385        let Unresolved::Missing { nearest, .. } = refused else {
386            panic!("a missing topic did not report as missing");
387        };
388        assert!(!nearest.is_empty());
389    }
390
391    #[test]
392    fn an_unknown_schema_or_shape_refuses() {
393        assert!(matches!(
394            Catalog::parse(b"catalog_schema = 9\n").unwrap_err(),
395            CatalogError::UnknownSchema(9)
396        ));
397        assert!(matches!(
398            Catalog::parse(b"catalog_schema = 1\nextra = 1\n").unwrap_err(),
399            CatalogError::Malformed(_)
400        ));
401    }
402
403    #[test]
404    fn a_document_topic_is_found_by_its_shelf_and_name() {
405        let catalog = sample();
406        assert_eq!(
407            catalog
408                .for_document(ShelfId::Method, "agent-context")
409                .unwrap()
410                .id,
411            "agent-context"
412        );
413        assert!(
414            catalog
415                .for_document(ShelfId::Spec, "agent-context")
416                .is_none()
417        );
418    }
419}