1use std::fmt;
4use std::io::Write as _;
5
6use crate::{formatdoc, writedoc};
7
8#[derive(Clone, Copy, Debug, Eq, PartialEq)]
15pub enum FlagClash {
16 Repeated,
18 Contradictory,
20 Redundant,
22}
23
24#[derive(Clone, Debug, Eq, PartialEq)]
30#[non_exhaustive]
31pub struct FlagWarning {
32 pub clash: FlagClash,
34 pub names: Vec<String>,
36}
37
38impl FlagWarning {
39 #[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#[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#[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
128pub 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}