///| 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"),
]
}