| // Copyright (c) 2026, the Dart project authors. Please see the AUTHORS file | |
| // for details. All rights reserved. Use of this source code is governed by a | |
| // BSD-style license that can be found in the LICENSE file. | |
| import 'dart:convert'; | |
| import 'dart:io'; | |
| import 'package:dart_data_home/dart_data_home.dart'; | |
| import 'package:path/path.dart' as path; | |
| import 'pubspec_scanner.dart'; | |
| /// Manage local disk caching of evaluated project telemetry in | |
| /// `~/.dart/dartdev/project_telemetry_cache.json`. | |
| /// | |
| /// Implements: | |
| /// - FNV-1a path hashing for developer anonymity. | |
| /// - Storage of pubspec content hash and last sent timestamp. | |
| /// - 100-project LRU eviction cap. | |
| /// - Silent failure guarantee via complete `try-catch` isolation. | |
| class ProjectTelemetryCache { | |
| static const int _maxProjects = 100; | |
| static const int supportedVersion = 1; | |
| final File cacheFile; | |
| ProjectTelemetryCache([File? file]) | |
| : cacheFile = | |
| file ?? | |
| File( | |
| path.join( | |
| getDartDataHome('dartdev'), | |
| 'project_telemetry_cache.json', | |
| ), | |
| ); | |
| /// Retrieves the raw cache entry for [projectPath]. Returns `null` on cache miss | |
| /// or any filesystem/parsing error. | |
| ProjectCacheEntry? getEntry(String projectPath) { | |
| try { | |
| if (!cacheFile.existsSync()) { | |
| return null; | |
| } | |
| final doc = ProjectCacheDocument.fromJson( | |
| jsonDecode(cacheFile.readAsStringSync()), | |
| ); | |
| if (doc == null) return null; | |
| final key = hash(projectPath); | |
| final entry = doc.projects[key]; | |
| if (entry == null) return null; | |
| return entry; | |
| } catch (_) { | |
| return null; // Fail silently | |
| } | |
| } | |
| /// Writes or updates the cache entry for [projectPath] with [telemetry], | |
| /// updating `last_sent_seconds` to now and evicting the oldest entries if the | |
| /// total exceeds 100 projects. | |
| void set( | |
| String projectPath, | |
| PubspecTelemetry telemetry, { | |
| required String contentHash, | |
| int? nowSeconds, | |
| }) { | |
| try { | |
| var doc = ProjectCacheDocument(); | |
| if (cacheFile.existsSync()) { | |
| try { | |
| doc = | |
| ProjectCacheDocument.fromJson( | |
| jsonDecode(cacheFile.readAsStringSync()), | |
| ) ?? | |
| ProjectCacheDocument(); | |
| } catch (_) { | |
| // If existing cache file is corrupted, start fresh | |
| } | |
| } | |
| final key = hash(projectPath); | |
| final now = nowSeconds ?? DateTime.now().millisecondsSinceEpoch ~/ 1000; | |
| doc.projects[key] = ProjectCacheEntry.fromTelemetry( | |
| telemetry, | |
| sentAtSeconds: now, | |
| contentHash: contentHash, | |
| ); | |
| // LRU Eviction: if exceeding 100 projects, remove oldest by last_sent_seconds | |
| if (doc.projects.length > _maxProjects) { | |
| final entries = doc.projects.entries.toList() | |
| ..sort( | |
| (a, b) => | |
| a.value.lastSentSeconds.compareTo(b.value.lastSentSeconds), | |
| ); | |
| while (entries.length > _maxProjects) { | |
| final oldest = entries.removeAt(0); | |
| doc.projects.remove(oldest.key); | |
| } | |
| } | |
| final output = jsonEncode(doc.toJson()); | |
| if (!cacheFile.parent.existsSync()) { | |
| cacheFile.parent.createSync(recursive: true); | |
| } | |
| cacheFile.writeAsStringSync(output); | |
| } catch (_) { | |
| // Fail silently to guarantee CLI stability | |
| } | |
| } | |
| /// Computes a 64-bit FNV-1a hash of [input] and returns it as a 16-char hex string. | |
| static String hash(String input) { | |
| var hash = 0xcbf29ce484222325; | |
| for (var i = 0; i < input.length; i++) { | |
| hash = (hash ^ input.codeUnitAt(i)) * 0x100000001b3; | |
| } | |
| return hash.toUnsigned(64).toRadixString(16).padLeft(16, '0'); | |
| } | |
| } | |
| /// A strongly-typed model representing an individual project entry in the | |
| /// telemetry disk cache. | |
| final class ProjectCacheEntry { | |
| final int lastSentSeconds; | |
| final String contentHash; | |
| final Set<String> publicDependencies; | |
| final bool hasFlutterSdk; | |
| final String? environmentSdk; | |
| const ProjectCacheEntry({ | |
| required this.lastSentSeconds, | |
| required this.contentHash, | |
| required this.publicDependencies, | |
| required this.hasFlutterSdk, | |
| this.environmentSdk, | |
| }); | |
| factory ProjectCacheEntry.fromTelemetry( | |
| PubspecTelemetry telemetry, { | |
| required int sentAtSeconds, | |
| required String contentHash, | |
| }) => ProjectCacheEntry( | |
| lastSentSeconds: sentAtSeconds, | |
| contentHash: contentHash, | |
| publicDependencies: telemetry.publicDependencies, | |
| hasFlutterSdk: telemetry.hasFlutterSdk, | |
| environmentSdk: telemetry.environmentSdk, | |
| ); | |
| Map<String, Object?> toJson() => { | |
| 'last_sent_seconds': lastSentSeconds, | |
| 'content_hash': contentHash, | |
| 'public_dependencies': publicDependencies.toList()..sort(), | |
| 'has_flutter_sdk': hasFlutterSdk, | |
| 'environment_sdk': environmentSdk, | |
| }; | |
| static ProjectCacheEntry? fromJson(Object? json) => switch (json) { | |
| { | |
| 'last_sent_seconds': int lastSentSeconds, | |
| 'content_hash': String contentHash, | |
| 'public_dependencies': List<dynamic> publicDeps, | |
| 'has_flutter_sdk': bool hasFlutterSdk, | |
| 'environment_sdk': String? environmentSdk, | |
| } => | |
| ProjectCacheEntry( | |
| lastSentSeconds: lastSentSeconds, | |
| contentHash: contentHash, | |
| publicDependencies: publicDeps.whereType<String>().toSet(), | |
| hasFlutterSdk: hasFlutterSdk, | |
| environmentSdk: environmentSdk, | |
| ), | |
| _ => null, | |
| }; | |
| } | |
| /// A strongly-typed model representing the top-level document structure of the | |
| /// telemetry disk cache file. | |
| final class ProjectCacheDocument { | |
| final int version; | |
| final Map<String, ProjectCacheEntry> projects; | |
| ProjectCacheDocument({ | |
| this.version = ProjectTelemetryCache.supportedVersion, | |
| Map<String, ProjectCacheEntry>? projects, | |
| }) : projects = projects ?? <String, ProjectCacheEntry>{}; | |
| Map<String, Object?> toJson() => { | |
| 'version': version, | |
| 'projects': projects.map((k, v) => MapEntry(k, v.toJson())), | |
| }; | |
| static ProjectCacheDocument? fromJson(Object? json) { | |
| if (json case { | |
| 'version': int version, | |
| 'projects': Map<String, dynamic> rawProjects, | |
| }) { | |
| if (version != ProjectTelemetryCache.supportedVersion) return null; | |
| final parsed = <String, ProjectCacheEntry>{}; | |
| for (final MapEntry(:key, :value) in rawProjects.entries) { | |
| if (ProjectCacheEntry.fromJson(value) case final entry?) { | |
| parsed[key] = entry; | |
| } | |
| } | |
| return ProjectCacheDocument(version: version, projects: parsed); | |
| } | |
| return null; | |
| } | |
| } |