Skip to main content

pfx_core/
cli.rs

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}