From dabc7c76206ac55606acf0b725526692a6d6a412 Mon Sep 17 00:00:00 2001 From: Hakim Jonas Ghoula Date: Tue, 9 Jun 2026 23:24:12 +0200 Subject: [PATCH] feat: passive update check (cached, opt-out, install-aware) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a normal query or REPL run, lam checks at most once a day whether a newer release exists and, if so, prints a one-line notice to stderr with the upgrade command for how it was installed. Closes the 0.14.0 update-ergonomics item. Design (bin/update_check.dart, all IO behind injectable seams): - The notice shown THIS run is decided synchronously from a small JSON cache (sub-millisecond, never blocks the query). The network refresh only updates the cache for the NEXT run, so it's fire-and-forget — unawaited, its result never needed — which sidesteps the background-future-vs-exit() race entirely. - Gated: fires only for the interactive query path and the REPL. OFF for -n/--null-input, --ndjson, --assert, --print-shape, --explain*, --version/--skill/--completions, in CI (CI env var), when stderr is not a terminal, via --no-update-check, or LAM_NO_UPDATE_CHECK. - Install-method detection (brew/scoop/pub/manual) from Platform.resolvedExecutable picks the upgrade command. No binary self-replacement. - Version compare is a small hand-rolled numeric semver; unparseable upstream => not-newer, so a bad response can't produce a bogus notice. The language (lib/) stays dart:io-free — this is all CLI harness, the same category as the existing file/stdin IO. MCP server untouched. Tests: 25 unit tests with in-memory fakes + fixed clock (no real network/disk) covering isNewer, decideNotice staleness, refreshCache, detectChannel, render per channel, the shouldCheck truth table, and cache-path resolution; plus integration tests asserting no notice leaks on the non-TTY path and --no-update-check is accepted. Docs: getting-started "Updating" section; man page --update-check option, ENVIRONMENT (LAM_NO_UPDATE_CHECK, CI), and FILES (cache path); CHANGELOG. analyze --fatal-infos, format, lint-changelog, generated-files all green; full suite 1714 + lambe_test 19 pass. --- CHANGELOG.md | 14 ++ bin/lam.dart | 66 ++++++++ bin/update_check.dart | 301 +++++++++++++++++++++++++++++++++ doc/getting-started.md | 25 +++ doc/lam.1 | 13 ++ doc/lam.1.md | 14 ++ test/cli_integration_test.dart | 23 +++ test/update_check_test.dart | 297 ++++++++++++++++++++++++++++++++ 8 files changed, 753 insertions(+) create mode 100644 bin/update_check.dart create mode 100644 test/update_check_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bf9337..700b3e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,17 @@ +## Unreleased + +### Features + +- Passive update check. On a normal `lam` run (a query or the REPL), + `lam` checks at most once a day whether a newer release exists and, if + so, prints a one-line notice to stderr with the upgrade command for how + it was installed (`brew upgrade lambe`, `scoop update lambe`, + `dart pub global activate lambe`, or re-running the installer). The + check is cached, never blocks the query, and never writes to stdout. It + is off automatically in CI and when stderr is not a terminal, and can + be disabled with `--no-update-check` or `LAM_NO_UPDATE_CHECK`. Lives + entirely in the CLI harness — the library stays `dart:io`-free. + ## 0.13.0 Distribution and install ergonomics: this release is about how people diff --git a/bin/lam.dart b/bin/lam.dart index cc636c1..86953d3 100644 --- a/bin/lam.dart +++ b/bin/lam.dart @@ -5,6 +5,7 @@ /// `cat data.json | lam 'expression'` library; +import 'dart:async'; import 'dart:convert'; import 'dart:io'; @@ -14,6 +15,7 @@ import 'package:lambe/lambe.dart'; import 'completions.dart'; import 'repl.dart' show runRepl; import 'schema_io.dart'; +import 'update_check.dart'; void main(List arguments) { final argParser = @@ -125,6 +127,16 @@ void main(List arguments) { negatable: false, help: 'Print the Lambë version and exit', ) + ..addFlag( + 'update-check', + defaultsTo: true, + help: + 'On a normal run, check for a newer lam release and print a ' + 'one-line notice to stderr. Cached daily, never blocks the ' + 'query. Disable with --no-update-check or by setting ' + 'LAM_NO_UPDATE_CHECK; off automatically in CI and when ' + 'stderr is not a terminal.', + ) ..addFlag('help', abbr: 'h', negatable: false, help: 'Show usage'); final ArgResults args; @@ -261,6 +273,22 @@ void main(List arguments) { } } + // Passive update notice — fires only for the interactive query path + // (a normal `lam EXPR file` run or the REPL). Suppressed for + // machine-consumed and scripted/static modes: ndjson streaming, + // -n/--null-input, --assert, --print-shape, and --explain*. (--version, + // --skill, --completions already returned above.) Synchronous cache + // read; any network refresh is fire-and-forget for the next run. + _maybeNotifyUpdate( + args, + suppressedMode: + isNdjsonMode || + nullInput || + isAssertMode || + isPrintShapeMode || + isExplainMode, + ); + if (isNdjsonMode) { if (isInteractive) { stderr.writeln('Error: --ndjson cannot be combined with --interactive.'); @@ -738,6 +766,44 @@ bool _looksLikeDataFile(String path) { return false; } +/// Emit a one-line "newer lam available" notice to stderr when every +/// gate allows it. The decision is a synchronous cache read; a stale +/// cache schedules a fire-and-forget network refresh for the next run +/// (never awaited, so it can't block or delay this run). +void _maybeNotifyUpdate(ArgResults args, {required bool suppressedMode}) { + final env = Platform.environment; + if (!shouldCheck( + stderrIsTerminal: stderr.hasTerminal, + optedOutByEnv: env.containsKey('LAM_NO_UPDATE_CHECK'), + // `--update-check` defaults true; `--no-update-check` makes it false. + optedOutByFlag: !args.flag('update-check'), + isCi: env.containsKey('CI'), + suppressedMode: suppressedMode, + )) { + return; + } + + final store = FileCacheStore(resolveCachePath(env)); + final now = DateTime.now(); + final decision = decideNotice( + currentVersion: lambeVersion, + store: store, + channel: detectChannel(Platform.resolvedExecutable), + now: now, + ); + if (decision.stale) { + unawaited( + refreshCache( + store: store, + fetch: defaultFetcher(currentVersion: lambeVersion), + now: now, + ), + ); + } + final notice = decision.notice; + if (notice != null) stderr.writeln(notice.render()); +} + /// Print usage information to stderr. void _usage(ArgParser parser) { stderr.writeln('Usage: lam [options] [file]'); diff --git a/bin/update_check.dart b/bin/update_check.dart new file mode 100644 index 0000000..54a28a0 --- /dev/null +++ b/bin/update_check.dart @@ -0,0 +1,301 @@ +/// Passive, cached, opt-out "a newer `lam` is available" notice. +/// +/// This lives in `bin/` because it touches the network, the filesystem, +/// and the environment — none of which belong in the `dart:io`-free +/// (WASM-clean) library. The language never checks for updates; only the +/// CLI harness does, the same way it already reads files and runs a REPL. +/// +/// Design: the *notice* shown on this run is decided synchronously from a +/// small cache file (sub-millisecond, never blocks the query). The +/// *network refresh* only updates that cache for the next run, so it is +/// fire-and-forget — its result is never needed by the current process, +/// which sidesteps the "background future vs. exit()" race entirely. If +/// the process exits before the refresh finishes, one cache update is +/// skipped and retried next run. +/// +/// Everything that touches IO is injected (clock, cache store, fetcher, +/// executable path, environment), so the policy is unit-testable without +/// real network or disk. +library; + +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +/// How `lam` was installed, used to pick the upgrade command. +enum InstallChannel { + /// Homebrew tap (`brew install hakimjonas/lambe/lambe`). + brew, + + /// Scoop (Windows). + scoop, + + /// `dart pub global activate lambe`. + pub, + + /// A downloaded binary, the curl installer, or build-from-source. + manual, +} + +/// The upgrade command to print for each channel. +String upgradeCommand(InstallChannel channel) => switch (channel) { + InstallChannel.brew => 'brew upgrade lambe', + InstallChannel.scoop => 'scoop update lambe', + InstallChannel.pub => 'dart pub global activate lambe', + InstallChannel.manual => + 'curl -fsSL https://raw.githubusercontent.com/hakimjonas/lambe/main/install.sh | sh', +}; + +/// A pending one-line update notice for stderr. +class UpdateNotice { + /// The currently-running version. + final String currentVersion; + + /// The newer version available upstream. + final String latestVersion; + + /// How `lam` was installed, selecting the upgrade command. + final InstallChannel channel; + + /// Creates an update notice. + const UpdateNotice(this.currentVersion, this.latestVersion, this.channel); + + /// The single stderr line, e.g. + /// `A new lam is available: 0.13.0 -> 0.14.0. Upgrade: brew upgrade lambe`. + String render() => + 'A new lam is available: $currentVersion -> $latestVersion. ' + 'Upgrade: ${upgradeCommand(channel)}'; +} + +/// Persistence seam for the update-check cache. Default wraps a file; +/// tests pass an in-memory implementation. +abstract interface class CacheStore { + /// Returns the cache contents, or null if absent/unreadable. + String? read(); + + /// Writes the cache contents, swallowing any IO failure (a read-only + /// cache dir must never break a query). + void write(String contents); +} + +/// Fetches the latest published version string, or null on any failure +/// (network error, timeout, malformed response). Never throws. +typedef VersionFetcher = Future Function(); + +/// Outcome of the synchronous notice decision. +typedef NoticeDecision = ({UpdateNotice? notice, bool stale}); + +/// Decide, synchronously and without network, whether to show a notice +/// on this run and whether the cache is stale enough to refresh. +/// +/// Reads [store] once. A notice is returned iff the cached latest version +/// is strictly newer than [currentVersion]. A missing or malformed cache +/// yields no notice and `stale: true` (so a refresh is scheduled). +NoticeDecision decideNotice({ + required String currentVersion, + required CacheStore store, + required InstallChannel channel, + required DateTime now, + Duration staleness = const Duration(hours: 24), +}) { + final raw = store.read(); + if (raw == null) return (notice: null, stale: true); + + final Object? decoded; + try { + decoded = jsonDecode(raw); + } on FormatException { + return (notice: null, stale: true); + } + if (decoded is! Map) return (notice: null, stale: true); + + final epoch = decoded['last_check_epoch']; + final latest = decoded['latest_version']; + final stale = + epoch is! int || + now + .difference( + DateTime.fromMillisecondsSinceEpoch(epoch * 1000, isUtc: true), + ) + .abs() > + staleness; + + if (latest is! String || !isNewer(currentVersion, latest)) { + return (notice: null, stale: stale); + } + return (notice: UpdateNotice(currentVersion, latest, channel), stale: stale); +} + +/// Fetch the latest version via [fetch] and write it to [store] for the +/// next run. Fire-and-forget: returns a future the caller ignores. Only +/// writes when the fetch succeeds; all errors are swallowed. +Future refreshCache({ + required CacheStore store, + required VersionFetcher fetch, + required DateTime now, +}) async { + final latest = await fetch(); + if (latest == null) return; + final epoch = now.toUtc().millisecondsSinceEpoch ~/ 1000; + store.write( + jsonEncode({'last_check_epoch': epoch, 'latest_version': latest}), + ); +} + +/// Whether [latest] is a strictly-newer dotted version than [current]. +/// +/// Compares numeric `X.Y.Z` components. Pre-release/build metadata after +/// `-` or `+` is stripped (releases are plain `X.Y.Z`). Anything that +/// does not parse as numeric components compares as not-newer, so a +/// malformed upstream response can never produce a bogus notice. +bool isNewer(String current, String latest) { + final a = _parseVersion(current); + final b = _parseVersion(latest); + if (a == null || b == null) return false; + for (var i = 0; i < a.length && i < b.length; i++) { + if (b[i] > a[i]) return true; + if (b[i] < a[i]) return false; + } + return b.length > a.length && b.skip(a.length).any((c) => c > 0); +} + +List? _parseVersion(String v) { + final core = v.trim().split(RegExp(r'[-+]')).first; + final parts = core.split('.'); + if (parts.isEmpty) return null; + final out = []; + for (final p in parts) { + final n = int.tryParse(p); + if (n == null || n < 0) return null; + out.add(n); + } + return out; +} + +/// Detect how `lam` was installed from its [resolvedExecutable] path +/// (pass `Platform.resolvedExecutable`). [isWindows] selects path +/// conventions. Unknown locations fall back to [InstallChannel.manual]. +InstallChannel detectChannel(String resolvedExecutable, {bool? isWindows}) { + final win = isWindows ?? Platform.isWindows; + final p = resolvedExecutable.toLowerCase().replaceAll(r'\', '/'); + if (p.contains('/cellar/') || + p.contains('/homebrew/') || + p.contains('/.linuxbrew/')) { + return InstallChannel.brew; + } + if (p.contains('/scoop/')) return InstallChannel.scoop; + // pub-cache: `.pub-cache` on unix, `pub/cache` on Windows. + if (p.contains('.pub-cache') || (win && p.contains('/pub/cache/'))) { + return InstallChannel.pub; + } + return InstallChannel.manual; +} + +/// The full gating predicate: check for updates only when every guard +/// allows it. Pure — takes the already-resolved booleans. +bool shouldCheck({ + required bool stderrIsTerminal, + required bool optedOutByEnv, + required bool optedOutByFlag, + required bool isCi, + required bool suppressedMode, +}) => + stderrIsTerminal && + !optedOutByEnv && + !optedOutByFlag && + !isCi && + !suppressedMode; + +// --------------------------------------------------------------------------- +// Default IO wiring. None of this runs under test (tests inject fakes). +// --------------------------------------------------------------------------- + +/// Resolve the cache file path from [env], honoring XDG / platform +/// conventions. Returns null if no writable home/cache root is known. +String? resolveCachePath(Map env, {bool? isWindows}) { + final win = isWindows ?? Platform.isWindows; + String join(String dir) => '$dir/lambe/update-check.json'; + if (win) { + final local = env['LOCALAPPDATA']; + if (local != null && local.isNotEmpty) return join(local); + } + final xdg = env['XDG_CACHE_HOME']; + if (xdg != null && xdg.isNotEmpty) return join(xdg); + final home = env['HOME']; + if (home != null && home.isNotEmpty) return join('$home/.cache'); + return null; +} + +/// File-backed [CacheStore] at [path]. A null path makes every read +/// return null and every write a no-op (no writable cache location). +class FileCacheStore implements CacheStore { + /// The resolved cache file path, or null if none is available. + final String? path; + + /// Creates a file-backed store at [path]. + const FileCacheStore(this.path); + + @override + String? read() { + final p = path; + if (p == null) return null; + try { + final f = File(p); + return f.existsSync() ? f.readAsStringSync() : null; + } on IOException { + return null; + } + } + + @override + void write(String contents) { + final p = path; + if (p == null) return; + try { + final f = File(p); + f.parent.createSync(recursive: true); + f.writeAsStringSync(contents); + } on IOException { + // Read-only or sandboxed cache dir: skip silently. + } + } +} + +/// Default network fetcher: pub.dev's package API, short timeout, every +/// error swallowed to null. pub.dev returns the bare semver in +/// `latest.version` (GitHub release tags would need the `v` stripped). +VersionFetcher defaultFetcher({ + Duration timeout = const Duration(seconds: 2), + String currentVersion = '', +}) => () async { + final client = HttpClient()..connectionTimeout = timeout; + try { + final req = await client + .getUrl(Uri.parse('https://pub.dev/api/packages/lambe')) + .timeout(timeout); + req.headers.set(HttpHeaders.userAgentHeader, 'lam/$currentVersion'); + req.headers.set(HttpHeaders.acceptHeader, 'application/json'); + final resp = await req.close().timeout(timeout); + if (resp.statusCode != 200) return null; + final body = await resp.transform(utf8.decoder).join().timeout(timeout); + final decoded = jsonDecodeOrNull(body); + if (decoded is! Map) return null; + final latest = decoded['latest']; + if (latest is! Map) return null; + final version = latest['version']; + return version is String ? version : null; + } on Object { + return null; + } finally { + client.close(force: true); + } +}; + +/// jsonDecode that returns null instead of throwing on malformed input. +Object? jsonDecodeOrNull(String s) { + try { + return jsonDecode(s); + } on FormatException { + return null; + } +} diff --git a/doc/getting-started.md b/doc/getting-started.md index 2d17e24..5bd700a 100644 --- a/doc/getting-started.md +++ b/doc/getting-started.md @@ -76,6 +76,31 @@ lam --completions zsh > "${fpath[1]}/_lam" lam --completions fish > ~/.config/fish/completions/lam.fish ``` +## Updating + +Update `lam` with whatever installed it: + +```bash +# Homebrew +brew upgrade lambe + +# Scoop (Windows) +scoop update lambe + +# pub +dart pub global activate lambe + +# curl installer or a downloaded binary — re-run the installer +curl -fsSL https://raw.githubusercontent.com/hakimjonas/lambe/main/install.sh | sh +``` + +On a normal run `lam` checks once a day whether a newer release exists and, +if so, prints a one-line notice to stderr with the right upgrade command for +how you installed it. The check is cached, never blocks the query, and never +writes to stdout. It is off automatically in CI and whenever stderr is not a +terminal (so it can't leak into pipes or scripts). Disable it explicitly with +`--no-update-check` or by setting `LAM_NO_UPDATE_CHECK`. + ## Your first query Create a file called `data.json`: diff --git a/doc/lam.1 b/doc/lam.1 index 720a670..81540e1 100644 --- a/doc/lam.1 +++ b/doc/lam.1 @@ -72,8 +72,18 @@ Print a shell completion script for \fISHELL\fR (\fBbash\fR, \fBzsh\fR, or \fBfi \fB--version\fR Print the Lambë version (\fClam \fR) and exit. .TP +\fB--update-check\fR, \fB--no-update-check\fR +On a normal run, check at most once a day whether a newer \fClam\fR release exists and, if so, print a one-line notice to stderr with the upgrade command for the detected install method. Enabled by default; the check is cached, never blocks the query, never writes to stdout, and is off automatically in CI and when stderr is not a terminal. Disable with \fB--no-update-check\fR or by setting \fBLAM_NO_UPDATE_CHECK\fR. +.TP \fB-h\fR, \fB--help\fR Show usage information. +.SH ENVIRONMENT +.TP +\fBLAM_NO_UPDATE_CHECK\fR +When set (to any value), disables the passive update check, equivalent to \fB--no-update-check\fR. +.TP +\fBCI\fR +When set (to any value), the passive update check is disabled automatically, so scripted and continuous-integration runs never emit a notice. .SH QUERY LANGUAGE .PP Queries start with \fB.\fR (the current document) and chain operations with \fB|\fR. @@ -225,6 +235,9 @@ Error, or \fB--assert\fR failed. .TP \fB~/.lambe_history\fR REPL command history, persisted between sessions. +.TP +\fB$XDG_CACHE_HOME/lambe/update-check.json\fR +Update-check cache (falls back to \fB~/.cache/lambe/\fR or, on Windows, \fB%LOCALAPPDATA%\\lambe\\\fR). Stores the last-check timestamp and the latest known version. Safe to delete; recreated on the next run. .SH EXAMPLES .PP Extract a value: diff --git a/doc/lam.1.md b/doc/lam.1.md index 37bf6ba..24b4db2 100644 --- a/doc/lam.1.md +++ b/doc/lam.1.md @@ -80,9 +80,20 @@ If no file is given, reads from standard input. **--version** : Print the Lambë version (`lam `) and exit. +**--update-check**, **--no-update-check** +: On a normal run, check at most once a day whether a newer `lam` release exists and, if so, print a one-line notice to stderr with the upgrade command for the detected install method. Enabled by default; the check is cached, never blocks the query, never writes to stdout, and is off automatically in CI and when stderr is not a terminal. Disable with **--no-update-check** or by setting **LAM_NO_UPDATE_CHECK**. + **-h**, **--help** : Show usage information. +# ENVIRONMENT + +**LAM_NO_UPDATE_CHECK** +: When set (to any value), disables the passive update check, equivalent to **--no-update-check**. + +**CI** +: When set (to any value), the passive update check is disabled automatically, so scripted and continuous-integration runs never emit a notice. + # QUERY LANGUAGE Queries start with **.** (the current document) and chain operations with **|**. @@ -250,6 +261,9 @@ Tab completes field names and pipeline operations. Up/Down navigates history. Ct **~/.lambe_history** : REPL command history, persisted between sessions. +**$XDG_CACHE_HOME/lambe/update-check.json** +: Update-check cache (falls back to **~/.cache/lambe/** or, on Windows, **%LOCALAPPDATA%\\lambe\\**). Stores the last-check timestamp and the latest known version. Safe to delete; recreated on the next run. + # EXAMPLES Extract a value: diff --git a/test/cli_integration_test.dart b/test/cli_integration_test.dart index a9796a7..73106aa 100644 --- a/test/cli_integration_test.dart +++ b/test/cli_integration_test.dart @@ -886,4 +886,27 @@ void main() { expect(out.trim(), 'lam $declared'); }); }); + + group('--update-check: passive update notice', () { + // _runLam captures stdout/stderr via pipes, so stderr is not a + // terminal — the update check must stay off and never leak a notice + // into either stream. (The notice policy itself is unit-tested with + // seams in update_check_test.dart; here we pin the integration gate.) + test('no notice leaks to stderr on a normal run (non-TTY)', () async { + final (code, out, err) = await _runLam(['.a'], stdinContents: '{"a":1}'); + expect(code, 0); + expect(out.trim(), '1'); + expect(err, isNot(contains('new lam is available'))); + expect(err, isEmpty); + }); + + test('--no-update-check is accepted on a normal run', () async { + final (code, out, _) = await _runLam([ + '--no-update-check', + '.a', + ], stdinContents: '{"a":1}'); + expect(code, 0); + expect(out.trim(), '1'); + }); + }); } diff --git a/test/update_check_test.dart b/test/update_check_test.dart new file mode 100644 index 0000000..7eaa84d --- /dev/null +++ b/test/update_check_test.dart @@ -0,0 +1,297 @@ +/// Unit tests for the passive update-check policy (`bin/update_check.dart`). +/// +/// Every IO touch point is a seam, so these run with in-memory fakes and +/// a fixed clock — no real network, no disk, CI-safe. They pin the policy: +/// when a notice fires, what it says per install channel, when the cache +/// is considered stale, and the full opt-out gate. +library; + +import 'dart:convert'; + +import 'package:test/test.dart'; + +import '../bin/update_check.dart'; + +/// In-memory [CacheStore] holding a single optional string. +class _FakeStore implements CacheStore { + String? contents; + int writes = 0; + _FakeStore([this.contents]); + + @override + String? read() => contents; + + @override + void write(String c) { + contents = c; + writes++; + } +} + +/// Build a cache JSON string for [version] last checked [epoch] seconds +/// since the unix epoch (UTC). +String _cache(String version, int epoch) => + jsonEncode({'last_check_epoch': epoch, 'latest_version': version}); + +void main() { + final now = DateTime.utc(2026, 6, 9, 12); + final fresh = + now.subtract(const Duration(hours: 1)).millisecondsSinceEpoch ~/ 1000; + final old = + now.subtract(const Duration(days: 3)).millisecondsSinceEpoch ~/ 1000; + + group('isNewer', () { + test('strictly newer patch / minor / major', () { + expect(isNewer('0.13.0', '0.14.0'), isTrue); + expect(isNewer('0.13.0', '0.13.1'), isTrue); + expect(isNewer('0.99.0', '1.0.0'), isTrue); + expect(isNewer('0.13.9', '0.14.0'), isTrue); + }); + + test('equal or older is not newer', () { + expect(isNewer('0.14.0', '0.14.0'), isFalse); + expect(isNewer('0.14.0', '0.13.9'), isFalse); + expect(isNewer('1.0.0', '0.99.99'), isFalse); + }); + + test('pre-release / build metadata is stripped', () { + expect(isNewer('0.13.0', '0.14.0-rc.1'), isTrue); + expect(isNewer('0.14.0-rc.1', '0.14.0'), isFalse); // 0.14.0 == 0.14.0 + }); + + test('differing component counts', () { + expect(isNewer('0.14', '0.14.1'), isTrue); + expect(isNewer('0.14.0', '0.14'), isFalse); + }); + + test('unparseable compares as not-newer (no bogus notice)', () { + expect(isNewer('0.13.0', 'garbage'), isFalse); + expect(isNewer('0.13.0', ''), isFalse); + expect(isNewer('0.13.0', '0.x.0'), isFalse); + }); + }); + + group('decideNotice', () { + test('newer available + fresh cache → notice, not stale', () { + final d = decideNotice( + currentVersion: '0.13.0', + store: _FakeStore(_cache('0.14.0', fresh)), + channel: InstallChannel.brew, + now: now, + ); + expect(d.stale, isFalse); + expect(d.notice, isNotNull); + expect(d.notice!.latestVersion, '0.14.0'); + expect(d.notice!.channel, InstallChannel.brew); + }); + + test('up to date → no notice', () { + final d = decideNotice( + currentVersion: '0.14.0', + store: _FakeStore(_cache('0.14.0', fresh)), + channel: InstallChannel.pub, + now: now, + ); + expect(d.notice, isNull); + expect(d.stale, isFalse); + }); + + test('stale cache (>24h) → stale true', () { + final d = decideNotice( + currentVersion: '0.14.0', + store: _FakeStore(_cache('0.14.0', old)), + channel: InstallChannel.pub, + now: now, + ); + expect(d.stale, isTrue); + }); + + test('newer available but stale → notice AND stale', () { + final d = decideNotice( + currentVersion: '0.13.0', + store: _FakeStore(_cache('0.14.0', old)), + channel: InstallChannel.scoop, + now: now, + ); + expect(d.notice, isNotNull); + expect(d.stale, isTrue); + }); + + test('absent cache → no notice, stale', () { + final d = decideNotice( + currentVersion: '0.13.0', + store: _FakeStore(), + channel: InstallChannel.manual, + now: now, + ); + expect(d.notice, isNull); + expect(d.stale, isTrue); + }); + + test('malformed cache → no notice, stale, no throw', () { + final d = decideNotice( + currentVersion: '0.13.0', + store: _FakeStore('not json{{'), + channel: InstallChannel.manual, + now: now, + ); + expect(d.notice, isNull); + expect(d.stale, isTrue); + }); + + test('cache missing epoch → treated as stale', () { + final d = decideNotice( + currentVersion: '0.13.0', + store: _FakeStore(jsonEncode({'latest_version': '0.14.0'})), + channel: InstallChannel.brew, + now: now, + ); + expect(d.notice, isNotNull); + expect(d.stale, isTrue); + }); + }); + + group('refreshCache', () { + test('fetch newer → store written with epoch + version', () async { + final store = _FakeStore(); + await refreshCache(store: store, fetch: () async => '0.14.0', now: now); + expect(store.writes, 1); + final decoded = jsonDecode(store.contents!) as Map; + expect(decoded['latest_version'], '0.14.0'); + expect(decoded['last_check_epoch'], now.millisecondsSinceEpoch ~/ 1000); + }); + + test('fetch null (failure) → store untouched', () async { + final store = _FakeStore(); + await refreshCache(store: store, fetch: () async => null, now: now); + expect(store.writes, 0); + expect(store.contents, isNull); + }); + }); + + group('detectChannel', () { + test('homebrew Cellar', () { + expect( + detectChannel( + '/opt/homebrew/Cellar/lambe/0.13.0/bin/lam', + isWindows: false, + ), + InstallChannel.brew, + ); + expect( + detectChannel('/home/linuxbrew/.linuxbrew/bin/lam', isWindows: false), + InstallChannel.brew, + ); + }); + + test('scoop', () { + expect( + detectChannel( + r'C:\Users\me\scoop\apps\lambe\current\lam.exe', + isWindows: true, + ), + InstallChannel.scoop, + ); + }); + + test('pub-cache (unix and windows)', () { + expect( + detectChannel('/home/me/.pub-cache/bin/lam', isWindows: false), + InstallChannel.pub, + ); + expect( + detectChannel( + r'C:\Users\me\AppData\Local\Pub\Cache\bin\lam.exe', + isWindows: true, + ), + InstallChannel.pub, + ); + }); + + test('manual / unknown', () { + expect( + detectChannel('/usr/local/bin/lam', isWindows: false), + InstallChannel.manual, + ); + }); + }); + + group('UpdateNotice.render', () { + test('one line, channel-appropriate command', () { + expect( + const UpdateNotice('0.13.0', '0.14.0', InstallChannel.brew).render(), + 'A new lam is available: 0.13.0 -> 0.14.0. Upgrade: brew upgrade lambe', + ); + expect( + const UpdateNotice('0.13.0', '0.14.0', InstallChannel.scoop).render(), + contains('scoop update lambe'), + ); + expect( + const UpdateNotice('0.13.0', '0.14.0', InstallChannel.pub).render(), + contains('dart pub global activate lambe'), + ); + expect( + const UpdateNotice('0.13.0', '0.14.0', InstallChannel.manual).render(), + contains('install.sh'), + ); + }); + }); + + group('shouldCheck gate', () { + bool gate({ + bool tty = true, + bool env = false, + bool flag = false, + bool ci = false, + bool mode = false, + }) => shouldCheck( + stderrIsTerminal: tty, + optedOutByEnv: env, + optedOutByFlag: flag, + isCi: ci, + suppressedMode: mode, + ); + + test('all clear → check', () { + expect(gate(), isTrue); + }); + + test('each guard independently blocks', () { + expect(gate(tty: false), isFalse); + expect(gate(env: true), isFalse); + expect(gate(flag: true), isFalse); + expect(gate(ci: true), isFalse); + expect(gate(mode: true), isFalse); + }); + }); + + group('resolveCachePath', () { + test('XDG_CACHE_HOME wins on unix', () { + expect( + resolveCachePath({ + 'XDG_CACHE_HOME': '/x', + 'HOME': '/h', + }, isWindows: false), + '/x/lambe/update-check.json', + ); + }); + + test('falls back to HOME/.cache', () { + expect( + resolveCachePath({'HOME': '/h'}, isWindows: false), + '/h/.cache/lambe/update-check.json', + ); + }); + + test('LOCALAPPDATA on windows', () { + expect( + resolveCachePath({'LOCALAPPDATA': r'C:\x'}, isWindows: true), + r'C:\x/lambe/update-check.json', + ); + }); + + test('null when no home root known', () { + expect(resolveCachePath({}, isWindows: false), isNull); + }); + }); +}