///| A value-level change between two merged views.
pub struct DiffItem {
path : String
before : String?
after : String?
kind : String
} derive(@debug.Debug, Eq)
///| Computes added, removed, and changed keys between two views.
pub fn diff_views(before : ConfigView, after : ConfigView) -> Array[DiffItem] {
let result : Array[DiffItem] = []
for old_value in before.values {
let path = entry_path(old_value.section, old_value.key)
match after.get_path(path) {
None => result.push({ path, before: Some(old_value.value), after: None, kind: "removed" })
Some(new_value) => if new_value != old_value.value {
result.push({ path, before: Some(old_value.value), after: Some(new_value), kind: "changed" })
}
}
}
for new_value in after.values {
let path = entry_path(new_value.section, new_value.key)
if before.get_path(path) == None {
result.push({ path, before: None, after: Some(new_value.value), kind: "added" })
}
}
result
}
///| Redacts a value when the path looks sensitive.
pub fn redact_value(path : String, value : String) -> String {
if is_sensitive_path(path) {
if value.length() == 0 {
""
} else {
"********"
}
} else {
value
}
}
///| Returns true for common secret-bearing key names.
pub fn is_sensitive_path(path : String) -> Bool {
let lower = ascii_lower_path(path)
lower.contains("password") ||
lower.contains("passwd") ||
lower.contains("secret") ||
lower.contains("token") ||
lower.contains("apikey") ||
lower.contains("api_key") ||
lower.contains("private_key")
}
fn ascii_lower_path(text : String) -> String {
let builder = StringBuilder()
let mut index = 0
while index < text.length() {
let ch = text[index]
if ch >= 'A' && ch <= 'Z' {
builder.write_char((ch.to_int() + 32).unsafe_to_char())
} else {
builder.write_char(ch.to_int().unsafe_to_char())
}
index = index + 1
}
builder.to_string()
}
///| Renders a Markdown report for humans and CI summaries.
pub fn render_markdown_report(view : ConfigView, validation_diagnostics : Array[Diagnostic]) -> String {
let builder = StringBuilder()
builder.write_string("# MoonConfigKit Report\n\n")
builder.write_string("## Summary\n\n")
builder.write_string("- Effective keys: " + view.values.length().to_string() + "\n")
builder.write_string("- Merge diagnostics: " + view.diagnostics.length().to_string() + "\n")
builder.write_string("- Validation diagnostics: " + validation_diagnostics.length().to_string() + "\n")
builder.write_string("- Errors: " + error_count(validation_diagnostics).to_string() + "\n\n")
builder.write_string("## Effective Values\n\n")
builder.write_string("| Path | Value | Layer | Source |\n")
builder.write_string("| --- | --- | --- | --- |\n")
for item in view.values {
let path = entry_path(item.section, item.key)
builder.write_string("| " + path + " | " + redact_value(path, item.value) + " | " + item.source_layer + " | " + item.source + ":" + item.span.line.to_string() + " |\n")
}
builder.write_string("\n## Diagnostics\n\n")
if validation_diagnostics.length() == 0 {
builder.write_string("No validation diagnostics.\n")
} else {
for item in validation_diagnostics {
builder.write_string("- " + severity_label(item.severity) + " `" + item.code + "`: " + item.message)
match item.hint {
Some(hint) => builder.write_string(" Hint: " + hint)
None => ()
}
builder.write_string("\n")
}
}
builder.to_string()
}
fn severity_label(severity : Severity) -> String {
match severity {
Error => "ERROR"
Warning => "WARN"
Info => "INFO"
}
}
///| Renders a compact JSON-like report without depending on a JSON package.
pub fn render_json_like_report(view : ConfigView, diagnostics : Array[Diagnostic]) -> String {
let builder = StringBuilder()
builder.write_string("{\n")
builder.write_string(" \"keys\": " + view.values.length().to_string() + ",\n")
builder.write_string(" \"errors\": " + error_count(diagnostics).to_string() + ",\n")
builder.write_string(" \"values\": [\n")
let mut index = 0
for item in view.values {
let path = entry_path(item.section, item.key)
builder.write_string(" {\"path\": \"" + escape_json(path) + "\", \"value\": \"" + escape_json(redact_value(path, item.value)) + "\", \"layer\": \"" + escape_json(item.source_layer) + "\"}")
index = index + 1
if index < view.values.length() {
builder.write_string(",")
}
builder.write_string("\n")
}
builder.write_string(" ]\n")
builder.write_string("}\n")
builder.to_string()
}
fn escape_json(text : String) -> String {
text.replace(old="\\", new="\\\\").replace(old="\"", new="\\\"").replace(old="\n", new="\\n")
}
///| Renders changes as a Markdown table.
pub fn render_diff_markdown(items : Array[DiffItem]) -> String {
let builder = StringBuilder()
builder.write_string("| Path | Kind | Before | After |\n")
builder.write_string("| --- | --- | --- | --- |\n")
for item in items {
builder.write_string("| " + item.path + " | " + item.kind + " | " + option_text(item.before) + " | " + option_text(item.after) + " |\n")
}
builder.to_string()
}
fn option_text(value : String?) -> String {
match value {
None => ""
Some(text) => text
}
}