blob: 9fd224bd798b95f1f4114ce27703a410cfbf772e [file] [edit]
// 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;
}
}