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 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)) .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);'''; 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.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)); }