diff --git a/lib/src/models/rfc_file.dart b/lib/src/models/rfc_file.dart new file mode 100644 index 0000000..75b5742 --- /dev/null +++ b/lib/src/models/rfc_file.dart @@ -0,0 +1,455 @@ +// 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:convert'; + +import 'package:clock/clock.dart'; +import 'package:path/path.dart' as p; +import 'package:yaml/yaml.dart'; + +import 'rfc_frontmatter.dart'; + +export 'rfc_author.dart'; +export 'rfc_frontmatter.dart'; + +/// Formatting extensions for RFC category and index numbers. +extension RfcNumberFormatting on int { + /// Formats this integer as a 4-digit zero-padded RFC index string (e.g. `1.toNNNN()` -> `"0001"`, `0.toNNNN()` -> `"0000"`). + String toNNNN() => toString().padLeft(4, '0'); + + /// Formats this integer as a 3-digit zero-padded RFC category string (e.g. `0.toAAA()` -> `"000"`, `110.toAAA()` -> `"110"`). + String toAAA() => toString().padLeft(3, '0'); +} + +/// Represents a parsed RFC markdown file. +class RfcFile { + /// File path. + final String path; + + /// 3-digit category (e.g. "000", "110"), or null if filename is invalid. + final String? category; + + /// Numerical category of the RFC (e.g. 0 for "000", 110 for "110"), or null if filename is invalid. + int? get categoryNumber => category != null ? int.tryParse(category!) : null; + + /// Numerical index of the RFC (e.g. 1 for "0001", 0 for "0000"), or null if filename is invalid. + final int? index; + + /// 4-digit zero-padded index string (e.g. "0001", "0000"), or null if filename is invalid. + String? get indexString => index?.toNNNN(); + + /// Slugified title in lowercase kebab-case, or null if filename is invalid. + final String? slug; + + /// Whether the file starts with and successfully parses YAML frontmatter. + final bool hasFrontmatter; + + /// Raw frontmatter content between the leading and closing `---` delimiters. + final String frontmatterRaw; + + /// Parsed strongly-typed RFC frontmatter, or null if missing or invalid schema. + final RfcFrontmatter? frontmatter; + + /// Structural or syntax error message encountered while parsing frontmatter + /// (e.g. unclosed delimiter or invalid YAML syntax), if any. + /// + /// For schema validation errors on frontmatter fields, see [frontmatterErrors] + /// and [frontmatterFeedback]. + final String? frontmatterError; + + /// All validation error messages encountered while parsing frontmatter. + final List frontmatterErrors; + + /// Markdown content after the closing `---` delimiter. + final String body; + + /// Full text of the first level-1 heading (`# RFC AAA.NNNN: `). + final String? firstHeading; + + /// RFC identifier extracted from the first level-1 heading. + final String? firstHeadingId; + + /// Title extracted from the first level-1 heading. + final String? firstHeadingTitle; + + /// Line number (1-based) where the first level-1 heading was found in the file. + final int? firstHeadingLine; + + /// Error message encountered while parsing the document heading, if any. + final String? headingError; + + const RfcFile._({ + required this.path, + required this.category, + required this.index, + required this.slug, + required this.hasFrontmatter, + required this.frontmatterRaw, + required this.frontmatter, + required this.frontmatterError, + required this.frontmatterErrors, + required this.body, + required this.firstHeading, + required this.firstHeadingId, + required this.firstHeadingTitle, + required this.firstHeadingLine, + required this.headingError, + }); + + /// Regular expression for RFC filenames: `AAA.NNNN-<slug>.md`. + static final RegExp filenamePattern = RegExp( + r'^(\d{3})\.(\d{4})-([a-z0-9]+(?:-[a-z0-9]+)*)\.md$', + ); + + /// Regular expression for first level-1 RFC heading: `# RFC AAA.NNNN: <Title>`. + static final RegExp headingPattern = RegExp( + r'^#\s+RFC\s+(\d{3}\.\d{4})(?::\s*(.*))?$', + ); + + /// Whether the file name conforms to `AAA.NNNN-<slug>.md`. + bool get hasValidFilename => + category != null && index != null && slug != null; + + /// Whether this file is a draft RFC (`.0000`). + bool get isDraft => index == 0; + + /// The combined RFC identifier (e.g. "000.0001"), or null if filename is invalid. + String? get rfcId => hasValidFilename ? '$category.${index!.toNNNN()}' : null; + + /// Formatted feedback containing all frontmatter errors and the expected schema template, + /// or null if frontmatter has no errors. + String? get frontmatterFeedback => frontmatterErrors.isNotEmpty + ? RfcFrontmatter.formatErrors(frontmatterErrors) + : null; + + /// Whether frontmatter conforms completely to the RFC schema. + bool get hasValidFrontmatter => + hasFrontmatter && + frontmatterError == null && + frontmatterErrors.isEmpty && + frontmatter != null; + + /// Whether the document has a valid first level-1 heading. + bool get hasValidHeading => + headingError == null && firstHeading != null && firstHeadingId != null; + + /// Extracts category, index, and slug from an RFC file path. + static ({String? category, int? index, String? slug}) _parseFilename( + String path, + ) { + final fileName = p.basename(path); + final fnMatch = filenamePattern.firstMatch(fileName); + final category = fnMatch?.group(1); + final indexStr = fnMatch?.group(2); + final index = indexStr != null ? int.tryParse(indexStr) : null; + final slug = fnMatch?.group(3); + return (category: category, index: index, slug: slug); + } + + /// Separates YAML frontmatter block from markdown body. + static ({ + bool hasFrontmatter, + String frontmatterRaw, + String body, + int frontmatterLineCount, + String? structuralError, + }) + _splitFrontmatter(String content) { + final lines = [...LineSplitter.split(content)]; + if (lines.isEmpty || lines.first.trim() != '---') { + return ( + hasFrontmatter: false, + frontmatterRaw: '', + body: content, + frontmatterLineCount: 0, + structuralError: + 'Line 1: File does not start with YAML frontmatter delimiter `---`.', + ); + } + + int closingLine = -1; + for (int i = 1; i < lines.length; i++) { + if (lines[i].trim() == '---') { + closingLine = i; + break; + } + } + + if (closingLine == -1) { + return ( + hasFrontmatter: false, + frontmatterRaw: '', + body: content, + frontmatterLineCount: 0, + structuralError: + 'Line 1: Unclosed YAML frontmatter delimiter (missing closing `---`).', + ); + } + + return ( + hasFrontmatter: true, + frontmatterRaw: lines.sublist(1, closingLine).join('\n'), + body: lines.sublist(closingLine + 1).join('\n'), + frontmatterLineCount: closingLine + 1, + structuralError: null, + ); + } + + /// Parses and validates YAML frontmatter content. + static ({RfcFrontmatter? frontmatter, String? error, List<String> errors}) + _parseFrontmatter(String frontmatterRaw) { + try { + final loaded = loadYaml(frontmatterRaw); + switch (loaded) { + case final YamlMap map: + final loadResult = RfcFrontmatter.tryLoad(map); + return ( + frontmatter: loadResult.frontmatter, + error: null, + errors: loadResult.errors, + ); + case null: + final empty = YamlMap(); + return ( + frontmatter: null, + error: null, + errors: RfcFrontmatter.validate(empty), + ); + default: + const err = 'Line 2: YAML frontmatter must be a key-value mapping.'; + return (frontmatter: null, error: err, errors: [err]); + } + } catch (e) { + final err = switch (e) { + YamlException(:final span?, :final message) => + 'Line ${span.start.line + 2}: Failed to parse YAML frontmatter: $message', + _ => 'Line 2: Failed to parse YAML frontmatter: $e', + }; + return (frontmatter: null, error: err, errors: [err]); + } + } + + /// Locates and parses the first level-1 heading on the first non-empty line of the markdown body. + static ({ + String? heading, + String? id, + String? title, + int? line, + String? error, + }) + _findFirstHeading( + String body, + int frontmatterLineCount, { + String? expectedId, + String? expectedTitle, + }) { + final idPart = expectedId ?? 'AAA.NNNN'; + final titlePart = (expectedTitle != null && expectedTitle.trim().isNotEmpty) + ? expectedTitle.trim() + : '<Title>'; + final expectedFormat = '# RFC $idPart: $titlePart'; + + int lineOffset = 0; + for (final rawLine in LineSplitter.split(body)) { + final line = rawLine.trim(); + if (line.isEmpty) { + lineOffset++; + continue; + } + final lineNum = frontmatterLineCount + lineOffset + 1; + if (line.startsWith('# ')) { + final headingMatch = headingPattern.firstMatch(line); + return ( + heading: line, + id: headingMatch?.group(1), + title: headingMatch?.group(2)?.trim() ?? '', + line: lineNum, + error: headingMatch == null + ? 'Line $lineNum: Heading must match format "$expectedFormat".' + : null, + ); + } + return ( + heading: null, + id: null, + title: null, + line: lineNum, + error: line.startsWith('#') + ? 'Line $lineNum: First heading must be a level-1 heading (`# ...`), but found a deeper heading level.' + : 'Line $lineNum: Markdown body must begin with a level-1 heading (`$expectedFormat`).', + ); + } + final lineNum = frontmatterLineCount + 1; + return ( + heading: null, + id: null, + title: null, + line: lineNum, + error: + 'Line $lineNum: Markdown body must begin with a level-1 heading (`$expectedFormat`).', + ); + } + + /// Converts a kebab-case slug into Title Case (e.g. `extract-value-notifier` -> `Extract Value Notifier`). + static String _slugToTitle(String slug) { + return slug + .split('-') + .where((w) => w.isNotEmpty) + .map((w) => '${w[0].toUpperCase()}${w.substring(1)}') + .join(' '); + } + + /// Parses an RFC markdown file content. + static RfcFile parse(String content, {required String path}) { + final (:category, :index, :slug) = _parseFilename(path); + final expectedId = category != null && index != null + ? '$category.${index.toNNNN()}' + : null; + + final split = _splitFrontmatter(content); + final parsedFm = split.hasFrontmatter + ? _parseFrontmatter(split.frontmatterRaw) + : ( + frontmatter: null, + error: split.structuralError, + errors: [if (split.structuralError != null) split.structuralError!], + ); + + final rawTitle = parsedFm.frontmatter?.title; + final expectedTitle = (rawTitle != null && rawTitle.trim().isNotEmpty) + ? rawTitle.trim() + : (slug != null ? _slugToTitle(slug) : null); + + final heading = _findFirstHeading( + split.body, + split.frontmatterLineCount, + expectedId: expectedId, + expectedTitle: expectedTitle, + ); + + return RfcFile._( + path: path, + category: category, + index: index, + slug: slug, + hasFrontmatter: split.hasFrontmatter, + frontmatterRaw: split.frontmatterRaw, + frontmatter: parsedFm.frontmatter, + frontmatterError: parsedFm.error, + frontmatterErrors: List.unmodifiable(parsedFm.errors), + body: split.body, + firstHeading: heading.heading, + firstHeadingId: heading.id, + firstHeadingTitle: heading.title, + firstHeadingLine: heading.line, + headingError: heading.error, + ); + } + + /// Generates transformed file content with updated category, index, and timestamp. + /// + /// Preserves all other frontmatter fields (such as `status: draft`, comments, formatting) + /// and updates the first level-1 heading without corrupting code blocks or body text. + String transformedContent({ + required Object newCategory, + required Object newIndex, + DateTime? updatedTime, + }) { + final String newCategoryStr; + if (newCategory is int) { + if (newCategory < 0 || newCategory > 999) { + throw ArgumentError( + 'newCategory must be between 0 and 999, got $newCategory', + ); + } + newCategoryStr = newCategory.toAAA(); + } else if (newCategory is String) { + if (!RegExp(r'^\d{3}$').hasMatch(newCategory)) { + throw ArgumentError( + 'newCategory must be a 3-digit string, got "$newCategory"', + ); + } + newCategoryStr = newCategory; + } else { + throw ArgumentError( + 'newCategory must be an int or a 3-digit String, got ${newCategory.runtimeType}', + ); + } + + final String newIndexStr; + if (newIndex is int) { + if (newIndex < 0 || newIndex > 9999) { + throw ArgumentError( + 'newIndex must be between 0 and 9999, got $newIndex', + ); + } + newIndexStr = newIndex.toNNNN(); + } else if (newIndex is String) { + if (!RegExp(r'^\d{4}$').hasMatch(newIndex)) { + throw ArgumentError( + 'newIndex must be a 4-digit zero-padded string, got "$newIndex"', + ); + } + newIndexStr = newIndex; + } else { + throw ArgumentError( + 'newIndex must be an int or a 4-digit String, got ${newIndex.runtimeType}', + ); + } + + final newId = '$newCategoryStr.$newIndexStr'; + final timestamp = (updatedTime ?? clock.now()).toUtc().toIso8601String(); + + // Update frontmatter lines + final fmLines = <String>[]; + bool rfcReplaced = false; + bool updatedReplaced = false; + + for (final line in LineSplitter.split(frontmatterRaw)) { + if (RegExp(r'^rfc:\s*.*$').hasMatch(line)) { + fmLines.add("rfc: '$newId'"); + rfcReplaced = true; + } else if (RegExp(r'^updated:\s*.*$').hasMatch(line)) { + fmLines.add('updated: $timestamp'); + updatedReplaced = true; + } else { + fmLines.add(line); + } + } + + if (!rfcReplaced) { + fmLines.insert(0, "rfc: '$newId'"); + } + if (!updatedReplaced) { + fmLines.add('updated: $timestamp'); + } + + final newFrontmatterRaw = fmLines.join('\n'); + + // Update first level-1 heading in body + final bodyLines = <String>[]; + bool headingReplaced = false; + + for (final line in LineSplitter.split(body)) { + if (!headingReplaced) { + final trimmed = line.trim(); + if (trimmed.isNotEmpty) { + final match = headingPattern.firstMatch(trimmed); + if (match != null) { + final title = match.group(2)?.trim() ?? ''; + bodyLines.add( + title.isNotEmpty ? '# RFC $newId: $title' : '# RFC $newId', + ); + headingReplaced = true; + continue; + } + } + } + bodyLines.add(line); + } + + final newBody = bodyLines.join('\n'); + return '---\n$newFrontmatterRaw\n---\n$newBody'; + } +} diff --git a/lib/src/models/rfc_frontmatter.dart b/lib/src/models/rfc_frontmatter.dart new file mode 100644 index 0000000..4c34632 --- /dev/null +++ b/lib/src/models/rfc_frontmatter.dart @@ -0,0 +1,619 @@ +// 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 'package:clock/clock.dart'; +import 'package:yaml/yaml.dart'; + +import 'rfc_author.dart'; + +/// RFC document lifecycle status. +enum RfcStatus { + draft('draft'), + review('review'), + stable('stable'), + superseded('superseded'), + withdrawn('withdrawn'), + rejected('rejected'), + deprecated('deprecated'); + + final String value; + const RfcStatus(this.value); + + /// Parses an RFC status string case-insensitively, or returns `null` if invalid. + static RfcStatus? tryParse(String? value) => + switch (value?.toLowerCase().trim()) { + 'draft' => RfcStatus.draft, + 'review' => RfcStatus.review, + 'stable' => RfcStatus.stable, + 'superseded' => RfcStatus.superseded, + 'withdrawn' => RfcStatus.withdrawn, + 'rejected' => RfcStatus.rejected, + 'deprecated' => RfcStatus.deprecated, + _ => null, + }; + + @override + String toString() => value; +} + +typedef RfcFrontmatterLoad = ({ + RfcFrontmatter? frontmatter, + List<String> errors, + String? feedback, +}); + +/// Strongly-typed model representing validated YAML frontmatter of an RFC. +class RfcFrontmatter { + /// Document type. Must be `'rfc'`. + final String type; + + /// RFC identifier (e.g. `'000.0001'`). + final String rfc; + + /// Proposal title. + final String title; + + /// High-level summary description. + final String description; + + /// Lifecycle status. + final RfcStatus status; + + /// ISO 8601 UTC creation timestamp. + final DateTime created; + + /// ISO 8601 UTC last updated timestamp. + final DateTime updated; + + /// Taxonomy and category tags. + final List<String> tags; + + /// List of typed author attributions. + final List<RfcAuthor> authors; + + /// Optional identifier of the RFC this document supersedes (e.g. `'000.0001'`). + final String? supersedes; + + /// Optional identifier of the RFC that supersedes this document (e.g. `'000.0002'`). + final String? supersededBy; + + const RfcFrontmatter({ + required this.type, + required this.rfc, + required this.title, + required this.description, + required this.status, + required this.created, + required this.updated, + required this.tags, + required this.authors, + this.supersedes, + this.supersededBy, + }); + + /// ISO 8601 UTC string representation of [created]. + String get createdIso => created.toUtc().toIso8601String(); + + /// ISO 8601 UTC string representation of [updated]. + String get updatedIso => updated.toUtc().toIso8601String(); + + /// Canonical expected frontmatter schema template showing all required fields + /// and common optional fields with format examples. + static const String expectedSchemaTemplate = ''' +type: rfc +rfc: '000.0001' +title: Proposal Title +description: High-level summary description of the RFC proposal. +status: draft +created: 2026-08-27T00:00:00Z +updated: 2026-08-27T00:00:00Z +tags: + - 000-meta +authors: + - https://github.com/octocat + - '"Author Name" <email@example.com>' +# supersedes: '000.0000' # Optional +# superseded_by: '000.0002' # Optional +'''; + + /// Canonical example frontmatter template. + static const String exampleTemplate = expectedSchemaTemplate; + + /// Formats a list of validation [errors] alongside the canonical expected schema template. + static String formatErrors(List<String> errors, {String? schemaTemplate}) { + final template = schemaTemplate ?? expectedSchemaTemplate; + final buffer = StringBuffer('Invalid RFC frontmatter:\n'); + for (final err in errors) { + buffer.writeln(' - $err'); + } + buffer.writeln(); + buffer.writeln('Expected frontmatter format:'); + buffer.write(template.trimRight()); + return buffer.toString(); + } + + /// List of valid status string values derived from [RfcStatus.values]. + static List<String> get validStatusStrings => [ + for (var status in RfcStatus.values) status.value, + ]; + + static int? _lineFor( + Map<dynamic, dynamic> yaml, + String key, { + int lineOffset = 2, + }) { + if (yaml is YamlMap) { + for (final entry in yaml.nodes.entries) { + final k = entry.key; + if (k is YamlNode && k.value == key) { + return k.span.start.line + lineOffset; + } + } + return 1; + } + return null; + } + + static int? _lineForListItem( + Map<dynamic, dynamic> yaml, + String key, + int itemIndex, { + int lineOffset = 2, + }) { + if (yaml is YamlMap) { + final valueNode = yaml.nodes[key]; + if (valueNode is YamlList && itemIndex < valueNode.nodes.length) { + return valueNode.nodes[itemIndex].span.start.line + lineOffset; + } + return _lineFor(yaml, key, lineOffset: lineOffset); + } + return null; + } + + /// Validates a mapping against the RFC frontmatter schema. + /// + /// Returns a list of error messages with actionable feedback, line numbers, and expected formats. + /// An empty list indicates valid frontmatter. + static List<String> validate( + Map<dynamic, dynamic> yaml, { + int lineOffset = 2, + }) { + final errors = <String>[]; + + void addError(String key, String message, [int? itemIndex]) { + final line = itemIndex != null + ? _lineForListItem(yaml, key, itemIndex, lineOffset: lineOffset) + : _lineFor(yaml, key, lineOffset: lineOffset); + final prefix = line != null ? 'Line $line: ' : ''; + errors.add('$prefix$message'); + } + + // 1. type + switch (yaml['type']) { + case 'rfc': + break; + case final typeVal: + addError( + 'type', + 'Frontmatter "type" must be "rfc" (found "$typeVal"). Expected format: "type: rfc".', + ); + } + + // 2. rfc + switch (yaml['rfc']) { + case null: + addError( + 'rfc', + 'Frontmatter "rfc" field is required. Missing value (found "null"). ' + 'Expected format: "AAA.NNNN" (e.g. \'000.0001\' or \'110.0000\').', + ); + case final rfcVal: + final rfcStr = '$rfcVal'.trim(); + if (!RegExp(r'^\d{3}\.\d{4}$').hasMatch(rfcStr)) { + addError( + 'rfc', + 'Frontmatter "rfc" must match format "AAA.NNNN" (found "$rfcStr"). ' + 'Expected format: "AAA.NNNN" (3 digits, dot, 4 digits, e.g. \'000.0001\' or \'110.0000\').', + ); + } + } + + // 3. title + switch (yaml['title']) { + case null: + addError( + 'title', + 'Frontmatter "title" is required and must be a non-empty string. ' + 'Missing value (found "null"). Expected format: title: Proposal Title.', + ); + case final String s when s.trim().isEmpty: + addError( + 'title', + 'Frontmatter "title" is required and must be a non-empty string. ' + 'Found empty string. Expected format: title: Proposal Title.', + ); + case String(): + break; + case final other: + addError( + 'title', + 'Frontmatter "title" is required and must be a non-empty string. ' + 'Found ${other.runtimeType} "$other". Expected format: title: Proposal Title.', + ); + } + + // 4. description + switch (yaml['description']) { + case null: + addError( + 'description', + 'Frontmatter "description" is required and must be a non-empty string. ' + 'Missing value (found "null"). Expected format: description: High-level summary description.', + ); + case final String s when s.trim().isEmpty: + addError( + 'description', + 'Frontmatter "description" is required and must be a non-empty string. ' + 'Found empty string. Expected format: description: High-level summary description.', + ); + case String(): + break; + case final other: + addError( + 'description', + 'Frontmatter "description" is required and must be a non-empty string. ' + 'Found ${other.runtimeType} "$other". Expected format: description: High-level summary description.', + ); + } + + // 5. status + switch (yaml['status']) { + case null: + addError( + 'status', + 'Frontmatter "status" must be one of: ${validStatusStrings.join(', ')} (found "null"). ' + 'Expected format: status: draft.', + ); + case final statusVal: + final statusStr = statusVal.toString().trim(); + final parsedStatus = RfcStatus.tryParse(statusStr); + if (parsedStatus == null) { + addError( + 'status', + 'Frontmatter "status" must be one of: ${validStatusStrings.join(', ')} (found "$statusStr"). ' + 'Expected format: status: draft.', + ); + } + } + + // 6. created + final createdVal = yaml['created']; + final createdResult = _parseUtcTimestamp(createdVal, 'created'); + if (createdResult.error != null) { + addError('created', createdResult.error!); + } + + // 7. updated + final updatedVal = yaml['updated']; + final updatedResult = _parseUtcTimestamp(updatedVal, 'updated'); + if (updatedResult.error != null) { + addError('updated', updatedResult.error!); + } + + // 8. tags + switch (yaml['tags']) { + case null: + addError( + 'tags', + 'Frontmatter "tags" must be a non-empty list of strings. ' + 'Missing value (found "null"). Expected format:\ntags:\n - 000-meta', + ); + case final List<Object?> list when list.isEmpty: + addError( + 'tags', + 'Frontmatter "tags" must be a non-empty list of strings. ' + 'Found empty list. Expected format:\ntags:\n - 000-meta', + ); + case final List<Object?> list: + for (var i = 0; i < list.length; i++) { + final tag = list[i]; + if (tag == null || tag.toString().trim().isEmpty) { + addError( + 'tags', + 'Frontmatter "tags" items must be non-empty strings. Found "$tag". ' + 'Expected format: a list of non-empty category/topic strings (e.g. 000-meta).', + i, + ); + break; + } + } + case final other: + addError( + 'tags', + 'Frontmatter "tags" must be a non-empty list of strings. ' + 'Found ${other.runtimeType} "$other". Expected format:\ntags:\n - 000-meta', + ); + } + + // 9. authors + switch (yaml['authors']) { + case null: + addError( + 'authors', + 'Frontmatter "authors" must be a non-empty list of authors. ' + 'Missing value (found "null"). Expected format:\nauthors:\n - https://github.com/<username>\n - \'"Display Name" <user@example.com>\'', + ); + case final List<Object?> list when list.isEmpty: + addError( + 'authors', + 'Frontmatter "authors" must be a non-empty list of authors. ' + 'Found empty list. Expected format:\nauthors:\n - https://github.com/<username>\n - \'"Display Name" <user@example.com>\'', + ); + case final List<Object?> list: + for (var i = 0; i < list.length; i++) { + final authorItem = list[i]; + if (authorItem == null) { + addError( + 'authors', + 'Author entries cannot be null. ' + 'Expected format: "https://github.com/<username>" or \'"Display Name" <user@example.com>\'.', + i, + ); + continue; + } + final authorStr = authorItem.toString().trim(); + if (authorStr.isEmpty) { + addError( + 'authors', + 'Author entries cannot be empty. ' + 'Expected format: "https://github.com/<username>" or \'"Display Name" <user@example.com>\'.', + i, + ); + continue; + } + final parsedAuthor = RfcAuthor.tryParse(authorStr); + if (parsedAuthor == null) { + addError( + 'authors', + 'Author "$authorStr" must be a GitHub profile URL ("https://github.com/<username>") ' + 'or RFC 5322 mailbox (\'"Display Name" <user@example.com>\'). ' + 'Expected format: "https://github.com/<username>" or \'"Display Name" <user@example.com>\'.', + i, + ); + } + } + case final other: + addError( + 'authors', + 'Frontmatter "authors" must be a non-empty list of authors. ' + 'Found ${other.runtimeType} "$other". Expected format:\nauthors:\n - https://github.com/<username>\n - \'"Display Name" <user@example.com>\'', + ); + } + + // 10. supersedes (optional) + switch (yaml['supersedes']) { + case null: + break; + case final supersedesVal: + final sStr = supersedesVal.toString().trim(); + if (!RegExp(r'^\d{3}\.\d{4}$').hasMatch(sStr)) { + addError( + 'supersedes', + 'Frontmatter "supersedes" must match format "AAA.NNNN" (found "$sStr"). ' + 'Expected format: 3 digits, dot, 4 digits (e.g. "000.0001").', + ); + } + } + + // 11. superseded_by (optional) + switch (yaml['superseded_by']) { + case null: + break; + case final supersededByVal: + final sStr = supersededByVal.toString().trim(); + if (!RegExp(r'^\d{3}\.\d{4}$').hasMatch(sStr)) { + addError( + 'superseded_by', + 'Frontmatter "superseded_by" must match format "AAA.NNNN" (found "$sStr"). ' + 'Expected format: 3 digits, dot, 4 digits (e.g. "000.0002").', + ); + } + } + + return errors; + } + + /// Parses a [Map] into [RfcFrontmatter]. + /// + /// Throws [FormatException] if validation errors are detected. + factory RfcFrontmatter.fromYaml(Map<dynamic, dynamic> yaml) { + final errors = validate(yaml); + if (errors.isNotEmpty) { + throw FormatException(formatErrors(errors)); + } + + final type = yaml['type'].toString().trim(); + final rfc = yaml['rfc'].toString().trim(); + final title = yaml['title'].toString().trim(); + final description = yaml['description'].toString().trim(); + final status = RfcStatus.tryParse(yaml['status'].toString().trim())!; + final created = _parseUtcTimestamp(yaml['created'], 'created').dateTime!; + final updated = _parseUtcTimestamp(yaml['updated'], 'updated').dateTime!; + final tags = List<String>.unmodifiable( + (yaml['tags'] as List).map((e) => e.toString().trim()), + ); + final authors = List<RfcAuthor>.unmodifiable( + (yaml['authors'] as List).map( + (e) => RfcAuthor.parse(e.toString().trim()), + ), + ); + final supersedes = yaml['supersedes']?.toString().trim(); + final supersededBy = yaml['superseded_by']?.toString().trim(); + + return RfcFrontmatter( + type: type, + rfc: rfc, + title: title, + description: description, + status: status, + created: created, + updated: updated, + tags: tags, + authors: authors, + supersedes: supersedes, + supersededBy: supersededBy, + ); + } + + /// Parses a raw YAML frontmatter string into [RfcFrontmatter]. + /// + /// Throws [FormatException] if the string cannot be parsed as a YAML mapping + /// or fails schema validation. + factory RfcFrontmatter.parse(String yamlString) { + dynamic loaded; + try { + loaded = loadYaml(yamlString); + } catch (e) { + throw FormatException( + formatErrors(['Failed to parse YAML frontmatter: $e']), + ); + } + if (loaded is! Map) { + throw FormatException( + formatErrors(const ['YAML frontmatter must be a key-value mapping.']), + ); + } + return RfcFrontmatter.fromYaml(loaded); + } + + /// Safely attempts to parse a [Map] or [YamlMap] into [RfcFrontmatter]. + /// + /// Returns a RfcFrontmatterLoad record with the parsed [frontmatter] + /// (or `null`), any [errors], and formatted [feedback] (or `null` if valid). + static RfcFrontmatterLoad tryLoad(dynamic yaml) { + if (yaml is! Map) { + const err = 'YAML frontmatter must be a key-value mapping.'; + return ( + frontmatter: null, + errors: const [err], + feedback: formatErrors([err]), + ); + } + final errors = validate(yaml); + if (errors.isNotEmpty) { + return ( + frontmatter: null, + errors: errors, + feedback: formatErrors(errors), + ); + } + try { + return ( + frontmatter: RfcFrontmatter.fromYaml(yaml), + errors: const <String>[], + feedback: null, + ); + } on FormatException catch (e) { + return (frontmatter: null, errors: [e.message], feedback: e.message); + } + } + + static ({DateTime? dateTime, String? error}) _parseUtcTimestamp( + dynamic val, + String fieldName, + ) { + final nowExample = clock.now().toUtc().toIso8601String(); + switch (val) { + case null: + return ( + dateTime: null, + error: + 'Frontmatter "$fieldName" must be an ISO 8601 UTC timestamp. Missing value (found "null"). ' + 'Expected format: YYYY-MM-DDTHH:MM:SSZ (e.g. $nowExample).', + ); + case DateTime dt when !dt.isUtc: + return ( + dateTime: null, + error: + 'Frontmatter "$fieldName" must be an ISO 8601 UTC timestamp. Found non-UTC "$dt". ' + 'Expected format: YYYY-MM-DDTHH:MM:SSZ (e.g. ${dt.toUtc().toIso8601String()}).', + ); + case DateTime dt: + return (dateTime: dt, error: null); + case String s: + final trimmed = s.trim(); + final upper = trimmed.toUpperCase(); + if (upper.endsWith('Z') || + upper.endsWith('+00:00') || + upper.endsWith('+0000') || + upper.endsWith('-00:00') || + upper.endsWith('-0000') || + upper.endsWith('+00') || + upper.endsWith('-00')) { + final dt = DateTime.tryParse(trimmed); + if (dt != null && dt.isUtc) { + return (dateTime: dt.toUtc(), error: null); + } + } + return ( + dateTime: null, + error: + 'Frontmatter "$fieldName" must be an ISO 8601 UTC timestamp. Found "$s". ' + 'Expected format: YYYY-MM-DDTHH:MM:SSZ (e.g. $nowExample).', + ); + case final invalid: + return ( + dateTime: null, + error: + 'Frontmatter "$fieldName" must be an ISO 8601 UTC timestamp. Found "$invalid". ' + 'Expected format: YYYY-MM-DDTHH:MM:SSZ (e.g. $nowExample).', + ); + } + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is RfcFrontmatter && + runtimeType == other.runtimeType && + type == other.type && + rfc == other.rfc && + title == other.title && + description == other.description && + status == other.status && + created == other.created && + updated == other.updated && + _listEquals(tags, other.tags) && + _listEquals(authors, other.authors) && + supersedes == other.supersedes && + supersededBy == other.supersededBy; + + @override + int get hashCode => Object.hash( + type, + rfc, + title, + description, + status, + created, + updated, + Object.hashAll(tags), + Object.hashAll(authors), + supersedes, + supersededBy, + ); + + @override + String toString() => + 'RfcFrontmatter(rfc: $rfc, title: "$title", status: ${status.value}, authors: $authors)'; + + static bool _listEquals<T>(List<T> a, List<T> b) { + if (identical(a, b)) return true; + if (a.length != b.length) return false; + for (int i = 0; i < a.length; i++) { + if (a[i] != b[i]) return false; + } + return true; + } +} diff --git a/test/rfc_file_test.dart b/test/rfc_file_test.dart new file mode 100644 index 0000000..b41b910 --- /dev/null +++ b/test/rfc_file_test.dart @@ -0,0 +1,675 @@ +// 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 'package:clock/clock.dart'; +import 'package:rfc_tools/src/models/rfc_file.dart'; +import 'package:test/test.dart'; + +void main() { + group('RfcFile', () { + const sample = '''--- +type: rfc +rfc: '110.0000' +title: Extract Value Notifier +description: Extract ValueNotifier into a foundation package. +status: draft +created: 2026-08-27T00:00:00Z +updated: 2026-08-27T00:00:00Z +tags: + - 110-foundation +authors: + - https://github.com/octocat +--- + +# RFC 110.0000: Extract Value Notifier + +## Overview +Some markdown text. + +```markdown +# RFC 999.9999: Fake Heading in Code Block +``` +'''; + + test('parses filename, frontmatter, and heading correctly', () { + final rfc = RfcFile.parse( + sample, + path: 'rfc/110.0000-extract-value-notifier.md', + ); + + expect(rfc.hasValidFilename, isTrue); + expect(rfc.category, equals('110')); + expect(rfc.index, equals(0)); + expect(rfc.indexString, equals('0000')); + expect(rfc.slug, equals('extract-value-notifier')); + expect(rfc.isDraft, isTrue); + expect(rfc.rfcId, equals('110.0000')); + + expect(rfc.hasFrontmatter, isTrue); + expect(rfc.hasValidFrontmatter, isTrue); + expect(rfc.frontmatter, isNotNull); + expect(rfc.frontmatter!.type, equals('rfc')); + expect(rfc.frontmatter!.rfc, equals('110.0000')); + expect(rfc.frontmatter!.status, equals(RfcStatus.draft)); + expect(rfc.frontmatter!.created, equals(DateTime.utc(2026, 8, 27))); + expect(rfc.frontmatter!.updated, equals(DateTime.utc(2026, 8, 27))); + + // Typed authors + expect(rfc.frontmatter!.authors, isNotNull); + expect(rfc.frontmatter!.authors.length, equals(1)); + expect(rfc.frontmatter!.authors.first, isA<GitHubAuthor>()); + expect( + (rfc.frontmatter!.authors.first as GitHubAuthor).username, + equals('octocat'), + ); + + // Strongly-typed frontmatter fields + expect(rfc.frontmatter!.type, equals('rfc')); + expect(rfc.frontmatter!.rfc, equals('110.0000')); + expect(rfc.frontmatter!.title, equals('Extract Value Notifier')); + expect( + rfc.frontmatter!.description, + equals('Extract ValueNotifier into a foundation package.'), + ); + expect(rfc.frontmatter!.status, equals(RfcStatus.draft)); + expect(rfc.frontmatter!.tags, equals(['110-foundation'])); + expect( + rfc.frontmatter!.authors.map((a) => a.raw).toList(), + equals(['https://github.com/octocat']), + ); + + expect(rfc.firstHeadingId, equals('110.0000')); + expect(rfc.firstHeadingTitle, equals('Extract Value Notifier')); + }); + + test('parses email authors into typed RfcAuthor list', () { + const emailSample = '''--- +type: rfc +rfc: '110.0000' +title: Email Author Test +description: Test with email author. +status: draft +created: 2026-08-27T00:00:00Z +updated: 2026-08-27T00:00:00Z +tags: + - 110-foundation +authors: + - '"John McDole" <codefu@google.com>' + - Jane Doe <jane@flutter.dev> +--- + +# RFC 110.0000: Email Author Test +'''; + final rfc = RfcFile.parse( + emailSample, + path: 'rfc/110.0000-email-author-test.md', + ); + + expect(rfc.hasValidFrontmatter, isTrue); + expect(rfc.frontmatter?.authors.length, equals(2)); + expect(rfc.frontmatter?.authors[0], isA<EmailAuthor>()); + final a1 = rfc.frontmatter?.authors[0] as EmailAuthor; + expect(a1.name, equals('John McDole')); + expect(a1.email, equals('codefu@google.com')); + + expect(rfc.frontmatter?.authors[1], isA<EmailAuthor>()); + final a2 = rfc.frontmatter?.authors[1] as EmailAuthor; + expect(a2.name, equals('Jane Doe')); + expect(a2.email, equals('jane@flutter.dev')); + }); + + test( + 'enforces schema during parsing and sets frontmatter to null on failure', + () { + const invalidSchemaSample = '''--- +type: rfc +rfc: '110.0000' +title: Incomplete Frontmatter +--- + +# RFC 110.0000: Incomplete Frontmatter +'''; + final rfc = RfcFile.parse( + invalidSchemaSample, + path: 'rfc/110.0000-incomplete.md', + ); + + expect(rfc.hasFrontmatter, isTrue); + expect(rfc.hasValidFrontmatter, isFalse); + expect(rfc.frontmatter, isNull); + expect(rfc.frontmatterErrors, isNotEmpty); + expect( + rfc.frontmatterErrors.any((e) => e.contains('description')), + isTrue, + ); + expect(rfc.frontmatterErrors.any((e) => e.contains('status')), isTrue); + expect(rfc.frontmatterErrors.any((e) => e.contains('created')), isTrue); + + // Rich feedback provides complete view of errors and expected format + expect(rfc.frontmatterFeedback, isNotNull); + expect(rfc.frontmatterFeedback, contains('Invalid RFC frontmatter:')); + expect( + rfc.frontmatterFeedback, + contains('Expected frontmatter format:'), + ); + expect( + rfc.frontmatterFeedback, + contains(RfcFrontmatter.expectedSchemaTemplate.trimRight()), + ); + }, + ); + + test( + 'collects feedback for multiple errors including missing authors and updated timestamps', + () { + const sampleMissingAuthorAndUpdated = '''--- +type: rfc +rfc: '110.0000' +title: Missing Fields +description: Missing author and updated timestamp. +status: draft +created: 2026-08-27T00:00:00Z +tags: + - 110-foundation +--- + +# RFC 110.0000: Missing Fields +'''; + + final rfc = RfcFile.parse( + sampleMissingAuthorAndUpdated, + path: 'rfc/110.0000-missing-fields.md', + ); + + expect(rfc.hasValidFrontmatter, isFalse); + expect(rfc.frontmatter, isNull); + expect(rfc.frontmatterErrors.length, equals(2)); + + final updatedErr = rfc.frontmatterErrors.firstWhere( + (e) => e.contains('"updated"'), + ); + expect( + updatedErr, + contains('Frontmatter "updated" must be an ISO 8601 UTC timestamp.'), + ); + expect(updatedErr, contains('Expected format: YYYY-MM-DDTHH:MM:SSZ')); + + final authorsErr = rfc.frontmatterErrors.firstWhere( + (e) => e.contains('"authors"'), + ); + expect( + authorsErr, + contains( + 'Frontmatter "authors" must be a non-empty list of authors.', + ), + ); + expect(authorsErr, contains('Expected format:')); + + // Complete view of frontmatter with expected format + expect(rfc.frontmatterFeedback, isNotNull); + expect(rfc.frontmatterFeedback, contains('Invalid RFC frontmatter:')); + expect(rfc.frontmatterFeedback, contains(updatedErr)); + expect(rfc.frontmatterFeedback, contains(authorsErr)); + expect( + rfc.frontmatterFeedback, + contains('Expected frontmatter format:'), + ); + expect( + rfc.frontmatterFeedback, + contains(RfcFrontmatter.exampleTemplate.trimRight()), + ); + }, + ); + + test( + 'frontmatterFeedback is null when frontmatter is completely valid', + () { + final rfc = RfcFile.parse( + sample, + path: 'rfc/110.0000-extract-value-notifier.md', + ); + expect(rfc.hasValidFrontmatter, isTrue); + expect(rfc.frontmatterFeedback, isNull); + }, + ); + + test( + 'transformedContent updates frontmatter rfc and header while preserving status and code blocks', + () { + final rfc = RfcFile.parse( + sample, + path: 'rfc/110.0000-extract-value-notifier.md', + ); + + final fixedTime = DateTime.utc(2026, 9, 1, 12, 0, 0); + final transformed = rfc.transformedContent( + newCategory: '110', + newIndex: '0042', + updatedTime: fixedTime, + ); + + // Verify frontmatter updates + expect(transformed, contains("rfc: '110.0042'")); + expect(transformed, contains('updated: 2026-09-01T12:00:00.000Z')); + expect( + transformed, + contains('status: draft'), + ); // Preserves draft status! + expect(transformed, contains('created: 2026-08-27T00:00:00Z')); + + // Verify header update + expect(transformed, contains('# RFC 110.0042: Extract Value Notifier')); + + // Verify code block is untouched + expect( + transformed, + contains('# RFC 999.9999: Fake Heading in Code Block'), + ); + }, + ); + + test( + 'transformedContent preserves custom user fields and comments in frontmatter', + () { + const customSample = '''--- +type: rfc +rfc: '110.0000' +title: Extract Value Notifier +description: Extract ValueNotifier into a foundation package. +status: draft +created: 2026-08-27T00:00:00Z +updated: 2026-08-27T00:00:00Z +tags: + - 110-foundation +authors: + - https://github.com/octocat +# Custom user rider data: +tracking_issue: https://github.com/flutter/flutter/issues/12345 +sponsor: + team: framework + lead: jane +custom_flags: [alpha, experimental] +--- + +# RFC 110.0000: Extract Value Notifier +'''; + + final rfc = RfcFile.parse( + customSample, + path: 'rfc/110.0000-extract-value-notifier.md', + ); + + final transformed = rfc.transformedContent( + newCategory: '110', + newIndex: '0042', + updatedTime: DateTime.utc(2026, 9, 1, 12, 0, 0), + ); + + expect(transformed, contains("rfc: '110.0042'")); + expect(transformed, contains('updated: 2026-09-01T12:00:00.000Z')); + expect(transformed, contains('# Custom user rider data:')); + expect( + transformed, + contains( + 'tracking_issue: https://github.com/flutter/flutter/issues/12345', + ), + ); + expect(transformed, contains(' team: framework')); + expect(transformed, contains('custom_flags: [alpha, experimental]')); + }, + ); + + test('detects invalid filenames', () { + final rfc1 = RfcFile.parse('', path: 'rfc/110.1-too-short.md'); + expect(rfc1.hasValidFilename, isFalse); + + final rfc2 = RfcFile.parse('', path: 'rfc/110.0001-UPPERCASE.md'); + expect(rfc2.hasValidFilename, isFalse); + + final rfc3 = RfcFile.parse('', path: 'rfc/invalid.md'); + expect(rfc3.hasValidFilename, isFalse); + }); + + test('parses CRLF input and normalizes output to LF (\\n)', () { + const crlfSample = + "---\r\ntype: rfc\r\nrfc: '110.0000'\r\ntitle: CRLF Test\r\ndescription: Test description\r\nstatus: draft\r\ncreated: 2026-08-27T00:00:00Z\r\nupdated: 2026-08-27T00:00:00Z\r\ntags: [110-foundation]\r\nauthors: [https://github.com/octocat]\r\n---\r\n\r\n# RFC 110.0000: CRLF Test\r\n"; + final rfc = RfcFile.parse(crlfSample, path: 'rfc/110.0000-crlf-test.md'); + expect(rfc.hasFrontmatter, isTrue); + expect(rfc.frontmatter?.title, equals('CRLF Test')); + expect(rfc.firstHeadingTitle, equals('CRLF Test')); + + final transformed = rfc.transformedContent( + newCategory: '110', + newIndex: '0005', + updatedTime: DateTime.utc(2026, 9, 1), + ); + expect(transformed, contains("rfc: '110.0005'")); + expect(transformed, contains('# RFC 110.0005: CRLF Test')); + expect(transformed, isNot(contains('\r\n'))); + }); + + test('handles trailing whitespace on frontmatter delimiter line', () { + const trailingSpaceSample = + "---\ntype: rfc\nrfc: '110.0000'\ntitle: Space Test\ndescription: Test description\nstatus: draft\ncreated: 2026-08-27T00:00:00Z\nupdated: 2026-08-27T00:00:00Z\ntags: [110-foundation]\nauthors: [https://github.com/octocat]\n--- \n\n# RFC 110.0000: Space Test\n"; + final rfc = RfcFile.parse( + trailingSpaceSample, + path: 'rfc/110.0000-space-test.md', + ); + expect(rfc.hasFrontmatter, isTrue); + expect(rfc.frontmatter?.title, equals('Space Test')); + expect(rfc.frontmatterError, isNull); + }); + + group('frontmatter error line numbers', () { + test('includes Line 1 for missing opening frontmatter delimiter', () { + final rfc = RfcFile.parse( + '# RFC 110.0000: No Frontmatter\n\nBody here.\n', + path: 'rfc/110.0000-no-fm.md', + ); + expect(rfc.hasFrontmatter, isFalse); + expect(rfc.frontmatterError, startsWith('Line 1: ')); + expect( + rfc.frontmatterError, + contains( + 'File does not start with YAML frontmatter delimiter `---`.', + ), + ); + }); + + test('includes Line 1 for unclosed frontmatter delimiter', () { + final rfc = RfcFile.parse( + '---\ntype: rfc\ntitle: Unclosed\n', + path: 'rfc/110.0000-unclosed.md', + ); + expect(rfc.hasFrontmatter, isFalse); + expect(rfc.frontmatterError, startsWith('Line 1: ')); + expect( + rfc.frontmatterError, + contains('Unclosed YAML frontmatter delimiter'), + ); + }); + + test('includes Line 2 for non-mapping frontmatter', () { + final rfc = RfcFile.parse( + '---\njust a scalar string\n---\n\n# RFC 110.0000: Scalar\n', + path: 'rfc/110.0000-scalar.md', + ); + expect(rfc.hasFrontmatter, isTrue); + expect(rfc.frontmatterError, startsWith('Line 2: ')); + expect( + rfc.frontmatterError, + contains('YAML frontmatter must be a key-value mapping.'), + ); + }); + + test('includes exact line number for YAML syntax error', () { + // Line 1: --- + // Line 2: type: rfc + // Line 3: title: [unclosed list + // Line 4: --- + final rfc = RfcFile.parse( + '---\ntype: rfc\ntitle: [unclosed list\n---\n\n# RFC 110.0000: Bad YAML\n', + path: 'rfc/110.0000-bad-yaml.md', + ); + expect(rfc.hasFrontmatter, isTrue); + expect(rfc.frontmatterError, startsWith('Line 3: ')); + expect( + rfc.frontmatterError, + contains('Failed to parse YAML frontmatter:'), + ); + }); + + test('includes exact line numbers for field schema errors', () { + // Line 1: --- + // Line 2: type: rfc + // Line 3: rfc: '110.0000' + // Line 4: title: Schema Error Test + // Line 5: description: Test + // Line 6: status: invalid_status + // Line 7: created: 2026-08-27T00:00:00Z + // Line 8: updated: 2026-08-27T00:00:00Z + // Line 9: tags: [110-foundation] + // Line 10: authors: [https://github.com/octocat] + // Line 11: --- + const sampleWithBadStatus = '''--- +type: rfc +rfc: '110.0000' +title: Schema Error Test +description: Test +status: invalid_status +created: 2026-08-27T00:00:00Z +updated: 2026-08-27T00:00:00Z +tags: [110-foundation] +authors: [https://github.com/octocat] +--- + +# RFC 110.0000: Schema Error Test +'''; + final rfc = RfcFile.parse( + sampleWithBadStatus, + path: 'rfc/110.0000-bad-status.md', + ); + expect(rfc.hasValidFrontmatter, isFalse); + expect(rfc.frontmatterErrors, isNotEmpty); + final statusError = rfc.frontmatterErrors.firstWhere( + (e) => e.contains('"status"'), + ); + expect(statusError, startsWith('Line 6: ')); + }); + }); + + test('rejects body that does not begin with a level-1 heading', () { + const precedingCodeBlockSample = '''--- +type: rfc +rfc: '110.0000' +title: Real Title +description: Test +status: draft +created: 2026-08-27T00:00:00Z +updated: 2026-08-27T00:00:00Z +tags: [110-foundation] +authors: [https://github.com/octocat] +--- + +Introductory code example: +```markdown +# RFC 999.9999: Code Block Heading Example +``` + +# RFC 110.0000: Real Title + +Content here. +'''; + + final rfc = RfcFile.parse( + precedingCodeBlockSample, + path: 'rfc/110.0000-real-title.md', + ); + + expect(rfc.firstHeading, isNull); + expect(rfc.firstHeadingId, isNull); + expect(rfc.hasValidHeading, isFalse); + expect(rfc.headingError, isNotNull); + expect( + rfc.headingError, + contains( + 'Markdown body must begin with a level-1 heading (`# RFC 110.0000: Real Title`).', + ), + ); + }); + + test('rejects body that begins with a level-2 heading', () { + const level2Sample = '''--- +type: rfc +rfc: '110.0000' +title: Level 2 Heading +description: Test +status: draft +created: 2026-08-27T00:00:00Z +updated: 2026-08-27T00:00:00Z +tags: [110-foundation] +authors: [https://github.com/octocat] +--- + +## RFC 110.0000: Level 2 Heading +'''; + + final rfc = RfcFile.parse(level2Sample, path: 'rfc/110.0000-level2.md'); + + expect(rfc.firstHeading, isNull); + expect(rfc.firstHeadingId, isNull); + expect(rfc.hasValidHeading, isFalse); + expect(rfc.headingError, isNotNull); + expect( + rfc.headingError, + contains('First heading must be a level-1 heading'), + ); + }); + + test( + 'heading error message derives title from slug when frontmatter has no title', + () { + const noTitleSample = '''--- +type: rfc +rfc: '110.0000' +status: draft +--- + +# Wrong Heading +'''; + + final rfc = RfcFile.parse( + noTitleSample, + path: 'rfc/110.0000-derived-slug-title.md', + ); + + expect(rfc.hasValidHeading, isFalse); + expect(rfc.headingError, isNotNull); + expect( + rfc.headingError, + contains( + 'Heading must match format "# RFC 110.0000: Derived Slug Title".', + ), + ); + }, + ); + + test('exposes supersedes and supersededBy accessors', () { + const supersedesSample = '''--- +type: rfc +rfc: '110.0002' +title: Supersedes Test +description: Test +status: stable +created: 2026-08-27T00:00:00Z +updated: 2026-08-27T00:00:00Z +tags: [110-foundation] +authors: [https://github.com/octocat] +supersedes: '110.0001' +superseded_by: '110.0003' +--- + +# RFC 110.0002: Supersedes Test +'''; + + final rfc = RfcFile.parse( + supersedesSample, + path: 'rfc/110.0002-supersedes-test.md', + ); + expect(rfc.index, equals(2)); + expect(rfc.indexString, equals('0002')); + expect(rfc.frontmatter?.supersedes, equals('110.0001')); + expect(rfc.frontmatter?.supersededBy, equals('110.0003')); + }); + + test('transformedContent validates newCategory and newIndex format', () { + final rfc = RfcFile.parse(sample, path: 'rfc/110.0000-sample.md'); + expect( + () => rfc.transformedContent(newCategory: '11', newIndex: '0001'), + throwsArgumentError, + ); + expect( + () => rfc.transformedContent(newCategory: '110', newIndex: '1'), + throwsArgumentError, + ); + expect( + () => rfc.transformedContent(newCategory: -1, newIndex: '0001'), + throwsArgumentError, + ); + expect( + () => rfc.transformedContent(newCategory: 1000, newIndex: '0001'), + throwsArgumentError, + ); + expect( + () => rfc.transformedContent(newCategory: 3.14, newIndex: '0001'), + throwsArgumentError, + ); + expect( + () => rfc.transformedContent(newCategory: '110', newIndex: -1), + throwsArgumentError, + ); + expect( + () => rfc.transformedContent(newCategory: '110', newIndex: 10000), + throwsArgumentError, + ); + expect( + () => rfc.transformedContent(newCategory: '110', newIndex: 3.14), + throwsArgumentError, + ); + }); + + test( + 'transformedContent accepts int newCategory and newIndex and formats properly', + () { + final rfc = RfcFile.parse(sample, path: 'rfc/110.0000-sample.md'); + final transformed = rfc.transformedContent( + newCategory: 0, + newIndex: 42, + updatedTime: DateTime.utc(2026, 9, 1), + ); + expect(transformed, contains("rfc: '000.0042'")); + expect(transformed, contains('# RFC 000.0042: Extract Value Notifier')); + }, + ); + + test('parses categoryNumber correctly', () { + final rfc = RfcFile.parse(sample, path: 'rfc/110.0000-sample.md'); + expect(rfc.categoryNumber, equals(110)); + expect(rfc.category, equals('110')); + + final invalid = RfcFile.parse('', path: 'rfc/invalid.md'); + expect(invalid.categoryNumber, isNull); + }); + + test('transformedContent uses clock.now() when updatedTime is omitted', () { + final rfc = RfcFile.parse(sample, path: 'rfc/110.0000-sample.md'); + final fixedTime = DateTime.utc(2026, 11, 12, 18, 45, 0); + withClock(Clock.fixed(fixedTime), () { + final transformed = rfc.transformedContent( + newCategory: '110', + newIndex: '0007', + ); + expect(transformed, contains('updated: 2026-11-12T18:45:00.000Z')); + }); + }); + + group('RfcNumberFormatting extension', () { + test('toNNNN formats integers to 4-digit zero-padded strings', () { + expect(0.toNNNN(), equals('0000')); + expect(1.toNNNN(), equals('0001')); + expect(42.toNNNN(), equals('0042')); + expect(110.toNNNN(), equals('0110')); + expect(9999.toNNNN(), equals('9999')); + }); + + test('toAAA formats integers to 3-digit zero-padded strings', () { + expect(0.toAAA(), equals('000')); + expect(1.toAAA(), equals('001')); + expect(42.toAAA(), equals('042')); + expect(110.toAAA(), equals('110')); + expect(999.toAAA(), equals('999')); + }); + }); + }); +} diff --git a/test/rfc_frontmatter_test.dart b/test/rfc_frontmatter_test.dart new file mode 100644 index 0000000..fd86075 --- /dev/null +++ b/test/rfc_frontmatter_test.dart @@ -0,0 +1,933 @@ +// 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 'package:clock/clock.dart'; +import 'package:rfc_tools/src/models/rfc_author.dart'; +import 'package:rfc_tools/src/models/rfc_frontmatter.dart'; +import 'package:test/test.dart'; +import 'package:yaml/yaml.dart'; + +void main() { + group('RfcFrontmatter', () { + const validYaml = ''' +type: rfc +rfc: '110.0001' +title: Foundation Architecture +description: Comprehensive architecture overview for foundation. +status: stable +created: 2026-08-27T00:00:00Z +updated: 2026-09-01T12:00:00Z +tags: + - 110-foundation + - 000-meta +authors: + - https://github.com/octocat + - '"John McDole" <codefu@google.com>' +'''; + + const withSupersedes = '''$validYaml +supersedes: '110.0000' +superseded_by: '110.0002' +'''; + + test('parses completely valid frontmatter into typed fields', () { + final fm = RfcFrontmatter.parse(validYaml); + + expect(fm.type, equals('rfc')); + expect(fm.rfc, equals('110.0001')); + expect(fm.title, equals('Foundation Architecture')); + expect( + fm.description, + equals('Comprehensive architecture overview for foundation.'), + ); + expect(fm.status, equals(RfcStatus.stable)); + expect(fm.created, equals(DateTime.utc(2026, 8, 27))); + expect(fm.created.isUtc, isTrue); + expect(fm.updated, equals(DateTime.utc(2026, 9, 1, 12, 0, 0))); + expect(fm.updated.isUtc, isTrue); + expect(fm.tags, equals(['110-foundation', '000-meta'])); + + expect(fm.authors.length, equals(2)); + expect(fm.authors[0], isA<GitHubAuthor>()); + expect((fm.authors[0] as GitHubAuthor).username, equals('octocat')); + expect(fm.authors[1], isA<EmailAuthor>()); + expect((fm.authors[1] as EmailAuthor).name, equals('John McDole')); + expect((fm.authors[1] as EmailAuthor).email, equals('codefu@google.com')); + + expect(fm.supersedes, isNull); + expect(fm.supersededBy, isNull); + }); + + test('parses optional supersedes and superseded_by fields', () { + final fm = RfcFrontmatter.parse(withSupersedes); + expect(fm.supersedes, equals('110.0000')); + expect(fm.supersededBy, equals('110.0002')); + }); + + test('supports value equality and hashCode', () { + final fm1 = RfcFrontmatter.parse(validYaml); + final fm2 = RfcFrontmatter.parse(validYaml); + expect(fm1, equals(fm2)); + expect(fm1.hashCode, equals(fm2.hashCode)); + + final fmDifferent = RfcFrontmatter.parse( + validYaml.replaceAll('110.0001', '110.0002'), + ); + expect(fm1, isNot(equals(fmDifferent))); + }); + + group('schema validation errors', () { + test('rejects missing or invalid type', () { + final yamlMissing = + loadYaml(validYaml.replaceAll('type: rfc', '')) as YamlMap; + final errors1 = RfcFrontmatter.validate(yamlMissing); + expect( + errors1.any((e) => e.contains('Frontmatter "type" must be "rfc"')), + isTrue, + ); + + final yamlInvalid = + loadYaml(validYaml.replaceAll('type: rfc', 'type: doc')) as YamlMap; + final errors2 = RfcFrontmatter.validate(yamlInvalid); + expect( + errors2.any( + (e) => e.contains('Frontmatter "type" must be "rfc" (found "doc")'), + ), + isTrue, + ); + }); + + test('rejects missing or invalid rfc ID', () { + final yamlMissing = + loadYaml(validYaml.replaceAll("rfc: '110.0001'", '')) as YamlMap; + final errors1 = RfcFrontmatter.validate(yamlMissing); + expect( + errors1.any( + (e) => e.contains('Frontmatter "rfc" field is required.'), + ), + isTrue, + ); + + final yamlInvalid = + loadYaml(validYaml.replaceAll("rfc: '110.0001'", "rfc: '11.1'")) + as YamlMap; + final errors2 = RfcFrontmatter.validate(yamlInvalid); + expect( + errors2.any( + (e) => e.contains('Frontmatter "rfc" must match format "AAA.NNNN"'), + ), + isTrue, + ); + }); + + test('rejects missing or empty title', () { + final yamlMissing = + loadYaml(validYaml.replaceAll('title: Foundation Architecture', '')) + as YamlMap; + expect( + RfcFrontmatter.validate(yamlMissing).any( + (e) => e.contains( + 'Frontmatter "title" is required and must be a non-empty string.', + ), + ), + isTrue, + ); + + final yamlEmpty = + loadYaml( + validYaml.replaceAll( + 'title: Foundation Architecture', + 'title: " "', + ), + ) + as YamlMap; + expect( + RfcFrontmatter.validate(yamlEmpty).any( + (e) => e.contains( + 'Frontmatter "title" is required and must be a non-empty string.', + ), + ), + isTrue, + ); + }); + + test('rejects missing or empty description', () { + final yamlMissing = + loadYaml( + validYaml.replaceAll( + 'description: Comprehensive architecture overview for foundation.', + '', + ), + ) + as YamlMap; + expect( + RfcFrontmatter.validate(yamlMissing).any( + (e) => e.contains( + 'Frontmatter "description" is required and must be a non-empty string.', + ), + ), + isTrue, + ); + }); + + test('rejects missing or invalid status', () { + final yamlMissing = + loadYaml(validYaml.replaceAll('status: stable', '')) as YamlMap; + expect( + RfcFrontmatter.validate( + yamlMissing, + ).any((e) => e.contains('Frontmatter "status" must be one of:')), + isTrue, + ); + + final yamlInvalid = + loadYaml( + validYaml.replaceAll( + 'status: stable', + 'status: invalid_status', + ), + ) + as YamlMap; + expect( + RfcFrontmatter.validate( + yamlInvalid, + ).any((e) => e.contains('(found "invalid_status")')), + isTrue, + ); + }); + + test('rejects non-UTC timestamps and accepts UTC with z suffix', () { + final yamlLocal = + loadYaml( + validYaml.replaceAll( + 'created: 2026-08-27T00:00:00Z', + 'created: 2026-08-27 12:00:00', + ), + ) + as YamlMap; + expect( + RfcFrontmatter.validate(yamlLocal).any( + (e) => e.contains( + 'Frontmatter "created" must be an ISO 8601 UTC timestamp', + ), + ), + isTrue, + ); + + final yamlNotDate = + loadYaml( + validYaml.replaceAll( + 'updated: 2026-09-01T12:00:00Z', + 'updated: "not-a-timestamp"', + ), + ) + as YamlMap; + expect( + RfcFrontmatter.validate(yamlNotDate).any( + (e) => e.contains( + 'Frontmatter "updated" must be an ISO 8601 UTC timestamp', + ), + ), + isTrue, + ); + + // Lowercase z should be accepted as valid UTC + final yamlLowerZ = + loadYaml( + validYaml.replaceAll( + 'created: 2026-08-27T00:00:00Z', + 'created: 2026-08-27T00:00:00z', + ), + ) + as YamlMap; + expect(RfcFrontmatter.validate(yamlLowerZ), isEmpty); + + // Explicit zero-offset UTC formats (+00:00, -00:00, +0000, -0000) + for (final offset in [ + '+00:00', + '-00:00', + '+0000', + '-0000', + '+00', + '-00', + ]) { + final yamlZeroOffset = + loadYaml( + validYaml.replaceAll( + 'created: 2026-08-27T00:00:00Z', + 'created: 2026-08-27T00:00:00$offset', + ), + ) + as YamlMap; + expect(RfcFrontmatter.validate(yamlZeroOffset), isEmpty); + } + + // Rejects non-zero offsets (not UTC) + for (final nonUtc in ['+02:00', '-05:00', '+14:00', '-12:00']) { + final yamlNonUtc = + loadYaml( + validYaml.replaceAll( + 'created: 2026-08-27T00:00:00Z', + 'created: 2026-08-27T00:00:00$nonUtc', + ), + ) + as YamlMap; + final errors = RfcFrontmatter.validate(yamlNonUtc); + expect(errors, isNotEmpty); + expect( + errors.any( + (e) => e.contains( + 'Frontmatter "created" must be an ISO 8601 UTC timestamp', + ), + ), + isTrue, + ); + } + }); + + test( + 'rejects non-string and non-DateTime created/updated timestamp values', + () { + final yamlIntCreated = { + 'type': 'rfc', + 'rfc': '000.0001', + 'title': 'Proposal Title', + 'description': 'Description', + 'status': 'draft', + 'created': 123456, + 'updated': '2026-08-27T00:00:00Z', + 'tags': ['000-meta'], + 'authors': ['https://github.com/octocat'], + }; + final errors = RfcFrontmatter.validate(yamlIntCreated); + expect( + errors.any( + (e) => e.contains( + 'Frontmatter "created" must be an ISO 8601 UTC timestamp. Found "123456"', + ), + ), + isTrue, + ); + + final yamlBoolCreated = { + 'type': 'rfc', + 'rfc': '000.0001', + 'title': 'Proposal Title', + 'description': 'Description', + 'status': 'draft', + 'created': true, + 'updated': '2026-08-27T00:00:00Z', + 'tags': ['000-meta'], + 'authors': ['https://github.com/octocat'], + }; + final boolErrors = RfcFrontmatter.validate(yamlBoolCreated); + expect( + boolErrors.any( + (e) => e.contains( + 'Frontmatter "created" must be an ISO 8601 UTC timestamp. Found "true"', + ), + ), + isTrue, + ); + + final yamlListUpdated = { + 'type': 'rfc', + 'rfc': '000.0001', + 'title': 'Proposal Title', + 'description': 'Description', + 'status': 'draft', + 'created': '2026-08-27T00:00:00Z', + 'updated': [2026, 8, 27], + 'tags': ['000-meta'], + 'authors': ['https://github.com/octocat'], + }; + final listErrors = RfcFrontmatter.validate(yamlListUpdated); + expect( + listErrors.any( + (e) => e.contains( + 'Frontmatter "updated" must be an ISO 8601 UTC timestamp. Found "[2026, 8, 27]"', + ), + ), + isTrue, + ); + }, + ); + + test('rejects invalid tags list or empty tag items', () { + final yamlNotList = + loadYaml( + validYaml.replaceAll( + 'tags:\n - 110-foundation\n - 000-meta', + 'tags: 110-foundation', + ), + ) + as YamlMap; + expect( + RfcFrontmatter.validate(yamlNotList).any( + (e) => e.contains( + 'Frontmatter "tags" must be a non-empty list of strings.', + ), + ), + isTrue, + ); + + final yamlEmptyList = + loadYaml( + validYaml.replaceAll( + 'tags:\n - 110-foundation\n - 000-meta', + 'tags: []', + ), + ) + as YamlMap; + expect( + RfcFrontmatter.validate(yamlEmptyList).any( + (e) => e.contains( + 'Frontmatter "tags" must be a non-empty list of strings.', + ), + ), + isTrue, + ); + + final yamlEmptyItem = + loadYaml( + validYaml.replaceAll( + 'tags:\n - 110-foundation\n - 000-meta', + 'tags: [""]', + ), + ) + as YamlMap; + expect( + RfcFrontmatter.validate(yamlEmptyItem).any( + (e) => e.contains( + 'Frontmatter "tags" items must be non-empty strings.', + ), + ), + isTrue, + ); + }); + + test('rejects invalid authors list or author formats', () { + final yamlEmptyList = + loadYaml( + validYaml.replaceAll( + 'authors:\n - https://github.com/octocat\n - \'"John McDole" <codefu@google.com>\'', + 'authors: []', + ), + ) + as YamlMap; + expect( + RfcFrontmatter.validate(yamlEmptyList).any( + (e) => e.contains( + 'Frontmatter "authors" must be a non-empty list of authors.', + ), + ), + isTrue, + ); + + final yamlInvalidAuthor = + loadYaml( + validYaml.replaceAll( + 'https://github.com/octocat', + 'not a valid author', + ), + ) + as YamlMap; + expect( + RfcFrontmatter.validate( + yamlInvalidAuthor, + ).any((e) => e.contains('Author "not a valid author" must be')), + isTrue, + ); + }); + + test('rejects invalid supersedes / superseded_by formatting', () { + final yaml = + loadYaml('''$validYaml +supersedes: 'invalid' +superseded_by: 'invalid' +''') + as YamlMap; + final errors = RfcFrontmatter.validate(yaml); + expect( + errors.any( + (e) => e.contains( + 'Frontmatter "supersedes" must match format "AAA.NNNN"', + ), + ), + isTrue, + ); + expect( + errors.any( + (e) => e.contains( + 'Frontmatter "superseded_by" must match format "AAA.NNNN"', + ), + ), + isTrue, + ); + }); + + test('fromYaml throws FormatException on invalid YAML', () { + final invalidYaml = loadYaml('type: invalid') as YamlMap; + expect( + () => RfcFrontmatter.fromYaml(invalidYaml), + throwsA(isA<FormatException>()), + ); + }); + + test('parse throws FormatException on non-mapping input', () { + expect( + () => RfcFrontmatter.parse('just a scalar string'), + throwsA( + isA<FormatException>().having( + (e) => e.message, + 'message', + allOf( + contains('YAML frontmatter must be a key-value mapping.'), + contains('Expected frontmatter format:'), + contains(RfcFrontmatter.expectedSchemaTemplate.trimRight()), + ), + ), + ), + ); + }); + + test('parse throws FormatException on malformed YAML syntax', () { + expect( + () => RfcFrontmatter.parse('key: [unclosed list'), + throwsA( + isA<FormatException>().having( + (e) => e.message, + 'message', + allOf( + contains('Failed to parse YAML frontmatter:'), + contains('Expected frontmatter format:'), + contains(RfcFrontmatter.expectedSchemaTemplate.trimRight()), + ), + ), + ), + ); + }); + + test('validates and parses standard Dart Map input', () { + final standardMap = <String, dynamic>{ + 'type': 'rfc', + 'rfc': '110.0001', + 'title': 'Foundation Architecture', + 'description': 'Comprehensive architecture overview for foundation.', + 'status': 'stable', + 'created': '2026-08-27T00:00:00Z', + 'updated': '2026-09-01T12:00:00+00:00', + 'tags': ['110-foundation', '000-meta'], + 'authors': ['https://github.com/octocat'], + }; + + final fm = RfcFrontmatter.fromYaml(standardMap); + expect(fm.rfc, equals('110.0001')); + expect(fm.updated, equals(DateTime.utc(2026, 9, 1, 12, 0, 0))); + + // Unmodifiable collections + expect(() => fm.tags.add('extra'), throwsUnsupportedError); + expect( + () => fm.authors.add(const GitHubAuthor(username: 'hacker')), + throwsUnsupportedError, + ); + }); + + test('rejects non-string title and description types', () { + final yamlStr = validYaml + .replaceAll( + 'title: Foundation Architecture', + 'title: [not, a, string]', + ) + .replaceAll( + 'description: Comprehensive architecture overview for foundation.', + 'description: 12345', + ); + final yaml = loadYaml(yamlStr) as YamlMap; + final errors = RfcFrontmatter.validate(yaml); + expect( + errors.any( + (e) => e.contains( + 'Frontmatter "title" is required and must be a non-empty string.', + ), + ), + isTrue, + ); + expect( + errors.any( + (e) => e.contains( + 'Frontmatter "description" is required and must be a non-empty string.', + ), + ), + isTrue, + ); + }); + + test( + 'tryLoad returns null frontmatter, populated errors, and rich feedback', + () { + final invalidYaml = loadYaml('type: invalid') as YamlMap; + final result = RfcFrontmatter.tryLoad(invalidYaml); + expect(result.frontmatter, isNull); + expect(result.errors, isNotEmpty); + expect(result.feedback, isNotNull); + expect(result.feedback, contains('Invalid RFC frontmatter:')); + expect(result.feedback, contains('Expected frontmatter format:')); + }, + ); + + test( + 'collects multiple validation errors together without early-halting (e.g. missing authors and updated)', + () { + final yamlMissingBoth = + loadYaml(''' +type: rfc +rfc: '110.0001' +title: Foundation Architecture +description: Comprehensive architecture overview for foundation. +status: stable +created: 2026-08-27T00:00:00Z +tags: + - 110-foundation +''') + as YamlMap; + + final errors = RfcFrontmatter.validate(yamlMissingBoth); + + // Both errors captured together + expect(errors.length, equals(2)); + + final updatedError = errors.firstWhere( + (e) => e.contains('"updated"'), + ); + expect( + updatedError, + contains( + 'Frontmatter "updated" must be an ISO 8601 UTC timestamp.', + ), + ); + expect(updatedError, contains('found "null"')); + expect( + updatedError, + contains('Expected format: YYYY-MM-DDTHH:MM:SSZ'), + ); + + final authorsError = errors.firstWhere( + (e) => e.contains('"authors"'), + ); + expect( + authorsError, + contains( + 'Frontmatter "authors" must be a non-empty list of authors.', + ), + ); + expect(authorsError, contains('found "null"')); + expect(authorsError, contains('Expected format:')); + expect(authorsError, contains('https://github.com/<username>')); + }, + ); + + test('collects all errors when all required fields are missing', () { + final emptyYaml = YamlMap(); + final errors = RfcFrontmatter.validate(emptyYaml); + + expect(errors.any((e) => e.contains('"type"')), isTrue); + expect(errors.any((e) => e.contains('"rfc"')), isTrue); + expect(errors.any((e) => e.contains('"title"')), isTrue); + expect(errors.any((e) => e.contains('"description"')), isTrue); + expect(errors.any((e) => e.contains('"status"')), isTrue); + expect(errors.any((e) => e.contains('"created"')), isTrue); + expect(errors.any((e) => e.contains('"updated"')), isTrue); + expect(errors.any((e) => e.contains('"tags"')), isTrue); + expect(errors.any((e) => e.contains('"authors"')), isTrue); + }); + + test( + 'fromYaml throws FormatException containing all errors and expected schema template', + () { + final yamlMissingBoth = + loadYaml(''' +type: rfc +rfc: '110.0001' +title: Foundation Architecture +description: Comprehensive architecture overview. +status: stable +created: 2026-08-27T00:00:00Z +tags: + - 110-foundation +''') + as YamlMap; + + expect( + () => RfcFrontmatter.fromYaml(yamlMissingBoth), + throwsA( + isA<FormatException>().having( + (e) => e.message, + 'message', + allOf( + contains('Invalid RFC frontmatter:'), + contains( + 'Frontmatter "updated" must be an ISO 8601 UTC timestamp', + ), + contains( + 'Frontmatter "authors" must be a non-empty list of authors', + ), + contains('Expected frontmatter format:'), + contains(RfcFrontmatter.expectedSchemaTemplate.trimRight()), + ), + ), + ), + ); + }, + ); + + test( + 'formatErrors formats error list and appends expected schema template', + () { + final formatted = RfcFrontmatter.formatErrors([ + 'Error one.', + 'Error two.', + ]); + expect(formatted, contains('Invalid RFC frontmatter:')); + expect(formatted, contains(' - Error one.')); + expect(formatted, contains(' - Error two.')); + expect(formatted, contains('Expected frontmatter format:')); + expect(formatted, contains('type: rfc')); + expect(formatted, contains('rfc: \'000.0001\'')); + }, + ); + + test( + 'canonical expectedSchemaTemplate and exampleTemplate are valid and parse cleanly', + () { + expect( + RfcFrontmatter.expectedSchemaTemplate, + equals(RfcFrontmatter.exampleTemplate), + ); + expect(RfcFrontmatter.expectedSchemaTemplate, contains('type: rfc')); + expect( + RfcFrontmatter.expectedSchemaTemplate, + contains('rfc: \'000.0001\''), + ); + expect( + RfcFrontmatter.expectedSchemaTemplate, + contains('status: draft'), + ); + expect(RfcFrontmatter.expectedSchemaTemplate, contains('authors:')); + + final parsed = RfcFrontmatter.parse( + RfcFrontmatter.expectedSchemaTemplate, + ); + expect(parsed.type, equals('rfc')); + expect(parsed.rfc, equals('000.0001')); + expect(parsed.status, equals(RfcStatus.draft)); + expect(parsed.authors, isNotEmpty); + }, + ); + + test( + 'uses clock.now() formatted timestamp in missing updated error feedback', + () { + final fixedTime = DateTime.utc(2026, 9, 15, 14, 30, 45); + withClock(Clock.fixed(fixedTime), () { + final yamlWithoutUpdated = + loadYaml(''' +type: rfc +rfc: '110.0001' +title: Foundation Architecture +description: Comprehensive architecture overview. +status: draft +created: 2026-08-27T00:00:00Z +tags: + - 110-foundation +authors: + - https://github.com/octocat +''') + as YamlMap; + + final errors = RfcFrontmatter.validate(yamlWithoutUpdated); + expect(errors.length, equals(1)); + expect( + errors.first, + contains( + 'Frontmatter "updated" must be an ISO 8601 UTC timestamp.', + ), + ); + expect(errors.first, contains('(e.g. 2026-09-15T14:30:45.000Z).')); + }); + }, + ); + + test( + 'uses clock.now() formatted timestamp in missing created error feedback', + () { + final fixedTime = DateTime.utc(2026, 10, 5, 8, 12, 0); + withClock(Clock.fixed(fixedTime), () { + final yamlWithoutCreated = + loadYaml(''' +type: rfc +rfc: '110.0001' +title: Foundation Architecture +description: Comprehensive architecture overview. +status: draft +updated: 2026-08-27T00:00:00Z +tags: + - 110-foundation +authors: + - https://github.com/octocat +''') + as YamlMap; + + final errors = RfcFrontmatter.validate(yamlWithoutCreated); + expect(errors.length, equals(1)); + expect( + errors.first, + contains( + 'Frontmatter "created" must be an ISO 8601 UTC timestamp.', + ), + ); + expect(errors.first, contains('(e.g. 2026-10-05T08:12:00.000Z).')); + }); + }, + ); + + test( + 'uses clock.now() formatted timestamp in invalid timestamp error feedback', + () { + final fixedTime = DateTime.utc(2026, 11, 20, 23, 59, 59); + withClock(Clock.fixed(fixedTime), () { + final yamlInvalidUpdated = + loadYaml(''' +type: rfc +rfc: '110.0001' +title: Foundation Architecture +description: Comprehensive architecture overview. +status: draft +created: 2026-08-27T00:00:00Z +updated: not-a-valid-date +tags: + - 110-foundation +authors: + - https://github.com/octocat +''') + as YamlMap; + + final errors = RfcFrontmatter.validate(yamlInvalidUpdated); + expect(errors.length, equals(1)); + expect(errors.first, contains('Found "not-a-valid-date"')); + expect(errors.first, contains('(e.g. 2026-11-20T23:59:59.000Z).')); + }); + }, + ); + + test('uses clock.now() in non-UTC DateTime error feedback', () { + final fixedTime = DateTime.utc(2026, 11, 20, 23, 59, 59); + final localTime = DateTime(2026, 8, 27, 12, 0); + withClock(Clock.fixed(fixedTime), () { + final errors = RfcFrontmatter.validate({ + 'type': 'rfc', + 'rfc': '110.0001', + 'title': 'Title', + 'description': 'Description', + 'status': 'draft', + 'created': DateTime(2026, 8, 27, 12, 0), // Local non-UTC DateTime + 'updated': '2026-08-27T00:00:00Z', + 'tags': ['110-foundation'], + 'authors': ['https://github.com/octocat'], + }); + expect( + errors, + contains( + allOf( + contains('Found non-UTC'), + contains('(e.g. ${localTime.toUtc().toIso8601String()}).'), + ), + ), + ); + }); + }); + + test( + 'uses clock.now() with non-UTC fixed clock properly converted to UTC', + () { + // Clock fixed to a timezone with +14:00 offset + final fixedNonUtc = DateTime.parse('2026-09-02T02:00:00+14:00'); + withClock(Clock.fixed(fixedNonUtc), () { + final yamlWithoutUpdated = + loadYaml(''' +type: rfc +rfc: '110.0001' +title: Foundation Architecture +description: Comprehensive architecture overview. +status: draft +created: 2026-08-27T00:00:00Z +tags: + - 110-foundation +authors: + - https://github.com/octocat +''') + as YamlMap; + + final errors = RfcFrontmatter.validate(yamlWithoutUpdated); + expect(errors.length, equals(1)); + // 02:00 at +14:00 corresponds to 12:00 on previous day in UTC + expect(errors.first, contains('(e.g. 2026-09-01T12:00:00.000Z).')); + }); + }, + ); + }); + + group('createdIso and updatedIso', () { + test('formats created and updated DateTime as ISO 8601 UTC strings', () { + final frontmatter = RfcFrontmatter( + type: 'rfc', + rfc: '110.0001', + title: 'Title', + description: 'Description', + status: RfcStatus.draft, + created: DateTime.utc(2026, 8, 27, 0, 0, 0), + updated: DateTime.utc(2026, 9, 1, 12, 0, 0), + tags: ['110-foundation'], + authors: [const GitHubAuthor(username: 'octocat')], + ); + expect(frontmatter.createdIso, equals('2026-08-27T00:00:00.000Z')); + expect(frontmatter.updatedIso, equals('2026-09-01T12:00:00.000Z')); + }); + }); + + group('RfcStatus', () { + test('parses all valid status enum values case-insensitively', () { + expect(RfcStatus.tryParse('draft'), equals(RfcStatus.draft)); + expect(RfcStatus.tryParse('DRAFT'), equals(RfcStatus.draft)); + expect(RfcStatus.tryParse('review'), equals(RfcStatus.review)); + expect(RfcStatus.tryParse('Review'), equals(RfcStatus.review)); + expect(RfcStatus.tryParse('stable'), equals(RfcStatus.stable)); + expect(RfcStatus.tryParse('superseded'), equals(RfcStatus.superseded)); + expect(RfcStatus.tryParse('withdrawn'), equals(RfcStatus.withdrawn)); + expect(RfcStatus.tryParse('rejected'), equals(RfcStatus.rejected)); + expect(RfcStatus.tryParse('deprecated'), equals(RfcStatus.deprecated)); + }); + + test('returns null for unknown status', () { + expect(RfcStatus.tryParse('unknown'), isNull); + expect(RfcStatus.tryParse(''), isNull); + expect(RfcStatus.tryParse(null), isNull); + }); + + test('supports switch expressions on status', () { + final status = RfcStatus.review; + final label = switch (status) { + RfcStatus.draft => 'draft', + RfcStatus.review => 'in-review', + RfcStatus.stable => 'stable', + RfcStatus.superseded => 'superseded', + RfcStatus.withdrawn => 'withdrawn', + RfcStatus.rejected => 'rejected', + RfcStatus.deprecated => 'deprecated', + }; + expect(label, equals('in-review')); + }); + }); + }); +}