use serde_json::Value;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TruncationRepairResult {
pub repaired: String,
pub changed: bool,
pub notes: Vec<String>,
pub fallback: bool,
}
pub fn repair_truncated_json(input: &str) -> TruncationRepairResult {
if input.trim().is_empty() {
let changed = input != "{}";
return TruncationRepairResult {
repaired: "{}".to_string(),
changed,
notes: if changed {
vec!["empty input → {}".to_string()]
} else {
Vec::new()
},
fallback: false,
};
}
if serde_json::from_str::<Value>(input).is_ok() {
return TruncationRepairResult {
repaired: input.to_string(),
changed: false,
notes: Vec::new(),
fallback: false,
};
}
let mut stack: Vec<char> = Vec::new();
let mut escaped = false;
let mut in_string = false;
let mut last_significant: Option<usize> = None;
let bytes = input.as_bytes();
let mut i = 0;
while i < bytes.len() {
let c = bytes[i] as char;
if !c.is_whitespace() {
last_significant = Some(i);
}
if escaped {
escaped = false;
i += 1;
continue;
}
if in_string {
if c == '\\' {
escaped = true;
i += 1;
continue;
}
if c == '"' {
in_string = false;
if matches!(stack.last(), Some('"')) {
stack.pop();
}
}
i += 1;
continue;
}
if c == '"' {
in_string = true;
stack.push('"');
} else if c == '{' || c == '[' {
stack.push(c);
} else if c == '}' || c == ']' {
if let Some(&top) = stack.last() {
let matches = (top == '{' && c == '}') || (top == '[' && c == ']');
if matches {
stack.pop();
}
}
}
i += 1;
}
let mut notes = Vec::new();
let cut = last_significant.map(|i| i + 1).unwrap_or(input.len());
let mut s = input[..cut].to_string();
if s.ends_with(',') {
s.pop();
notes.push("trimmed trailing comma".to_string());
}
if ends_with_dangling_key(&s) {
s.push_str(" null");
notes.push("filled dangling key with null".to_string());
}
if in_string {
s.push('"');
if matches!(stack.last(), Some('"')) {
stack.pop();
}
notes.push("closed unterminated string".to_string());
}
while let Some(top) = stack.pop() {
match top {
'{' => s.push('}'),
'[' => s.push(']'),
'"' => s.push('"'),
_ => {}
}
}
if serde_json::from_str::<Value>(&s).is_ok() {
return TruncationRepairResult {
repaired: s.clone(),
changed: s != input,
notes,
fallback: false,
};
}
const PREVIEW_CAP: usize = 500;
let preview = if input.len() <= PREVIEW_CAP {
input.to_string()
} else {
let mut cap = PREVIEW_CAP;
while !input.is_char_boundary(cap) && cap > 0 {
cap -= 1;
}
format!("{} …[+{} chars]", &input[..cap], input.len() - cap)
};
notes.push("fallback to {}".to_string());
notes.push(format!(
"unrecoverable truncation — original args preview: {}",
preview
));
TruncationRepairResult {
repaired: "{}".to_string(),
changed: true,
notes,
fallback: true,
}
}
fn ends_with_dangling_key(s: &str) -> bool {
let bytes = s.as_bytes();
let mut i = bytes.len();
while i > 0 && (bytes[i - 1] as char).is_whitespace() {
i -= 1;
}
if i == 0 || bytes[i - 1] != b':' {
return false;
}
i -= 1;
while i > 0 && (bytes[i - 1] as char).is_whitespace() {
i -= 1;
}
i > 0 && bytes[i - 1] == b'"'
}