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 Topic {
80 name: "sparql",
81 summary: "Raw SPARQL passthrough: `dsp vre sparql query`.",
82 body: include_str!("../../docs/topics/sparql.md"),
83 },
84];
85
86pub fn run(args: &DocsArgs) -> Result<(), Diagnostic> {
91 let mut out = crate::util::BrokenPipeWriter::new(io::stdout().lock());
92 run_impl(args, &mut out)
93}
94
95#[derive(Serialize)]
102struct DocsJsonEnvelope<'a> {
103 _meta: EmptyMeta,
104 data: Vec<TopicIndexEntry<'a>>,
105}
106
107#[derive(Serialize)]
111struct EmptyMeta {}
112
113#[derive(Serialize)]
116struct TopicIndexEntry<'a> {
117 name: &'a str,
118 summary: &'a str,
119}
120
121fn run_impl(args: &DocsArgs, out: &mut dyn Write) -> Result<(), Diagnostic> {
125 if args.json {
126 return write_topic_index_json(out);
127 }
128 match args.topic.as_deref() {
129 None => write_topic_list(out),
130 Some(name) => match find_topic(name) {
131 Some(topic) => emit(topic.body, args.pager, out),
132 None => Err(not_found(name)),
133 },
134 }
135}
136
137fn write_topic_index_json(out: &mut dyn Write) -> Result<(), Diagnostic> {
144 let data: Vec<TopicIndexEntry<'_>> = TOPICS
145 .iter()
146 .map(|t| TopicIndexEntry { name: t.name, summary: t.summary })
147 .collect();
148 let envelope = DocsJsonEnvelope { _meta: EmptyMeta {}, data };
149 let json =
150 serde_json::to_string(&envelope).map_err(|e| Diagnostic::Internal(format!("json serialisation error: {e}")))?;
151 writeln!(out, "{json}")?;
152 Ok(())
153}
154
155fn find_topic(name: &str) -> Option<&'static Topic> {
158 TOPICS.iter().find(|t| t.name == name)
159}
160
161fn not_found(name: &str) -> Diagnostic {
164 let suggestion = match suggest(name) {
165 Some(s) => format!(" Did you mean '{s}'?"),
166 None => String::new(),
167 };
168 Diagnostic::NotFound(format!(
169 "no documentation topic named '{name}'.{suggestion} Run `dsp docs` to see all topics."
170 ))
171}
172
173fn suggest(name: &str) -> Option<&'static str> {
175 if name.is_empty() {
176 return None;
177 }
178 let threshold = 3.min(name.len());
181 TOPICS
182 .iter()
183 .map(|t| (levenshtein(name, t.name), t.name))
184 .filter(|(dist, _)| *dist <= threshold)
185 .min_by_key(|(dist, _)| *dist)
186 .map(|(_, n)| n)
187}
188
189fn levenshtein(a: &str, b: &str) -> usize {
192 let a: Vec<char> = a.chars().collect();
193 let b: Vec<char> = b.chars().collect();
194 let mut prev: Vec<usize> = (0..=b.len()).collect();
195 let mut curr: Vec<usize> = vec![0; b.len() + 1];
196 for (i, ca) in a.iter().enumerate() {
197 curr[0] = i + 1;
198 for (j, cb) in b.iter().enumerate() {
199 let cost = if ca == cb { 0 } else { 1 };
200 curr[j + 1] = (prev[j + 1] + 1).min(curr[j] + 1).min(prev[j] + cost);
201 }
202 std::mem::swap(&mut prev, &mut curr);
203 }
204 prev[b.len()]
205}
206
207fn write_topic_list(out: &mut dyn Write) -> Result<(), Diagnostic> {
209 let width = TOPICS.iter().map(|t| t.name.len()).max().unwrap_or(0);
210 writeln!(out, "Available documentation topics:")?;
211 writeln!(out)?;
212 for t in TOPICS {
213 writeln!(out, " {:<width$} {}", t.name, t.summary, width = width)?;
214 }
215 writeln!(out)?;
216 writeln!(out, "Run `dsp docs <topic>` to read one.")?;
217 Ok(())
218}
219
220fn emit(content: &str, use_pager: bool, out: &mut dyn Write) -> Result<(), Diagnostic> {
223 if use_pager && try_pager(content).is_ok() {
224 return Ok(());
225 }
226 out.write_all(content.as_bytes())?;
227 Ok(())
228}
229
230fn try_pager(content: &str) -> io::Result<()> {
234 let pager = std::env::var("PAGER").unwrap_or_else(|_| "less".to_string());
235 let mut parts = pager.split_whitespace();
236 let program = parts.next().unwrap_or("less");
237 let mut child = Command::new(program).args(parts).stdin(Stdio::piped()).spawn()?;
238 if let Some(mut stdin) = child.stdin.take() {
239 stdin.write_all(content.as_bytes())?;
240 }
241 child.wait()?;
242 Ok(())
243}
244
245#[cfg(test)]
246mod tests {
247 use super::*;
248
249 fn render_list() -> String {
250 let mut buf: Vec<u8> = Vec::new();
251 write_topic_list(&mut buf).unwrap();
252 String::from_utf8(buf).unwrap()
253 }
254
255 #[test]
256 fn find_topic_hits_known_name() {
257 assert!(find_topic("concepts").is_some());
258 assert_eq!(find_topic("concepts").unwrap().name, "concepts");
259 }
260
261 #[test]
262 fn find_topic_misses_unknown_name() {
263 assert!(find_topic("nope").is_none());
264 }
265
266 #[test]
267 fn find_topic_is_exact_not_prefix() {
268 assert!(find_topic("concept").is_none());
270 assert!(find_topic("dsp-").is_none());
271 }
272
273 #[test]
274 fn suggest_finds_near_neighbour() {
275 assert_eq!(suggest("concept"), Some("concepts")); assert_eq!(suggest("conecting"), Some("connecting")); assert_eq!(suggest("error"), Some("errors")); }
279
280 #[test]
281 fn suggest_returns_none_for_far_input() {
282 assert_eq!(suggest("xyzzy"), None);
283 assert_eq!(suggest(""), None);
284 }
285
286 #[test]
287 fn levenshtein_basics() {
288 assert_eq!(levenshtein("kitten", "sitting"), 3);
289 assert_eq!(levenshtein("same", "same"), 0);
290 assert_eq!(levenshtein("", "abc"), 3);
291 assert_eq!(levenshtein("abc", ""), 3);
292 }
293
294 #[test]
295 fn not_found_includes_suggestion_when_close() {
296 let msg = not_found("concept").to_string();
297 assert!(msg.contains("no documentation topic named 'concept'"));
298 assert!(msg.contains("Did you mean 'concepts'?"));
299 assert!(msg.contains("Run `dsp docs`"));
300 }
301
302 #[test]
303 fn not_found_omits_suggestion_when_far() {
304 let msg = not_found("xyzzy").to_string();
305 assert!(msg.contains("no documentation topic named 'xyzzy'"));
306 assert!(!msg.contains("Did you mean"));
307 }
308
309 #[test]
310 fn topic_list_includes_every_topic() {
311 let list = render_list();
312 for t in TOPICS {
313 assert!(list.contains(t.name), "list missing topic {}", t.name);
314 assert!(list.contains(t.summary), "list missing summary for {}", t.name);
315 }
316 }
317
318 #[test]
319 fn all_topic_bodies_are_present_and_well_formed() {
320 for t in TOPICS {
323 assert!(!t.body.trim().is_empty(), "empty body for topic {}", t.name);
324 assert!(t.body.starts_with("# "), "topic {} body must start with an h1 heading", t.name);
325 }
326 }
327
328 #[test]
329 fn catalog_has_ten_topics_with_unique_names() {
330 assert_eq!(TOPICS.len(), 10);
331 for (i, t) in TOPICS.iter().enumerate() {
332 for other in &TOPICS[i + 1..] {
333 assert_ne!(t.name, other.name, "duplicate topic name {}", t.name);
334 }
335 }
336 }
337
338 #[test]
339 fn run_impl_no_topic_writes_list() {
340 let args = DocsArgs { topic: None, pager: false, json: false };
341 let mut buf: Vec<u8> = Vec::new();
342 run_impl(&args, &mut buf).unwrap();
343 let out = String::from_utf8(buf).unwrap();
344 assert!(out.contains("Available documentation topics:"));
345 assert!(out.contains("workflows"));
346 }
347
348 #[test]
349 fn run_impl_known_topic_writes_body() {
350 let args = DocsArgs {
351 topic: Some("concepts".to_string()),
352 pager: false,
353 json: false,
354 };
355 let mut buf: Vec<u8> = Vec::new();
356 run_impl(&args, &mut buf).unwrap();
357 let out = String::from_utf8(buf).unwrap();
358 assert!(out.starts_with("# "));
359 }
360
361 #[test]
362 fn run_impl_unknown_topic_errors() {
363 let args = DocsArgs { topic: Some("nope".to_string()), pager: false, json: false };
364 let mut buf: Vec<u8> = Vec::new();
365 let err = run_impl(&args, &mut buf).unwrap_err();
366 assert!(matches!(err, Diagnostic::NotFound(_)));
367 assert!(buf.is_empty(), "nothing should be written on error");
368 }
369}