Skip to main content

spec_driven_docs/commands/
docs.rs

1//! `docs` subcommand: runtime-shape.
2//!
3//! The bare verb is the index. One argument, or several, is a topic. The
4//! index is what an always-loaded routing line can name in one clause, so
5//! an agent in a repository that adopted this tool reaches any chapter in
6//! two commands.
7//!
8//! A command target is printed rather than run. A verb that dispatched to
9//! another verb would hide which one answered.
10
11use serde::Serialize;
12
13use crate::cli::docs::DocsArgs;
14use crate::context::AppContext;
15use crate::domain::docs_catalog::{CATALOG, JSON_SCHEMA, Kind, ShelfId, Target, Topic};
16use crate::error::AppError;
17use crate::output;
18use crate::services::reader;
19
20/// One topic, as the machine reads it.
21#[derive(Debug, Serialize)]
22struct Entry<'a> {
23    id: &'a str,
24    title: &'a str,
25    summary: &'a str,
26    kind: &'static str,
27    aliases: &'a [String],
28    target: &'a Target,
29    /// The embedded document's size, or null for a command target.
30    bytes: Option<usize>,
31    /// Passed through from the document's own frontmatter, never invented.
32    #[serde(skip_serializing_if = "Option::is_none")]
33    token_estimate: Option<u32>,
34}
35
36/// The whole index, as the machine reads it.
37#[derive(Debug, Serialize)]
38struct Report<'a> {
39    schema: &'static str,
40    topics: Vec<Entry<'a>>,
41}
42
43/// Read the corpus this binary carries.
44///
45/// # Errors
46///
47/// [`AppError::Usage`] when a query matches no topic or more than one.
48pub fn run(_ctx: &AppContext, args: &DocsArgs) -> Result<(), AppError> {
49    if args.topic.is_empty() {
50        if args.json {
51            return index_json();
52        }
53        index_text();
54        return Ok(());
55    }
56    let query = args.topic.join(" ");
57    let topic = CATALOG
58        .resolve(&query)
59        .map_err(|refusal| AppError::Usage(refusal.to_string()))?;
60    if args.json {
61        return output::json(&entry(topic));
62    }
63    render(topic)
64}
65
66/// The shelf a document target names.
67const fn shelf_of(shelf: ShelfId) -> &'static reader::Shelf {
68    match shelf {
69        ShelfId::Method => &reader::METHOD,
70        ShelfId::Spec => &reader::SPECS,
71        ShelfId::Template => &reader::TEMPLATES,
72    }
73}
74
75/// The embedded text one topic points at, where it points at one.
76fn document(topic: &Topic) -> Option<&'static str> {
77    match &topic.target {
78        Target::Document { shelf, name } => reader::get(shelf_of(*shelf), name),
79        Target::Command(_) => None,
80    }
81}
82
83/// The token estimate a document's own frontmatter declares.
84///
85/// Read, never computed. This repository runs no tokenizer, and a number
86/// it did not measure is a number it cannot stand behind.
87fn declared_token_estimate(text: &str) -> Option<u32> {
88    let body = text.strip_prefix("---\n")?;
89    let front = body.split("\n---").next()?;
90    front
91        .lines()
92        .find_map(|line| line.strip_prefix("token-estimate:"))
93        .and_then(|value| value.trim().parse().ok())
94}
95
96fn entry(topic: &Topic) -> Entry<'_> {
97    let text = document(topic);
98    Entry {
99        id: &topic.id,
100        title: &topic.title,
101        summary: &topic.summary,
102        kind: topic.kind.as_str(),
103        aliases: &topic.aliases,
104        target: &topic.target,
105        bytes: text.map(str::len),
106        token_estimate: text.and_then(declared_token_estimate),
107    }
108}
109
110fn index_json() -> Result<(), AppError> {
111    output::json(&Report {
112        schema: JSON_SCHEMA,
113        topics: CATALOG.topics.iter().map(entry).collect(),
114    })
115}
116
117fn index_text() {
118    output::line("Read one topic with `sdd docs <topic>`. A topic takes several words.");
119    let width = CATALOG
120        .topics
121        .iter()
122        .map(|topic| topic.id.len())
123        .max()
124        .unwrap_or(0);
125    for kind in Kind::every() {
126        let held: Vec<&Topic> = CATALOG
127            .topics
128            .iter()
129            .filter(|topic| topic.kind == kind)
130            .collect();
131        if held.is_empty() {
132            continue;
133        }
134        output::line("");
135        output::line(kind.heading());
136        for topic in held {
137            output::line(format!(
138                "  {:width$}  {}",
139                topic.id,
140                topic.summary,
141                width = width
142            ));
143        }
144    }
145}
146
147fn render(topic: &Topic) -> Result<(), AppError> {
148    match &topic.target {
149        Target::Document { shelf, name } => {
150            let Some(text) = reader::get(shelf_of(*shelf), name) else {
151                return Err(AppError::Usage(format!(
152                    "{} names {}/{name}, which no shelf serves",
153                    topic.id,
154                    shelf.as_str()
155                )));
156            };
157            output::line(text.trim_end_matches('\n'));
158            Ok(())
159        }
160        Target::Command(argv) => {
161            output::line(format!("{}: {}", topic.title, topic.summary));
162            output::line(format!("Run: sdd {}", argv.join(" ")));
163            Ok(())
164        }
165    }
166}
167
168#[cfg(test)]
169mod tests {
170    #![allow(
171        clippy::unwrap_used,
172        reason = "a test panics as its failure signal, not as control flow"
173    )]
174
175    use super::*;
176
177    #[test]
178    fn a_declared_token_estimate_is_passed_through_and_never_invented() {
179        let declared = "---\ndigest-of: method/\ntoken-estimate: 580\n---\n\n# AGENTS\n";
180        assert_eq!(declared_token_estimate(declared), Some(580));
181        assert_eq!(declared_token_estimate("# Chapter\n\nProse.\n"), None);
182    }
183
184    #[test]
185    fn every_catalog_target_resolves() {
186        let names = crate::cli::subcommand_names();
187        for topic in &CATALOG.topics {
188            match &topic.target {
189                Target::Document { shelf, name } => assert!(
190                    reader::get(shelf_of(*shelf), name).is_some(),
191                    "{} names {}/{name}, which no shelf serves",
192                    topic.id,
193                    shelf.as_str()
194                ),
195                Target::Command(argv) => {
196                    let verb = argv.first().unwrap();
197                    assert!(
198                        names.contains(verb),
199                        "{} names the verb '{verb}', which the parser does not offer",
200                        topic.id
201                    );
202                }
203            }
204        }
205    }
206
207    #[test]
208    fn every_catalog_id_and_alias_is_unique_case_folded() {
209        let mut seen = std::collections::BTreeSet::new();
210        for topic in &CATALOG.topics {
211            for key in std::iter::once(&topic.id).chain(topic.aliases.iter()) {
212                assert!(
213                    seen.insert(key.to_lowercase()),
214                    "'{key}' is declared twice in the catalog"
215                );
216            }
217        }
218    }
219
220    #[test]
221    fn the_catalog_summaries_are_one_line_and_within_their_cap() {
222        for topic in &CATALOG.topics {
223            assert!(
224                !topic.summary.contains('\n'),
225                "{} carries a multi-line summary",
226                topic.id
227            );
228            assert!(
229                topic.summary.len() <= 100,
230                "{} carries a {}-character summary, over the 100 cap",
231                topic.id,
232                topic.summary.len()
233            );
234        }
235    }
236
237    #[test]
238    fn size_is_computed_and_never_authored() {
239        let authored = crate::embedded::asset(crate::domain::docs_catalog::CATALOG_PATH).unwrap();
240        let text = std::str::from_utf8(authored).unwrap();
241        for forbidden in ["bytes =", "token_estimate =", "token-estimate ="] {
242            assert!(
243                !text.contains(forbidden),
244                "the catalog authors '{forbidden}', which it reads from the payload instead"
245            );
246        }
247    }
248}