use crate::semantic::{Change, ChangeKind, DiffResult};
use serde_json::Value;
const FIELD_COL_WIDTH: usize = 40;
const VALUE_COL_WIDTH: usize = 20;
#[derive(Debug, Clone)]
pub struct SideLabels {
pub new_side: String,
pub old_side: String,
}
pub fn format_text(result: &DiffResult, resource_name: &str, labels: &SideLabels) -> String {
if result.is_equal {
return format!("{}: no changes\n", resource_name);
}
let mut output = String::new();
output.push_str(&format!(
"{} — differs ({} field(s))\n\n",
resource_name,
result.changes.len()
));
output.push_str(&format!(
" {:<fw$} {:<vw$} {}\n",
"field",
labels.new_side,
labels.old_side,
fw = FIELD_COL_WIDTH,
vw = VALUE_COL_WIDTH
));
for change in &result.changes {
output.push_str(&format_change_row(change));
}
output
}
fn format_change_row(change: &Change) -> String {
if let Some(desc) = &change.description {
return format!(" {}\n", desc);
}
let (new_str, old_str) = match change.kind {
ChangeKind::Added => (
table_value(change.new_value.as_ref()),
"(absent)".to_string(),
),
ChangeKind::Removed => (
"(absent)".to_string(),
table_value(change.old_value.as_ref()),
),
ChangeKind::Modified => (
table_value(change.new_value.as_ref()),
table_value(change.old_value.as_ref()),
),
};
if change.path.len() > FIELD_COL_WIDTH {
let indent = " ".repeat(FIELD_COL_WIDTH + 3);
format!(
" {}\n{indent}{:<vw$} {}\n",
change.path,
new_str,
old_str,
vw = VALUE_COL_WIDTH
)
} else {
format!(
" {:<fw$} {:<vw$} {}\n",
change.path,
new_str,
old_str,
fw = FIELD_COL_WIDTH,
vw = VALUE_COL_WIDTH
)
}
}
const TABLE_VALUE_MAX: usize = 80;
fn table_value(value: Option<&Value>) -> String {
let preview = format_value_preview(value);
if preview.chars().count() <= TABLE_VALUE_MAX {
return preview;
}
let cut: String = preview.chars().take(TABLE_VALUE_MAX - 3).collect();
format!("{cut}...")
}
pub fn format_value_preview(value: Option<&Value>) -> String {
match value {
None => "(none)".to_string(),
Some(Value::Null) => "null".to_string(),
Some(Value::Bool(b)) => b.to_string(),
Some(Value::Number(n)) => n.to_string(),
Some(Value::String(s)) => {
if s.chars().count() > 500 {
let cut: String = s.chars().take(497).collect();
format!("\"{cut}...\" ({} chars)", s.chars().count())
} else {
format!("\"{}\"", s)
}
}
Some(Value::Array(arr)) => {
if arr.is_empty() {
"[]".to_string()
} else if arr.len() <= 3 && arr.iter().all(is_simple_value) {
let items: Vec<String> =
arr.iter().map(|v| format_value_preview(Some(v))).collect();
format!("[{}]", items.join(", "))
} else {
format!("[{} items]", arr.len())
}
}
Some(Value::Object(obj)) => {
if obj.len() == 1 {
"{...} (1 key)".to_string()
} else {
format!("{{...}} ({} keys)", obj.len())
}
}
}
}
fn is_simple_value(value: &Value) -> bool {
matches!(
value,
Value::String(_) | Value::Number(_) | Value::Bool(_) | Value::Null
)
}
pub fn format_json(result: &DiffResult) -> String {
serde_json::to_string_pretty(result).unwrap_or_else(|_| "{}".to_string())
}
pub fn format_report(
diffs: &[(String, DiffResult)],
format: OutputFormat,
labels: &SideLabels,
) -> String {
match format {
OutputFormat::Text => format_report_text(diffs, labels),
OutputFormat::Json => format_report_json(diffs),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OutputFormat {
Text,
Json,
}
fn format_report_text(diffs: &[(String, DiffResult)], labels: &SideLabels) -> String {
let mut output = String::new();
let (changed, unchanged): (Vec<_>, Vec<_>) = diffs.iter().partition(|(_, r)| !r.is_equal);
if changed.is_empty() {
output.push_str("No changes detected.\n");
return output;
}
output.push_str(&format!(
"Found {} resource(s) with changes:\n\n",
changed.len()
));
for (name, result) in &changed {
output.push_str(&format_text(result, name, labels));
output.push('\n');
}
if !unchanged.is_empty() {
output.push_str(&format!("{} resource(s) unchanged.\n", unchanged.len()));
}
output
}
fn format_report_json(diffs: &[(String, DiffResult)]) -> String {
let report: Vec<_> = diffs
.iter()
.map(|(name, result)| {
serde_json::json!({
"resource": name,
"changed": !result.is_equal,
"changes": result.changes
})
})
.collect();
serde_json::to_string_pretty(&report).unwrap_or_else(|_| "[]".to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::semantic::diff;
use serde_json::json;
fn labels() -> SideLabels {
SideLabels {
new_side: "local".to_string(),
old_side: "Azure (dev)".to_string(),
}
}
#[test]
fn test_format_text_no_changes() {
let result = DiffResult {
is_equal: true,
changes: vec![],
};
let output = format_text(&result, "test-index", &labels());
assert!(output.contains("no changes"));
}
#[test]
fn test_format_text_with_changes() {
let old = json!({"name": "test", "value": 1});
let new = json!({"name": "test", "value": 2});
let result = diff(&old, &new, "name");
let output = format_text(&result, "test-index", &labels());
assert!(output.contains("1 field"));
assert!(output.contains("value"));
assert!(!output.contains(" was "));
assert!(!output.contains(" now "));
}
#[test]
fn test_format_text_uses_description_when_set() {
let result = DiffResult {
is_equal: false,
changes: vec![Change {
path: "description".to_string(),
kind: ChangeKind::Modified,
old_value: Some(json!("old")),
new_value: Some(json!("new")),
description: Some(
"The description differs: locally has \"old\" while on the server has \"new\""
.to_string(),
),
}],
};
let output = format_text(&result, "test-index", &labels());
assert!(output.contains("The description differs"));
assert!(!output.contains("~")); }
#[test]
fn test_format_json() {
let result = DiffResult {
is_equal: false,
changes: vec![Change {
path: "name".to_string(),
kind: ChangeKind::Modified,
old_value: Some(json!("old")),
new_value: Some(json!("new")),
description: None,
}],
};
let output = format_json(&result);
assert!(output.contains("modified"));
assert!(output.contains("name"));
}
#[test]
fn test_format_value_preview_long_string() {
let long = "a".repeat(600);
let preview = format_value_preview(Some(&json!(long)));
assert!(preview.contains("..."));
assert!(preview.contains("600 chars"));
}
#[test]
fn test_format_value_preview_medium_string_not_truncated() {
let medium = "a".repeat(400);
let preview = format_value_preview(Some(&json!(medium)));
assert!(!preview.contains("..."));
assert_eq!(preview, format!("\"{}\"", medium));
}
#[test]
fn test_format_value_preview_small_array() {
let preview = format_value_preview(Some(&json!([1, 2, 3])));
assert_eq!(preview, "[1, 2, 3]");
}
#[test]
fn test_format_value_preview_small_string_array() {
let preview = format_value_preview(Some(&json!(["a", "b"])));
assert_eq!(preview, "[\"a\", \"b\"]");
}
#[test]
fn test_format_value_preview_large_array() {
let preview = format_value_preview(Some(&json!([1, 2, 3, 4])));
assert_eq!(preview, "[4 items]");
}
#[test]
fn test_format_value_preview_empty_array() {
let preview = format_value_preview(Some(&json!([])));
assert_eq!(preview, "[]");
}
#[test]
fn test_format_value_preview_complex_array_items() {
let preview = format_value_preview(Some(&json!([{"a": 1}])));
assert_eq!(preview, "[1 items]");
}
#[test]
fn test_format_value_preview_object_singular_key() {
let preview = format_value_preview(Some(&json!({"a": 1})));
assert_eq!(preview, "{...} (1 key)");
}
#[test]
fn test_format_value_preview_object_plural_keys() {
let preview = format_value_preview(Some(&json!({"a": 1, "b": 2})));
assert_eq!(preview, "{...} (2 keys)");
}
#[test]
fn test_modified_row_has_no_temporal_words() {
let change = Change {
path: "description".to_string(),
kind: ChangeKind::Modified,
old_value: Some(json!("old text")),
new_value: Some(json!("new text")),
description: None,
};
let output = format_change_row(&change);
assert!(!output.contains(" was "));
assert!(!output.contains(" now "));
assert!(!output.contains("->"));
assert!(output.contains("old text"));
assert!(output.contains("new text"));
}
#[test]
fn table_renders_both_sides_with_labels_no_temporal_words() {
let result = diff(
&json!({"name": "a", "model": "gpt-5.6-luna"}), &json!({"name": "a", "model": "gpt-5.2-chat"}), "name",
);
let labels = SideLabels {
new_side: "local".to_string(),
old_side: "Azure (dev)".to_string(),
};
let out = format_text(&result, "regulus/agents/Regulus", &labels);
assert!(out.contains("local"), "{out}");
assert!(out.contains("Azure (dev)"), "{out}");
assert!(
out.contains("gpt-5.2-chat") && out.contains("gpt-5.6-luna"),
"{out}"
);
let row = out.lines().find(|l| l.contains("model")).unwrap();
let li = row.find("gpt-5.2-chat").unwrap();
let ri = row.find("gpt-5.6-luna").unwrap();
assert!(li < ri, "local value first: {row}");
assert!(!out.contains(" was "), "{out}");
assert!(!out.contains(" now "), "{out}");
}
#[test]
fn table_renders_absent_for_one_sided_values() {
let result = diff(
&json!({"name": "a", "reasoning": {"effort": "high"}}), &json!({"name": "a"}), "name",
);
let labels = SideLabels {
new_side: "local".into(),
old_side: "Azure (dev)".into(),
};
let out = format_text(&result, "r", &labels);
assert!(out.contains("(absent)"), "{out}");
assert!(
out.contains("1 key)") && !out.contains("1 keys"),
"pluralization: {out}"
);
}
#[test]
fn table_has_no_change_kind_markers() {
let result = DiffResult {
is_equal: false,
changes: vec![
Change {
path: "added_field".to_string(),
kind: ChangeKind::Added,
old_value: None,
new_value: Some(json!("x")),
description: None,
},
Change {
path: "removed_field".to_string(),
kind: ChangeKind::Removed,
old_value: Some(json!("y")),
new_value: None,
description: None,
},
Change {
path: "modified_field".to_string(),
kind: ChangeKind::Modified,
old_value: Some(json!("a")),
new_value: Some(json!("b")),
description: None,
},
],
};
let out = format_text(&result, "r", &labels());
for line in out.lines() {
assert!(!line.starts_with(" - "), "removed marker in: {line}");
assert!(!line.starts_with(" + "), "added marker in: {line}");
assert!(!line.starts_with(" ~ "), "modified marker in: {line}");
}
assert!(out.contains("(absent)"), "{out}");
}
#[test]
fn long_field_paths_get_their_own_line() {
let long_path = "metadata.microsoft.voice-live.configuration";
assert!(long_path.len() > FIELD_COL_WIDTH, "fixture must be long");
let result = DiffResult {
is_equal: false,
changes: vec![Change {
path: long_path.to_string(),
kind: ChangeKind::Modified,
old_value: Some(json!("OLDVAL")),
new_value: Some(json!("NEWVAL")),
description: None,
}],
};
let out = format_text(&result, "r", &labels());
let lines: Vec<&str> = out.lines().collect();
let path_idx = lines
.iter()
.position(|l| l.contains(long_path))
.expect("path line present");
let path_line = lines[path_idx];
assert!(
!path_line.contains("NEWVAL") && !path_line.contains("OLDVAL"),
"path line must not carry values: {path_line}"
);
let value_line = lines[path_idx + 1];
assert!(
value_line.contains("NEWVAL") && value_line.contains("OLDVAL"),
"next line must carry both values: {value_line}"
);
let normal_row = format_change_row(&Change {
path: "short".to_string(),
kind: ChangeKind::Modified,
old_value: Some(json!("o")),
new_value: Some(json!("NEWVAL")),
description: None,
});
let expected_col = normal_row.find("NEWVAL").expect("value in normal row");
let actual_col = value_line.find("NEWVAL").expect("value in wrapped row");
assert_eq!(
actual_col, expected_col,
"wrapped value column should match normal row's value column\nnormal: {normal_row:?}\nwrapped: {value_line:?}"
);
}
#[test]
fn long_values_truncated_in_table() {
let long_value = "å".repeat(200);
let result = DiffResult {
is_equal: false,
changes: vec![Change {
path: "field".to_string(),
kind: ChangeKind::Modified,
old_value: Some(json!(long_value)),
new_value: Some(json!("short")),
description: None,
}],
};
let out = format_text(&result, "r", &labels());
assert!(out.contains("..."), "{out}");
assert!(
!out.contains(&long_value),
"full 300-char value must not appear verbatim: {out}"
);
}
#[test]
fn markdown_cells_truncated_and_unmarked() {
let long_value = "å".repeat(200);
let result = DiffResult {
is_equal: false,
changes: vec![Change {
path: "field".to_string(),
kind: ChangeKind::Modified,
old_value: Some(json!(long_value)),
new_value: Some(json!("short")),
description: None,
}],
};
let md = format_markdown(&[("r".to_string(), result)], &labels());
assert!(md.contains("..."), "{md}");
assert!(
!md.contains(&long_value),
"full 300-char value must not appear verbatim: {md}"
);
for line in md.lines().filter(|l| l.starts_with('|')) {
assert!(!line.contains("| - "), "removed marker in: {line}");
assert!(!line.contains("| + "), "added marker in: {line}");
assert!(!line.contains("| ~ "), "modified marker in: {line}");
}
}
}
pub fn format_markdown(diffs: &[(String, DiffResult)], labels: &SideLabels) -> String {
let changed: Vec<_> = diffs.iter().filter(|(_, d)| !d.is_equal).collect();
if changed.is_empty() {
return "✅ No differences.\n".to_string();
}
let mut out = String::new();
out.push_str(&format!(
"## rigg diff — {} resource(s) differ\n\n",
changed.len()
));
for (name, result) in changed {
out.push_str(&format!(
"### `{}` — {} change(s)\n\n",
name,
result.changes.len()
));
out.push_str(&format!(
"| field | {} | {} |\n",
escape_md(&labels.new_side),
escape_md(&labels.old_side)
));
out.push_str("| --- | --- | --- |\n");
for change in &result.changes {
out.push_str(&format_change_markdown_row(change));
}
out.push('\n');
}
out
}
fn format_change_markdown_row(change: &Change) -> String {
if let Some(desc) = &change.description {
return format!("| {} | | |\n", escape_md(desc));
}
let (new_str, old_str) = match change.kind {
ChangeKind::Added => (
table_value(change.new_value.as_ref()),
"(absent)".to_string(),
),
ChangeKind::Removed => (
"(absent)".to_string(),
table_value(change.old_value.as_ref()),
),
ChangeKind::Modified => (
table_value(change.new_value.as_ref()),
table_value(change.old_value.as_ref()),
),
};
format!(
"| {} | {} | {} |\n",
escape_md(&change.path),
escape_md(&new_str),
escape_md(&old_str)
)
}
fn escape_md(s: &str) -> String {
s.replace('|', "\\|")
}
#[cfg(test)]
mod markdown_tests {
use super::*;
use serde_json::json;
fn labels() -> SideLabels {
SideLabels {
new_side: "local".to_string(),
old_side: "Azure (dev)".to_string(),
}
}
#[test]
fn markdown_report_renders_table() {
let d = crate::semantic::diff(
&json!({"name": "i", "a": 1}),
&json!({"name": "i", "a": 2, "b": true}),
"name",
);
let md = format_markdown(&[("indexes/i".to_string(), d)], &labels());
assert!(md.contains("### `indexes/i`"));
assert!(md.contains("| field | local | Azure (dev) |"));
assert!(md.contains("| b | true | (absent) |"));
assert!(md.contains("| a | 2 | 1 |"));
assert!(!md.contains(" was "));
}
#[test]
fn markdown_report_clean() {
let d = crate::semantic::diff(&json!({"a": 1}), &json!({"a": 1}), "name");
assert_eq!(
format_markdown(&[("x".into(), d)], &labels()),
"✅ No differences.\n"
);
}
#[test]
fn markdown_is_a_table_with_side_columns() {
let result = crate::semantic::diff(
&json!({"name": "a", "model": "x"}),
&json!({"name": "a", "model": "y"}),
"name",
);
let labels = SideLabels {
new_side: "local".into(),
old_side: "Azure (dev)".into(),
};
let out = format_markdown(&[("p/agents/a".to_string(), result)], &labels);
assert!(
out.contains("| field |") || out.contains("| Field |"),
"{out}"
);
assert!(
out.contains("| local |") || out.contains("local |"),
"{out}"
);
assert!(!out.contains(" was "), "{out}");
}
#[test]
fn long_multibyte_string_preview_does_not_panic() {
let long = "å".repeat(600);
let out = format_value_preview(Some(&Value::String(long)));
assert!(out.ends_with("(600 chars)"), "{out}");
assert!(out.contains("..."));
}
}