Skip to main content

scx_nitosis/
undefok_flags.rs

1use std::collections::HashSet;
2use std::ffi::OsString;
3
4use anyhow::{anyhow, bail, Context, Result};
5use clap::{CommandFactory, Parser};
6
7pub struct IgnoredUndefOkFlag {
8    pub long: String,
9}
10
11pub struct ParsedArgs<T> {
12    pub opts: T,
13    pub ignored_undefok_flags: Vec<IgnoredUndefOkFlag>,
14}
15
16fn normalize_undefok_name(name: &str) -> Result<String> {
17    let normalized = name.trim();
18    if normalized.is_empty() {
19        bail!("--undefok entries must not be empty");
20    }
21    if normalized.starts_with('-') {
22        bail!("--undefok entries must not include leading dashes");
23    }
24    if normalized.contains('=') {
25        bail!("--undefok entry {name:?} must name a flag, not include a value");
26    }
27    Ok(normalized.to_owned())
28}
29
30fn collect_configured_undefok_flags(args: &[OsString]) -> Result<HashSet<String>> {
31    let mut configured = HashSet::new();
32    let mut iter = args.iter().peekable();
33
34    while let Some(arg) = iter.next() {
35        let Some(arg_str) = arg.to_str() else {
36            continue;
37        };
38
39        let value = if let Some(value) = arg_str.strip_prefix("--undefok=") {
40            value
41        } else if arg_str == "--undefok" {
42            let next = iter
43                .next()
44                .ok_or_else(|| anyhow!("--undefok requires a comma-separated value"))?;
45            next.to_str()
46                .ok_or_else(|| anyhow!("--undefok value must be valid UTF-8"))?
47        } else {
48            continue;
49        };
50
51        for raw_name in value.split(',') {
52            configured.insert(normalize_undefok_name(raw_name)?);
53        }
54    }
55
56    Ok(configured)
57}
58
59fn filter_undefok_args(
60    args: Vec<OsString>,
61    configured_undefok_flags: &HashSet<String>,
62    active_longs: &HashSet<String>,
63) -> (Vec<OsString>, Vec<IgnoredUndefOkFlag>) {
64    let mut filtered = Vec::with_capacity(args.len());
65    let mut ignored_undefok_flags = Vec::new();
66    let mut iter = args.into_iter().peekable();
67
68    while let Some(arg) = iter.next() {
69        let Some(arg_str) = arg.to_str() else {
70            filtered.push(arg);
71            continue;
72        };
73
74        if arg_str == "--undefok" {
75            filtered.push(arg);
76            if let Some(next) = iter.next() {
77                filtered.push(next);
78            }
79            continue;
80        }
81
82        if arg_str.starts_with("--undefok=") {
83            filtered.push(arg);
84            continue;
85        }
86
87        if !arg_str.starts_with("--") || arg_str == "--" {
88            filtered.push(arg);
89            continue;
90        }
91
92        let body = &arg_str[2..];
93        let (name, has_inline_value) = match body.split_once('=') {
94            Some((name, _)) => (name, true),
95            None => (body, false),
96        };
97
98        if !configured_undefok_flags.contains(name) || active_longs.contains(name) {
99            filtered.push(arg);
100            continue;
101        }
102
103        ignored_undefok_flags.push(IgnoredUndefOkFlag {
104            long: name.to_owned(),
105        });
106
107        if !has_inline_value {
108            let should_consume_value = iter
109                .peek()
110                .and_then(|next| next.to_str())
111                .is_some_and(|next| !next.starts_with('-'));
112            if should_consume_value {
113                let _ = iter.next();
114            }
115        }
116    }
117
118    (filtered, ignored_undefok_flags)
119}
120
121fn parse_args_from<T>(raw_args: Vec<OsString>) -> Result<ParsedArgs<T>>
122where
123    T: Parser + CommandFactory,
124{
125    let configured_undefok_flags = collect_configured_undefok_flags(&raw_args)?;
126    let active_longs: HashSet<String> = T::command()
127        .get_arguments()
128        .filter_map(|arg| arg.get_long().map(str::to_owned))
129        .collect();
130
131    let (filtered_args, ignored_undefok_flags) =
132        filter_undefok_args(raw_args, &configured_undefok_flags, &active_longs);
133    let opts = T::try_parse_from(filtered_args).context("parsing command-line arguments")?;
134
135    Ok(ParsedArgs {
136        opts,
137        ignored_undefok_flags,
138    })
139}
140
141pub fn parse_args<T>() -> Result<ParsedArgs<T>>
142where
143    T: Parser + CommandFactory,
144{
145    parse_args_from(std::env::args_os().collect())
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151    use clap::Parser;
152
153    fn os_vec(args: &[&str]) -> Vec<OsString> {
154        args.iter().map(OsString::from).collect()
155    }
156
157    fn strings(args: Vec<OsString>) -> Vec<String> {
158        args.into_iter()
159            .map(|arg| arg.into_string().expect("test args must be valid UTF-8"))
160            .collect()
161    }
162
163    #[derive(Debug, Parser)]
164    struct NoOverlapOpts {
165        #[clap(long, value_delimiter = ',')]
166        undefok: Vec<String>,
167        #[clap(long)]
168        monitor_interval_s: Option<u64>,
169    }
170
171    #[test]
172    fn collects_inline_undefok_entries() {
173        let configured = collect_configured_undefok_flags(&os_vec(&[
174            "scx_nitosis",
175            "--undefok=reconfiguration-interval-s,rebalance-cpus-interval-s",
176        ]))
177        .expect("inline undefok should parse");
178
179        assert!(configured.contains("reconfiguration-interval-s"));
180        assert!(configured.contains("rebalance-cpus-interval-s"));
181    }
182
183    #[test]
184    fn collects_separate_undefok_value() {
185        let configured = collect_configured_undefok_flags(&os_vec(&[
186            "scx_nitosis",
187            "--undefok",
188            "reconfiguration-interval-s",
189        ]))
190        .expect("separate undefok should parse");
191
192        assert!(configured.contains("reconfiguration-interval-s"));
193    }
194
195    #[test]
196    fn filters_configured_undefok_flag_with_inline_value() {
197        let configured = collect_configured_undefok_flags(&os_vec(&[
198            "scx_nitosis",
199            "--undefok=reconfiguration-interval-s",
200        ]))
201        .expect("undefok should parse");
202
203        let (filtered, ignored) = filter_undefok_args(
204            os_vec(&[
205                "scx_nitosis",
206                "--undefok=reconfiguration-interval-s",
207                "--reconfiguration-interval-s=10",
208                "--monitor-interval-s",
209                "2",
210            ]),
211            &configured,
212            &HashSet::from([String::from("monitor-interval-s")]),
213        );
214
215        assert_eq!(
216            strings(filtered),
217            vec![
218                "scx_nitosis",
219                "--undefok=reconfiguration-interval-s",
220                "--monitor-interval-s",
221                "2"
222            ]
223        );
224        assert_eq!(ignored.len(), 1);
225        assert_eq!(ignored[0].long, "reconfiguration-interval-s");
226    }
227
228    #[test]
229    fn filters_configured_undefok_flag_with_separate_value() {
230        let configured = collect_configured_undefok_flags(&os_vec(&[
231            "scx_nitosis",
232            "--undefok=rebalance-cpus-interval-s",
233        ]))
234        .expect("undefok should parse");
235
236        let (filtered, ignored) = filter_undefok_args(
237            os_vec(&[
238                "scx_nitosis",
239                "--undefok=rebalance-cpus-interval-s",
240                "--rebalance-cpus-interval-s",
241                "5",
242                "--monitor-interval-s",
243                "2",
244            ]),
245            &configured,
246            &HashSet::from([String::from("monitor-interval-s")]),
247        );
248
249        assert_eq!(
250            strings(filtered),
251            vec![
252                "scx_nitosis",
253                "--undefok=rebalance-cpus-interval-s",
254                "--monitor-interval-s",
255                "2"
256            ]
257        );
258        assert_eq!(ignored.len(), 1);
259        assert_eq!(ignored[0].long, "rebalance-cpus-interval-s");
260    }
261
262    #[test]
263    fn missing_undefok_value_does_not_consume_next_flag() {
264        let configured = collect_configured_undefok_flags(&os_vec(&[
265            "scx_nitosis",
266            "--undefok=rebalance-cpus-interval-s",
267        ]))
268        .expect("undefok should parse");
269
270        let (filtered, ignored) = filter_undefok_args(
271            os_vec(&[
272                "scx_nitosis",
273                "--undefok=rebalance-cpus-interval-s",
274                "--rebalance-cpus-interval-s",
275                "--monitor-interval-s",
276                "2",
277            ]),
278            &configured,
279            &HashSet::from([String::from("monitor-interval-s")]),
280        );
281
282        assert_eq!(
283            strings(filtered),
284            vec![
285                "scx_nitosis",
286                "--undefok=rebalance-cpus-interval-s",
287                "--monitor-interval-s",
288                "2"
289            ]
290        );
291        assert_eq!(ignored.len(), 1);
292    }
293
294    #[test]
295    fn unknown_flags_are_left_for_clap_when_not_listed_in_undefok() {
296        let configured = collect_configured_undefok_flags(&os_vec(&[
297            "scx_nitosis",
298            "--undefok=reconfiguration-interval-s",
299        ]))
300        .expect("undefok should parse");
301
302        let (filtered, ignored) = filter_undefok_args(
303            os_vec(&[
304                "scx_nitosis",
305                "--undefok=reconfiguration-interval-s",
306                "--unknown-flag",
307                "value",
308                "--monitor-interval-s",
309                "2",
310            ]),
311            &configured,
312            &HashSet::from([String::from("monitor-interval-s")]),
313        );
314
315        assert_eq!(
316            strings(filtered),
317            vec![
318                "scx_nitosis",
319                "--undefok=reconfiguration-interval-s",
320                "--unknown-flag",
321                "value",
322                "--monitor-interval-s",
323                "2"
324            ]
325        );
326        assert!(ignored.is_empty());
327    }
328
329    #[test]
330    fn listed_flag_may_be_absent() {
331        let configured = collect_configured_undefok_flags(&os_vec(&[
332            "scx_nitosis",
333            "--undefok=reconfiguration-interval-s",
334        ]))
335        .expect("undefok should parse");
336
337        let (filtered, ignored) = filter_undefok_args(
338            os_vec(&[
339                "scx_nitosis",
340                "--undefok=reconfiguration-interval-s",
341                "--monitor-interval-s",
342                "2",
343            ]),
344            &configured,
345            &HashSet::from([String::from("monitor-interval-s")]),
346        );
347
348        assert_eq!(
349            strings(filtered),
350            vec![
351                "scx_nitosis",
352                "--undefok=reconfiguration-interval-s",
353                "--monitor-interval-s",
354                "2"
355            ]
356        );
357        assert!(ignored.is_empty());
358    }
359
360    #[test]
361    fn active_flag_may_be_listed_in_undefok_without_being_filtered() {
362        let configured = collect_configured_undefok_flags(&os_vec(&[
363            "scx_nitosis",
364            "--undefok=monitor-interval-s",
365        ]))
366        .expect("undefok should parse");
367
368        let (filtered, ignored) = filter_undefok_args(
369            os_vec(&[
370                "scx_nitosis",
371                "--undefok=monitor-interval-s",
372                "--monitor-interval-s",
373                "2",
374            ]),
375            &configured,
376            &HashSet::from([String::from("monitor-interval-s")]),
377        );
378
379        assert_eq!(
380            strings(filtered),
381            vec![
382                "scx_nitosis",
383                "--undefok=monitor-interval-s",
384                "--monitor-interval-s",
385                "2"
386            ]
387        );
388        assert!(ignored.is_empty());
389    }
390
391    #[test]
392    fn rejects_empty_undefok_entry() {
393        let err = collect_configured_undefok_flags(&os_vec(&[
394            "scx_nitosis",
395            "--undefok=reconfiguration-interval-s,",
396        ]))
397        .expect_err("empty undefok entry should fail");
398
399        assert!(
400            err.to_string()
401                .contains("--undefok entries must not be empty"),
402            "unexpected error: {err}"
403        );
404    }
405
406    #[test]
407    fn rejects_undefok_entry_with_leading_dashes() {
408        let err = collect_configured_undefok_flags(&os_vec(&[
409            "scx_nitosis",
410            "--undefok=--reconfiguration-interval-s",
411        ]))
412        .expect_err("leading dashes in undefok entries should fail");
413
414        assert!(
415            err.to_string()
416                .contains("--undefok entries must not include leading dashes"),
417            "unexpected error: {err}"
418        );
419    }
420
421    #[test]
422    fn parse_from_keeps_active_flag_functional_when_listed_in_undefok() {
423        let parsed = parse_args_from::<NoOverlapOpts>(os_vec(&[
424            "scx_nitosis",
425            "--undefok=monitor-interval-s",
426            "--monitor-interval-s",
427            "2",
428        ]))
429        .expect("active flags listed in undefok should still parse");
430
431        assert_eq!(parsed.opts.undefok, vec!["monitor-interval-s"]);
432        assert_eq!(parsed.opts.monitor_interval_s, Some(2));
433        assert!(parsed.ignored_undefok_flags.is_empty());
434    }
435
436    #[test]
437    fn parse_from_ignores_and_records_unknown_flag_listed_in_undefok() {
438        let parsed = parse_args_from::<NoOverlapOpts>(os_vec(&[
439            "scx_nitosis",
440            "--undefok=reconfiguration-interval-s",
441            "--reconfiguration-interval-s",
442            "10",
443            "--monitor-interval-s",
444            "2",
445        ]))
446        .expect("unknown flags listed in undefok should be ignored");
447
448        assert_eq!(parsed.opts.undefok, vec!["reconfiguration-interval-s"]);
449        assert_eq!(parsed.opts.monitor_interval_s, Some(2));
450        assert_eq!(parsed.ignored_undefok_flags.len(), 1);
451        assert_eq!(
452            parsed.ignored_undefok_flags[0].long,
453            "reconfiguration-interval-s"
454        );
455    }
456
457    #[test]
458    fn parse_from_rejects_unknown_flag_not_listed_in_undefok() {
459        let err = match parse_args_from::<NoOverlapOpts>(os_vec(&[
460            "scx_nitosis",
461            "--unknown-flag",
462            "10",
463        ])) {
464            Ok(_) => panic!("unknown flags not listed in undefok should still fail"),
465            Err(err) => err,
466        };
467
468        assert!(
469            err.chain().any(|cause| cause
470                .to_string()
471                .contains("unexpected argument '--unknown-flag'")),
472            "unexpected error: {err}"
473        );
474    }
475}