Skip to main content

ctl_core/flags/
warn.rs

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