1use std::sync::LazyLock;
13
14use serde::{Deserialize, Serialize};
15use thiserror::Error;
16
17pub const CATALOG_PATH: &str = "instance/docs-catalog.toml";
19
20pub const SCHEMA: u32 = 1;
22
23pub const JSON_SCHEMA: &str = "sdd.docs/1";
25
26#[derive(Debug, Error, PartialEq, Eq)]
28pub enum CatalogError {
29 #[error("{CATALOG_PATH} does not parse: {0}")]
31 Malformed(String),
32
33 #[error("{CATALOG_PATH} declares schema {0}, and this engine reads {SCHEMA}")]
35 UnknownSchema(u32),
36}
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
40#[serde(rename_all = "kebab-case")]
41pub enum ShelfId {
42 Method,
44 Spec,
46 Template,
48}
49
50impl ShelfId {
51 #[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
69#[serde(rename_all = "kebab-case", deny_unknown_fields)]
70pub enum Target {
71 Document {
73 shelf: ShelfId,
75 name: String,
77 },
78 Command(Vec<String>),
80}
81
82#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
84#[serde(rename_all = "kebab-case")]
85pub enum Kind {
86 MethodChapter,
88 SpecSeed,
90 Template,
92 OperatorTask,
94}
95
96impl Kind {
97 #[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 #[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 #[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
133#[serde(deny_unknown_fields)]
134pub struct Topic {
135 pub id: String,
137 pub title: String,
139 pub summary: String,
141 pub kind: Kind,
143 #[serde(default)]
145 pub aliases: Vec<String>,
146 pub target: Target,
148}
149
150#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
152#[serde(deny_unknown_fields)]
153pub struct Catalog {
154 pub catalog_schema: u32,
156 #[serde(default, rename = "topic")]
158 pub topics: Vec<Topic>,
159}
160
161#[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#[derive(Debug, Clone, PartialEq, Eq, Error)]
178pub enum Unresolved {
179 #[error("'{query}' matches {}; name one", .candidates.join(", "))]
181 Ambiguous {
182 query: String,
184 candidates: Vec<String>,
186 },
187
188 #[error("no topic matches '{query}'; nearest: {}", .nearest.join(", "))]
190 Missing {
191 query: String,
193 nearest: Vec<String>,
195 },
196}
197
198fn fold(value: &str) -> String {
200 value
201 .split_whitespace()
202 .collect::<Vec<_>>()
203 .join(" ")
204 .to_lowercase()
205}
206
207impl Catalog {
208 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 #[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 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 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}