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];
80
81/// Display embedded documentation: list topics, or print one topic's body.
82pub fn run(args: &DocsArgs) -> Result<(), Diagnostic> {
83    let mut out = io::stdout().lock();
84    run_impl(args, &mut out)
85}
86
87/// Machine-readable JSON topic index: the outer envelope.
88///
89/// `_meta` is declared first so serde's insertion-order serialisation places it
90/// before `data` in the output — a load-bearing ordering guaranteed by the
91/// `preserve_order` feature on `serde_json` (ADR-0003). For `dsp docs -j` there
92/// is no server/auth context, so `_meta` is the empty object `{}` (plan 020 D4).
93#[derive(Serialize)]
94struct DocsJsonEnvelope<'a> {
95    _meta: EmptyMeta,
96    data: Vec<TopicIndexEntry<'a>>,
97}
98
99/// The empty `_meta` block for `dsp docs -j` (no server/auth context applies to
100/// embedded documentation). Serialises as `{}`. See plan 020 D4 and ADR-0003
101/// amendment for the empty-`_meta` carve-out.
102#[derive(Serialize)]
103struct EmptyMeta {}
104
105/// One entry in the JSON topic index: name + summary only (bodies never
106/// JSON-wrapped — they are raw markdown surfaced via `dsp docs <topic>`).
107#[derive(Serialize)]
108struct TopicIndexEntry<'a> {
109    name: &'a str,
110    summary: &'a str,
111}
112
113/// Testable core: writes the topic list or a topic body to `out`. A bad topic
114/// name returns `NotFound` (exit 1) with a "did you mean" suggestion; `main.rs`
115/// prints that to stderr.
116fn 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
129/// Emit the JSON topic index: `{"_meta":{},"data":[{"name":"…","summary":"…"},…]}`.
130///
131/// Uses compact `serde_json::to_string` (same style as `src/render/json.rs`) so
132/// `dsp docs -j` output is visually uniform with other `dsp … -j` commands.
133/// `_meta` is first because it is the first declared field on `DocsJsonEnvelope`
134/// (ADR-0003, plan 020 D4).
135fn 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
153/// Exact-match topic lookup. No partial/prefix matching (ADR-0010: silent-shadowing
154/// risk when topics are added later).
155fn find_topic(name: &str) -> Option<&'static Topic> {
156    TOPICS.iter().find(|t| t.name == name)
157}
158
159/// Build the not-found diagnostic, appending a "did you mean" when a close topic
160/// name exists.
161fn 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
171/// The closest topic name within an edit-distance threshold, or `None`.
172fn suggest(name: &str) -> Option<&'static str> {
173    if name.is_empty() {
174        return None;
175    }
176    // Threshold: small absolute distance, capped below the input length so a short
177    // garbage string doesn't match a long topic name.
178    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
187/// Classic dynamic-programming Levenshtein edit distance (insert/delete/substitute,
188/// cost 1 each). Inlined to avoid a dependency for one small use.
189fn 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
205/// Render the topic list (a successful command: data to stdout, exit 0).
206fn 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
218/// Write a topic body, optionally through a pager. Pager failure (or no pager
219/// available) falls back to a direct write — never fatal.
220fn 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
228/// Pipe `content` through `$PAGER` (default `less`). The pager inherits our stdout,
229/// so this bypasses `out` entirely; it is engaged only on the real-binary `--pager`
230/// path and is not unit-tested.
231fn 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        // "concept" must NOT match "concepts" (ADR-0010: no partial matching).
270        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")); // distance 1
277        assert_eq!(suggest("conecting"), Some("connecting")); // distance 1
278        assert_eq!(suggest("error"), Some("errors")); // distance 1
279    }
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        // Smoke test in lieu of brittle per-body snapshots (ADR-0009 exception, plan
326        // 018 D5): every catalogued body is non-empty and starts with an h1 heading.
327        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}