use std::io::{self, Write};
use std::process::{Command, Stdio};
use serde::Serialize;
use crate::cli::DocsArgs;
use crate::diagnostic::Diagnostic;
pub struct Topic {
pub name: &'static str,
pub summary: &'static str,
pub body: &'static str,
}
const TOPICS: &[Topic] = &[
Topic {
name: "dsp-cli",
summary: "What this tool is, who it's for, and its design principles.",
body: include_str!("../../docs/topics/dsp-cli.md"),
},
Topic {
name: "dsp",
summary: "The DaSCH Service Platform in brief: VRE vs Repository.",
body: include_str!("../../docs/topics/dsp.md"),
},
Topic {
name: "concepts",
summary: "The vocabulary dsp-cli speaks (project, data-model, resource-type, …).",
body: include_str!("../../docs/topics/concepts.md"),
},
Topic {
name: "identifiers",
summary: "How to name projects, data-models, and resource-types.",
body: include_str!("../../docs/topics/identifiers.md"),
},
Topic {
name: "connecting",
summary: "Servers, environments, and authentication.",
body: include_str!("../../docs/topics/connecting.md"),
},
Topic {
name: "output",
summary: "Output formats, channels, and the JSON envelope.",
body: include_str!("../../docs/topics/output.md"),
},
Topic {
name: "workflows",
summary: "Chaining commands into real tasks.",
body: include_str!("../../docs/topics/workflows.md"),
},
Topic {
name: "errors",
summary: "Exit codes and how to recover from failures.",
body: include_str!("../../docs/topics/errors.md"),
},
Topic {
name: "dsp-tools",
summary: "When to use dsp-cli versus dsp-tools.",
body: include_str!("../../docs/topics/dsp-tools.md"),
},
];
pub fn run(args: &DocsArgs) -> Result<(), Diagnostic> {
let mut out = io::stdout().lock();
run_impl(args, &mut out)
}
#[derive(Serialize)]
struct DocsJsonEnvelope<'a> {
_meta: EmptyMeta,
data: Vec<TopicIndexEntry<'a>>,
}
#[derive(Serialize)]
struct EmptyMeta {}
#[derive(Serialize)]
struct TopicIndexEntry<'a> {
name: &'a str,
summary: &'a str,
}
fn run_impl(args: &DocsArgs, out: &mut dyn Write) -> Result<(), Diagnostic> {
if args.json {
return write_topic_index_json(out);
}
match args.topic.as_deref() {
None => write_topic_list(out),
Some(name) => match find_topic(name) {
Some(topic) => emit(topic.body, args.pager, out),
None => Err(not_found(name)),
},
}
}
fn write_topic_index_json(out: &mut dyn Write) -> Result<(), Diagnostic> {
let data: Vec<TopicIndexEntry<'_>> = TOPICS
.iter()
.map(|t| TopicIndexEntry {
name: t.name,
summary: t.summary,
})
.collect();
let envelope = DocsJsonEnvelope {
_meta: EmptyMeta {},
data,
};
let json = serde_json::to_string(&envelope)
.map_err(|e| Diagnostic::Internal(format!("json serialisation error: {e}")))?;
writeln!(out, "{json}")?;
Ok(())
}
fn find_topic(name: &str) -> Option<&'static Topic> {
TOPICS.iter().find(|t| t.name == name)
}
fn not_found(name: &str) -> Diagnostic {
let suggestion = match suggest(name) {
Some(s) => format!(" Did you mean '{s}'?"),
None => String::new(),
};
Diagnostic::NotFound(format!(
"no documentation topic named '{name}'.{suggestion} Run `dsp docs` to see all topics."
))
}
fn suggest(name: &str) -> Option<&'static str> {
if name.is_empty() {
return None;
}
let threshold = 3.min(name.len());
TOPICS
.iter()
.map(|t| (levenshtein(name, t.name), t.name))
.filter(|(dist, _)| *dist <= threshold)
.min_by_key(|(dist, _)| *dist)
.map(|(_, n)| n)
}
fn levenshtein(a: &str, b: &str) -> usize {
let a: Vec<char> = a.chars().collect();
let b: Vec<char> = b.chars().collect();
let mut prev: Vec<usize> = (0..=b.len()).collect();
let mut curr: Vec<usize> = vec![0; b.len() + 1];
for (i, ca) in a.iter().enumerate() {
curr[0] = i + 1;
for (j, cb) in b.iter().enumerate() {
let cost = if ca == cb { 0 } else { 1 };
curr[j + 1] = (prev[j + 1] + 1).min(curr[j] + 1).min(prev[j] + cost);
}
std::mem::swap(&mut prev, &mut curr);
}
prev[b.len()]
}
fn write_topic_list(out: &mut dyn Write) -> Result<(), Diagnostic> {
let width = TOPICS.iter().map(|t| t.name.len()).max().unwrap_or(0);
writeln!(out, "Available documentation topics:")?;
writeln!(out)?;
for t in TOPICS {
writeln!(out, " {:<width$} {}", t.name, t.summary, width = width)?;
}
writeln!(out)?;
writeln!(out, "Run `dsp docs <topic>` to read one.")?;
Ok(())
}
fn emit(content: &str, use_pager: bool, out: &mut dyn Write) -> Result<(), Diagnostic> {
if use_pager && try_pager(content).is_ok() {
return Ok(());
}
out.write_all(content.as_bytes())?;
Ok(())
}
fn try_pager(content: &str) -> io::Result<()> {
let pager = std::env::var("PAGER").unwrap_or_else(|_| "less".to_string());
let mut parts = pager.split_whitespace();
let program = parts.next().unwrap_or("less");
let mut child = Command::new(program)
.args(parts)
.stdin(Stdio::piped())
.spawn()?;
if let Some(mut stdin) = child.stdin.take() {
stdin.write_all(content.as_bytes())?;
}
child.wait()?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn render_list() -> String {
let mut buf: Vec<u8> = Vec::new();
write_topic_list(&mut buf).unwrap();
String::from_utf8(buf).unwrap()
}
#[test]
fn find_topic_hits_known_name() {
assert!(find_topic("concepts").is_some());
assert_eq!(find_topic("concepts").unwrap().name, "concepts");
}
#[test]
fn find_topic_misses_unknown_name() {
assert!(find_topic("nope").is_none());
}
#[test]
fn find_topic_is_exact_not_prefix() {
assert!(find_topic("concept").is_none());
assert!(find_topic("dsp-").is_none());
}
#[test]
fn suggest_finds_near_neighbour() {
assert_eq!(suggest("concept"), Some("concepts")); assert_eq!(suggest("conecting"), Some("connecting")); assert_eq!(suggest("error"), Some("errors")); }
#[test]
fn suggest_returns_none_for_far_input() {
assert_eq!(suggest("xyzzy"), None);
assert_eq!(suggest(""), None);
}
#[test]
fn levenshtein_basics() {
assert_eq!(levenshtein("kitten", "sitting"), 3);
assert_eq!(levenshtein("same", "same"), 0);
assert_eq!(levenshtein("", "abc"), 3);
assert_eq!(levenshtein("abc", ""), 3);
}
#[test]
fn not_found_includes_suggestion_when_close() {
let msg = not_found("concept").to_string();
assert!(msg.contains("no documentation topic named 'concept'"));
assert!(msg.contains("Did you mean 'concepts'?"));
assert!(msg.contains("Run `dsp docs`"));
}
#[test]
fn not_found_omits_suggestion_when_far() {
let msg = not_found("xyzzy").to_string();
assert!(msg.contains("no documentation topic named 'xyzzy'"));
assert!(!msg.contains("Did you mean"));
}
#[test]
fn topic_list_includes_every_topic() {
let list = render_list();
for t in TOPICS {
assert!(list.contains(t.name), "list missing topic {}", t.name);
assert!(
list.contains(t.summary),
"list missing summary for {}",
t.name
);
}
}
#[test]
fn all_topic_bodies_are_present_and_well_formed() {
for t in TOPICS {
assert!(!t.body.trim().is_empty(), "empty body for topic {}", t.name);
assert!(
t.body.starts_with("# "),
"topic {} body must start with an h1 heading",
t.name
);
}
}
#[test]
fn catalog_has_nine_topics_with_unique_names() {
assert_eq!(TOPICS.len(), 9);
for (i, t) in TOPICS.iter().enumerate() {
for other in &TOPICS[i + 1..] {
assert_ne!(t.name, other.name, "duplicate topic name {}", t.name);
}
}
}
#[test]
fn run_impl_no_topic_writes_list() {
let args = DocsArgs {
topic: None,
pager: false,
json: false,
};
let mut buf: Vec<u8> = Vec::new();
run_impl(&args, &mut buf).unwrap();
let out = String::from_utf8(buf).unwrap();
assert!(out.contains("Available documentation topics:"));
assert!(out.contains("workflows"));
}
#[test]
fn run_impl_known_topic_writes_body() {
let args = DocsArgs {
topic: Some("concepts".to_string()),
pager: false,
json: false,
};
let mut buf: Vec<u8> = Vec::new();
run_impl(&args, &mut buf).unwrap();
let out = String::from_utf8(buf).unwrap();
assert!(out.starts_with("# "));
}
#[test]
fn run_impl_unknown_topic_errors() {
let args = DocsArgs {
topic: Some("nope".to_string()),
pager: false,
json: false,
};
let mut buf: Vec<u8> = Vec::new();
let err = run_impl(&args, &mut buf).unwrap_err();
assert!(matches!(err, Diagnostic::NotFound(_)));
assert!(buf.is_empty(), "nothing should be written on error");
}
}