# Changelog
The Rust CLI and MCP server. The VS Code extension has its own
[CHANGELOG](../CHANGELOG.md) and its own version — the two products in
this repository release on their own cadence.
Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.2.2] - 2026-08-15
### Added
- **The crates.io page carries a demo of the CLI.** It had the icon and
nothing else, because the only recording in the repository was of the
VS Code extension reading an editor buffer — a clip of something this
binary does not do. `assets/demo.tape` records the real binary against
the files in `assets/demo/`, so the clip is reproducible (`cd assets
&& vhs demo.tape`) rather than an artifact nobody can regenerate.
## [0.2.1] - 2026-08-15
### Fixed
- **The crates.io page shows the icon.** It lived only in the repository
README, and that file is not the one `cargo publish` ships — the
published README is this directory's. A relative path would not have
fixed it: the crate is published from `crate/`, so crates.io resolves
a relative link against `path_in_vcs` and looks for the asset below
the crate directory rather than beside it. The image is an absolute
URL, which every surface renders.
No demo goes with it. `src/assets/images/demo.gif` records the
extension reading an editor buffer, which is not what this binary
does; the demo that belongs here is a recording of the CLI, and there
is not one yet.
## [0.2.0] - 2026-08-14
The release where this reads a whole repository rather than the parts of
one it happened to recognise, and where the numbers it reports about
digits stop being generous.
**Expect the count to go up, and expect none of the old ones to be
missing.** A large tree that reported 488 dates reports 615 now; every
date the previous version found is still found, and the difference is
files that were never opened plus notations that were never read.
### Added
- **Every text file is read.** The walk skipped any file whose name
resolved to none of nine formats, so a repository of Python, Go, Rust,
TOML and Markdown was walked and almost entirely unread — and the
reader saw a clean report rather than a skipped one. A format only ever
*adds* patterns to the shared ones, so the shared scan is the correct
reading of a document nobody named: `resolve_format` now yields
`unknown` instead of nothing, `extract` has a fallback arm, and
`has_a_format()` is gone from `walk.rs`. `toml`, `ini`, `cfg`, `conf`,
`properties`, `markdown` and `md` are named formats rather than
unknowns; `toml` and `markdown` join the tool schema's enum.
- **Four date shapes that appear in real files and that JavaScript
cannot read at all**: ISO 8601 week dates (`2024-W03`, `2024-W03-1`),
ordinal dates (`2024-015`), the basic format (`20240115`,
`20240115T103045Z`), and the timezone abbreviations `CEST CET BST JST
AEST IST` as fixed offsets. Every one of them is `NaN` to `Date.parse`,
so every one of them used to be dropped.
`week`, `ordinal` and `basic` are new `format` values in the output,
which is worth knowing if you filter on that field. Each shape is
normalised into a string V8 *does* read rather than resolved here, so a
week date is UTC for the same reason `2024-01-15` is, and the CLI and
the editor answer identically. `IST` names three different zones —
India, Ireland and Israel — and India is the one taken; SPEC.md says so
plainly rather than leaving it to be discovered.
- **Unix epochs in microseconds and nanoseconds** — 16 and 19 digits,
same plausible-range floor, converted by taking the leading 13
*characters* rather than by dividing, because 19 digits do not fit a
double and a division would round in JavaScript and not in Rust.
- Corpus documents `settings.py`, `handler.go`, `release-notes.md`,
`pyproject.toml` and `notations.txt`, and MCP cases for each.
- Corpus document `calendar.js`, so `javascript` is a format the two
frontends have actually been compared on. The tool schema had
advertised it since 0.1.0 with nothing behind it — `dates.ts` covers
`typescript`, and the two are separate keys on purpose.
- **Six CI jobs, and every one of them found something.** `hazards` and
`platform` run the built binary on all three operating systems over
trees built at run time — byte-order marks, CRLF, lone carriage
returns, NUL bytes, undecodable text, a megabyte-long line, a hundred
thousand lines, symlink loops, FIFOs, unreadable files, reserved
Windows names and paths past 260 characters — and assert the process
never panics, never hangs and never leaves by a signal. `differential`
generates over twelve hundred documents from a printed seed and
requires both servers' `extract_dates` to answer byte-identically.
`fuzz` mutates the V8 oracle's own inputs against the parsers and the
scan for sixty seconds a target. `budget` puts a wall-clock ceiling
and a linearity check on a generated 500-file tree. `coverage-matrix`
requires a report line for every extension in the alias table and a
corpus document for every advertised format. Each job names the input,
the platform and the seed that broke it.
### Fixed
- **A date after a megabyte of anything else was silently dropped.**
`fancy-regex` stops after a million backtracking steps by default and
an unanchored search spends one per starting position, so past about a
megabyte every pattern stopped matching — and the scan read the
engine's error as "no more matches" and reported the file as clean. The
patterns are built with no backtrack limit now (none of them nests a
quantifier; `tests/scenarios.rs` holds the megabyte-scale documents
that would show it if one did), and the error is no longer swallowed.
Found by `hazards`.
- **The process aborted on a word with an accent in it.** `Date.parse`'s
keyword table matches on a word's first three *characters* and the
crate sliced three *bytes*, so `new Date('Jaé…')` or
`datetime="Café day"` killed the run — both of those hand the parser
arbitrary text. Found by `fuzz`.
- **Words were read with Rust's notion of a letter rather than V8's.**
V8 scans a word while the character is `A` or above, so an accent, a
non-breaking space, an ideographic space and a byte-order mark are all
*inside* a word: `Jané 15 2024` is January and `Jan 15 2024\u{a0}` is a
refusal. Cases added to the oracle for all of it, which now holds 178.
- **The two servers disagreed about an unclosed XML comment.** The
extension masks with a regex that requires the closing marker, so
`<a>1</a><!-- 2024-01-15` still yields that date; this swallowed the
rest of the document. The opening `<!--` can no longer close itself
either — `<!-->` is not an empty comment. Found by `differential`.
- **The two servers disagreed about whitespace.** JavaScript's
whitespace set includes U+FEFF and excludes U+0085; Rust's Unicode
`White_Space` does the opposite. `extract_dates` with
`format: "\u{feff}json"` resolved to `json` in the editor and
`unknown` here, and `datetime\u{feff}="March 5, 2024"` was a date in
one frontend and not the other. A new `extract/js.rs` defines
JavaScript's set once, in two forms held equal by a test, and the
format normaliser and every `\s` in the patterns go through it. The
normaliser also strips one leading dot rather than all of them, which
is what `replace(/^\./, '')` does. Found by `differential`.
- **A nineteen-digit year overflowed the calendar arithmetic.**
`week_date`, `ordinal_date` and `basic_format` accepted any numeral
`parse` would swallow, where the extension's anchored regexes accept
exactly four digits and three. The widths are checked now. Found by
`fuzz`.
- **Report paths used the platform separator.** `file` in every report
line — stdout is protocol — was spelled with `\` on Windows and `/`
everywhere else, so the same tree produced JSON only one consumer
could read. One spelling now, on every platform.
### Changed
- **The MCP envelope keeps the schema's field order.** `serde_json`
sorts object keys by default, so an agent reading `content[0].text`
from this server got them alphabetised where the npm server gave them
in the documented order — the same answer, spelled two ways, from what
is meant to be one tool. `serde_json` gains the `preserve_order`
feature (and with it `indexmap`) so the claim of byte-identical output
is true rather than nearly true.
- **`--format` no longer refuses a name it does not recognise**, and
`--stdin` no longer requires one: both fall back to the shared
patterns. The same on the MCP surface — `extract_dates` with no
`format` and no `filename`, and `dates_le_scan` with an unrecognised
`format`, were refusals and are answers now, carrying
`fileType: "unknown"`. The two corpus cases that pinned those refusals
were updated deliberately.
- **A binary file is skipped silently rather than reported.** Widening
the walk means a PNG now reaches the reader; a NUL byte in the first
8KB (ripgrep's heuristic) means the file was never a text candidate, so
it produces no report line and does not affect `--strict` — which
would otherwise exit 2 on any repository containing an image. They are
counted in the stderr summary (`, 14 binary files skipped`) so the
coverage is still stated. A file that *is* text and cannot be read
keeps its named `skipped` diagnostic and still fails `--strict`.
- **A long run of digits now has to be a plausible date, not merely the
right length.** This is the one change here that can remove something
from your output, and it has two halves.
What it removes: a 13-, 16- or 19-digit run is read as a timestamp only
if it lands on or after 2001-09-09 and before 2100-01-01. Card numbers,
request ids and large constants are not dates any more — a card number
was being reported as 2113, `Number.MAX_SAFE_INTEGER` as 2255, and a
13-digit `request_id` in this project's own fixture as December 2282.
What it costs: a genuinely far-future timestamp written in
milliseconds, microseconds or nanoseconds is no longer reported. Those
units are machine-stamped — `Date.now()`, `time.time_ns()`,
`UnixNano()` — and record when a program ran; a cutoff a person wrote
is a date or a seconds epoch. If you keep the year 2200 in
milliseconds, this release stops seeing it.
The reason the rule has to exist at all: past ten digits the digit
count stops bounding anything. At ten digits it does — the widest
ten-digit numeral is the year 2286 — but at thirteen, sixteen and
nineteen *every* numeral of that width already lands in 2001–2286, so
the old floor excluded nothing whatsoever.
- **The ten-digit rule is unchanged, and it still has one honest false
positive.** A ten-digit phone number is a valid seconds epoch and
reads as 2145-11-29. Nothing about its shape or its instant can tell it
from a real timestamp, so it is pinned in the corpus as the false
positive it is rather than hidden by a rule that would also throw away
real dates. `1111111111111111111` is the same story: it is 2005-03-18,
and only its digits say otherwise.
## [0.1.0] - 2026-08-11
First release. The extension's extraction engine, ported and pinned
against a shared corpus, over a tree instead of one open document.
### Added
- **Extraction for all nine format names** the extension reads — JSON,
YAML, CSV, XML, log, plaintext, JavaScript, TypeScript and HTML —
reproducing its values, notations, instants and positions for every
case in `fixtures/`. `typescript` and `plaintext` stay their own keys
rather than aliases of `javascript` and `log`, because the key is
user-visible as `fileType` and the two servers must not disagree about
what they just read.
- **`Date.parse` as V8 implements it**, both parsers: the ECMA-262 Date
Time String Format and the legacy one no standard describes. Its rules
were established by asking V8 and are pinned in
`fixtures/date-parse.json`, 140 cases that `cargo test` replays —
garbage words legal before the first number and fatal after it,
weekday names read and discarded, months matched on three letters,
`EST` a fixed −5 rather than a zone, a two-digit year 1900s from 50,
parenthesised comments skipped closed or not, and the ISO parser
committing at the `T` so that `2024-01-15T10:30:45 GMT` is a refusal
while `2024-01-15 10:30:45` is not.
- **Local time, honestly.** Four of the six shapes carry no timezone, so
their instant is a property of the machine; `TZ` is honoured and the
corpus pins a zone with daylight saving, because no other kind can
catch a wrong conversion. At a transition the offset in force before
it wins, at both edges.
- **`--tz <zone>`**, naming the zone that dates without one resolve in.
A timestamp read on a laptop in Chicago and on a server in UTC are
different instants, and for the reviews this is built for that
difference is the finding rather than a detail. It applies to
`--after` and `--before` as well, so where on the command line it
appears cannot change an answer.
- **`--after` / `--before` / `--sort`**, which spend the instant
extraction already resolved, and read their own boundaries with the
same parser they read documents with.
- **`--iso`**, **`--dedupe`**, **`--values`**, **`--year`**, `--stdin`,
`--hidden`, `--no-ignore`. Exit codes follow grep: 0 found, 1 none,
2 malformed.
- **An MCP server** (`dates-le mcp`) offering `extract_dates`, shared
byte-for-byte with the extension's server and held there by the same
corpus, and `dates_le_scan` for files and directories.
### Notes
Two findings from the port, neither visible to a passing test suite.
**Positions were quadratic on a document with one line.** Resolving a
column counts UTF-16 units, and counting them from the start of the line
is fine until the file is one line long: a log with 200,000 timestamps
on it did not finish. Offsets now resolve together in a single ordered
pass. Ninety unit tests were green while the binary hung.
**Masking an XML comment cannot preserve both lengths.** Keeping the
byte length is mandatory — a two-byte `é` replaced by one space slides
every offset after it, far enough to slice mid-character and abort — but
padding to the byte length changes the UTF-16 length, and with it every
column after the comment on that line. Positions are therefore located
against the original document rather than the mask. The corpus carries
an accented inline comment with a date after it on the same line, since
a shift before a line start cancels out in the column and would have
proved nothing.
### Fixed
- **A leading byte-order mark is no longer part of the document.** Three
invisible bytes, added by Notepad, Excel and a PowerShell redirect, and
stripped by VS Code before the extension ever sees a file — so the two
frontends read the same file differently. It shifted every column on
line one, and before a `{` it made a structured parser reject the whole
document, which is indistinguishable from a file with no dates in it.
- **A file that cannot be read no longer fails the run.** Every
repository has a PNG, a zip and something the runner lacks permission
for. Exiting 2 on those made the tool unusable in CI, which is the one
place it is most worth running. Such a file is now named on stderr and
carried in the report with a `skipped` diagnostic, and the exit code
reflects what was found. `--strict` restores the old behaviour for a
pipeline that wants zero tolerance.
- **A file that is not text is named rather than dropped.** It used to
vanish from the report entirely, which reads to whoever ran it as
"that file was clean".
[0.2.2]: https://crates.io/crates/dates-le/0.2.2
[0.2.1]: https://crates.io/crates/dates-le/0.2.1
[0.2.0]: https://crates.io/crates/dates-le/0.2.0
[0.1.0]: https://crates.io/crates/dates-le/0.1.0