shuyi_cli/contract/
explain.rs1use 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#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
16#[serde(deny_unknown_fields)]
17pub struct ExplainInput {
18 #[serde(default)]
21 pub code: Option<String>,
22}
23
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
26#[serde(deny_unknown_fields)]
27pub struct Explained {
28 pub code: String,
30 pub name: String,
32 pub class: Class,
34 pub exit_code: u8,
36 pub title: String,
38 pub help: String,
40 pub retired: bool,
42 pub page: Option<String>,
44}
45
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
48#[serde(deny_unknown_fields)]
49pub struct ExplainOutput {
50 pub entries: Vec<Explained>,
52}
53
54fn 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
68pub 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
92pub 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
103pub 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
111pub 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 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}