///|
/// A suggested configuration key migration.
pub struct RenameRule {
old_path : String
new_path : String
reason : String
} derive(@debug.Debug, Eq)
///|
/// A concrete migration action derived from a view.
pub struct MigrationAction {
old_path : String
new_path : String
value : String
source_layer : String
reason : String
} derive(@debug.Debug, Eq)
///|
pub fn rename_rule(
old_path : String,
new_path : String,
reason? : String = "",
) -> RenameRule {
{ old_path, new_path, reason }
}
///|
/// Builds migration actions for rename rules that match existing keys.
pub fn plan_renames(
view : ConfigView,
rules : Array[RenameRule],
) -> Array[MigrationAction] {
let actions : Array[MigrationAction] = []
for rule in rules {
match find_resolved_by_path(view, rule.old_path) {
None => ()
Some(item) =>
actions.push({
old_path: rule.old_path,
new_path: rule.new_path,
value: item.value,
source_layer: item.source_layer,
reason: rule.reason,
})
}
}
actions
}
///|
fn find_resolved_by_path(view : ConfigView, path : String) -> ResolvedValue? {
for item in view.values {
if entry_path(item.section, item.key) == path {
return Some(item)
}
}
None
}
///|
/// Renders migration actions as a readable checklist.
pub fn render_migration_plan(actions : Array[MigrationAction]) -> String {
let builder = StringBuilder()
builder.write_string("# Configuration Migration Plan\n\n")
if actions.length() == 0 {
builder.write_string("No matching migration actions.\n")
} else {
for action in actions {
builder.write_string(
"- Rename `" + action.old_path + "` to `" + action.new_path + "`",
)
builder.write_string(
" using value `" +
redact_value(action.old_path, action.value) +
"` from layer `" +
action.source_layer +
"`",
)
if action.reason != "" {
builder.write_string(". Reason: " + action.reason)
}
builder.write_string("\n")
}
}
builder.to_string()
}
///|
/// Applies rename actions into a new properties-style patch.
pub fn render_migration_patch(actions : Array[MigrationAction]) -> String {
let builder = StringBuilder()
for action in actions {
builder.write_string("# migrated from " + action.old_path + "\n")
builder.write_string(
action.new_path + "=" + action.value.replace(old="\n", new="\\n") + "\n",
)
}
builder.to_string()
}
///|
/// Compares required schema keys across multiple profiles.
pub fn profile_gap_report(
schema : ConfigSchema,
profiles : Array[(String, ConfigView)],
) -> Array[Diagnostic] {
let diagnostics : Array[Diagnostic] = []
for pair in profiles {
let profile_name = pair.0
let view = pair.1
for schema_section in schema.sections {
for schema_field in schema_section.fields {
if schema_field.required && view.get_path(schema_field.path) == None {
diagnostics.push(
error(
"profile-missing",
"profile '" +
profile_name +
"' misses required key '" +
schema_field.path +
"'",
),
)
}
}
}
}
diagnostics
}
///|
/// Finds keys that are only present in some profiles.
pub fn profile_presence_matrix(
profiles : Array[(String, ConfigView)],
) -> String {
let all_paths = collect_all_profile_paths(profiles)
let builder = StringBuilder()
builder.write_string("| Path |")
for pair in profiles {
builder.write_string(" " + pair.0 + " |")
}
builder.write_string("\n| --- |")
for _ in profiles {
builder.write_string(" --- |")
}
builder.write_string("\n")
for path in all_paths {
builder.write_string("| " + path + " |")
for pair in profiles {
if pair.1.get_path(path) == None {
builder.write_string(" no |")
} else {
builder.write_string(" yes |")
}
}
builder.write_string("\n")
}
builder.to_string()
}
///|
fn collect_all_profile_paths(
profiles : Array[(String, ConfigView)],
) -> Array[String] {
let result : Array[String] = []
for pair in profiles {
for item in pair.1.values {
let path = entry_path(item.section, item.key)
if !contains_profile_path(result, path) {
result.push(path)
}
}
}
result
}
///|
fn contains_profile_path(paths : Array[String], target : String) -> Bool {
for path in paths {
if path == target {
return true
}
}
false
}
///|
/// Common migration rules for older service configs.
pub fn common_service_rename_rules() -> Array[RenameRule] {
[
rename_rule(
"server.bind",
"server.host",
reason="server.host is clearer for host names and IP addresses",
),
rename_rule(
"server.listen",
"server.port",
reason="server.port separates address and port concerns",
),
rename_rule(
"db.url",
"database.url",
reason="database section uses full names",
),
rename_rule(
"db.pass",
"database.password",
reason="secret keys should use explicit names",
),
rename_rule(
"auth.legacy_token",
"auth.token",
reason="legacy_token is kept for backward compatibility only",
),
rename_rule(
"auth.client",
"auth.client_id",
reason="OAuth settings use provider terminology",
),
]
}