diffutilslib/
params.rs

1use std::ffi::OsString;
2use std::path::PathBuf;
3
4use regex::Regex;
5
6#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
7pub enum Format {
8    #[default]
9    Normal,
10    Unified,
11    Context,
12    Ed,
13}
14
15#[derive(Clone, Debug, Eq, PartialEq)]
16pub struct Params {
17    pub executable: OsString,
18    pub from: OsString,
19    pub to: OsString,
20    pub format: Format,
21    pub context_count: usize,
22    pub report_identical_files: bool,
23    pub brief: bool,
24    pub expand_tabs: bool,
25    pub tabsize: usize,
26}
27
28impl Default for Params {
29    fn default() -> Self {
30        Self {
31            executable: OsString::default(),
32            from: OsString::default(),
33            to: OsString::default(),
34            format: Format::default(),
35            context_count: 3,
36            report_identical_files: false,
37            brief: false,
38            expand_tabs: false,
39            tabsize: 8,
40        }
41    }
42}
43
44pub fn parse_params<I: IntoIterator<Item = OsString>>(opts: I) -> Result<Params, String> {
45    let mut opts = opts.into_iter().peekable();
46    // parse CLI
47
48    let Some(executable) = opts.next() else {
49        return Err("Usage: <exe> <from> <to>".to_string());
50    };
51    let mut params = Params {
52        executable,
53        ..Default::default()
54    };
55    let mut from = None;
56    let mut to = None;
57    let mut format = None;
58    let mut context = None;
59    let tabsize_re = Regex::new(r"^--tabsize=(?<num>\d+)$").unwrap();
60    while let Some(param) = opts.next() {
61        let next_param = opts.peek();
62        if param == "--" {
63            break;
64        }
65        if param == "-" {
66            if from.is_none() {
67                from = Some(param);
68            } else if to.is_none() {
69                to = Some(param);
70            } else {
71                return Err(format!(
72                    "Usage: {} <from> <to>",
73                    params.executable.to_string_lossy()
74                ));
75            }
76            continue;
77        }
78        if param == "-s" || param == "--report-identical-files" {
79            params.report_identical_files = true;
80            continue;
81        }
82        if param == "-q" || param == "--brief" {
83            params.brief = true;
84            continue;
85        }
86        if param == "-t" || param == "--expand-tabs" {
87            params.expand_tabs = true;
88            continue;
89        }
90        if param == "--normal" {
91            if format.is_some() && format != Some(Format::Normal) {
92                return Err("Conflicting output style options".to_string());
93            }
94            format = Some(Format::Normal);
95            continue;
96        }
97        if param == "-e" || param == "--ed" {
98            if format.is_some() && format != Some(Format::Ed) {
99                return Err("Conflicting output style options".to_string());
100            }
101            format = Some(Format::Ed);
102            continue;
103        }
104        if tabsize_re.is_match(param.to_string_lossy().as_ref()) {
105            // Because param matches the regular expression,
106            // it is safe to assume it is valid UTF-8.
107            let param = param.into_string().unwrap();
108            let tabsize_str = tabsize_re
109                .captures(param.as_str())
110                .unwrap()
111                .name("num")
112                .unwrap()
113                .as_str();
114            params.tabsize = match tabsize_str.parse::<usize>() {
115                Ok(num) => num,
116                Err(_) => return Err(format!("invalid tabsize «{tabsize_str}»")),
117            };
118            continue;
119        }
120        match match_context_diff_params(&param, next_param, format) {
121            Ok(DiffStyleMatch {
122                is_match,
123                context_count,
124                next_param_consumed,
125            }) => {
126                if is_match {
127                    format = Some(Format::Context);
128                    if context_count.is_some() {
129                        context = context_count;
130                    }
131                    if next_param_consumed {
132                        opts.next();
133                    }
134                    continue;
135                }
136            }
137            Err(error) => return Err(error),
138        }
139        match match_unified_diff_params(&param, next_param, format) {
140            Ok(DiffStyleMatch {
141                is_match,
142                context_count,
143                next_param_consumed,
144            }) => {
145                if is_match {
146                    format = Some(Format::Unified);
147                    if context_count.is_some() {
148                        context = context_count;
149                    }
150                    if next_param_consumed {
151                        opts.next();
152                    }
153                    continue;
154                }
155            }
156            Err(error) => return Err(error),
157        }
158        if param.to_string_lossy().starts_with('-') {
159            return Err(format!("Unknown option: {:?}", param));
160        }
161        if from.is_none() {
162            from = Some(param);
163        } else if to.is_none() {
164            to = Some(param);
165        } else {
166            return Err(format!(
167                "Usage: {} <from> <to>",
168                params.executable.to_string_lossy()
169            ));
170        }
171    }
172    params.from = if let Some(from) = from {
173        from
174    } else if let Some(param) = opts.next() {
175        param
176    } else {
177        return Err(format!(
178            "Usage: {} <from> <to>",
179            params.executable.to_string_lossy()
180        ));
181    };
182    params.to = if let Some(to) = to {
183        to
184    } else if let Some(param) = opts.next() {
185        param
186    } else {
187        return Err(format!(
188            "Usage: {} <from> <to>",
189            params.executable.to_string_lossy()
190        ));
191    };
192
193    // diff DIRECTORY FILE => diff DIRECTORY/FILE FILE
194    // diff FILE DIRECTORY => diff FILE DIRECTORY/FILE
195    let mut from_path: PathBuf = PathBuf::from(&params.from);
196    let mut to_path: PathBuf = PathBuf::from(&params.to);
197
198    if from_path.is_dir() && to_path.is_file() {
199        from_path.push(to_path.file_name().unwrap());
200        params.from = from_path.into_os_string();
201    } else if from_path.is_file() && to_path.is_dir() {
202        to_path.push(from_path.file_name().unwrap());
203        params.to = to_path.into_os_string();
204    }
205
206    params.format = format.unwrap_or(Format::default());
207    if let Some(context_count) = context {
208        params.context_count = context_count;
209    }
210    Ok(params)
211}
212
213struct DiffStyleMatch {
214    is_match: bool,
215    context_count: Option<usize>,
216    next_param_consumed: bool,
217}
218
219fn match_context_diff_params(
220    param: &OsString,
221    next_param: Option<&OsString>,
222    format: Option<Format>,
223) -> Result<DiffStyleMatch, String> {
224    const CONTEXT_RE: &str = r"^(-[cC](?<num1>\d*)|--context(=(?<num2>\d*))?|-(?<num3>\d+)c)$";
225    let regex = Regex::new(CONTEXT_RE).unwrap();
226    let is_match = regex.is_match(param.to_string_lossy().as_ref());
227    let mut context_count = None;
228    let mut next_param_consumed = false;
229    if is_match {
230        if format.is_some() && format != Some(Format::Context) {
231            return Err("Conflicting output style options".to_string());
232        }
233        let captures = regex.captures(param.to_str().unwrap()).unwrap();
234        let num = captures
235            .name("num1")
236            .or(captures.name("num2"))
237            .or(captures.name("num3"));
238        if let Some(numvalue) = num {
239            if !numvalue.as_str().is_empty() {
240                context_count = Some(numvalue.as_str().parse::<usize>().unwrap());
241            }
242        }
243        if param == "-C" && next_param.is_some() {
244            match next_param.unwrap().to_string_lossy().parse::<usize>() {
245                Ok(context_size) => {
246                    context_count = Some(context_size);
247                    next_param_consumed = true;
248                }
249                Err(_) => {
250                    return Err(format!(
251                        "invalid context length '{}'",
252                        next_param.unwrap().to_string_lossy()
253                    ))
254                }
255            }
256        }
257    }
258    Ok(DiffStyleMatch {
259        is_match,
260        context_count,
261        next_param_consumed,
262    })
263}
264
265fn match_unified_diff_params(
266    param: &OsString,
267    next_param: Option<&OsString>,
268    format: Option<Format>,
269) -> Result<DiffStyleMatch, String> {
270    const UNIFIED_RE: &str = r"^(-[uU](?<num1>\d*)|--unified(=(?<num2>\d*))?|-(?<num3>\d+)u)$";
271    let regex = Regex::new(UNIFIED_RE).unwrap();
272    let is_match = regex.is_match(param.to_string_lossy().as_ref());
273    let mut context_count = None;
274    let mut next_param_consumed = false;
275    if is_match {
276        if format.is_some() && format != Some(Format::Unified) {
277            return Err("Conflicting output style options".to_string());
278        }
279        let captures = regex.captures(param.to_str().unwrap()).unwrap();
280        let num = captures
281            .name("num1")
282            .or(captures.name("num2"))
283            .or(captures.name("num3"));
284        if let Some(numvalue) = num {
285            if !numvalue.as_str().is_empty() {
286                context_count = Some(numvalue.as_str().parse::<usize>().unwrap());
287            }
288        }
289        if param == "-U" && next_param.is_some() {
290            match next_param.unwrap().to_string_lossy().parse::<usize>() {
291                Ok(context_size) => {
292                    context_count = Some(context_size);
293                    next_param_consumed = true;
294                }
295                Err(_) => {
296                    return Err(format!(
297                        "invalid context length '{}'",
298                        next_param.unwrap().to_string_lossy()
299                    ))
300                }
301            }
302        }
303    }
304    Ok(DiffStyleMatch {
305        is_match,
306        context_count,
307        next_param_consumed,
308    })
309}
310
311#[cfg(test)]
312mod tests {
313    use super::*;
314    fn os(s: &str) -> OsString {
315        OsString::from(s)
316    }
317    #[test]
318    fn basics() {
319        assert_eq!(
320            Ok(Params {
321                executable: os("diff"),
322                from: os("foo"),
323                to: os("bar"),
324                ..Default::default()
325            }),
326            parse_params([os("diff"), os("foo"), os("bar")].iter().cloned())
327        );
328        assert_eq!(
329            Ok(Params {
330                executable: os("diff"),
331                from: os("foo"),
332                to: os("bar"),
333                ..Default::default()
334            }),
335            parse_params(
336                [os("diff"), os("--normal"), os("foo"), os("bar")]
337                    .iter()
338                    .cloned()
339            )
340        );
341    }
342    #[test]
343    fn basics_ed() {
344        for arg in ["-e", "--ed"] {
345            assert_eq!(
346                Ok(Params {
347                    executable: os("diff"),
348                    from: os("foo"),
349                    to: os("bar"),
350                    format: Format::Ed,
351                    ..Default::default()
352                }),
353                parse_params([os("diff"), os(arg), os("foo"), os("bar")].iter().cloned())
354            );
355        }
356    }
357    #[test]
358    fn context_valid() {
359        for args in [vec!["-c"], vec!["--context"], vec!["--context="]] {
360            let mut params = vec!["diff"];
361            params.extend(args);
362            params.extend(["foo", "bar"]);
363            assert_eq!(
364                Ok(Params {
365                    executable: os("diff"),
366                    from: os("foo"),
367                    to: os("bar"),
368                    format: Format::Context,
369                    ..Default::default()
370                }),
371                parse_params(params.iter().map(|x| os(x)))
372            );
373        }
374        for args in [
375            vec!["-c42"],
376            vec!["-C42"],
377            vec!["-C", "42"],
378            vec!["--context=42"],
379            vec!["-42c"],
380        ] {
381            let mut params = vec!["diff"];
382            params.extend(args);
383            params.extend(["foo", "bar"]);
384            assert_eq!(
385                Ok(Params {
386                    executable: os("diff"),
387                    from: os("foo"),
388                    to: os("bar"),
389                    format: Format::Context,
390                    context_count: 42,
391                    ..Default::default()
392                }),
393                parse_params(params.iter().map(|x| os(x)))
394            );
395        }
396    }
397    #[test]
398    fn context_invalid() {
399        for args in [
400            vec!["-c", "42"],
401            vec!["-c=42"],
402            vec!["-c="],
403            vec!["-C"],
404            vec!["-C=42"],
405            vec!["-C="],
406            vec!["--context42"],
407            vec!["--context", "42"],
408            vec!["-42C"],
409        ] {
410            let mut params = vec!["diff"];
411            params.extend(args);
412            params.extend(["foo", "bar"]);
413            assert!(parse_params(params.iter().map(|x| os(x))).is_err());
414        }
415    }
416    #[test]
417    fn unified_valid() {
418        for args in [vec!["-u"], vec!["--unified"], vec!["--unified="]] {
419            let mut params = vec!["diff"];
420            params.extend(args);
421            params.extend(["foo", "bar"]);
422            assert_eq!(
423                Ok(Params {
424                    executable: os("diff"),
425                    from: os("foo"),
426                    to: os("bar"),
427                    format: Format::Unified,
428                    ..Default::default()
429                }),
430                parse_params(params.iter().map(|x| os(x)))
431            );
432        }
433        for args in [
434            vec!["-u42"],
435            vec!["-U42"],
436            vec!["-U", "42"],
437            vec!["--unified=42"],
438            vec!["-42u"],
439        ] {
440            let mut params = vec!["diff"];
441            params.extend(args);
442            params.extend(["foo", "bar"]);
443            assert_eq!(
444                Ok(Params {
445                    executable: os("diff"),
446                    from: os("foo"),
447                    to: os("bar"),
448                    format: Format::Unified,
449                    context_count: 42,
450                    ..Default::default()
451                }),
452                parse_params(params.iter().map(|x| os(x)))
453            );
454        }
455    }
456    #[test]
457    fn unified_invalid() {
458        for args in [
459            vec!["-u", "42"],
460            vec!["-u=42"],
461            vec!["-u="],
462            vec!["-U"],
463            vec!["-U=42"],
464            vec!["-U="],
465            vec!["--unified42"],
466            vec!["--unified", "42"],
467            vec!["-42U"],
468        ] {
469            let mut params = vec!["diff"];
470            params.extend(args);
471            params.extend(["foo", "bar"]);
472            assert!(parse_params(params.iter().map(|x| os(x))).is_err());
473        }
474    }
475    #[test]
476    fn context_count() {
477        assert_eq!(
478            Ok(Params {
479                executable: os("diff"),
480                from: os("foo"),
481                to: os("bar"),
482                format: Format::Unified,
483                context_count: 54,
484                ..Default::default()
485            }),
486            parse_params(
487                [os("diff"), os("-u54"), os("foo"), os("bar")]
488                    .iter()
489                    .cloned()
490            )
491        );
492        assert_eq!(
493            Ok(Params {
494                executable: os("diff"),
495                from: os("foo"),
496                to: os("bar"),
497                format: Format::Unified,
498                context_count: 54,
499                ..Default::default()
500            }),
501            parse_params(
502                [os("diff"), os("-U54"), os("foo"), os("bar")]
503                    .iter()
504                    .cloned()
505            )
506        );
507        assert_eq!(
508            Ok(Params {
509                executable: os("diff"),
510                from: os("foo"),
511                to: os("bar"),
512                format: Format::Unified,
513                context_count: 54,
514                ..Default::default()
515            }),
516            parse_params(
517                [os("diff"), os("-U"), os("54"), os("foo"), os("bar")]
518                    .iter()
519                    .cloned()
520            )
521        );
522        assert_eq!(
523            Ok(Params {
524                executable: os("diff"),
525                from: os("foo"),
526                to: os("bar"),
527                format: Format::Context,
528                context_count: 54,
529                ..Default::default()
530            }),
531            parse_params(
532                [os("diff"), os("-c54"), os("foo"), os("bar")]
533                    .iter()
534                    .cloned()
535            )
536        );
537    }
538    #[test]
539    fn report_identical_files() {
540        assert_eq!(
541            Ok(Params {
542                executable: os("diff"),
543                from: os("foo"),
544                to: os("bar"),
545                ..Default::default()
546            }),
547            parse_params([os("diff"), os("foo"), os("bar")].iter().cloned())
548        );
549        assert_eq!(
550            Ok(Params {
551                executable: os("diff"),
552                from: os("foo"),
553                to: os("bar"),
554                report_identical_files: true,
555                ..Default::default()
556            }),
557            parse_params([os("diff"), os("-s"), os("foo"), os("bar")].iter().cloned())
558        );
559        assert_eq!(
560            Ok(Params {
561                executable: os("diff"),
562                from: os("foo"),
563                to: os("bar"),
564                report_identical_files: true,
565                ..Default::default()
566            }),
567            parse_params(
568                [
569                    os("diff"),
570                    os("--report-identical-files"),
571                    os("foo"),
572                    os("bar"),
573                ]
574                .iter()
575                .cloned()
576            )
577        );
578    }
579    #[test]
580    fn brief() {
581        assert_eq!(
582            Ok(Params {
583                executable: os("diff"),
584                from: os("foo"),
585                to: os("bar"),
586                ..Default::default()
587            }),
588            parse_params([os("diff"), os("foo"), os("bar")].iter().cloned())
589        );
590        assert_eq!(
591            Ok(Params {
592                executable: os("diff"),
593                from: os("foo"),
594                to: os("bar"),
595                brief: true,
596                ..Default::default()
597            }),
598            parse_params([os("diff"), os("-q"), os("foo"), os("bar")].iter().cloned())
599        );
600        assert_eq!(
601            Ok(Params {
602                executable: os("diff"),
603                from: os("foo"),
604                to: os("bar"),
605                brief: true,
606                ..Default::default()
607            }),
608            parse_params(
609                [os("diff"), os("--brief"), os("foo"), os("bar"),]
610                    .iter()
611                    .cloned()
612            )
613        );
614    }
615    #[test]
616    fn expand_tabs() {
617        assert_eq!(
618            Ok(Params {
619                executable: os("diff"),
620                from: os("foo"),
621                to: os("bar"),
622                ..Default::default()
623            }),
624            parse_params([os("diff"), os("foo"), os("bar")].iter().cloned())
625        );
626        for option in ["-t", "--expand-tabs"] {
627            assert_eq!(
628                Ok(Params {
629                    executable: os("diff"),
630                    from: os("foo"),
631                    to: os("bar"),
632                    expand_tabs: true,
633                    ..Default::default()
634                }),
635                parse_params(
636                    [os("diff"), os(option), os("foo"), os("bar")]
637                        .iter()
638                        .cloned()
639                )
640            );
641        }
642    }
643    #[test]
644    fn tabsize() {
645        assert_eq!(
646            Ok(Params {
647                executable: os("diff"),
648                from: os("foo"),
649                to: os("bar"),
650                ..Default::default()
651            }),
652            parse_params([os("diff"), os("foo"), os("bar")].iter().cloned())
653        );
654        assert_eq!(
655            Ok(Params {
656                executable: os("diff"),
657                from: os("foo"),
658                to: os("bar"),
659                tabsize: 0,
660                ..Default::default()
661            }),
662            parse_params(
663                [os("diff"), os("--tabsize=0"), os("foo"), os("bar")]
664                    .iter()
665                    .cloned()
666            )
667        );
668        assert_eq!(
669            Ok(Params {
670                executable: os("diff"),
671                from: os("foo"),
672                to: os("bar"),
673                tabsize: 42,
674                ..Default::default()
675            }),
676            parse_params(
677                [os("diff"), os("--tabsize=42"), os("foo"), os("bar")]
678                    .iter()
679                    .cloned()
680            )
681        );
682        assert!(parse_params(
683            [os("diff"), os("--tabsize"), os("foo"), os("bar")]
684                .iter()
685                .cloned()
686        )
687        .is_err());
688        assert!(parse_params(
689            [os("diff"), os("--tabsize="), os("foo"), os("bar")]
690                .iter()
691                .cloned()
692        )
693        .is_err());
694        assert!(parse_params(
695            [os("diff"), os("--tabsize=r2"), os("foo"), os("bar")]
696                .iter()
697                .cloned()
698        )
699        .is_err());
700        assert!(parse_params(
701            [os("diff"), os("--tabsize=-1"), os("foo"), os("bar")]
702                .iter()
703                .cloned()
704        )
705        .is_err());
706        assert!(parse_params(
707            [os("diff"), os("--tabsize=r2"), os("foo"), os("bar")]
708                .iter()
709                .cloned()
710        )
711        .is_err());
712        assert!(parse_params(
713            [
714                os("diff"),
715                os("--tabsize=92233720368547758088"),
716                os("foo"),
717                os("bar")
718            ]
719            .iter()
720            .cloned()
721        )
722        .is_err());
723    }
724    #[test]
725    fn double_dash() {
726        assert_eq!(
727            Ok(Params {
728                executable: os("diff"),
729                from: os("-g"),
730                to: os("-h"),
731                ..Default::default()
732            }),
733            parse_params([os("diff"), os("--"), os("-g"), os("-h")].iter().cloned())
734        );
735    }
736    #[test]
737    fn default_to_stdin() {
738        assert_eq!(
739            Ok(Params {
740                executable: os("diff"),
741                from: os("foo"),
742                to: os("-"),
743                ..Default::default()
744            }),
745            parse_params([os("diff"), os("foo"), os("-")].iter().cloned())
746        );
747        assert_eq!(
748            Ok(Params {
749                executable: os("diff"),
750                from: os("-"),
751                to: os("bar"),
752                ..Default::default()
753            }),
754            parse_params([os("diff"), os("-"), os("bar")].iter().cloned())
755        );
756        assert_eq!(
757            Ok(Params {
758                executable: os("diff"),
759                from: os("-"),
760                to: os("-"),
761                ..Default::default()
762            }),
763            parse_params([os("diff"), os("-"), os("-")].iter().cloned())
764        );
765        assert!(parse_params([os("diff"), os("foo"), os("bar"), os("-")].iter().cloned()).is_err());
766        assert!(parse_params([os("diff"), os("-"), os("-"), os("-")].iter().cloned()).is_err());
767    }
768    #[test]
769    fn missing_arguments() {
770        assert!(parse_params([os("diff")].iter().cloned()).is_err());
771        assert!(parse_params([os("diff"), os("foo")].iter().cloned()).is_err());
772    }
773    #[test]
774    fn unknown_argument() {
775        assert!(
776            parse_params([os("diff"), os("-g"), os("foo"), os("bar")].iter().cloned()).is_err()
777        );
778        assert!(parse_params([os("diff"), os("-g"), os("bar")].iter().cloned()).is_err());
779        assert!(parse_params([os("diff"), os("-g")].iter().cloned()).is_err());
780    }
781    #[test]
782    fn empty() {
783        assert!(parse_params([].iter().cloned()).is_err());
784    }
785    #[test]
786    fn conflicting_output_styles() {
787        for (arg1, arg2) in [
788            ("-u", "-c"),
789            ("-u", "-e"),
790            ("-c", "-u"),
791            ("-c", "-U42"),
792            ("-u", "--normal"),
793            ("--normal", "-e"),
794            ("--context", "--normal"),
795        ] {
796            assert!(parse_params(
797                [os("diff"), os(arg1), os(arg2), os("foo"), os("bar")]
798                    .iter()
799                    .cloned()
800            )
801            .is_err());
802        }
803    }
804}