///|
/// 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(
"| " +
escape_markdown_cell(path) +
" | " +
escape_markdown_cell(redact_value(path, item.value)) +
" | " +
escape_markdown_cell(item.source_layer) +
" | " +
escape_markdown_cell(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="\r", new="\\r")
.replace(old="\n", new="\\n")
.replace(old="\t", new="\\t")
}
///|
/// 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(
"| " +
escape_markdown_cell(item.path) +
" | " +
escape_markdown_cell(item.kind) +
" | " +
escape_markdown_cell(option_text(item.path, item.before)) +
" | " +
escape_markdown_cell(option_text(item.path, item.after)) +
" |\n",
)
}
builder.to_string()
}
///|
fn option_text(path : String, value : String?) -> String {
match value {
None => ""
Some(text) => redact_value(path, text)
}
}
///|
fn escape_markdown_cell(text : String) -> String {
text
.replace(old="\\", new="\\\\")
.replace(old="|", new="\\|")
.replace(old="\r", new="")
.replace(old="\n", new="
")
}