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