Skip to main content

ctl_core/flags/
warn.rs

1//! Warn on repeated or contradictory shared flags. The last value still wins.
2
3use std::fmt;
4use std::io::Write as _;
5
6use crate::{formatdoc, writedoc};
7
8/// The type of clash between flags that a [`FlagWarning`] reports. The last
9/// value still wins in every case.
10///
11/// The set is closed, and the enum is deliberately not `#[non_exhaustive]`.
12/// Two flags given together are the same flag, two names of one flag, or
13/// opposites; no other relation between them exists.
14#[derive(Clone, Copy, Debug, Eq, PartialEq)]
15pub enum FlagClash {
16    /// Same flag twice (`--format json --format pretty`).
17    Repeated,
18    /// Opposite flags (`--color always --no-color`).
19    Contradictory,
20    /// Aliases of the same flag (`--dry-run --preview`).
21    Redundant,
22}
23
24/// One flag clash. Its `Display` text is the sentence a warning line holds
25/// after the `{bin}: warning: ` prefix.
26///
27/// [`chassis_warnings`] builds them. A field can join the struct in a minor
28/// release.
29#[derive(Clone, Debug, Eq, PartialEq)]
30#[non_exhaustive]
31pub struct FlagWarning {
32    /// The type of clash.
33    pub clash: FlagClash,
34    /// The flags involved, spelled as in a warning (`--color`, `--no-color`).
35    pub names: Vec<String>,
36}
37
38impl FlagWarning {
39    /// The full warning line for stderr, `{bin}: warning: …`, without a
40    /// trailing newline.
41    #[must_use]
42    pub fn line(&self, bin: &str) -> String {
43        formatdoc!("{bin}: warning: {self}")
44    }
45}
46
47impl fmt::Display for FlagWarning {
48    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
49        let names = match self.clash {
50            FlagClash::Repeated => self.names.join("/"),
51            FlagClash::Contradictory | FlagClash::Redundant => self.names.join(" and "),
52        };
53        match self.clash {
54            FlagClash::Repeated => {
55                writedoc!(f, "{names} repeated; last value wins")
56            }
57            FlagClash::Contradictory => {
58                writedoc!(f, "{names} both set; last wins")
59            }
60            FlagClash::Redundant => {
61                writedoc!(f, "{names} are the same flag")
62            }
63        }
64    }
65}
66
67/// Find clashes among ctl-core's shared flags in `args`.
68///
69/// `args` excludes the program name. Scanning stops at `--`. The result lists
70/// a repeated `--color`, `--format`, `--quiet`, or `--no-color`, a `--color`
71/// with `--no-color`, and a repeated or aliased `--dry-run`.
72#[must_use]
73pub fn chassis_warnings<'a, I>(args: I) -> Vec<FlagWarning>
74where
75    I: IntoIterator<Item = &'a str>,
76{
77    let seen = collect(args);
78    let mut out = Vec::new();
79    push_repeat(&mut out, &seen.color, "--color");
80    push_repeat(&mut out, &seen.format, "--format");
81    push_repeat(&mut out, &seen.quiet, "--quiet");
82    push_repeat(&mut out, &seen.color_off, "--no-color");
83    if !seen.color.is_empty() && !seen.color_off.is_empty() {
84        out.push(FlagWarning {
85            clash: FlagClash::Contradictory,
86            names: vec!["--color".into(), "--no-color".into()],
87        });
88    }
89    push_dry(&mut out, &seen.dry);
90    out
91}
92
93/// Find a `--foo` / `--no-foo` pair given together in `args`.
94///
95/// `yes` and `no` list the spellings of each side. The result holds one
96/// [`FlagClash::Contradictory`] warning, naming the first spelling of each
97/// side, or is empty. Scanning stops at `--`. Pair it with Clap's
98/// `overrides_with` on both sides, so the last flag wins and the clash is not
99/// silent.
100#[must_use]
101pub fn warn_opposites<'a, I>(args: I, yes: &[&str], no: &[&str]) -> Vec<FlagWarning>
102where
103    I: IntoIterator<Item = &'a str>,
104{
105    let mut saw_yes = false;
106    let mut saw_no = false;
107    for arg in args {
108        if arg == "--" {
109            break;
110        }
111        if matches_any(arg, yes) {
112            saw_yes = true;
113        }
114        if matches_any(arg, no) {
115            saw_no = true;
116        }
117    }
118    if saw_yes && saw_no {
119        vec![FlagWarning {
120            clash: FlagClash::Contradictory,
121            names: vec![yes[0].to_owned(), no[0].to_owned()],
122        }]
123    } else {
124        Vec::new()
125    }
126}
127
128/// Print each warning to stderr as `{bin}: warning: …`, one per line.
129pub fn emit_warnings<'a, I>(bin: &str, warnings: I)
130where
131    I: IntoIterator<Item = &'a FlagWarning>,
132{
133    for warning in warnings {
134        let _ = writeln!(std::io::stderr().lock(), "{}", warning.line(bin));
135    }
136}
137
138#[derive(Default)]
139struct Seen {
140    color: Vec<String>,
141    color_off: Vec<String>,
142    format: Vec<String>,
143    quiet: Vec<String>,
144    dry: Vec<String>,
145}
146
147fn collect<'a, I>(args: I) -> Seen
148where
149    I: IntoIterator<Item = &'a str>,
150{
151    let mut seen = Seen::default();
152    let mut args = args.into_iter().peekable();
153    while let Some(arg) = args.next() {
154        if arg == "--" {
155            break;
156        }
157        if arg == "--no-color" {
158            seen.color_off.push(arg.to_owned());
159            continue;
160        }
161        if matches_any(arg, &["-q", "--quiet"]) {
162            seen.quiet.push("--quiet".into());
163            continue;
164        }
165        if matches_any(arg, &["-n", "--dry-run", "--preview"]) {
166            seen.dry.push(arg.to_owned());
167            continue;
168        }
169        if take_value(&mut args, arg, &["-c", "--color"]) {
170            seen.color.push("--color".into());
171            continue;
172        }
173        if take_value(&mut args, arg, &["-f", "--format"]) {
174            seen.format.push("--format".into());
175        }
176    }
177    seen
178}
179
180fn take_value<'a, I>(args: &mut std::iter::Peekable<I>, arg: &str, names: &[&str]) -> bool
181where
182    I: Iterator<Item = &'a str>,
183{
184    if names.contains(&arg) {
185        if matches!(args.peek(), Some(next) if !next.starts_with('-')) {
186            args.next();
187        }
188        return true;
189    }
190    names.iter().any(|name| {
191        name.starts_with("--")
192            && arg
193                .strip_prefix(name)
194                .is_some_and(|rest| rest.starts_with('='))
195    })
196}
197
198fn matches_any(arg: &str, names: &[&str]) -> bool {
199    names.contains(&arg)
200}
201
202fn push_repeat(out: &mut Vec<FlagWarning>, hits: &[String], name: &str) {
203    if hits.len() > 1 {
204        out.push(repeat(name));
205    }
206}
207
208fn push_dry(out: &mut Vec<FlagWarning>, hits: &[String]) {
209    if hits.len() < 2 {
210        return;
211    }
212    let aliases = hits.iter().any(|hit| hit == "--preview")
213        && hits.iter().any(|hit| hit == "--dry-run" || hit == "-n");
214    if aliases {
215        out.push(FlagWarning {
216            clash: FlagClash::Redundant,
217            names: vec!["--dry-run".into(), "--preview".into()],
218        });
219    } else {
220        out.push(repeat("--dry-run"));
221    }
222}
223
224fn repeat(name: &str) -> FlagWarning {
225    FlagWarning {
226        clash: FlagClash::Repeated,
227        names: vec![name.to_owned()],
228    }
229}
230
231#[cfg(test)]
232mod tests {
233    use super::{FlagClash, FlagWarning, chassis_warnings, warn_opposites};
234
235    fn clashes(args: &[&str]) -> Vec<FlagClash> {
236        chassis_warnings(args.iter().copied())
237            .into_iter()
238            .map(|warning| warning.clash)
239            .collect()
240    }
241
242    #[test]
243    fn equals_form_is_an_occurrence() {
244        assert_eq!(
245            clashes(&["--format=json", "--format=pretty"]),
246            [FlagClash::Repeated]
247        );
248        assert_eq!(
249            clashes(&["--color=always", "--color=never"]),
250            [FlagClash::Repeated]
251        );
252    }
253
254    #[test]
255    fn peek_does_not_eat_the_following_flag() {
256        let hits = clashes(&["--color", "--no-color"]);
257        assert!(hits.contains(&FlagClash::Contradictory), "{hits:?}");
258    }
259
260    #[test]
261    fn stops_at_double_dash() {
262        assert_eq!(
263            clashes(&["--format", "json", "--", "--format", "pretty"]),
264            Vec::<FlagClash>::new()
265        );
266        assert_eq!(
267            warn_opposites(["--pr", "--", "--no-pr"], &["--pr"], &["--no-pr"]),
268            Vec::<FlagWarning>::new()
269        );
270    }
271
272    #[test]
273    fn repeated_no_color() {
274        assert_eq!(
275            clashes(&["--no-color", "--no-color"]),
276            [FlagClash::Repeated]
277        );
278    }
279
280    #[test]
281    fn short_and_long_color_is_repeated() {
282        assert_eq!(
283            clashes(&["-c", "always", "--color", "never"]),
284            [FlagClash::Repeated]
285        );
286    }
287}