Skip to main content

xbp_cli/utils/
process_monitor_json.rs

1use serde_json::Value;
2use std::fs;
3use std::path::Path;
4
5#[derive(Debug, Clone, PartialEq, Eq)]
6pub struct CursorProcessMonitorJsonFix {
7    pub sample_count: usize,
8}
9
10pub fn fix_cursor_process_monitor_json(
11    content: &str,
12) -> Result<(String, Option<CursorProcessMonitorJsonFix>), String> {
13    if let Ok(value) = serde_json::from_str::<Value>(content) {
14        if cooking_sample_count(&value).is_some() {
15            return Ok((content.to_string(), None));
16        }
17    }
18
19    let fixed = apply_cursor_process_monitor_json_fixes(content)?;
20    let value: Value = serde_json::from_str(&fixed)
21        .map_err(|error| format!("Fixed content is still invalid JSON: {error}"))?;
22
23    let sample_count = cooking_sample_count(&value)
24        .ok_or_else(|| "Fixed JSON is missing a `cooking` sample array".to_string())?;
25
26    Ok((fixed, Some(CursorProcessMonitorJsonFix { sample_count })))
27}
28
29pub fn fix_cursor_process_monitor_json_file(
30    path: &Path,
31) -> Result<Option<CursorProcessMonitorJsonFix>, String> {
32    let content = fs::read_to_string(path)
33        .map_err(|error| format!("Failed to read {}: {error}", path.display()))?;
34
35    let (fixed, report) = fix_cursor_process_monitor_json(&content)?;
36
37    if let Some(report) = report {
38        fs::write(path, fixed).map_err(|error| {
39            format!(
40                "Failed to write repaired process monitor JSON {}: {error}",
41                path.display()
42            )
43        })?;
44        return Ok(Some(report));
45    }
46
47    Ok(None)
48}
49
50fn apply_cursor_process_monitor_json_fixes(content: &str) -> Result<String, String> {
51    let mut content = normalize_start(content);
52    content = insert_sample_commas(&content);
53    content = normalize_end(&content);
54    Ok(content)
55}
56
57fn insert_sample_commas(content: &str) -> String {
58    const MARKER: &str = r#""origin":"local"}"#;
59    let mut out = String::with_capacity(content.len() + 32);
60    let mut rest = content;
61
62    while let Some(index) = rest.find(MARKER) {
63        out.push_str(&rest[..index + MARKER.len()]);
64        rest = &rest[index + MARKER.len()..];
65
66        let whitespace_len = rest.len() - rest.trim_start().len();
67        if rest[whitespace_len..].starts_with('{') {
68            out.push(',');
69        }
70
71        out.push_str(&rest[..whitespace_len]);
72        rest = &rest[whitespace_len..];
73    }
74
75    out.push_str(rest);
76    out
77}
78
79fn normalize_start(content: &str) -> String {
80    if let Some(rest) = content.strip_prefix("\"cooking\": {{") {
81        return format!("{{\"cooking\": [{{{rest}");
82    }
83
84    if let Some(rest) = content.strip_prefix("\"cooking\": {") {
85        return format!("{{\"cooking\": [{{{rest}");
86    }
87
88    if let Some(rest) = content.strip_prefix("{\"cooking\": [\"sampleStart\"") {
89        return format!("{{\"cooking\": [{{\"sampleStart\"{rest}");
90    }
91
92    content.to_string()
93}
94
95fn normalize_end(content: &str) -> String {
96    let trimmed_len = content.trim_end().len();
97    let suffix = content[trimmed_len..].to_string();
98    let trimmed = &content[..trimmed_len];
99
100    let normalized = if trimmed.ends_with("\"origin\":\"local\"}]}") {
101        trimmed.to_string()
102    } else if let Some(trimmed) = trimmed.strip_suffix("\"origin\":\"local\"]}") {
103        format!("{}{}", trimmed, "\"origin\":\"local\"}]}")
104    } else if trimmed.ends_with("}}") {
105        format!("{}]}}", &trimmed[..trimmed.len() - 1])
106    } else if trimmed.ends_with("\"origin\":\"local\"}") {
107        format!("{trimmed}]}}")
108    } else {
109        trimmed.to_string()
110    };
111
112    format!("{normalized}{suffix}")
113}
114
115fn cooking_sample_count(value: &Value) -> Option<usize> {
116    value
117        .get("cooking")
118        .and_then(Value::as_array)
119        .map(|samples| samples.len())
120}
121
122#[cfg(test)]
123mod tests {
124    use super::{
125        apply_cursor_process_monitor_json_fixes, fix_cursor_process_monitor_json,
126        fix_cursor_process_monitor_json_file,
127    };
128    use serde_json::Value;
129    use std::fs;
130    use std::path::PathBuf;
131
132    fn make_temp_path(name: &str) -> PathBuf {
133        let mut path = std::env::temp_dir();
134        path.push(format!(
135            "xbp-process-monitor-json-{}-{}",
136            name,
137            std::process::id()
138        ));
139        path
140    }
141
142    fn malformed_single_sample_file(start: &str, end: &str) -> String {
143        format!(
144            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"}}"#
145        )
146    }
147
148    fn malformed_multi_sample_file() -> String {
149        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()
150    }
151
152    #[test]
153    fn repairs_cursor_process_monitor_json_with_double_braces() {
154        let broken = malformed_single_sample_file("100", "200");
155        let (fixed, report) =
156            fix_cursor_process_monitor_json(&broken).expect("process monitor json should repair");
157
158        let report = report.expect("repair report should be returned");
159        assert_eq!(report.sample_count, 1);
160
161        let value: Value = serde_json::from_str(&fixed).expect("fixed json should parse");
162        let cooking = value
163            .get("cooking")
164            .and_then(Value::as_array)
165            .expect("cooking should be an array");
166        assert_eq!(cooking.len(), 1);
167        assert_eq!(
168            cooking[0].get("origin"),
169            Some(&Value::String("local".to_string()))
170        );
171    }
172
173    #[test]
174    fn inserts_commas_between_concatenated_samples() {
175        let broken = malformed_multi_sample_file();
176
177        let (fixed, report) =
178            fix_cursor_process_monitor_json(&broken).expect("multi-sample json should repair");
179        assert_eq!(report.expect("report").sample_count, 2);
180        assert!(fixed.contains(r#""origin":"local"},{"sampleStart":201"#));
181    }
182
183    #[test]
184    fn leaves_valid_process_monitor_json_unchanged() {
185        let valid = r#"{"cooking":[{"sampleStart":1,"sampleEnd":2,"rows":[],"origin":"local"}]}"#;
186        let (fixed, report) =
187            fix_cursor_process_monitor_json(valid).expect("valid json should parse");
188
189        assert!(report.is_none());
190        assert_eq!(fixed, valid);
191    }
192
193    #[test]
194    fn repairs_partially_fixed_start_and_end() {
195        let broken = r#"{"cooking": ["sampleStart":1,"sampleEnd":2,"rows":[],"origin":"local"]}"#;
196        let fixed = apply_cursor_process_monitor_json_fixes(broken)
197            .expect("partially fixed json should normalize");
198        assert_eq!(
199            fixed,
200            r#"{"cooking": [{"sampleStart":1,"sampleEnd":2,"rows":[],"origin":"local"}]}"#
201        );
202    }
203
204    #[test]
205    fn writes_repaired_json_to_disk() {
206        let path = make_temp_path("file-repair");
207        let broken = malformed_single_sample_file("100", "200");
208        fs::write(&path, broken).expect("broken json should be written");
209
210        let report = fix_cursor_process_monitor_json_file(&path)
211            .expect("file repair should succeed")
212            .expect("file should have been repaired");
213        assert_eq!(report.sample_count, 1);
214
215        let written = fs::read_to_string(&path).expect("repaired file should be readable");
216        serde_json::from_str::<Value>(&written).expect("repaired file should parse as json");
217
218        fs::remove_file(path).expect("temp file should be removed");
219    }
220}