diff --git a/bin/assign_rfc_number.dart b/bin/assign_rfc_number.dart new file mode 100644 index 0000000..413c800 --- /dev/null +++ b/bin/assign_rfc_number.dart @@ -0,0 +1,85 @@ +// Copyright 2026 The Flutter Authors. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:io'; +import 'package:args/args.dart'; +import 'package:file/local.dart'; +import 'package:rfc_tools/src/assigner.dart'; + +void main(List arguments) async { + final parser = ArgParser() + ..addOption( + 'target-file', + help: + 'Specific RFC file path to assign (defaults to auto-detecting draft .0000).', + ) + ..addFlag( + 'dry-run', + negatable: false, + help: 'Simulate assignment without modifying files.', + ) + ..addFlag( + 'help', + abbr: 'h', + negatable: false, + help: 'Show usage instructions.', + ); + + ArgResults results; + try { + results = parser.parse(arguments); + } catch (e) { + stderr.writeln('Error parsing arguments: $e\n'); + stderr.writeln(parser.usage); + exitCode = 1; + return; + } + + if (results.flag('help')) { + stdout.writeln('RFC Number Assigner - Flutter RFC Repository Tooling\n'); + stdout.writeln(parser.usage); + return; + } + + final targetFile = results.rest.firstOrNull ?? results.option('target-file'); + final dryRun = results.flag('dry-run'); + + const fs = LocalFileSystem(); + final assigner = RfcAssigner(fs: fs); + + try { + stdout.writeln('Assigning RFC number...'); + final result = await assigner.assign( + targetPath: targetFile, + dryRun: dryRun, + ); + + if (result.dryRun) { + stdout.writeln( + '[DRY RUN] Would assign RFC identifier: ${result.newRfcId}', + ); + stdout.writeln('[DRY RUN] Target file: ${result.oldPath}'); + stdout.writeln('[DRY RUN] Rename to: ${result.newPath}'); + } else { + stdout.writeln( + 'Successfully assigned RFC identifier: ${result.newRfcId}', + ); + stdout.writeln('File: ${result.newPath}'); + + final githubOutput = Platform.environment['GITHUB_OUTPUT']; + if (githubOutput != null && githubOutput.isNotEmpty) { + await fs + .file(githubOutput) + .writeAsString( + 'rfc_id=${result.newRfcId}\nnew_path=${result.newPath}\n', + mode: FileMode.append, + ); + } + } + } catch (e) { + stderr.writeln('Assignment failed: $e'); + exitCode = 1; + return; + } +} diff --git a/lib/src/assigner.dart b/lib/src/assigner.dart new file mode 100644 index 0000000..843cbea --- /dev/null +++ b/lib/src/assigner.dart @@ -0,0 +1,324 @@ +// Copyright 2026 The Flutter Authors. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:io' show Process; +import 'dart:math'; +import 'package:clock/clock.dart'; +import 'package:file/file.dart'; +import 'package:path/path.dart' as p; + +import 'git_lister.dart'; +import 'git_lister.dart' as git_lister; +import 'github_client.dart' show ProcessRunner; +import 'models/rfc_file.dart'; + +export 'git_lister.dart' show GitListFunction, defaultGitList; + +/// Result of an RFC number assignment operation. +class AssignmentResult { + final String oldPath; + final String newPath; + final String category; + final int oldIndex; + final int newIndex; + final String newContent; + final bool dryRun; + + const AssignmentResult({ + required this.oldPath, + required this.newPath, + required this.category, + required this.oldIndex, + required this.newIndex, + required this.newContent, + required this.dryRun, + }); + + String get newRfcId => '$category.${newIndex.toNNNN()}'; +} + +/// Allocates sequential RFC numbers, updates frontmatter & headings, and handles git/label sync. +class RfcAssigner { + /// Default directory name containing RFC markdown documents. + static const String rfcDir = 'rfc'; + + final FileSystem fs; + final GitListFunction gitList; + + const RfcAssigner({ + required this.fs, + this.gitList = RfcAssigner.defaultGitList, + }); + + /// Discovers RFC filenames in main branch via git. + static Future> defaultGitList({ + String baseBranch = 'origin/main', + ProcessRunner processRunner = Process.run, + }) => git_lister.defaultGitList( + baseBranch: baseBranch, + processRunner: processRunner, + ); + + Future> _getMainBranchRfcFiles() async => await gitList(); + + /// Allocates the next sequential RFC number in the target category. + /// + /// Workflow: + /// 1. Identifies the target RFC file using [targetPath] if provided, or discovers + /// a single `.0000` draft in [rfcDir]. If no `.0000` draft exists, checks + /// for re-allocation on an existing RFC colliding with [gitList]. + /// 2. Discovers all used numeric indices within the matching category (`AAA.`) + /// across the working tree and the main branch. + /// 3. Computes the next available sequential index number (1-9999). + /// 4. Transforms the RFC document content: updates frontmatter `rfc` and + /// `updated` timestamp (using [updatedTime] or `clock.now()`), and + /// updates the top-level `# RFC AAA.NNNN: Title` heading while preserving + /// `status: draft`. + /// 5. Renames and writes the file on disk, unless [dryRun] is `true`. + /// + /// Parameters: + /// - [targetPath]: Explicit path to the RFC file to assign. If `null`, + /// automatically selects the single candidate draft file in [rfcDir]. + /// - [dryRun]: When `true`, computes the assignment and transformed content + /// without modifying the filesystem. Defaults to `false`. + /// - [updatedTime]: Timestamp to write into the frontmatter `updated` field. + /// Defaults to `clock.now()`. + /// + /// Returns an [AssignmentResult] containing paths, category, index numbers, + /// transformed content, and [dryRun] status. + /// + /// Throws: + /// - [ArgumentError] if [targetPath] does not exist. + /// - [StateError] if the filename does not conform to `AAA.NNNN-.md`, + /// if multiple draft RFCs exist without an explicit [targetPath], + /// if no RFC requiring assignment is found, or if category index 9999 is reached. + Future assign({ + String? targetPath, + bool dryRun = false, + DateTime? updatedTime, + }) async { + Set? cachedMainFiles; + Future> getMainFiles() async => + cachedMainFiles ??= await _getMainBranchRfcFiles(); + + final dir = fs.directory(rfcDir); + + // Identify the target RFC file to assign. + final RfcFile targetRfc; + List? cachedEntries; + + if (targetPath != null) { + targetRfc = await _resolveExplicitTarget(targetPath, dir); + } else { + (targetRfc, cachedEntries) = await _discoverTarget(dir, getMainFiles); + } + + final category = targetRfc.category!; + final oldIndex = targetRfc.index!; + + // Discover all existing indices in this category across working tree and main. + // Reject out of hand anything that doesn't start with category. + final usedIndices = {}; + final prefix = '$category.'; + final targetBasename = p.basename(targetRfc.path); + + final entries = cachedEntries ?? await dir.list().toList(); + for (final entry in entries) { + if (entry is! File) continue; + final fileName = p.basename(entry.path); + if (fileName == targetBasename) continue; + if (!fileName.startsWith(prefix)) continue; + final match = RfcFile.filenamePattern.firstMatch(fileName); + if (match != null) { + final idx = int.parse(match.group(2)!); + if (idx != 0) { + usedIndices.add(idx); + } + } + } + + // From main branch + final mainFiles = await getMainFiles(); + for (final mf in mainFiles) { + final fileName = p.basename(mf); + if (!fileName.startsWith(prefix)) continue; + final match = RfcFile.filenamePattern.firstMatch(fileName); + if (match != null) { + final idx = int.parse(match.group(2)!); + if (idx != 0) { + usedIndices.add(idx); + } + } + } + + // Compute next sequential number. + final candidate = usedIndices.isEmpty ? 1 : usedIndices.reduce(max) + 1; + + // Congrats, you win the overflow prize. + if (candidate > 9999) { + throw StateError( + 'Maximum RFC index (9999) reached in category "$category".', + ); + } + + final newIndexStr = candidate.toNNNN(); + final newFileName = '$category.$newIndexStr-${targetRfc.slug}.md'; + final newPath = p.join(p.dirname(targetRfc.path), newFileName); + + // Transform content (preserves status: draft, updates rfc & updated, updates header) + final effectiveUpdatedTime = updatedTime ?? clock.now(); + final newContent = targetRfc.transformedContent( + newCategory: category, + newIndex: candidate, + updatedTime: effectiveUpdatedTime, + ); + + // Execute filesystem changes if not dryRun + if (!dryRun) { + final oldFile = fs.file(targetRfc.path); + final newFile = fs.file(newPath); + + if (newPath != targetRfc.path) { + await newFile.writeAsString(newContent); + await oldFile.delete(); + } else { + await oldFile.writeAsString(newContent); + } + } + + return AssignmentResult( + oldPath: targetRfc.path, + newPath: newPath, + category: category, + oldIndex: oldIndex, + newIndex: candidate, + newContent: newContent, + dryRun: dryRun, + ); + } + + Future _resolveExplicitTarget( + String targetPath, + Directory dir, + ) async { + final targetFile = fs.file(targetPath); + if (!await targetFile.exists()) { + throw ArgumentError('Target RFC file "$targetPath" does not exist.'); + } + final fileName = p.basename(targetPath); + if (!RfcFile.filenamePattern.hasMatch(fileName)) { + throw StateError( + 'Target RFC "$targetPath" does not conform to format ' + '"AAA.NNNN-.md".', + ); + } + if (!await dir.exists()) { + throw StateError('RFC directory "$rfcDir" does not exist.'); + } + final content = await targetFile.readAsString(); + return RfcFile.parse(content, path: targetPath); + } + + Future<(RfcFile, List)> _discoverTarget( + Directory dir, + Future> Function() getMainFiles, + ) async { + if (!await dir.exists()) { + throw StateError('RFC directory "$rfcDir" does not exist.'); + } + final cachedEntries = await dir.list().toList(); + final draftFiles = []; + for (final entry in cachedEntries) { + if (entry is File) { + final fileName = p.basename(entry.path); + final match = RfcFile.filenamePattern.firstMatch(fileName); + if (match != null && int.parse(match.group(2)!) == 0) { + draftFiles.add(entry); + } + } + } + + if (draftFiles.length == 1) { + final draftFile = draftFiles.first; + final content = await draftFile.readAsString(); + return (RfcFile.parse(content, path: draftFile.path), cachedEntries); + } else if (draftFiles.length > 1) { + throw StateError( + 'Multiple draft RFCs (.0000) found in "$rfcDir". ' + 'Specify --target-file explicitly.', + ); + } else { + // Collision recovery: When no .0000 draft is found, reallocate an already-assigned + // RFC that collides with main (e.g. another PR merged with the same number while + // this PR was in review). The validator prevents the collision from landing in main; + // this auto-discovers the colliding file so [assign] can reallocate it. + final targetRfc = await _reallocate(cachedEntries, getMainFiles); + return (targetRfc, cachedEntries); + } + } + + /// Discovers an assigned RFC in the working tree that collides with main + /// for reallocation. + /// + /// This serves as an automated collision recovery mechanism: + /// 1. A PR is assigned a number (e.g. `110.0042`) and is no longer `.0000`. + /// 2. Another PR merges into `main` with that same number (`110.0042`) first. + /// 3. `validate_rfc_number` detects the collision and blocks the PR from merging. + /// 4. To resolve the collision, the maintainer re-triggers the assigner (e.g. + /// via the `assign-rfc-number` label). + /// + /// Because the file is already numbered `110.0042`, this helper locates the + /// colliding RFC so [assign] can re-allocate it to the next available number + /// (e.g. `110.0043`) without requiring the author to manually revert to `.0000`. + Future _reallocate( + List cachedEntries, + Future> Function() getMainFiles, + ) async { + final mainFiles = await getMainFiles(); + final mainByCategoryIndex = >{}; + for (final mainName in mainFiles.map(p.basename)) { + final match = RfcFile.filenamePattern.firstMatch(mainName); + if (match != null) { + final cat = match.group(1)!; + final idx = int.parse(match.group(2)!); + final key = '$cat.${idx.toNNNN()}'; + (mainByCategoryIndex[key] ??= {}).add(mainName); + } + } + + final collidingFiles = []; + for (final entry in cachedEntries) { + if (entry is File) { + final fileName = p.basename(entry.path); + final match = RfcFile.filenamePattern.firstMatch(fileName); + if (match == null) continue; + final category = match.group(1)!; + final index = int.parse(match.group(2)!); + if (index == 0) continue; + + final key = '$category.${index.toNNNN()}'; + final mainNames = mainByCategoryIndex[key]; + if (mainNames != null && + mainNames.any((mainName) => mainName != fileName)) { + collidingFiles.add(entry); + } + } + } + + if (collidingFiles.length == 1) { + final collidingFile = collidingFiles.first; + final content = await collidingFile.readAsString(); + return RfcFile.parse(content, path: collidingFile.path); + } else if (collidingFiles.length > 1) { + throw StateError( + 'Multiple colliding RFCs found. Specify --target-file explicitly.', + ); + } else { + throw StateError( + 'No RFC requiring number assignment found in "$rfcDir". ' + 'Draft RFCs must use index ".0000" to be assigned a number.', + ); + } + } +} diff --git a/lib/src/git_lister.dart b/lib/src/git_lister.dart index cc963aa..5edd1ff 100644 --- a/lib/src/git_lister.dart +++ b/lib/src/git_lister.dart @@ -2,14 +2,32 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'dart:io'; +import 'dart:convert'; +import 'dart:io' show Process, ProcessResult, stdout, stderr; import 'github_client.dart' show ProcessRunner; -/// Signature for querying RFC files on a remote/base git branch. +/// Function signature for discovering RFC filenames in the main branch via git. typedef GitListFunction = Future> Function({String baseBranch}); -/// Default implementation querying git via `git ls-tree`. +/// Parses lines of git ls-tree output into a Set of file paths. +Set parseLsTreeOutput(dynamic stdout) { + final String output = stdout is List ? utf8.decode(stdout) : '$stdout'; + return { + for (var line in LineSplitter.split(output)) + if (line.trim() case final trimmed when trimmed.isNotEmpty) trimmed, + }; +} + +void _logGitError(ProcessResult result) { + stdout.writeln('exit code: ${result.exitCode}'); + stdout.writeln('git ls-tree stdout:'); + stdout.writeln(result.stdout); + stderr.writeln('git ls-tree stderr:'); + stderr.writeln(result.stderr); +} + +/// Discovers RFC filenames in main branch via git. Future> defaultGitList({ String baseBranch = 'origin/main', ProcessRunner processRunner = Process.run, @@ -20,24 +38,33 @@ Future> defaultGitList({ '-r', '--name-only', baseBranch, - '--', 'rfc/', ]); - if (result.exitCode != 0) { - stdout.writeln('exit code: ${result.exitCode}'); - stdout.writeln('git ls-tree stdout:'); - stdout.writeln(result.stdout); - stderr.writeln('git ls-tree stderr:'); - stderr.writeln(result.stderr); - return const {}; + if (result.exitCode == 0) { + return parseLsTreeOutput(result.stdout); + } + + final cleanBranch = baseBranch.replaceFirst( + RegExp(r'^(?:remotes\/)?(?:origin|upstream)\/'), + '', + ); + if (cleanBranch != baseBranch) { + final locResult = await processRunner('git', [ + 'ls-tree', + '-r', + '--name-only', + cleanBranch, + 'rfc/', + ]); + if (locResult.exitCode == 0) { + return parseLsTreeOutput(locResult.stdout); + } + _logGitError(locResult); + } else { + _logGitError(result); } - final stdoutStr = result.stdout as String; - return stdoutStr - .split('\n') - .map((s) => s.trim()) - .where((s) => s.isNotEmpty) - .toSet(); - } catch (_) { - return const {}; + } catch (e) { + stderr.writeln('git ls-tree exception: $e'); } + return {}; } diff --git a/test/assign_rfc_number_test.dart b/test/assign_rfc_number_test.dart new file mode 100644 index 0000000..d58983e --- /dev/null +++ b/test/assign_rfc_number_test.dart @@ -0,0 +1,641 @@ +// Copyright 2026 The Flutter Authors. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:io'; + +import 'package:clock/clock.dart'; +import 'package:file/file.dart'; +import 'package:file/memory.dart'; +import 'package:rfc_tools/src/assigner.dart'; +import 'package:rfc_tools/src/models/rfc_file.dart'; +import 'package:rfc_tools/src/validator.dart'; +import 'package:test/test.dart'; + +import 'mock_process_runner.dart'; + +void main() { + group('RfcAssigner', () { + late MemoryFileSystem fs; + + RfcAssigner createAssigner({ + FileSystem? fileSystem, + GitListFunction? gitList, + }) { + return RfcAssigner( + fs: fileSystem ?? fs, + gitList: + gitList ?? + ({String baseBranch = 'origin/main'}) async => {}, + ); + } + + setUp(() async { + fs = MemoryFileSystem(); + await fs.directory('rfc').create(recursive: true); + }); + + String rfcBody(String rfcNumber, String title) => + '''--- +type: rfc +rfc: '$rfcNumber' +title: $title +description: Test +status: draft +created: 2026-08-27T00:00:00Z +updated: 2026-08-27T00:00:00Z +tags: [000-meta] +authors: [https://github.com/octocat] +--- +# RFC $rfcNumber: $title +'''; + + test('allocates 0003 when 0001 and 0002 exist', () async { + // Existing RFCs + await fs + .file('rfc/000.0001-taxonomy.md') + .writeAsString(rfcBody('000.0001', 'Taxonomy')); + + await fs + .file('rfc/000.0002-process.md') + .writeAsString(rfcBody('000.0002', 'Process')); + + // New draft to assign + await fs + .file('rfc/000.0000-third-document.md') + .writeAsString(rfcBody('000.0000', 'Third Document')); + + final assigner = createAssigner(); + final result = await assigner.assign(); + + expect(result.category, equals('000')); + expect(result.oldIndex, equals(0)); + expect(result.oldIndex.toNNNN(), equals('0000')); + expect(result.newIndex, equals(3)); + expect(result.newIndex.toNNNN(), equals('0003')); + expect(result.newPath, equals('rfc/000.0003-third-document.md')); + + // Old draft file removed, new file created + expect(await fs.file('rfc/000.0000-third-document.md').exists(), isFalse); + expect(await fs.file('rfc/000.0003-third-document.md').exists(), isTrue); + + final newFileContent = await fs + .file('rfc/000.0003-third-document.md') + .readAsString(); + final parsed = RfcFile.parse(newFileContent, path: result.newPath); + expect(parsed.frontmatter?.rfc, equals('000.0003')); + expect( + parsed.frontmatter?.status, + equals(RfcStatus.draft), + ); // Preserves draft status! + expect(parsed.firstHeadingId, equals('000.0003')); + }); + + test('allocates 0001 when category has no previous RFCs', () async { + await fs + .file('rfc/110.0000-foundation-feature.md') + .writeAsString(rfcBody('110.0000', 'Foundation Feature')); + + final assigner = createAssigner(); + final result = await assigner.assign(); + + expect(result.category, equals('110')); + expect(result.newIndex, equals(1)); + expect(result.newPath, equals('rfc/110.0001-foundation-feature.md')); + expect( + await fs.file('rfc/110.0001-foundation-feature.md').exists(), + isTrue, + ); + }); + + test( + 'allocates next sequential number considering files present on main branch via gitList', + () async { + await fs + .file('rfc/110.0000-new-feature.md') + .writeAsString(rfcBody('110.0000', 'New Feature')); + + final simulatedMain = { + 'rfc/110.0001-main-feature.md', + 'rfc/110.0002-main-feature-2.md', + }; + + final assigner = createAssigner( + gitList: ({String baseBranch = 'origin/main'}) async => simulatedMain, + ); + final result = await assigner.assign(); + + expect(result.category, equals('110')); + expect(result.newIndex, equals(3)); + expect(result.newPath, equals('rfc/110.0003-new-feature.md')); + expect(await fs.file('rfc/110.0003-new-feature.md').exists(), isTrue); + }, + ); + + test( + 'handles Edge Case 1: re-allocates when collision occurs against main', + () async { + // Local branch has 110.0002-feature-b.md, but main already merged 110.0002-feature-a.md! + await fs + .file('rfc/110.0002-feature-b.md') + .writeAsString(rfcBody('110.0002', 'Feature B')); + + final simulatedMain = { + 'rfc/110.0001-foundation.md', + 'rfc/110.0002-feature-a.md', // Collision! + }; + + final assigner = createAssigner( + gitList: ({String baseBranch = 'origin/main'}) async => simulatedMain, + ); + final result = await assigner.assign(); + + expect(result.category, equals('110')); + expect(result.oldIndex, equals(2)); + expect(result.newIndex, equals(3)); // Re-allocated to next available! + expect(result.newPath, equals('rfc/110.0003-feature-b.md')); + expect(await fs.file('rfc/110.0003-feature-b.md').exists(), isTrue); + expect(await fs.file('rfc/110.0002-feature-b.md').exists(), isFalse); + }, + ); + + test( + 'handles full lifecycle: 0000 -> assigned 0042 -> collision on main -> re-labeled -> 0043', + () async { + // 1. User starts with AAA.0000 + await fs + .file('rfc/110.0000-state-management.md') + .writeAsString(rfcBody('110.0000', 'State Management Revamp')); + + // Main branch has up to 0041 merged. + final initialMain = { + for (var i = 1; i <= 41; i++) 'rfc/110.${i.toNNNN()}-feature-$i.md', + }; + + // 2 & 3. Shepherd uses assign-rfc-number -> allocates 0042 + final gitMain = Set.from(initialMain); + final initialAssigner = createAssigner( + gitList: ({String baseBranch = 'origin/main'}) async => gitMain, + ); + final assignResult1 = await initialAssigner.assign(); + + expect(assignResult1.newIndex, equals(42)); + expect( + assignResult1.newPath, + equals('rfc/110.0042-state-management.md'), + ); + expect( + await fs.file('rfc/110.0000-state-management.md').exists(), + isFalse, + ); + expect( + await fs.file('rfc/110.0042-state-management.md').exists(), + isTrue, + ); + + // 4. While under review, someone else lands 0042 on main! + gitMain.add('rfc/110.0042-competing-feature.md'); + + // 5.a) Validator detects semantic conflict against main (checkMain) + final validator = RfcValidator( + fs: fs, + gitList: ({String baseBranch = 'origin/main'}) async => gitMain, + ); + final valResult = await validator.validate(checkMain: true); + expect(valResult.isValid, isFalse); + expect( + valResult.errors.any( + (e) => + e.message.contains( + 'collides with existing RFC in origin/main', + ) && + e.message.contains('110.0042-competing-feature.md'), + ), + isTrue, + ); + + // 5.b) Shepherd re-labels with assign-rfc-number -> re-allocates to 0043! + final reAssigner = createAssigner( + gitList: ({String baseBranch = 'origin/main'}) async => gitMain, + ); + final assignResult2 = await reAssigner.assign(); + + expect(assignResult2.oldIndex, equals(42)); + expect(assignResult2.newIndex, equals(43)); + expect( + assignResult2.newPath, + equals('rfc/110.0043-state-management.md'), + ); + + // Filesystem check: 0042 deleted, 0043 created + expect( + await fs.file('rfc/110.0042-state-management.md').exists(), + isFalse, + ); + expect( + await fs.file('rfc/110.0043-state-management.md').exists(), + isTrue, + ); + + // Document content check: frontmatter, title, timestamp, draft status + final newContent = await fs + .file('rfc/110.0043-state-management.md') + .readAsString(); + final parsed = RfcFile.parse(newContent, path: assignResult2.newPath); + expect(parsed.frontmatter?.rfc, equals('110.0043')); + expect(parsed.frontmatter?.status, equals(RfcStatus.draft)); + expect(parsed.firstHeadingId, equals('110.0043')); + expect(parsed.firstHeadingTitle, equals('State Management Revamp')); + + // Post-reassignment validation check: now passes cleanly against main! + final postValResult = await validator.validate(checkMain: true); + expect(postValResult.isValid, isTrue); + }, + ); + + test('dry-run mode does not modify filesystem or labels', () async { + await fs + .file('rfc/210.0000-engine-work.md') + .writeAsString(rfcBody('210.0000', 'Engine Work')); + + final assigner = createAssigner(); + final result = await assigner.assign(dryRun: true); + + expect(result.dryRun, isTrue); + expect(result.newIndex, equals(1)); + // File should NOT be changed on disk + expect(await fs.file('rfc/210.0000-engine-work.md').exists(), isTrue); + expect(await fs.file('rfc/210.0001-engine-work.md').exists(), isFalse); + }); + + test('assigns specific targetPath when multiple drafts exist', () async { + await fs + .file('rfc/110.0000-draft-a.md') + .writeAsString(rfcBody('110.0000', 'Draft A')); + + await fs + .file('rfc/110.0000-draft-b.md') + .writeAsString(rfcBody('110.0000', 'Draft B')); + + final assigner = createAssigner(); + // Throws without targetPath + expect(() => assigner.assign(), throwsStateError); + + // Succeeds with explicit targetPath + final result = await assigner.assign( + targetPath: 'rfc/110.0000-draft-b.md', + ); + expect(result.newIndex, equals(1)); + expect(result.newPath, equals('rfc/110.0001-draft-b.md')); + expect(await fs.file('rfc/110.0001-draft-b.md').exists(), isTrue); + expect(await fs.file('rfc/110.0000-draft-a.md').exists(), isTrue); + }); + + test('throws StateError when no RFC requiring assignment exists', () async { + await fs + .file('rfc/110.0001-existing.md') + .writeAsString(rfcBody('110.0001', 'Existing')); + + final assigner = createAssigner(); + expect(() => assigner.assign(), throwsStateError); + }); + + test( + 'assign uses clock.now() for updated timestamp when updatedTime is omitted', + () async { + await fs + .file('rfc/110.0000-clock-test.md') + .writeAsString(rfcBody('110.0000', 'Clock Test')); + + final fixedTime = DateTime.utc(2026, 12, 25, 10, 30, 0); + await withClock(Clock.fixed(fixedTime), () async { + final assigner = createAssigner(); + final result = await assigner.assign(); + expect( + result.newContent, + contains('updated: 2026-12-25T10:30:00.000Z'), + ); + }); + }, + ); + + test( + 'assign uses clock.now() and converts non-UTC clock to Zulu UTC string', + () async { + await fs + .file('rfc/110.0000-clock-offset.md') + .writeAsString(rfcBody('110.0000', 'Offset Test')); + + // Non-UTC timezone (e.g. +14:00) + final fixedOffsetTime = DateTime.parse('2026-09-02T02:00:00+14:00'); + await withClock(Clock.fixed(fixedOffsetTime), () async { + final assigner = createAssigner(); + final result = await assigner.assign(); + expect( + result.newContent, + contains('updated: 2026-09-01T12:00:00.000Z'), + ); + }); + }, + ); + + test('throws ArgumentError when targetPath does not exist', () async { + final assigner = createAssigner(); + expect( + () => assigner.assign(targetPath: 'rfc/110.0000-nonexistent.md'), + throwsArgumentError, + ); + }); + + test( + 'throws StateError when targetPath does not conform to AAA.NNNN-.md', + () async { + await fs.file('rfc/invalid-name.md').writeAsString('# Invalid'); + final assigner = createAssigner(); + expect( + () => assigner.assign(targetPath: 'rfc/invalid-name.md'), + throwsStateError, + ); + }, + ); + + test('ignores non-matching files and files from other categories', () async { + // Files that do not conform to RFC pattern or belong to other categories + await fs.file('rfc/README.md').writeAsString('# Notes'); + await fs.file('rfc/.DS_Store').writeAsString(''); + await fs + .file('rfc/200.0001-other-category.md') + .writeAsString(rfcBody('200.0001', 'Other')); + + // Target draft in category 110 + await fs + .file('rfc/110.0000-target.md') + .writeAsString(rfcBody('110.0000', 'Target')); + + final assigner = createAssigner(); + final result = await assigner.assign(); + + expect(result.category, equals('110')); + expect(result.newIndex, equals(1)); + expect(result.newPath, equals('rfc/110.0001-target.md')); + }); + + test( + 'throws StateError when multiple collisions occur against main', + () async { + await fs + .file('rfc/110.0002-feature-b.md') + .writeAsString(rfcBody('110.0002', 'Feature B')); + + await fs + .file('rfc/110.0003-feature-c.md') + .writeAsString(rfcBody('110.0003', 'Feature C')); + + final simulatedMain = { + 'rfc/110.0002-feature-a.md', // Collides with 110.0002-feature-b.md + 'rfc/110.0003-feature-z.md', // Collides with 110.0003-feature-c.md + }; + + final assigner = createAssigner( + gitList: ({String baseBranch = 'origin/main'}) async => simulatedMain, + ); + + expect(() => assigner.assign(), throwsStateError); + }, + ); + + test('handles gaps in existing numbers correctly', () async { + // NOTE: The validator will fail when there are gaps; this is only + // checking the assign assigns max+1. + await fs + .file('rfc/110.0001-first.md') + .writeAsString(rfcBody('110.0001', 'First')); + + await fs + .file('rfc/110.0004-fourth.md') + .writeAsString(rfcBody('110.0004', 'Fourth')); + + await fs + .file('rfc/110.0000-new.md') + .writeAsString(rfcBody('110.0000', 'New')); + + final assigner = createAssigner(); + final result = await assigner.assign(); + + expect(result.newIndex, equals(5)); + expect(result.newPath, equals('rfc/110.0005-new.md')); + }); + + test('assigns correctly when targetPath is an absolute path', () async { + await fs + .file('rfc/110.0000-abs-path.md') + .writeAsString(rfcBody('110.0000', 'Absolute Path')); + + final absPath = fs.file('rfc/110.0000-abs-path.md').absolute.path; + final assigner = createAssigner(); + final result = await assigner.assign(targetPath: absPath); + + expect(result.category, equals('110')); + expect(result.newIndex, equals(1)); + expect(await fs.file(result.newPath).exists(), isTrue); + expect(await fs.file(absPath).exists(), isFalse); + }); + + test('throws StateError when category exceeds index 9999', () async { + await fs + .file('rfc/110.9999-last.md') + .writeAsString(rfcBody('110.9999', 'Last')); + + await fs + .file('rfc/110.0000-overflow.md') + .writeAsString(rfcBody('110.0000', 'Overflow')); + + final assigner = createAssigner(); + expect(() => assigner.assign(), throwsStateError); + }); + + test('throws ArgumentError when targetPath does not exist', () async { + final assigner = createAssigner(); + expect( + () => assigner.assign(targetPath: 'rfc/110.0000-nonexistent.md'), + throwsArgumentError, + ); + }); + + test('default constructor uses RfcAssigner.defaultGitList', () { + final assigner = RfcAssigner(fs: fs); + expect(assigner.gitList, equals(RfcAssigner.defaultGitList)); + }); + + test( + 'RfcAssigner.defaultGitList accepts processRunner and queries rfc/ directory', + () async { + final runner = MockProcessRunner( + exitCode: 0, + stdout: 'rfc/000.0001-taxonomy.md\n', + ); + final list = await RfcAssigner.defaultGitList( + processRunner: runner.run, + ); + expect(list, equals({'rfc/000.0001-taxonomy.md'})); + expect( + runner.calls.last.arguments, + equals(['ls-tree', '-r', '--name-only', 'origin/main', 'rfc/']), + ); + }, + ); + }); + + group('RfcAssigner.defaultGitList', () { + test('remote branch success returns parsed filenames', () async { + final runner = MockProcessRunner( + exitCode: 0, + stdout: 'rfc/000.0001-taxonomy.md\nrfc/000.0002-process.md\n', + ); + + final files = await RfcAssigner.defaultGitList( + baseBranch: 'origin/main', + processRunner: runner.run, + ); + + expect( + files, + equals({'rfc/000.0001-taxonomy.md', 'rfc/000.0002-process.md'}), + ); + expect(runner.calls, hasLength(1)); + expect(runner.calls.first.executable, equals('git')); + expect( + runner.calls.first.arguments, + equals(['ls-tree', '-r', '--name-only', 'origin/main', 'rfc/']), + ); + }); + + test('local branch fallback when remote branch fails', () async { + final runner = MockProcessRunner( + handler: (executable, arguments) async { + if (arguments.contains('origin/main')) { + return ProcessResult( + 1, + 1, + '', + 'fatal: Not a valid object name origin/main', + ); + } + if (arguments.contains('main')) { + return ProcessResult(2, 0, 'rfc/000.0001-taxonomy.md\n', ''); + } + return ProcessResult(3, 1, '', 'unexpected'); + }, + ); + + final files = await RfcAssigner.defaultGitList( + baseBranch: 'origin/main', + processRunner: runner.run, + ); + + expect(files, equals({'rfc/000.0001-taxonomy.md'})); + expect(runner.calls, hasLength(2)); + expect( + runner.calls[0].arguments, + equals(['ls-tree', '-r', '--name-only', 'origin/main', 'rfc/']), + ); + expect( + runner.calls[1].arguments, + equals(['ls-tree', '-r', '--name-only', 'main', 'rfc/']), + ); + }); + + test('upstream branch fallback when remote branch fails', () async { + final runner = MockProcessRunner( + handler: (executable, arguments) async { + if (arguments.contains('upstream/main')) { + return ProcessResult( + 1, + 1, + '', + 'fatal: Not a valid object name upstream/main', + ); + } + if (arguments.contains('main')) { + return ProcessResult(2, 0, 'rfc/000.0001-taxonomy.md\n', ''); + } + return ProcessResult(3, 1, '', 'unexpected'); + }, + ); + + final files = await RfcAssigner.defaultGitList( + baseBranch: 'upstream/main', + processRunner: runner.run, + ); + + expect(files, equals({'rfc/000.0001-taxonomy.md'})); + expect(runner.calls, hasLength(2)); + expect( + runner.calls[0].arguments, + equals(['ls-tree', '-r', '--name-only', 'upstream/main', 'rfc/']), + ); + expect( + runner.calls[1].arguments, + equals(['ls-tree', '-r', '--name-only', 'main', 'rfc/']), + ); + }); + + test( + 'local branch without remote prefix fails in single execution', + () async { + final runner = MockProcessRunner(exitCode: 1, stderr: 'fatal error'); + + final files = await RfcAssigner.defaultGitList( + baseBranch: 'main', + processRunner: runner.run, + ); + + expect(files, isEmpty); + expect(runner.calls, hasLength(1)); + }, + ); + + test('error handling when both remote and local fail', () async { + final runner = MockProcessRunner(exitCode: 1, stderr: 'fatal error'); + + final files = await RfcAssigner.defaultGitList( + baseBranch: 'origin/main', + processRunner: runner.run, + ); + + expect(files, isEmpty); + expect(runner.calls, hasLength(2)); + }); + + test('error handling when processRunner throws', () async { + final runner = MockProcessRunner( + exceptionToThrow: const ProcessException('git', [ + 'ls-tree', + ], 'not found'), + ); + + final files = await RfcAssigner.defaultGitList( + baseBranch: 'origin/main', + processRunner: runner.run, + ); + + expect(files, isEmpty); + }); + + test('handles stdout returned as byte list', () async { + final runner = MockProcessRunner( + exitCode: 0, + stdout: [ + 114, 102, 99, 47, 48, 48, 48, 46, // + 48, 48, 48, 49, 45, 116, 97, 120, + 111, 110, 111, 109, 121, 46, 109, + 100, 10, + ], // "rfc/000.0001-taxonomy.md\n" + ); + + final files = await RfcAssigner.defaultGitList(processRunner: runner.run); + + expect(files, equals({'rfc/000.0001-taxonomy.md'})); + }); + }); +} diff --git a/test/git_lister_test.dart b/test/git_lister_test.dart index 6bf5b33..8c06de2 100644 --- a/test/git_lister_test.dart +++ b/test/git_lister_test.dart @@ -27,7 +27,7 @@ void main() { expect(runner.calls, hasLength(1)); expect( runner.calls.first.arguments, - equals(['ls-tree', '-r', '--name-only', 'origin/main', '--', 'rfc/']), + equals(['ls-tree', '-r', '--name-only', 'origin/main', 'rfc/']), ); });