Files
alvocool 38881d1fe1 Add app source and tests
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-11 10:15:30 +03:00

294 lines
9.4 KiB
Dart

import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'package:mason/mason.dart';
/// Adds one persisted field to local storage by injecting the same
/// getter/setter pair into all three files that have to agree:
///
/// ConnectInternalMemory → the contract
/// InternalMemory → the implementation (+ its storage key)
/// AppDataManager → the delegation
///
/// Doing this by hand drifts: it's easy to add the pair to two of the three and
/// only find out at compile time, and the secure-key constant should never be
/// hand-picked.
Future<void> run(HookContext context) async {
final logger = context.logger;
final functionName = (context.vars['function_name'] ?? '') as String;
final requestObject = (context.vars['request_object'] ?? '') as String;
final secure = (context.vars['secure'] ?? false) as bool;
if (functionName.isEmpty || requestObject.isEmpty) {
logger.err('Both a field name and a type are required.');
return;
}
final pName = functionName.pascalCase;
final keyConst = '_${functionName.camelCase}Key';
final keyValue = secure
? _secureKeyValue(functionName)
: 'peach.${functionName.snakeCase}';
final isList = requestObject.startsWith('List<');
final isString = requestObject.toLowerCase() == 'string';
final baseType = isList
? requestObject.substring(5, requestObject.length - 1)
: requestObject;
const memoryDir = 'lib/Peach/memory';
const connectPath = '$memoryDir/ConnectInternalMemory.dart';
const memoryPath = '$memoryDir/InternalMemory.dart';
const managerPath = 'lib/Peach/informatics/AppDataManager.dart';
// Model types need importing into all three files; String doesn't.
String? modelImportFor(String fromFile) =>
isString ? null : _relativeImport(baseType, fromFile);
final duplicate = 'get$pName()';
// ── 1. the contract ────────────────────────────────────────────────────────
final contract = '''
Future<$requestObject> get$pName();
Future set$pName($requestObject data);''';
_inject(
connectPath,
contract,
duplicate: duplicate,
importLine: modelImportFor(connectPath),
logger: logger,
);
// ── 2. the implementation ──────────────────────────────────────────────────
final read = secure
? "await SecretStore.read($keyConst)"
: "(await SharedPreferences.getInstance()).getString($keyConst)";
final write = secure
? (String v) => "await SecretStore.write($keyConst, $v);"
: (String v) =>
"await (await SharedPreferences.getInstance()).setString($keyConst, $v);";
String getBody;
String setBody;
if (isString) {
getBody = ' return $read ?? \'\';';
setBody = ' ${write('data')}';
} else if (isList) {
getBody = '''
final stored = $read;
if (stored == null || stored.isEmpty) return [];
return (jsonDecode(stored) as List)
.map((item) => $baseType.fromJson(item as Map<String, dynamic>))
.toList();''';
setBody = '''
final encoded = jsonEncode(data.map((item) => item.toJson()).toList());
${write('encoded')}''';
} else {
getBody = '''
final stored = $read;
if (stored == null || stored.isEmpty) return null;
return $baseType.fromJson(jsonDecode(stored) as Map<String, dynamic>);''';
setBody = '''
${write('jsonEncode(data.toJson())')}''';
}
// A single object can legitimately be absent, so its getter is nullable.
final returnType = (isString || isList) ? requestObject : '$requestObject?';
final implementation = '''
@override
Future<$returnType> get$pName() async {
$getBody
}
@override
Future set$pName($requestObject data) async {
$setBody
}''';
_injectImplementation(
memoryPath,
keyLine: " static const $keyConst = '$keyValue';",
methods: implementation,
duplicate: duplicate,
importLine: modelImportFor(memoryPath),
needsConvert: !isString,
needsSecretStore: secure,
needsPrefs: !secure,
logger: logger,
);
// Keep the contract's nullability in step with the implementation.
if (!isString && !isList) {
_replaceInFile(
connectPath,
'Future<$requestObject> get$pName();',
'Future<$requestObject?> get$pName();',
);
}
// ── 3. the delegation ──────────────────────────────────────────────────────
final delegation = '''
@override
Future<$returnType> get$pName() => memory.get$pName();
@override
Future set$pName($requestObject data) => memory.set$pName(data);''';
_inject(
managerPath,
delegation,
duplicate: duplicate,
importLine: modelImportFor(managerPath),
logger: logger,
);
try {
await Process.run('dart', ['format', 'lib/Peach/'], runInShell: true);
} catch (_) {
// Formatting is a nicety; a missing dart on PATH shouldn't fail the run.
}
logger.success(
'✨ $pName wired through ConnectInternalMemory, InternalMemory and '
'AppDataManager.');
}
/// A random-suffixed key, so a stored secret isn't sitting under a guessable
/// name in the OS keystore.
String _secureKeyValue(String field) {
final random = Random.secure();
final bytes = List<int>.generate(12, (_) => random.nextInt(256));
final suffix =
base64Url.encode(bytes).replaceAll('=', '').substring(0, 16);
return 'peach.${field.snakeCase}.$suffix';
}
/// Finds [className]'s file and builds an import relative to [fromFile] — the
/// project uses relative imports inside lib/Peach, not package: ones.
String? _relativeImport(String className, String fromFile) {
final dir = Directory('lib');
if (!dir.existsSync()) return null;
for (final entity in dir.listSync(recursive: true)) {
if (entity is! File || !entity.path.endsWith('.dart')) continue;
final content = entity.readAsStringSync();
// Built from raw parts: in a normal string `\s` would collapse to a plain
// `s`, quietly turning this into a pattern that never matches.
final declaration =
RegExp(r'(class|enum)\s+' + RegExp.escape(className) + r'\b');
if (!declaration.hasMatch(content)) continue;
final target = entity.path.replaceAll(r'\', '/');
final from = fromFile.replaceAll(r'\', '/');
return "import '${_relativePath(from, target)}';";
}
return null;
}
String _relativePath(String fromFile, String toFile) {
final fromParts = fromFile.split('/')..removeLast();
final toParts = toFile.split('/');
var shared = 0;
while (shared < fromParts.length &&
shared < toParts.length - 1 &&
fromParts[shared] == toParts[shared]) {
shared++;
}
final up = List.filled(fromParts.length - shared, '..');
final down = toParts.sublist(shared);
return [...up, ...down].join('/');
}
/// Appends [content] just before the class's closing brace.
void _inject(
String path,
String content, {
required String duplicate,
required Logger logger,
String? importLine,
}) {
final file = File(path);
if (!file.existsSync()) {
logger.err('Missing $path — is this being run from the project root?');
return;
}
var source = file.readAsStringSync();
if (source.contains(duplicate)) {
logger.info('$path already has $duplicate — skipped.');
return;
}
source = _addImport(source, importLine);
final close = source.lastIndexOf('}');
if (close == -1) return;
file.writeAsStringSync(
'${source.substring(0, close).trimRight()}\n\n$content\n${source.substring(close)}');
}
/// Like [_inject], but also puts the storage key at the top of the class and
/// pulls in whatever the generated body needs.
void _injectImplementation(
String path, {
required String keyLine,
required String methods,
required String duplicate,
required bool needsConvert,
required bool needsSecretStore,
required bool needsPrefs,
required Logger logger,
String? importLine,
}) {
final file = File(path);
if (!file.existsSync()) {
logger.err('Missing $path — is this being run from the project root?');
return;
}
var source = file.readAsStringSync();
if (source.contains(duplicate)) {
logger.info('$path already has $duplicate — skipped.');
return;
}
source = _addImport(source, importLine);
if (needsConvert) source = _addImport(source, "import 'dart:convert';");
if (needsPrefs) {
source = _addImport(
source, "import 'package:shared_preferences/shared_preferences.dart';");
}
if (needsSecretStore) {
source = _addImport(source, "import 'SecretStore.dart';");
}
final classOpen = source.indexOf('{', source.indexOf('class InternalMemory'));
if (classOpen != -1) {
source =
'${source.substring(0, classOpen + 1)}\n$keyLine\n${source.substring(classOpen + 1)}';
}
final close = source.lastIndexOf('}');
if (close == -1) return;
file.writeAsStringSync(
'${source.substring(0, close).trimRight()}\n\n$methods\n${source.substring(close)}');
}
String _addImport(String source, String? importLine) {
if (importLine == null || source.contains(importLine)) return source;
return '$importLine\n$source';
}
void _replaceInFile(String path, String from, String to) {
final file = File(path);
if (!file.existsSync()) return;
final source = file.readAsStringSync();
if (!source.contains(from)) return;
file.writeAsStringSync(source.replaceFirst(from, to));
}