1use 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
71const 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
134fn 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 "--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
218fn instant(raw: &str) -> Result<i64, String> {
222 extended::instant(raw).ok_or_else(|| format!("{raw:?} is not a date this can read"))
223}
224
225struct Scanned {
233 reports: Vec<FileReport>,
234 binary: usize,
235}
236
237fn gather(invocation: &Invocation) -> Result<Scanned, String> {
238 if invocation.stdin {
239 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 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 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 #[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 #[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 #[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}