use serde_json::Value;
use std::fs;
use std::path::Path;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CursorProcessMonitorJsonFix {
pub sample_count: usize,
}
pub fn fix_cursor_process_monitor_json(
content: &str,
) -> Result<(String, Option<CursorProcessMonitorJsonFix>), String> {
if let Ok(value) = serde_json::from_str::<Value>(content) {
if cooking_sample_count(&value).is_some() {
return Ok((content.to_string(), None));
}
}
let fixed = apply_cursor_process_monitor_json_fixes(content)?;
let value: Value = serde_json::from_str(&fixed)
.map_err(|error| format!("Fixed content is still invalid JSON: {error}"))?;
let sample_count = cooking_sample_count(&value)
.ok_or_else(|| "Fixed JSON is missing a `cooking` sample array".to_string())?;
Ok((fixed, Some(CursorProcessMonitorJsonFix { sample_count })))
}
pub fn fix_cursor_process_monitor_json_file(
path: &Path,
) -> Result<Option<CursorProcessMonitorJsonFix>, String> {
let content = fs::read_to_string(path)
.map_err(|error| format!("Failed to read {}: {error}", path.display()))?;
let (fixed, report) = fix_cursor_process_monitor_json(&content)?;
if let Some(report) = report {
fs::write(path, fixed).map_err(|error| {
format!(
"Failed to write repaired process monitor JSON {}: {error}",
path.display()
)
})?;
return Ok(Some(report));
}
Ok(None)
}
fn apply_cursor_process_monitor_json_fixes(content: &str) -> Result<String, String> {
let mut content = normalize_start(content);
content = insert_sample_commas(&content);
content = normalize_end(&content);
Ok(content)
}
fn insert_sample_commas(content: &str) -> String {
const MARKER: &str = r#""origin":"local"}"#;
let mut out = String::with_capacity(content.len() + 32);
let mut rest = content;
while let Some(index) = rest.find(MARKER) {
out.push_str(&rest[..index + MARKER.len()]);
rest = &rest[index + MARKER.len()..];
let whitespace_len = rest.len() - rest.trim_start().len();
if rest[whitespace_len..].starts_with('{') {
out.push(',');
}
out.push_str(&rest[..whitespace_len]);
rest = &rest[whitespace_len..];
}
out.push_str(rest);
out
}
fn normalize_start(content: &str) -> String {
if let Some(rest) = content.strip_prefix("\"cooking\": {{") {
return format!("{{\"cooking\": [{{{rest}");
}
if let Some(rest) = content.strip_prefix("\"cooking\": {") {
return format!("{{\"cooking\": [{{{rest}");
}
if let Some(rest) = content.strip_prefix("{\"cooking\": [\"sampleStart\"") {
return format!("{{\"cooking\": [{{\"sampleStart\"{rest}");
}
content.to_string()
}
fn normalize_end(content: &str) -> String {
let trimmed_len = content.trim_end().len();
let suffix = content[trimmed_len..].to_string();
let trimmed = &content[..trimmed_len];
let normalized = if trimmed.ends_with("\"origin\":\"local\"}]}") {
trimmed.to_string()
} else if trimmed.ends_with("\"origin\":\"local\"]}") {
format!(
"{}{}",
&trimmed[..trimmed.len() - "\"origin\":\"local\"]}".len()],
"\"origin\":\"local\"}]}"
)
} else if trimmed.ends_with("}}") {
format!("{}]}}", &trimmed[..trimmed.len() - 1])
} else if trimmed.ends_with("\"origin\":\"local\"}") {
format!("{trimmed}]}}")
} else {
trimmed.to_string()
};
format!("{normalized}{suffix}")
}
fn cooking_sample_count(value: &Value) -> Option<usize> {
value
.get("cooking")
.and_then(Value::as_array)
.map(|samples| samples.len())
}
#[cfg(test)]
mod tests {
use super::{
apply_cursor_process_monitor_json_fixes, fix_cursor_process_monitor_json,
fix_cursor_process_monitor_json_file,
};
use serde_json::Value;
use std::fs;
use std::path::PathBuf;
fn make_temp_path(name: &str) -> PathBuf {
let mut path = std::env::temp_dir();
path.push(format!(
"xbp-process-monitor-json-{}-{}",
name,
std::process::id()
));
path
}
fn malformed_single_sample_file(start: &str, end: &str) -> String {
format!(
r#""cooking": {{{{"sampleStart":{start},"sampleEnd":{end},"numSubsamples":1,"sessionId":"abc","rows":[{{"pid":1,"ppid":0,"processName":"Cursor.exe","extensionId":"","argv":[],"sampleCpuTimeMs":0,"sampleAvgMemMb":1.0,"samplePeakMemMb":1,"sessionPeakMemMb":1,"memoryDuringSamplePeakMb":1,"cpuDuringSamplePeakPct":0}}],"origin":"local"}}"#
)
}
fn malformed_multi_sample_file() -> String {
r#""cooking": {{"sampleStart":100,"sampleEnd":200,"numSubsamples":1,"sessionId":"abc","rows":[],"origin":"local"}{"sampleStart":201,"sampleEnd":300,"numSubsamples":1,"sessionId":"abc","rows":[],"origin":"local"}}"#.to_string()
}
#[test]
fn repairs_cursor_process_monitor_json_with_double_braces() {
let broken = malformed_single_sample_file("100", "200");
let (fixed, report) =
fix_cursor_process_monitor_json(&broken).expect("process monitor json should repair");
let report = report.expect("repair report should be returned");
assert_eq!(report.sample_count, 1);
let value: Value = serde_json::from_str(&fixed).expect("fixed json should parse");
let cooking = value
.get("cooking")
.and_then(Value::as_array)
.expect("cooking should be an array");
assert_eq!(cooking.len(), 1);
assert_eq!(
cooking[0].get("origin"),
Some(&Value::String("local".to_string()))
);
}
#[test]
fn inserts_commas_between_concatenated_samples() {
let broken = malformed_multi_sample_file();
let (fixed, report) =
fix_cursor_process_monitor_json(&broken).expect("multi-sample json should repair");
assert_eq!(report.expect("report").sample_count, 2);
assert!(fixed.contains(r#""origin":"local"},{"sampleStart":201"#));
}
#[test]
fn leaves_valid_process_monitor_json_unchanged() {
let valid = r#"{"cooking":[{"sampleStart":1,"sampleEnd":2,"rows":[],"origin":"local"}]}"#;
let (fixed, report) =
fix_cursor_process_monitor_json(valid).expect("valid json should parse");
assert!(report.is_none());
assert_eq!(fixed, valid);
}
#[test]
fn repairs_partially_fixed_start_and_end() {
let broken = r#"{"cooking": ["sampleStart":1,"sampleEnd":2,"rows":[],"origin":"local"]}"#;
let fixed = apply_cursor_process_monitor_json_fixes(broken)
.expect("partially fixed json should normalize");
assert_eq!(
fixed,
r#"{"cooking": [{"sampleStart":1,"sampleEnd":2,"rows":[],"origin":"local"}]}"#
);
}
#[test]
fn writes_repaired_json_to_disk() {
let path = make_temp_path("file-repair");
let broken = malformed_single_sample_file("100", "200");
fs::write(&path, broken).expect("broken json should be written");
let report = fix_cursor_process_monitor_json_file(&path)
.expect("file repair should succeed")
.expect("file should have been repaired");
assert_eq!(report.sample_count, 1);
let written = fs::read_to_string(&path).expect("repaired file should be readable");
serde_json::from_str::<Value>(&written).expect("repaired file should parse as json");
fs::remove_file(path).expect("temp file should be removed");
}
}