xbp_cli/utils/
process_monitor_json.rs1use 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 trimmed.ends_with("\"origin\":\"local\"]}") {
103 format!(
104 "{}{}",
105 &trimmed[..trimmed.len() - "\"origin\":\"local\"]}".len()],
106 "\"origin\":\"local\"}]}"
107 )
108 } else if trimmed.ends_with("}}") {
109 format!("{}]}}", &trimmed[..trimmed.len() - 1])
110 } else if trimmed.ends_with("\"origin\":\"local\"}") {
111 format!("{trimmed}]}}")
112 } else {
113 trimmed.to_string()
114 };
115
116 format!("{normalized}{suffix}")
117}
118
119fn cooking_sample_count(value: &Value) -> Option<usize> {
120 value
121 .get("cooking")
122 .and_then(Value::as_array)
123 .map(|samples| samples.len())
124}
125
126#[cfg(test)]
127mod tests {
128 use super::{
129 apply_cursor_process_monitor_json_fixes, fix_cursor_process_monitor_json,
130 fix_cursor_process_monitor_json_file,
131 };
132 use serde_json::Value;
133 use std::fs;
134 use std::path::PathBuf;
135
136 fn make_temp_path(name: &str) -> PathBuf {
137 let mut path = std::env::temp_dir();
138 path.push(format!(
139 "xbp-process-monitor-json-{}-{}",
140 name,
141 std::process::id()
142 ));
143 path
144 }
145
146 fn malformed_single_sample_file(start: &str, end: &str) -> String {
147 format!(
148 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"}}"#
149 )
150 }
151
152 fn malformed_multi_sample_file() -> String {
153 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()
154 }
155
156 #[test]
157 fn repairs_cursor_process_monitor_json_with_double_braces() {
158 let broken = malformed_single_sample_file("100", "200");
159 let (fixed, report) =
160 fix_cursor_process_monitor_json(&broken).expect("process monitor json should repair");
161
162 let report = report.expect("repair report should be returned");
163 assert_eq!(report.sample_count, 1);
164
165 let value: Value = serde_json::from_str(&fixed).expect("fixed json should parse");
166 let cooking = value
167 .get("cooking")
168 .and_then(Value::as_array)
169 .expect("cooking should be an array");
170 assert_eq!(cooking.len(), 1);
171 assert_eq!(
172 cooking[0].get("origin"),
173 Some(&Value::String("local".to_string()))
174 );
175 }
176
177 #[test]
178 fn inserts_commas_between_concatenated_samples() {
179 let broken = malformed_multi_sample_file();
180
181 let (fixed, report) =
182 fix_cursor_process_monitor_json(&broken).expect("multi-sample json should repair");
183 assert_eq!(report.expect("report").sample_count, 2);
184 assert!(fixed.contains(r#""origin":"local"},{"sampleStart":201"#));
185 }
186
187 #[test]
188 fn leaves_valid_process_monitor_json_unchanged() {
189 let valid = r#"{"cooking":[{"sampleStart":1,"sampleEnd":2,"rows":[],"origin":"local"}]}"#;
190 let (fixed, report) =
191 fix_cursor_process_monitor_json(valid).expect("valid json should parse");
192
193 assert!(report.is_none());
194 assert_eq!(fixed, valid);
195 }
196
197 #[test]
198 fn repairs_partially_fixed_start_and_end() {
199 let broken = r#"{"cooking": ["sampleStart":1,"sampleEnd":2,"rows":[],"origin":"local"]}"#;
200 let fixed = apply_cursor_process_monitor_json_fixes(broken)
201 .expect("partially fixed json should normalize");
202 assert_eq!(
203 fixed,
204 r#"{"cooking": [{"sampleStart":1,"sampleEnd":2,"rows":[],"origin":"local"}]}"#
205 );
206 }
207
208 #[test]
209 fn writes_repaired_json_to_disk() {
210 let path = make_temp_path("file-repair");
211 let broken = malformed_single_sample_file("100", "200");
212 fs::write(&path, broken).expect("broken json should be written");
213
214 let report = fix_cursor_process_monitor_json_file(&path)
215 .expect("file repair should succeed")
216 .expect("file should have been repaired");
217 assert_eq!(report.sample_count, 1);
218
219 let written = fs::read_to_string(&path).expect("repaired file should be readable");
220 serde_json::from_str::<Value>(&written).expect("repaired file should parse as json");
221
222 fs::remove_file(path).expect("temp file should be removed");
223 }
224}