1use 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
63const 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
126fn 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 "--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
220fn 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 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 #[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 #[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}