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 dsp-cli/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 (dsp-cli/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.
87///
88/// Stdout is wrapped in `BrokenPipeWriter` so `dsp docs ... | head` exits 0
89/// silently instead of surfacing a broken pipe as `Diagnostic::Internal`.
90pub 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/// Machine-readable JSON topic index: the outer envelope.
96///
97/// `_meta` is declared first so serde's insertion-order serialisation places it
98/// before `data` in the output — a load-bearing ordering guaranteed by the
99/// `preserve_order` feature on `serde_json` (dsp-cli/ADR-0003). For `dsp docs -j` there
100/// is no server/auth context, so `_meta` is the empty object `{}` (plan 020 D4).
101#[derive(Serialize)]
102struct DocsJsonEnvelope<'a> {
103    _meta: EmptyMeta,
104    data: Vec<TopicIndexEntry<'a>>,
105}
106
107/// The empty `_meta` block for `dsp docs -j` (no server/auth context applies to
108/// embedded documentation). Serialises as `{}`. See plan 020 D4 and dsp-cli/ADR-0003
109/// amendment for the empty-`_meta` carve-out.
110#[derive(Serialize)]
111struct EmptyMeta {}
112
113/// One entry in the JSON topic index: name + summary only (bodies never
114/// JSON-wrapped — they are raw markdown surfaced via `dsp docs <topic>`).
115#[derive(Serialize)]
116struct TopicIndexEntry<'a> {
117    name: &'a str,
118    summary: &'a str,
119}
120
121/// Testable core: writes the topic list or a topic body to `out`. A bad topic
122/// name returns `NotFound` (exit 1) with a "did you mean" suggestion; `main.rs`
123/// prints that to stderr.
124fn 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
137/// Emit the JSON topic index: `{"_meta":{},"data":[{"name":"…","summary":"…"},…]}`.
138///
139/// Uses compact `serde_json::to_string` (same style as `src/render/json.rs`) so
140/// `dsp docs -j` output is visually uniform with other `dsp … -j` commands.
141/// `_meta` is first because it is the first declared field on `DocsJsonEnvelope`
142/// (dsp-cli/ADR-0003, plan 020 D4).
143fn 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
155/// Exact-match topic lookup. No partial/prefix matching (dsp-cli/ADR-0010: silent-shadowing
156/// risk when topics are added later).
157fn find_topic(name: &str) -> Option<&'static Topic> {
158    TOPICS.iter().find(|t| t.name == name)
159}
160
161/// Build the not-found diagnostic, appending a "did you mean" when a close topic
162/// name exists.
163fn 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
173/// The closest topic name within an edit-distance threshold, or `None`.
174fn suggest(name: &str) -> Option<&'static str> {
175    if name.is_empty() {
176        return None;
177    }
178    // Threshold: small absolute distance, capped below the input length so a short
179    // garbage string doesn't match a long topic name.
180    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
189/// Classic dynamic-programming Levenshtein edit distance (insert/delete/substitute,
190/// cost 1 each). Inlined to avoid a dependency for one small use.
191fn 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
207/// Render the topic list (a successful command: data to stdout, exit 0).
208fn 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
220/// Write a topic body, optionally through a pager. Pager failure (or no pager
221/// available) falls back to a direct write — never fatal.
222fn 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
230/// Pipe `content` through `$PAGER` (default `less`). The pager inherits our stdout,
231/// so this bypasses `out` entirely; it is engaged only on the real-binary `--pager`
232/// path and is not unit-tested.
233fn 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        // "concept" must NOT match "concepts" (dsp-cli/ADR-0010: no partial matching).
269        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")); // distance 1
276        assert_eq!(suggest("conecting"), Some("connecting")); // distance 1
277        assert_eq!(suggest("error"), Some("errors")); // distance 1
278    }
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        // Smoke test in lieu of brittle per-body snapshots (dsp-cli/ADR-0009 exception, plan
321        // 018 D5): every catalogued body is non-empty and starts with an h1 heading.
322        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}