parseConfigFile function
Blueprint
parseConfigFile(
- String yamlSource, {
- required Catalog catalog,
- 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,
);
}