Skip to main content

dates_le/
cli.rs

1//! The terminal surface.
2//!
3//! stdout is always protocol — one JSON report per line, one line per
4//! file. stderr is always for the human, and is a projection of the same
5//! reports rather than parallel prose.
6
7use std::io::{Read, Write};
8use std::path::PathBuf;
9use std::process::ExitCode;
10
11use crate::extract::{SUPPORTED_FORMATS, parse::date_parse, resolve_format};
12use crate::scan::{self, FileReport, ScanOptions};
13use crate::walk::{self, WalkOptions};
14
15const USAGE: &str = "usage: dates-le [options] <file|dir>...
16       dates-le [options] --stdin --format <format>
17       dates-le mcp
18       dates-le --version | --help
19
20Finds every date and timestamp in a tree and puts them where a person
21can read them: ISO 8601, RFC 2822, Unix epochs, US notations, log and
22syslog lines, Apache access logs, and the strings inside date
23constructors that nothing else would recognise as dates.
24
25Each one carries the instant it actually resolves to, so `2024-01-15`,
26`1705276800` and `Mon, 15 Jan 2024` can be compared rather than read.
27
28Options:
29  --after <date>       keep dates at or after this instant
30  --before <date>      keep dates strictly before this instant
31  --sort               order by instant rather than by position
32  --dedupe             collapse repeated dates to their first occurrence
33  --iso                add each instant as a UTC ISO 8601 string
34  --tz <zone>          resolve dates that carry no timezone in this
35                       IANA zone, e.g. UTC or America/New_York, instead
36                       of this machine's
37  --format <format>    force a format instead of inferring it from the
38                       file name
39  --year <year>        the year a syslog line is assumed to be in,
40                       since the line does not carry one. Defaults to
41                       this one, which makes that answer move
42  --values             print only the dates, one per line, for piping
43  --strict             exit 2 if any file could not be read, rather than
44                       reporting it and carrying on
45  --stdin              read one document from stdin
46  --hidden             walk hidden files and directories too
47  --no-ignore          walk files that .gitignore excludes
48
49--after and --before accept anything this tool can read, so
50`--after 2024-01-15` and `--after 'March 5, 2024'` both work.
51
52A date with no timezone resolves against this machine's. Use --tz to
53name one instead; the answer genuinely differs by zone, exactly as it
54does for the code being read.
55
56Files that are not text, or that cannot be opened, are named on stderr
57and carried in the report, and do not by themselves fail the run — every
58repository has a PNG in it. --strict turns them back into a failure.
59
60Exit codes follow grep: 0 dates found · 1 none found · 2 malformed
61question. Finding none is an answer, not an error.";
62
63/// Every flag the parser accepts. Held equal to the flags named in
64/// USAGE by a test, and consulted at runtime so the list is what the
65/// parser actually honours.
66const FLAGS: [&str; 14] = [
67    "--strict",
68    "--tz",
69    "--after",
70    "--before",
71    "--sort",
72    "--dedupe",
73    "--iso",
74    "--format",
75    "--year",
76    "--values",
77    "--stdin",
78    "--hidden",
79    "--no-ignore",
80    "--help",
81];
82
83#[derive(Debug)]
84struct Invocation {
85    scan: ScanOptions,
86    walk: WalkOptions,
87    values: bool,
88    strict: bool,
89    stdin: bool,
90    roots: Vec<PathBuf>,
91}
92
93pub fn run(arguments: &[String]) -> ExitCode {
94    if arguments.first().map(String::as_str) == Some("mcp") {
95        return crate::mcp::serve();
96    }
97    if arguments.iter().any(|argument| argument == "--version") {
98        println!("dates-le {}", env!("CARGO_PKG_VERSION"));
99        return ExitCode::SUCCESS;
100    }
101    if arguments.is_empty() || arguments.iter().any(|argument| argument == "--help") {
102        println!("{USAGE}");
103        return ExitCode::SUCCESS;
104    }
105
106    let invocation = match parse_arguments(arguments) {
107        Ok(invocation) => invocation,
108        Err(message) => return refuse(&message),
109    };
110
111    let reports = match gather(&invocation) {
112        Ok(reports) => reports,
113        Err(message) => return refuse(&message),
114    };
115
116    report(&reports, invocation.values);
117    scan::exit_code(&reports, invocation.strict)
118}
119
120fn refuse(message: &str) -> ExitCode {
121    eprintln!("dates-le: {message}");
122    eprintln!("try `dates-le --help`");
123    ExitCode::from(2)
124}
125
126/// `--tz` is honoured before anything else is read, because `--after`
127/// and `--before` parse dates too and must land in the same zone as the
128/// documents. Order on the command line should not change an answer.
129fn apply_zone_first(arguments: &[String]) -> Result<(), String> {
130    let Some(index) = arguments.iter().position(|argument| argument == "--tz") else {
131        return Ok(());
132    };
133    let raw = arguments.get(index + 1).ok_or("--tz needs a value")?;
134    let zone = crate::extract::time::zone_by_name(raw)
135        .ok_or_else(|| format!("{raw:?} is not an IANA timezone name"))?;
136    crate::extract::time::set_zone(Some(zone));
137    Ok(())
138}
139
140fn parse_arguments(arguments: &[String]) -> Result<Invocation, String> {
141    apply_zone_first(arguments)?;
142    let mut invocation = Invocation {
143        scan: ScanOptions::default(),
144        walk: WalkOptions::default(),
145        values: false,
146        strict: false,
147        stdin: false,
148        roots: Vec::new(),
149    };
150
151    let mut index = 0;
152    while index < arguments.len() {
153        let argument = arguments[index].as_str();
154        let value = |name: &str| -> Result<String, String> {
155            arguments
156                .get(index + 1)
157                .cloned()
158                .ok_or_else(|| format!("{name} needs a value"))
159        };
160
161        match argument {
162            "--after" => {
163                let raw = value("--after")?;
164                invocation.scan.after = Some(instant(&raw)?);
165                index += 1;
166            }
167            "--before" => {
168                let raw = value("--before")?;
169                invocation.scan.before = Some(instant(&raw)?);
170                index += 1;
171            }
172            "--format" => {
173                let raw = value("--format")?;
174                if resolve_format(Some(&raw), None).is_none() {
175                    return Err(format!(
176                        "unknown format {raw:?} — one of: {}",
177                        SUPPORTED_FORMATS.join(", ")
178                    ));
179                }
180                invocation.scan.format = Some(raw);
181                index += 1;
182            }
183            // Already applied by `apply_zone_first`; skip its value.
184            "--tz" => index += 1,
185            "--year" => {
186                let raw = value("--year")?;
187                invocation.scan.year = raw
188                    .parse()
189                    .map_err(|_| format!("--year needs a year, not {raw:?}"))?;
190                index += 1;
191            }
192            "--sort" => invocation.scan.sort = true,
193            "--dedupe" => invocation.scan.dedupe = true,
194            "--iso" => invocation.scan.iso = true,
195            "--values" => invocation.values = true,
196            "--strict" => invocation.strict = true,
197            "--stdin" => invocation.stdin = true,
198            "--hidden" => invocation.walk.hidden = true,
199            "--no-ignore" => invocation.walk.respect_ignore = false,
200            other if other.starts_with("--") => {
201                return Err(format!(
202                    "unknown option {other:?} — one of: {}",
203                    FLAGS.join(", ")
204                ));
205            }
206            path => invocation.roots.push(PathBuf::from(path)),
207        }
208        index += 1;
209    }
210
211    if invocation.stdin && invocation.scan.format.is_none() {
212        return Err("--stdin needs --format, since there is no file name to infer one from".into());
213    }
214    if !invocation.stdin && invocation.roots.is_empty() {
215        return Err("name a file or directory, or pass --stdin".into());
216    }
217    Ok(invocation)
218}
219
220/// A boundary for `--after` / `--before`, read by the same parser that
221/// reads the documents. A tool that could not read its own output as
222/// input would be a strange one.
223fn instant(raw: &str) -> Result<i64, String> {
224    date_parse(raw).ok_or_else(|| format!("{raw:?} is not a date this can read"))
225}
226
227fn gather(invocation: &Invocation) -> Result<Vec<FileReport>, String> {
228    if invocation.stdin {
229        let language = invocation
230            .scan
231            .format
232            .as_deref()
233            .and_then(|format| resolve_format(Some(format), None))
234            .ok_or("--stdin needs --format")?;
235        let mut content = String::new();
236        std::io::stdin()
237            .read_to_string(&mut content)
238            .map_err(|error| format!("could not read stdin: {error}"))?;
239        return Ok(vec![scan::scan_text(
240            "<stdin>",
241            scan::without_bom(&content),
242            language,
243            &invocation.scan,
244        )]);
245    }
246
247    for root in &invocation.roots {
248        if !root.exists() {
249            return Err(format!("{} does not exist", root.display()));
250        }
251    }
252    let files = walk::collect(&invocation.roots, invocation.walk);
253    Ok(files
254        .iter()
255        .map(|path| scan::scan_file(path, &invocation.scan))
256        .collect())
257}
258
259fn report(reports: &[FileReport], values_only: bool) {
260    let stdout = std::io::stdout();
261    let mut out = stdout.lock();
262
263    if values_only {
264        for report in reports {
265            for date in &report.dates {
266                let _ = writeln!(out, "{}", date.value);
267            }
268        }
269        return;
270    }
271
272    for report in reports {
273        if let Ok(line) = serde_json::to_string(report) {
274            let _ = writeln!(out, "{line}");
275        }
276    }
277
278    let total: usize = reports.iter().map(|report| report.dates.len()).sum();
279    let skipped = reports
280        .iter()
281        .filter(|report| report.skipped.is_some())
282        .count();
283    let files = reports.len() - skipped;
284    eprintln!(
285        "{total} date{} in {files} file{}{}",
286        plural(total),
287        plural(files),
288        if skipped == 0 {
289            String::new()
290        } else {
291            format!(", {skipped} skipped")
292        }
293    );
294    // Named, every one of them. A tool that quietly reads fewer files
295    // than it was pointed at is worse than one that fails.
296    for report in reports.iter().filter(|report| report.skipped.is_some()) {
297        eprintln!(
298            "  skipped {}: {}",
299            report.file,
300            report.skipped.as_deref().unwrap_or_default()
301        );
302    }
303}
304
305fn plural(count: usize) -> &'static str {
306    if count == 1 { "" } else { "s" }
307}
308
309#[cfg(test)]
310mod tests {
311    use super::*;
312
313    fn parse(arguments: &[&str]) -> Result<Invocation, String> {
314        parse_arguments(
315            &arguments
316                .iter()
317                .map(|argument| (*argument).to_string())
318                .collect::<Vec<_>>(),
319        )
320    }
321
322    #[test]
323    fn every_flag_the_parser_honours_is_documented() {
324        for flag in FLAGS {
325            assert!(USAGE.contains(flag), "{flag} is not in the usage text");
326        }
327    }
328
329    #[test]
330    fn every_flag_the_usage_names_is_honoured() {
331        for word in USAGE.split_whitespace() {
332            let flag = word.trim_end_matches([',', '.', '·']);
333            if flag.starts_with("--") && flag.len() > 2 {
334                assert!(
335                    FLAGS.contains(&flag) || flag == "--version",
336                    "{flag} is documented and not honoured"
337                );
338            }
339        }
340    }
341
342    #[test]
343    fn every_exit_code_is_documented() {
344        for code in ["0", "1", "2"] {
345            assert!(USAGE.contains(code), "exit code {code} is undocumented");
346        }
347    }
348
349    #[test]
350    fn a_boundary_is_read_by_the_same_parser_as_a_document() {
351        let invocation = parse(&["--after", "2024-01-15", "."]).expect("parses");
352        assert_eq!(invocation.scan.after, Some(1_705_276_800_000));
353        let written = parse(&["--after", "March 5, 2024", "."]).expect("parses");
354        assert!(written.scan.after.is_some());
355    }
356
357    #[test]
358    fn a_boundary_that_is_not_a_date_is_refused() {
359        let error = parse(&["--after", "soon", "."]).expect_err("refuses");
360        assert!(error.contains("not a date"), "{error}");
361    }
362
363    #[test]
364    fn an_unknown_format_names_the_ones_that_work() {
365        let error = parse(&["--format", "rust", "."]).expect_err("refuses");
366        assert!(error.contains("typescript"), "{error}");
367    }
368
369    #[test]
370    fn stdin_without_a_format_is_refused() {
371        let error = parse(&["--stdin"]).expect_err("refuses");
372        assert!(error.contains("--format"), "{error}");
373    }
374
375    #[test]
376    fn naming_nothing_at_all_is_refused() {
377        let error = parse(&["--sort"]).expect_err("refuses");
378        assert!(error.contains("file or directory"), "{error}");
379    }
380
381    #[test]
382    fn an_unknown_option_is_refused_rather_than_read_as_a_path() {
383        let error = parse(&["--nope", "."]).expect_err("refuses");
384        assert!(error.contains("--nope"), "{error}");
385    }
386
387    #[test]
388    fn a_flag_missing_its_value_says_which() {
389        assert!(
390            parse(&["--after"])
391                .expect_err("refuses")
392                .contains("--after")
393        );
394        assert!(parse(&["--year"]).expect_err("refuses").contains("--year"));
395    }
396
397    #[test]
398    fn a_year_that_is_not_a_number_is_refused() {
399        let error = parse(&["--year", "soon", "."]).expect_err("refuses");
400        assert!(error.contains("--year"), "{error}");
401    }
402
403    #[test]
404    fn a_named_zone_is_accepted_and_a_made_up_one_is_not() {
405        assert!(parse(&["--tz", "UTC", "."]).is_ok());
406        assert!(parse(&["--tz", "America/New_York", "."]).is_ok());
407        let error = parse(&["--tz", "Mars/Olympus", "."]).expect_err("refuses");
408        assert!(error.contains("IANA"), "{error}");
409        crate::extract::time::set_zone(None);
410    }
411
412    /// The zone has to apply to the boundaries too, or `--after` would
413    /// mean something different depending on where it was typed.
414    #[test]
415    fn the_zone_applies_before_a_boundary_is_read() {
416        let utc = parse(&["--tz", "UTC", "--after", "2024-01-15 00:00:00", "."])
417            .expect("parses")
418            .scan
419            .after;
420        let east = parse(&[
421            "--tz",
422            "America/New_York",
423            "--after",
424            "2024-01-15 00:00:00",
425            ".",
426        ])
427        .expect("parses")
428        .scan
429        .after;
430        crate::extract::time::set_zone(None);
431        assert_ne!(utc, east, "a zone-less boundary is not zone-independent");
432        assert_eq!(east.unwrap() - utc.unwrap(), 5 * 3_600_000);
433    }
434
435    /// Written after `--tz` was read in argument order and quietly gave
436    /// a different answer depending on which flag came first.
437    #[test]
438    fn the_zone_applies_wherever_it_appears() {
439        let first = parse(&["--tz", "UTC", "--after", "2024-01-15 00:00:00", "."])
440            .expect("parses")
441            .scan
442            .after;
443        let last = parse(&["--after", "2024-01-15 00:00:00", "--tz", "UTC", "."])
444            .expect("parses")
445            .scan
446            .after;
447        crate::extract::time::set_zone(None);
448        assert_eq!(first, last);
449    }
450
451    #[test]
452    fn paths_accumulate() {
453        let invocation = parse(&["a.json", "b.log"]).expect("parses");
454        assert_eq!(invocation.roots.len(), 2);
455    }
456
457    #[test]
458    fn the_walk_flags_invert_the_defaults() {
459        let invocation = parse(&["--hidden", "--no-ignore", "."]).expect("parses");
460        assert!(invocation.walk.hidden);
461        assert!(!invocation.walk.respect_ignore);
462    }
463
464    #[test]
465    fn the_default_year_is_this_one() {
466        assert_eq!(
467            ScanOptions::default().year,
468            crate::extract::time::current_year()
469        );
470    }
471}