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> {
88 let mut out = io::stdout().lock();
89 run_impl(args, &mut out)
90}
91
92#[derive(Serialize)]
99struct DocsJsonEnvelope<'a> {
100 _meta: EmptyMeta,
101 data: Vec<TopicIndexEntry<'a>>,
102}
103
104#[derive(Serialize)]
108struct EmptyMeta {}
109
110#[derive(Serialize)]
113struct TopicIndexEntry<'a> {
114 name: &'a str,
115 summary: &'a str,
116}
117
118fn run_impl(args: &DocsArgs, out: &mut dyn Write) -> Result<(), Diagnostic> {
122 if args.json {
123 return write_topic_index_json(out);
124 }
125 match args.topic.as_deref() {
126 None => write_topic_list(out),
127 Some(name) => match find_topic(name) {
128 Some(topic) => emit(topic.body, args.pager, out),
129 None => Err(not_found(name)),
130 },
131 }
132}
133
134fn write_topic_index_json(out: &mut dyn Write) -> Result<(), Diagnostic> {
141 let data: Vec<TopicIndexEntry<'_>> = TOPICS
142 .iter()
143 .map(|t| TopicIndexEntry {
144 name: t.name,
145 summary: t.summary,
146 })
147 .collect();
148 let envelope = DocsJsonEnvelope {
149 _meta: EmptyMeta {},
150 data,
151 };
152 let json = serde_json::to_string(&envelope)
153 .map_err(|e| Diagnostic::Internal(format!("json serialisation error: {e}")))?;
154 writeln!(out, "{json}")?;
155 Ok(())
156}
157
158fn find_topic(name: &str) -> Option<&'static Topic> {
161 TOPICS.iter().find(|t| t.name == name)
162}
163
164fn not_found(name: &str) -> Diagnostic {
167 let suggestion = match suggest(name) {
168 Some(s) => format!(" Did you mean '{s}'?"),
169 None => String::new(),
170 };
171 Diagnostic::NotFound(format!(
172 "no documentation topic named '{name}'.{suggestion} Run `dsp docs` to see all topics."
173 ))
174}
175
176fn suggest(name: &str) -> Option<&'static str> {
178 if name.is_empty() {
179 return None;
180 }
181 let threshold = 3.min(name.len());
184 TOPICS
185 .iter()
186 .map(|t| (levenshtein(name, t.name), t.name))
187 .filter(|(dist, _)| *dist <= threshold)
188 .min_by_key(|(dist, _)| *dist)
189 .map(|(_, n)| n)
190}
191
192fn levenshtein(a: &str, b: &str) -> usize {
195 let a: Vec<char> = a.chars().collect();
196 let b: Vec<char> = b.chars().collect();
197 let mut prev: Vec<usize> = (0..=b.len()).collect();
198 let mut curr: Vec<usize> = vec![0; b.len() + 1];
199 for (i, ca) in a.iter().enumerate() {
200 curr[0] = i + 1;
201 for (j, cb) in b.iter().enumerate() {
202 let cost = if ca == cb { 0 } else { 1 };
203 curr[j + 1] = (prev[j + 1] + 1).min(curr[j] + 1).min(prev[j] + cost);
204 }
205 std::mem::swap(&mut prev, &mut curr);
206 }
207 prev[b.len()]
208}
209
210fn write_topic_list(out: &mut dyn Write) -> Result<(), Diagnostic> {
212 let width = TOPICS.iter().map(|t| t.name.len()).max().unwrap_or(0);
213 writeln!(out, "Available documentation topics:")?;
214 writeln!(out)?;
215 for t in TOPICS {
216 writeln!(out, " {:<width$} {}", t.name, t.summary, width = width)?;
217 }
218 writeln!(out)?;
219 writeln!(out, "Run `dsp docs <topic>` to read one.")?;
220 Ok(())
221}
222
223fn emit(content: &str, use_pager: bool, out: &mut dyn Write) -> Result<(), Diagnostic> {
226 if use_pager && try_pager(content).is_ok() {
227 return Ok(());
228 }
229 out.write_all(content.as_bytes())?;
230 Ok(())
231}
232
233fn try_pager(content: &str) -> io::Result<()> {
237 let pager = std::env::var("PAGER").unwrap_or_else(|_| "less".to_string());
238 let mut parts = pager.split_whitespace();
239 let program = parts.next().unwrap_or("less");
240 let mut child = Command::new(program)
241 .args(parts)
242 .stdin(Stdio::piped())
243 .spawn()?;
244 if let Some(mut stdin) = child.stdin.take() {
245 stdin.write_all(content.as_bytes())?;
246 }
247 child.wait()?;
248 Ok(())
249}
250
251#[cfg(test)]
252mod tests {
253 use super::*;
254
255 fn render_list() -> String {
256 let mut buf: Vec<u8> = Vec::new();
257 write_topic_list(&mut buf).unwrap();
258 String::from_utf8(buf).unwrap()
259 }
260
261 #[test]
262 fn find_topic_hits_known_name() {
263 assert!(find_topic("concepts").is_some());
264 assert_eq!(find_topic("concepts").unwrap().name, "concepts");
265 }
266
267 #[test]
268 fn find_topic_misses_unknown_name() {
269 assert!(find_topic("nope").is_none());
270 }
271
272 #[test]
273 fn find_topic_is_exact_not_prefix() {
274 assert!(find_topic("concept").is_none());
276 assert!(find_topic("dsp-").is_none());
277 }
278
279 #[test]
280 fn suggest_finds_near_neighbour() {
281 assert_eq!(suggest("concept"), Some("concepts")); assert_eq!(suggest("conecting"), Some("connecting")); assert_eq!(suggest("error"), Some("errors")); }
285
286 #[test]
287 fn suggest_returns_none_for_far_input() {
288 assert_eq!(suggest("xyzzy"), None);
289 assert_eq!(suggest(""), None);
290 }
291
292 #[test]
293 fn levenshtein_basics() {
294 assert_eq!(levenshtein("kitten", "sitting"), 3);
295 assert_eq!(levenshtein("same", "same"), 0);
296 assert_eq!(levenshtein("", "abc"), 3);
297 assert_eq!(levenshtein("abc", ""), 3);
298 }
299
300 #[test]
301 fn not_found_includes_suggestion_when_close() {
302 let msg = not_found("concept").to_string();
303 assert!(msg.contains("no documentation topic named 'concept'"));
304 assert!(msg.contains("Did you mean 'concepts'?"));
305 assert!(msg.contains("Run `dsp docs`"));
306 }
307
308 #[test]
309 fn not_found_omits_suggestion_when_far() {
310 let msg = not_found("xyzzy").to_string();
311 assert!(msg.contains("no documentation topic named 'xyzzy'"));
312 assert!(!msg.contains("Did you mean"));
313 }
314
315 #[test]
316 fn topic_list_includes_every_topic() {
317 let list = render_list();
318 for t in TOPICS {
319 assert!(list.contains(t.name), "list missing topic {}", t.name);
320 assert!(
321 list.contains(t.summary),
322 "list missing summary for {}",
323 t.name
324 );
325 }
326 }
327
328 #[test]
329 fn all_topic_bodies_are_present_and_well_formed() {
330 for t in TOPICS {
333 assert!(!t.body.trim().is_empty(), "empty body for topic {}", t.name);
334 assert!(
335 t.body.starts_with("# "),
336 "topic {} body must start with an h1 heading",
337 t.name
338 );
339 }
340 }
341
342 #[test]
343 fn catalog_has_ten_topics_with_unique_names() {
344 assert_eq!(TOPICS.len(), 10);
345 for (i, t) in TOPICS.iter().enumerate() {
346 for other in &TOPICS[i + 1..] {
347 assert_ne!(t.name, other.name, "duplicate topic name {}", t.name);
348 }
349 }
350 }
351
352 #[test]
353 fn run_impl_no_topic_writes_list() {
354 let args = DocsArgs {
355 topic: None,
356 pager: false,
357 json: false,
358 };
359 let mut buf: Vec<u8> = Vec::new();
360 run_impl(&args, &mut buf).unwrap();
361 let out = String::from_utf8(buf).unwrap();
362 assert!(out.contains("Available documentation topics:"));
363 assert!(out.contains("workflows"));
364 }
365
366 #[test]
367 fn run_impl_known_topic_writes_body() {
368 let args = DocsArgs {
369 topic: Some("concepts".to_string()),
370 pager: false,
371 json: false,
372 };
373 let mut buf: Vec<u8> = Vec::new();
374 run_impl(&args, &mut buf).unwrap();
375 let out = String::from_utf8(buf).unwrap();
376 assert!(out.starts_with("# "));
377 }
378
379 #[test]
380 fn run_impl_unknown_topic_errors() {
381 let args = DocsArgs {
382 topic: Some("nope".to_string()),
383 pager: false,
384 json: false,
385 };
386 let mut buf: Vec<u8> = Vec::new();
387 let err = run_impl(&args, &mut buf).unwrap_err();
388 assert!(matches!(err, Diagnostic::NotFound(_)));
389 assert!(buf.is_empty(), "nothing should be written on error");
390 }
391}