spec_driven_docs/commands/
docs.rs1use 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#[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 bytes: Option<usize>,
31 #[serde(skip_serializing_if = "Option::is_none")]
33 token_estimate: Option<u32>,
34}
35
36#[derive(Debug, Serialize)]
38struct Report<'a> {
39 schema: &'static str,
40 topics: Vec<Entry<'a>>,
41}
42
43pub 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
66const 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
75fn 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
83fn 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}