Skip to main content

shuyi_cli/contract/
explain.rs

1//! `explain [<code>]`: every problem code, or one code's page
2//! ([`codes`]). It needs no project, like `doctor` and `schema`, and is
3//! how an agent or a person learns what an error's code means and how to
4//! fix it (`shuyi explain SH2003`, as `rustc --explain E0277`).
5
6use std::fmt::Write as _;
7
8use schemars::JsonSchema;
9use serde::{Deserialize, Serialize};
10
11use super::codes::{self, Class, Entry};
12use super::{Answer, Op};
13
14/// What `explain` explains.
15#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
16#[serde(deny_unknown_fields)]
17pub struct ExplainInput {
18    /// The code (`SH2003`) or its name (`missing-file`); every code if
19    /// none.
20    #[serde(default)]
21    pub code: Option<String>,
22}
23
24/// One code explained.
25#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
26#[serde(deny_unknown_fields)]
27pub struct Explained {
28    /// The code (`SH2003`).
29    pub code: String,
30    /// Its kebab-case name (`missing-file`), as stable as the code.
31    pub name: String,
32    /// Its class, which sets the exit code.
33    pub class: Class,
34    /// The exit code of an error with it.
35    pub exit_code: u8,
36    /// What went wrong, in a few words.
37    pub title: String,
38    /// How to fix it, in a line.
39    pub help: String,
40    /// Whether it is retired: no error gives it any more.
41    pub retired: bool,
42    /// Its page, in Markdown, when one code was asked for.
43    pub page: Option<String>,
44}
45
46/// What `explain` answers.
47#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
48#[serde(deny_unknown_fields)]
49pub struct ExplainOutput {
50    /// The code asked for, or every code in order.
51    pub entries: Vec<Explained>,
52}
53
54/// `entry`, with its page if `page`.
55fn explained(entry: &Entry, page: bool) -> Explained {
56    Explained {
57        code: entry.code.0.to_owned(),
58        name: entry.name.to_owned(),
59        class: entry.class,
60        exit_code: entry.class.exit_code(),
61        title: entry.title.to_owned(),
62        help: entry.help.to_owned(),
63        retired: entry.retired,
64        page: page.then(|| entry.page.to_owned()),
65    }
66}
67
68/// `explain`: the code `input` asks for, with its page, or every code.
69pub fn explain(input: &ExplainInput) -> Answer<ExplainOutput> {
70    let entries = match &input.code {
71        None => codes::TABLE.iter().map(|e| explained(e, false)).collect(),
72        Some(asked) => {
73            let found = codes::TABLE
74                .iter()
75                .find(|e| e.code.0.eq_ignore_ascii_case(asked) || e.name == asked);
76            match found {
77                Some(entry) => vec![explained(entry, true)],
78                None => {
79                    return Answer::failed(
80                        Op::Explain,
81                        codes::WRONG_ARGUMENTS,
82                        format!("no problem code {asked}"),
83                    )
84                    .with_help("`shuyi explain` lists every code");
85                }
86            }
87        }
88    };
89    Answer::new(Op::Explain, true, ExplainOutput { entries }, Vec::new())
90}
91
92/// Reads `explain`'s arguments (not `--json`): none, or a code.
93pub fn parse_args(args: &[String]) -> Result<ExplainInput, String> {
94    match args {
95        [] => Ok(ExplainInput::default()),
96        [code] if !code.starts_with("--") => Ok(ExplainInput {
97            code: Some(code.clone()),
98        }),
99        _ => Err("one code, or none".to_owned()),
100    }
101}
102
103/// `explain`'s usage, with `command` as the tool's name.
104pub fn usage(command: &str) -> String {
105    format!(
106        "usage: {command} explain [<code>] [--json]\n\n\
107         Lists every problem code, or prints the page of one (SH2003, or its name)."
108    )
109}
110
111/// What `explain` found, for people: one code's page, or a line a code.
112pub fn text(output: &ExplainOutput) -> String {
113    if let [one] = output.entries.as_slice()
114        && let Some(page) = &one.page
115    {
116        return page.clone();
117    }
118    let mut out = String::new();
119    for e in &output.entries {
120        let retired = if e.retired { " (retired)" } else { "" };
121        // Writing to a `String` can't fail.
122        let _ = writeln!(
123            out,
124            "{}  {:<9}  {:<15}  {}{retired}",
125            e.code,
126            e.class.name(),
127            e.name,
128            e.title
129        );
130    }
131    out
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137
138    fn asked(code: &str) -> ExplainInput {
139        ExplainInput {
140            code: Some(code.to_owned()),
141        }
142    }
143
144    #[test]
145    fn with_no_code_it_lists_every_code() {
146        let answer = explain(&ExplainInput::default());
147        assert!(answer.ok);
148        let output = answer.result.unwrap();
149        assert_eq!(output.entries.len(), codes::TABLE.len());
150        assert!(output.entries.iter().all(|e| e.page.is_none()));
151        let said = text(&output);
152        assert_eq!(said.lines().count(), codes::TABLE.len());
153        assert!(
154            said.contains("SH2003  content    missing-file     A file isn't there\n"),
155            "{said}"
156        );
157        let mut retired = output;
158        retired.entries.truncate(1);
159        retired.entries[0].retired = true;
160        assert!(text(&retired).ends_with(" (retired)\n"));
161    }
162
163    #[test]
164    fn with_a_code_or_its_name_it_prints_its_page() {
165        let answer = explain(&asked("SH2003"));
166        let output = answer.result.unwrap();
167        let [one] = output.entries.as_slice() else {
168            panic!("{output:?}")
169        };
170        assert_eq!(one.name, "missing-file");
171        assert_eq!(one.exit_code, 1);
172        assert_eq!(one.class, Class::Content);
173        assert!(text(&output).starts_with("# SH2003: A file isn't there\n"));
174        let by_name = explain(&asked("missing-file")).result.unwrap();
175        assert_eq!(by_name, output);
176        assert_eq!(explain(&asked("sh2003")).result.unwrap(), output);
177        let json: serde_json::Value =
178            serde_json::from_str(&explain(&asked("SH1002")).to_json()).unwrap();
179        assert_eq!(json["result"]["entries"][0]["class"], "setup");
180        assert_eq!(json["result"]["entries"][0]["exit_code"], 3);
181    }
182
183    #[test]
184    fn an_unknown_code_is_a_wrong_argument() {
185        let answer = explain(&asked("SH9999"));
186        assert!(!answer.ok);
187        assert_eq!(answer.code.as_deref(), Some("SH0001"));
188        assert_eq!(answer.exit_code(), 2);
189        assert!(answer.help.unwrap().contains("`shuyi explain`"));
190    }
191
192    #[test]
193    fn it_takes_one_code_or_none() {
194        assert_eq!(parse_args(&[]), Ok(ExplainInput::default()));
195        assert_eq!(parse_args(&["SH0001".to_owned()]), Ok(asked("SH0001")));
196        assert!(parse_args(&["--x".to_owned()]).is_err());
197        assert!(parse_args(&["a".to_owned(), "b".to_owned()]).is_err());
198        assert!(usage("shuyi").starts_with("usage: shuyi explain"));
199    }
200}