1use std::fmt;
2use std::io::IsTerminal;
3
4const RESET: &str = "\x1b[0m";
5const ERROR: &str = "\x1b[1m\x1b[31m";
6const INVALID: &str = "\x1b[33m";
7const VALID: &str = "\x1b[32m";
8const LITERAL: &str = "\x1b[1m";
9const HEADER: &str = "\x1b[1m\x1b[4m";
10const REQUIRED: &str = "the following required arguments were not provided:";
11
12#[derive(Clone, Debug, PartialEq, Eq)]
13pub struct Refusal {
14 pub message: String,
15 pub usage: Option<String>,
16}
17
18impl Refusal {
19 pub fn new(message: impl Into<String>) -> Self {
20 Self {
21 message: message.into(),
22 usage: None,
23 }
24 }
25
26 pub fn under(mut self, usage: impl Into<String>) -> Self {
27 if self.usage.is_none() {
28 self.usage = Some(usage.into());
29 }
30 self
31 }
32
33 pub fn render(&self, color: bool) -> String {
34 let message = if color {
35 style(&self.message)
36 } else {
37 self.message.clone()
38 };
39 let mut text = format!("{} {message}\n\n", paint("error:", ERROR, color));
40 if let Some(usage) = self.usage.as_deref().filter(|_| !bare(&self.message)) {
41 let usage = if color {
42 style_usage(usage)
43 } else {
44 usage.to_string()
45 };
46 text.push_str(&format!("{} {usage}\n\n", paint("Usage:", HEADER, color)));
47 }
48 text.push_str(&format!(
49 "For more information, try '{}'.",
50 paint("--help", LITERAL, color)
51 ));
52 text
53 }
54
55 pub fn print(&self) {
56 eprintln!("{}", self.render(color()));
57 }
58}
59
60impl From<String> for Refusal {
61 fn from(message: String) -> Self {
62 Self::new(message)
63 }
64}
65
66impl From<&str> for Refusal {
67 fn from(message: &str) -> Self {
68 Self::new(message)
69 }
70}
71
72impl fmt::Display for Refusal {
73 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
74 f.write_str(&self.render(false))
75 }
76}
77
78pub fn color() -> bool {
79 let set = |name: &str| std::env::var_os(name).is_some_and(|value| !value.is_empty());
80 if set("NO_COLOR") {
81 return false;
82 }
83 if set("CLICOLOR_FORCE") && std::env::var_os("CLICOLOR_FORCE").is_some_and(|v| v != "0") {
84 return true;
85 }
86 !cfg!(windows)
87 && std::io::stderr().is_terminal()
88 && std::env::var_os("TERM").is_none_or(|term| term != "dumb")
89 && std::env::var_os("CLICOLOR").is_none_or(|value| value != "0")
90}
91
92pub fn with_value(flag: &str) -> String {
93 let name = flag
94 .trim_start_matches('-')
95 .replace('-', "_")
96 .to_uppercase();
97 format!("{flag} <{name}>")
98}
99
100pub fn unexpected(argument: &str) -> String {
101 format!("unexpected argument '{argument}' found")
102}
103
104pub fn unrecognized(command: &str) -> String {
105 format!("unrecognized subcommand '{command}'")
106}
107
108pub fn no_command(path: &str, commands: &[&str]) -> String {
109 format!(
110 "'{path}' requires a subcommand but one was not provided\n [subcommands: {}]",
111 commands.join(", ")
112 )
113}
114
115pub fn repeated(argument: &str) -> String {
116 format!("the argument '{argument}' cannot be used multiple times")
117}
118
119pub fn conflict(first: &str, second: &str) -> String {
120 format!("the argument '{first}' cannot be used with '{second}'")
121}
122
123pub fn missing_value(flag: &str) -> String {
124 format!(
125 "a value is required for '{}' but none was supplied",
126 with_value(flag)
127 )
128}
129
130pub fn unexpected_value(value: &str, flag: &str) -> String {
131 format!("unexpected value '{value}' for '{flag}' found; no more were expected")
132}
133
134pub fn required<S: AsRef<str>>(arguments: &[S]) -> String {
135 let mut text = REQUIRED.to_string();
136 for argument in arguments {
137 text.push_str("\n ");
138 text.push_str(argument.as_ref());
139 }
140 text
141}
142
143pub fn invalid(value: &str, flag: &str, reason: impl fmt::Display) -> String {
144 format!(
145 "invalid value '{value}' for '{}': {reason}",
146 with_value(flag)
147 )
148}
149
150pub fn invalid_choice(value: &str, flag: &str, choices: &[&str]) -> String {
151 format!(
152 "invalid value '{value}' for '{}'\n [possible values: {}]",
153 with_value(flag),
154 choices.join(", ")
155 )
156}
157
158fn bare(message: &str) -> bool {
159 message.starts_with("a value is required for ") || message.starts_with("invalid value ")
160}
161
162fn paint(text: &str, style: &str, color: bool) -> String {
163 if color {
164 format!("{style}{text}{RESET}")
165 } else {
166 text.to_string()
167 }
168}
169
170fn quotes(line: &str, tone: impl Fn(usize, &str) -> &'static str) -> String {
171 let parts: Vec<&str> = line.split('\'').collect();
172 if parts.len().is_multiple_of(2) {
173 return line.to_string();
174 }
175 let mut text = String::new();
176 for (index, part) in parts.iter().enumerate() {
177 if index.is_multiple_of(2) {
178 text.push_str(part);
179 } else {
180 text.push('\'');
181 text.push_str(&paint(part, tone(index / 2, parts[index - 1]), true));
182 text.push('\'');
183 }
184 }
185 text
186}
187
188fn style(message: &str) -> String {
189 let mut listing = false;
190 let mut lines = Vec::new();
191 for (index, line) in message.split('\n').enumerate() {
192 if index == 0 {
193 listing = line == REQUIRED;
194 lines.push(quotes(line, |nth, before| {
195 if nth > 0 && before.ends_with("for ") {
196 LITERAL
197 } else {
198 INVALID
199 }
200 }));
201 } else if let Some(tip) = line.strip_prefix(" tip:") {
202 lines.push(format!(
203 " {}{}",
204 paint("tip:", VALID, true),
205 quotes(tip, |_, _| VALID)
206 ));
207 } else if let Some((label, items)) = line
208 .strip_prefix(" [")
209 .and_then(|rest| rest.strip_suffix(']'))
210 .and_then(|inner| inner.split_once(": "))
211 {
212 let items: Vec<String> = items
213 .split(", ")
214 .map(|item| paint(item, VALID, true))
215 .collect();
216 lines.push(format!(" [{label}: {}]", items.join(", ")));
217 } else if let Some(item) = line.strip_prefix(" ").filter(|_| listing) {
218 lines.push(format!(" {}", paint(item, VALID, true)));
219 } else {
220 lines.push(line.to_string());
221 }
222 }
223 lines.join("\n")
224}
225
226fn style_usage(usage: &str) -> String {
227 let words: Vec<&str> = usage.split(' ').collect();
228 let path = words
229 .iter()
230 .take_while(|word| word.starts_with(|c: char| c.is_alphanumeric()))
231 .count()
232 .max(1);
233 let mut parts = vec![paint(&words[..path].join(" "), LITERAL, true)];
234 for word in &words[path..] {
235 if word.starts_with('-') {
236 parts.push(paint(word, LITERAL, true));
237 } else {
238 parts.push(word.to_string());
239 }
240 }
241 parts.join(" ")
242}
243
244#[cfg(test)]
245mod tests {
246 use super::*;
247
248 #[test]
249 fn a_refusal_reads_and_colours_like_clap() {
250 let tui = Refusal::new(unrecognized("tui")).under("pfx <COMMAND>");
251 assert_eq!(
252 tui.render(false),
253 "error: unrecognized subcommand 'tui'\n\nUsage: pfx <COMMAND>\n\nFor more information, try '--help'."
254 );
255 assert_eq!(
256 tui.render(true),
257 "\u{1b}[1m\u{1b}[31merror:\u{1b}[0m unrecognized subcommand '\u{1b}[33mtui\u{1b}[0m'\n\n\u{1b}[1m\u{1b}[4mUsage:\u{1b}[0m \u{1b}[1mpfx\u{1b}[0m <COMMAND>\n\nFor more information, try '\u{1b}[1m--help\u{1b}[0m'."
258 );
259 let missing = Refusal::new(missing_value("--out")).under("pfx bake [OPTIONS]");
260 assert_eq!(
261 missing.render(false),
262 "error: a value is required for '--out <OUT>' but none was supplied\n\nFor more information, try '--help'."
263 );
264 assert_eq!(
265 Refusal::new(no_command("pfx steam", &["stage"]))
266 .under("pfx steam <COMMAND>")
267 .render(true),
268 "\u{1b}[1m\u{1b}[31merror:\u{1b}[0m '\u{1b}[33mpfx steam\u{1b}[0m' requires a subcommand but one was not provided\n [subcommands: \u{1b}[32mstage\u{1b}[0m]\n\n\u{1b}[1m\u{1b}[4mUsage:\u{1b}[0m \u{1b}[1mpfx steam\u{1b}[0m <COMMAND>\n\nFor more information, try '\u{1b}[1m--help\u{1b}[0m'."
269 );
270 assert_eq!(
271 Refusal::new(required(&[with_value("--scene")]))
272 .under("pfx render [OPTIONS] --scene <SCENE> <--clip|--still>")
273 .render(true),
274 "\u{1b}[1m\u{1b}[31merror:\u{1b}[0m the following required arguments were not provided:\n \u{1b}[32m--scene <SCENE>\u{1b}[0m\n\n\u{1b}[1m\u{1b}[4mUsage:\u{1b}[0m \u{1b}[1mpfx render\u{1b}[0m [OPTIONS] \u{1b}[1m--scene\u{1b}[0m <SCENE> <--clip|--still>\n\nFor more information, try '\u{1b}[1m--help\u{1b}[0m'."
275 );
276 assert_eq!(
277 Refusal::new(invalid("x", "--samples", "invalid digit found in string")).render(true),
278 "\u{1b}[1m\u{1b}[31merror:\u{1b}[0m invalid value '\u{1b}[33mx\u{1b}[0m' for '\u{1b}[1m--samples <SAMPLES>\u{1b}[0m': invalid digit found in string\n\nFor more information, try '\u{1b}[1m--help\u{1b}[0m'."
279 );
280 assert_eq!(
281 Refusal::new(format!(
282 "{}\n\n tip: a similar argument exists: '--scene'",
283 unexpected("--scen")
284 ))
285 .under("pfx render [OPTIONS]")
286 .render(true),
287 "\u{1b}[1m\u{1b}[31merror:\u{1b}[0m unexpected argument '\u{1b}[33m--scen\u{1b}[0m' found\n\n \u{1b}[32mtip:\u{1b}[0m a similar argument exists: '\u{1b}[32m--scene\u{1b}[0m'\n\n\u{1b}[1m\u{1b}[4mUsage:\u{1b}[0m \u{1b}[1mpfx render\u{1b}[0m [OPTIONS]\n\nFor more information, try '\u{1b}[1m--help\u{1b}[0m'."
288 );
289 assert_eq!(
290 Refusal::new(conflict("--clip", "--still")).render(true),
291 "\u{1b}[1m\u{1b}[31merror:\u{1b}[0m the argument '\u{1b}[33m--clip\u{1b}[0m' cannot be used with '\u{1b}[33m--still\u{1b}[0m'\n\nFor more information, try '\u{1b}[1m--help\u{1b}[0m'."
292 );
293 }
294}