Skip to main content

dsp_cli/actions/
docs.rs

1//! Actions for `dsp docs [topic]`.
2//!
3//! `docs` is the one action with neither a `DspClient` (it reads embedded files,
4//! never the network) nor a `Renderer`: its output is raw markdown (a topic body)
5//! or a plain prose topic list, both format-agnostic, so the five-format renderer
6//! matrix does not apply and `DocsArgs` carries no `--format` flag. Output goes to
7//! an injected writer via the `run` / `run_impl` seam (the same testability pattern
8//! as `auth::status`). See ADR-0010.
9//!
10//! Topics are authored as markdown under `docs/topics/` and embedded at compile
11//! time with `include_str!` — a missing file is a compile error.
12
13use std::io::{self, Write};
14use std::process::{Command, Stdio};
15
16use serde::Serialize;
17
18use crate::cli::DocsArgs;
19use crate::diagnostic::Diagnostic;
20
21/// A unit of embedded end-user documentation. See CONTEXT.md ("Topic").
22pub struct Topic {
23    /// The short name used as `dsp docs <name>`.
24    pub name: &'static str,
25    /// One-line description shown in the topic list.
26    pub summary: &'static str,
27    /// The full markdown body, embedded at compile time.
28    pub body: &'static str,
29}
30
31/// The v1 topic catalog (ADR-0010, expanded by plan 018). Table order is the
32/// display order in the topic list; it follows a first-read progression.
33const 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
86/// Display embedded documentation: list topics, or print one topic's body.
87pub fn run(args: &DocsArgs) -> Result<(), Diagnostic> {
88    let mut out = io::stdout().lock();
89    run_impl(args, &mut out)
90}
91
92/// Machine-readable JSON topic index: the outer envelope.
93///
94/// `_meta` is declared first so serde's insertion-order serialisation places it
95/// before `data` in the output — a load-bearing ordering guaranteed by the
96/// `preserve_order` feature on `serde_json` (ADR-0003). For `dsp docs -j` there
97/// is no server/auth context, so `_meta` is the empty object `{}` (plan 020 D4).
98#[derive(Serialize)]
99struct DocsJsonEnvelope<'a> {
100    _meta: EmptyMeta,
101    data: Vec<TopicIndexEntry<'a>>,
102}
103
104/// The empty `_meta` block for `dsp docs -j` (no server/auth context applies to
105/// embedded documentation). Serialises as `{}`. See plan 020 D4 and ADR-0003
106/// amendment for the empty-`_meta` carve-out.
107#[derive(Serialize)]
108struct EmptyMeta {}
109
110/// One entry in the JSON topic index: name + summary only (bodies never
111/// JSON-wrapped — they are raw markdown surfaced via `dsp docs <topic>`).
112#[derive(Serialize)]
113struct TopicIndexEntry<'a> {
114    name: &'a str,
115    summary: &'a str,
116}
117
118/// Testable core: writes the topic list or a topic body to `out`. A bad topic
119/// name returns `NotFound` (exit 1) with a "did you mean" suggestion; `main.rs`
120/// prints that to stderr.
121fn 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
134/// Emit the JSON topic index: `{"_meta":{},"data":[{"name":"…","summary":"…"},…]}`.
135///
136/// Uses compact `serde_json::to_string` (same style as `src/render/json.rs`) so
137/// `dsp docs -j` output is visually uniform with other `dsp … -j` commands.
138/// `_meta` is first because it is the first declared field on `DocsJsonEnvelope`
139/// (ADR-0003, plan 020 D4).
140fn 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
158/// Exact-match topic lookup. No partial/prefix matching (ADR-0010: silent-shadowing
159/// risk when topics are added later).
160fn find_topic(name: &str) -> Option<&'static Topic> {
161    TOPICS.iter().find(|t| t.name == name)
162}
163
164/// Build the not-found diagnostic, appending a "did you mean" when a close topic
165/// name exists.
166fn 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
176/// The closest topic name within an edit-distance threshold, or `None`.
177fn suggest(name: &str) -> Option<&'static str> {
178    if name.is_empty() {
179        return None;
180    }
181    // Threshold: small absolute distance, capped below the input length so a short
182    // garbage string doesn't match a long topic name.
183    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
192/// Classic dynamic-programming Levenshtein edit distance (insert/delete/substitute,
193/// cost 1 each). Inlined to avoid a dependency for one small use.
194fn 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
210/// Render the topic list (a successful command: data to stdout, exit 0).
211fn 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
223/// Write a topic body, optionally through a pager. Pager failure (or no pager
224/// available) falls back to a direct write — never fatal.
225fn 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
233/// Pipe `content` through `$PAGER` (default `less`). The pager inherits our stdout,
234/// so this bypasses `out` entirely; it is engaged only on the real-binary `--pager`
235/// path and is not unit-tested.
236fn 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        // "concept" must NOT match "concepts" (ADR-0010: no partial matching).
275        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")); // distance 1
282        assert_eq!(suggest("conecting"), Some("connecting")); // distance 1
283        assert_eq!(suggest("error"), Some("errors")); // distance 1
284    }
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        // Smoke test in lieu of brittle per-body snapshots (ADR-0009 exception, plan
331        // 018 D5): every catalogued body is non-empty and starts with an h1 heading.
332        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}