1use std::io::{self, Write};
14use std::process::{Command, Stdio};
15
16use serde::Serialize;
17
18use crate::cli::DocsArgs;
19use crate::diagnostic::Diagnostic;
20
21pub struct Topic {
23 pub name: &'static str,
25 pub summary: &'static str,
27 pub body: &'static str,
29}
30
31const TOPICS: &[Topic] = &[
34 Topic {
35 name: "dsp-cli",
36 summary: "What this tool is, who it's for, and its design principles.",
37 body: include_str!("../../docs/topics/dsp-cli.md"),
38 },
39 Topic {
40 name: "dsp",
41 summary: "The DaSCH Service Platform in brief: VRE vs Repository.",
42 body: include_str!("../../docs/topics/dsp.md"),
43 },
44 Topic {
45 name: "concepts",
46 summary: "The vocabulary dsp-cli speaks (project, data-model, resource-type, …).",
47 body: include_str!("../../docs/topics/concepts.md"),
48 },
49 Topic {
50 name: "identifiers",
51 summary: "How to name projects, data-models, and resource-types.",
52 body: include_str!("../../docs/topics/identifiers.md"),
53 },
54 Topic {
55 name: "connecting",
56 summary: "Servers, environments, and authentication.",
57 body: include_str!("../../docs/topics/connecting.md"),
58 },
59 Topic {
60 name: "output",
61 summary: "Output formats, channels, and the JSON envelope.",
62 body: include_str!("../../docs/topics/output.md"),
63 },
64 Topic {
65 name: "workflows",
66 summary: "Chaining commands into real tasks.",
67 body: include_str!("../../docs/topics/workflows.md"),
68 },
69 Topic {
70 name: "errors",
71 summary: "Exit codes and how to recover from failures.",
72 body: include_str!("../../docs/topics/errors.md"),
73 },
74 Topic {
75 name: "dsp-tools",
76 summary: "When to use dsp-cli versus dsp-tools.",
77 body: include_str!("../../docs/topics/dsp-tools.md"),
78 },
79];
80
81pub fn run(args: &DocsArgs) -> Result<(), Diagnostic> {
83 let mut out = io::stdout().lock();
84 run_impl(args, &mut out)
85}
86
87#[derive(Serialize)]
94struct DocsJsonEnvelope<'a> {
95 _meta: EmptyMeta,
96 data: Vec<TopicIndexEntry<'a>>,
97}
98
99#[derive(Serialize)]
103struct EmptyMeta {}
104
105#[derive(Serialize)]
108struct TopicIndexEntry<'a> {
109 name: &'a str,
110 summary: &'a str,
111}
112
113fn run_impl(args: &DocsArgs, out: &mut dyn Write) -> Result<(), Diagnostic> {
117 if args.json {
118 return write_topic_index_json(out);
119 }
120 match args.topic.as_deref() {
121 None => write_topic_list(out),
122 Some(name) => match find_topic(name) {
123 Some(topic) => emit(topic.body, args.pager, out),
124 None => Err(not_found(name)),
125 },
126 }
127}
128
129fn write_topic_index_json(out: &mut dyn Write) -> Result<(), Diagnostic> {
136 let data: Vec<TopicIndexEntry<'_>> = TOPICS
137 .iter()
138 .map(|t| TopicIndexEntry {
139 name: t.name,
140 summary: t.summary,
141 })
142 .collect();
143 let envelope = DocsJsonEnvelope {
144 _meta: EmptyMeta {},
145 data,
146 };
147 let json = serde_json::to_string(&envelope)
148 .map_err(|e| Diagnostic::Internal(format!("json serialisation error: {e}")))?;
149 writeln!(out, "{json}")?;
150 Ok(())
151}
152
153fn find_topic(name: &str) -> Option<&'static Topic> {
156 TOPICS.iter().find(|t| t.name == name)
157}
158
159fn not_found(name: &str) -> Diagnostic {
162 let suggestion = match suggest(name) {
163 Some(s) => format!(" Did you mean '{s}'?"),
164 None => String::new(),
165 };
166 Diagnostic::NotFound(format!(
167 "no documentation topic named '{name}'.{suggestion} Run `dsp docs` to see all topics."
168 ))
169}
170
171fn suggest(name: &str) -> Option<&'static str> {
173 if name.is_empty() {
174 return None;
175 }
176 let threshold = 3.min(name.len());
179 TOPICS
180 .iter()
181 .map(|t| (levenshtein(name, t.name), t.name))
182 .filter(|(dist, _)| *dist <= threshold)
183 .min_by_key(|(dist, _)| *dist)
184 .map(|(_, n)| n)
185}
186
187fn levenshtein(a: &str, b: &str) -> usize {
190 let a: Vec<char> = a.chars().collect();
191 let b: Vec<char> = b.chars().collect();
192 let mut prev: Vec<usize> = (0..=b.len()).collect();
193 let mut curr: Vec<usize> = vec![0; b.len() + 1];
194 for (i, ca) in a.iter().enumerate() {
195 curr[0] = i + 1;
196 for (j, cb) in b.iter().enumerate() {
197 let cost = if ca == cb { 0 } else { 1 };
198 curr[j + 1] = (prev[j + 1] + 1).min(curr[j] + 1).min(prev[j] + cost);
199 }
200 std::mem::swap(&mut prev, &mut curr);
201 }
202 prev[b.len()]
203}
204
205fn write_topic_list(out: &mut dyn Write) -> Result<(), Diagnostic> {
207 let width = TOPICS.iter().map(|t| t.name.len()).max().unwrap_or(0);
208 writeln!(out, "Available documentation topics:")?;
209 writeln!(out)?;
210 for t in TOPICS {
211 writeln!(out, " {:<width$} {}", t.name, t.summary, width = width)?;
212 }
213 writeln!(out)?;
214 writeln!(out, "Run `dsp docs <topic>` to read one.")?;
215 Ok(())
216}
217
218fn emit(content: &str, use_pager: bool, out: &mut dyn Write) -> Result<(), Diagnostic> {
221 if use_pager && try_pager(content).is_ok() {
222 return Ok(());
223 }
224 out.write_all(content.as_bytes())?;
225 Ok(())
226}
227
228fn try_pager(content: &str) -> io::Result<()> {
232 let pager = std::env::var("PAGER").unwrap_or_else(|_| "less".to_string());
233 let mut parts = pager.split_whitespace();
234 let program = parts.next().unwrap_or("less");
235 let mut child = Command::new(program)
236 .args(parts)
237 .stdin(Stdio::piped())
238 .spawn()?;
239 if let Some(mut stdin) = child.stdin.take() {
240 stdin.write_all(content.as_bytes())?;
241 }
242 child.wait()?;
243 Ok(())
244}
245
246#[cfg(test)]
247mod tests {
248 use super::*;
249
250 fn render_list() -> String {
251 let mut buf: Vec<u8> = Vec::new();
252 write_topic_list(&mut buf).unwrap();
253 String::from_utf8(buf).unwrap()
254 }
255
256 #[test]
257 fn find_topic_hits_known_name() {
258 assert!(find_topic("concepts").is_some());
259 assert_eq!(find_topic("concepts").unwrap().name, "concepts");
260 }
261
262 #[test]
263 fn find_topic_misses_unknown_name() {
264 assert!(find_topic("nope").is_none());
265 }
266
267 #[test]
268 fn find_topic_is_exact_not_prefix() {
269 assert!(find_topic("concept").is_none());
271 assert!(find_topic("dsp-").is_none());
272 }
273
274 #[test]
275 fn suggest_finds_near_neighbour() {
276 assert_eq!(suggest("concept"), Some("concepts")); assert_eq!(suggest("conecting"), Some("connecting")); assert_eq!(suggest("error"), Some("errors")); }
280
281 #[test]
282 fn suggest_returns_none_for_far_input() {
283 assert_eq!(suggest("xyzzy"), None);
284 assert_eq!(suggest(""), None);
285 }
286
287 #[test]
288 fn levenshtein_basics() {
289 assert_eq!(levenshtein("kitten", "sitting"), 3);
290 assert_eq!(levenshtein("same", "same"), 0);
291 assert_eq!(levenshtein("", "abc"), 3);
292 assert_eq!(levenshtein("abc", ""), 3);
293 }
294
295 #[test]
296 fn not_found_includes_suggestion_when_close() {
297 let msg = not_found("concept").to_string();
298 assert!(msg.contains("no documentation topic named 'concept'"));
299 assert!(msg.contains("Did you mean 'concepts'?"));
300 assert!(msg.contains("Run `dsp docs`"));
301 }
302
303 #[test]
304 fn not_found_omits_suggestion_when_far() {
305 let msg = not_found("xyzzy").to_string();
306 assert!(msg.contains("no documentation topic named 'xyzzy'"));
307 assert!(!msg.contains("Did you mean"));
308 }
309
310 #[test]
311 fn topic_list_includes_every_topic() {
312 let list = render_list();
313 for t in TOPICS {
314 assert!(list.contains(t.name), "list missing topic {}", t.name);
315 assert!(
316 list.contains(t.summary),
317 "list missing summary for {}",
318 t.name
319 );
320 }
321 }
322
323 #[test]
324 fn all_topic_bodies_are_present_and_well_formed() {
325 for t in TOPICS {
328 assert!(!t.body.trim().is_empty(), "empty body for topic {}", t.name);
329 assert!(
330 t.body.starts_with("# "),
331 "topic {} body must start with an h1 heading",
332 t.name
333 );
334 }
335 }
336
337 #[test]
338 fn catalog_has_nine_topics_with_unique_names() {
339 assert_eq!(TOPICS.len(), 9);
340 for (i, t) in TOPICS.iter().enumerate() {
341 for other in &TOPICS[i + 1..] {
342 assert_ne!(t.name, other.name, "duplicate topic name {}", t.name);
343 }
344 }
345 }
346
347 #[test]
348 fn run_impl_no_topic_writes_list() {
349 let args = DocsArgs {
350 topic: None,
351 pager: false,
352 json: false,
353 };
354 let mut buf: Vec<u8> = Vec::new();
355 run_impl(&args, &mut buf).unwrap();
356 let out = String::from_utf8(buf).unwrap();
357 assert!(out.contains("Available documentation topics:"));
358 assert!(out.contains("workflows"));
359 }
360
361 #[test]
362 fn run_impl_known_topic_writes_body() {
363 let args = DocsArgs {
364 topic: Some("concepts".to_string()),
365 pager: false,
366 json: false,
367 };
368 let mut buf: Vec<u8> = Vec::new();
369 run_impl(&args, &mut buf).unwrap();
370 let out = String::from_utf8(buf).unwrap();
371 assert!(out.starts_with("# "));
372 }
373
374 #[test]
375 fn run_impl_unknown_topic_errors() {
376 let args = DocsArgs {
377 topic: Some("nope".to_string()),
378 pager: false,
379 json: false,
380 };
381 let mut buf: Vec<u8> = Vec::new();
382 let err = run_impl(&args, &mut buf).unwrap_err();
383 assert!(matches!(err, Diagnostic::NotFound(_)));
384 assert!(buf.is_empty(), "nothing should be written on error");
385 }
386}