parseConfigFile function

Blueprint parseConfigFile(
  1. String yamlSource, {
  2. required Catalog catalog,
  3. required RecipeLock lock,
})

Reads a versioned YAML --config into the same v3 Blueprint the interactive configurator produces (FR-043, FR-044, contracts/config-file-schema.md). A pure function of explicit inputs only — no path may inject a hidden default the other cannot express (tension 2, CF-1/INV-NI1): flutterSdkRequirement is the one field neither path ever prompts for, so both derive it identically from lock, the same non-hidden, versioned source.

Implementation

Blueprint parseConfigFile(
  String yamlSource, {
  required Catalog catalog,
  required RecipeLock lock,
}) {
  final Map<String, dynamic> raw;
  try {
    raw = _deepConvert(loadYaml(yamlSource)) as Map<String, dynamic>;
  } on YamlException catch (error) {
    throw ConfigFileException(
      'INVALID_CONFIG_YAML',
      'Config is not valid YAML: ${error.message}',
      exitCode: 2,
    );
  } on TypeError {
    throw const ConfigFileException(
      'INVALID_CONFIG_YAML',
      'Config must be a YAML mapping at the top level.',
      exitCode: 2,
    );
  }

  final schemaVersion = raw['schema_version'];
  if (schemaVersion == 1) {
    // CF-3: schema_version 1 is accepted and auto-upgrades — the same
    // migration rule as any other v1 input (T054/T055), not a second one.
    try {
      return migrateOrParseBlueprint(_asV1Json(raw), lock: lock);
    } on BlueprintMigrationException catch (error) {
      throw ConfigFileException(error.code, error.message);
    }
  }
  if (schemaVersion != 2 && schemaVersion != 3) {
    throw ConfigFileException(
      'UNKNOWN_CONFIG_SCHEMA_VERSION',
      'Unknown or missing schema_version "$schemaVersion".',
    );
  }

  final projectName = _asStringOrNull(raw['project_name']);
  final organization = _asStringOrNull(raw['organization']);
  final destination = _asStringOrNull(raw['destination']);
  final flutterSdk = _asStringOrNull(raw['flutter_sdk']);
  final targetPlatforms = _asPlatformSet(raw['target_platforms']);
  final git = raw['git'] is bool ? raw['git'] as bool : null;
  final validationProfileName = _asStringOrNull(raw['validation_profile']);
  final setupModeName = _asStringOrNull(raw['setup_mode']);

  final missing = <String>[
    if (projectName == null) 'project_name',
    if (organization == null) 'organization',
    if (destination == null) 'destination',
    if (targetPlatforms == null || targetPlatforms.isEmpty) 'target_platforms',
    if (git == null) 'git',
    if (setupModeName == null) 'setup_mode',
  ];
  if (missing.isNotEmpty) {
    throw ConfigFileException(
      'MISSING_REQUIRED_CONFIG_FIELD',
      'Missing required config field(s): ${missing.join(', ')}.',
    );
  }

  final setupMode = SetupMode.values
      .where((e) => e.name == setupModeName)
      .firstOrNull;
  if (setupMode == null) {
    throw ConfigFileException(
      'INVALID_SETUP_MODE',
      'Unknown setup_mode "$setupModeName".',
    );
  }
  final validationProfile = ValidationProfile.values
      .where((e) => e.name == (validationProfileName ?? 'standard'))
      .firstOrNull;
  if (validationProfile == null) {
    throw ConfigFileException(
      'INVALID_VALIDATION_PROFILE',
      'Unknown validation_profile "$validationProfileName".',
    );
  }

  // CF-5: presentation fields (badges, section order, prompt text) are
  // rejected/ignored — accomplished by construction: only the documented
  // keys above are ever read, so anything else in the config simply never
  // reaches the Blueprint.
  var explicitSingle = _asSingleSelections(raw['single_selections']);
  final explicitMulti = _asMultiSelections(raw['multi_selections']);

  if (schemaVersion == 3 && containsLegacyRelocatedSelections(explicitSingle)) {
    throw const ConfigFileException(
      'LEGACY_RELOCATED_KEY',
      'schema_version 3 uses a retired org.project_organization or '
          'org.localization key.',
    );
  }
  if (schemaVersion == 2) {
    explicitSingle = relocateV2SingleSelections(explicitSingle);
  }
  final clearedFoundationCapabilities = explicitSingle.entries
      .where(
        (entry) =>
            entry.key.startsWith('foundation.') &&
            const {'none', 'skip', ''}.contains(entry.value.toLowerCase()),
      )
      .map((entry) => entry.key)
      .toSet();
  explicitSingle = Map.of(explicitSingle)
    ..removeWhere((key, _) => clearedFoundationCapabilities.contains(key));

  final Map<String, String> singleSelections;
  final Map<String, List<String>> multiSelections;
  switch (setupMode) {
    case SetupMode.recommended:
      singleSelections = {
        ...(schemaVersion == 2
            ? migratedRecommendedSingleSelections
            : recommendedSingleSelections),
        ...explicitSingle,
      };
      multiSelections = {
        ...(schemaVersion == 2
            ? migratedRecommendedMultiSelections
            : recommendedMultiSelections),
        ...explicitMulti,
      };
    case SetupMode.minimal:
      singleSelections = {
        ...(schemaVersion == 2
            ? migratedMinimalSingleSelections
            : minimalSingleSelections),
        ...explicitSingle,
      };
      multiSelections = {...minimalMultiSelections, ...explicitMulti};
    case SetupMode.customize:
      singleSelections = explicitSingle;
      multiSelections = explicitMulti;
  }
  for (final capabilityId in clearedFoundationCapabilities) {
    singleSelections.remove(capabilityId);
  }

  // CF-4: every ID must resolve to a selectable catalog entry — unknown,
  // misspelled, or Deferred is a structured error, never a nearest-match.
  for (final MapEntry(key: capabilityId, value: optionId)
      in singleSelections.entries) {
    _requireSelectable(catalog, capabilityId, optionId);
  }
  for (final MapEntry(key: capabilityId, value: optionIds)
      in multiSelections.entries) {
    for (final optionId in optionIds) {
      _requireSelectable(catalog, capabilityId, optionId);
    }
  }

  return Blueprint(
    schemaVersion: 3,
    projectName: projectName!,
    organization: organization!,
    destination: destination!,
    flutterSdkRequirement: flutterSdk ?? lock.flutterRange,
    targetPlatforms: targetPlatforms!,
    recipeId: lock.recipeId,
    recipeVersion: lock.recipeVersion,
    engineVersion: lock.engineVersion,
    validationProfile: validationProfile,
    gitPreference: git!,
    setupMode: setupMode,
    singleSelections: singleSelections,
    multiSelections: multiSelections,
  );
}