1use std::fs;
8use std::path::Path;
9
10use serde_json::{json, Value};
11
12use crate::annotations::read_annotations;
13use crate::storage::{
14 now_ms, read_segment_events, read_segment_first_line, read_segment_last_line, read_segment_text,
15 segment_id_from_filename, segment_path,
16};
17
18pub const BUNDLE_FORMAT: &str = "prism-session";
20pub const BUNDLE_VERSION: i64 = 1;
21
22pub fn validate_segment_id(id: &str) -> bool {
25 let Some((label, n)) = id.split_once('#') else {
26 return false;
27 };
28 if label.is_empty() || n.is_empty() {
29 return false;
30 }
31 if !label
32 .bytes()
33 .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
34 {
35 return false;
36 }
37 n.bytes().all(|b| b.is_ascii_digit())
38}
39
40pub fn validate_session_id(id: &str) -> bool {
44 !id.is_empty() && id.bytes().all(|b| b.is_ascii_digit())
45}
46
47pub fn unique_id(root: &Path) -> String {
49 let mut id = now_ms();
50 loop {
51 let s = id.to_string();
52 if !root.join(&s).exists() {
53 return s;
54 }
55 id += 1;
56 }
57}
58
59pub fn read_session(dir: &Path) -> Result<Value, String> {
63 let session = serde_json::from_str::<Value>(
64 &fs::read_to_string(dir.join("session.json")).map_err(|e| e.to_string())?,
65 )
66 .map_err(|e| e.to_string())?;
67
68 let windows: Vec<Value> = fs::read_to_string(dir.join("windows.jsonl"))
70 .ok()
71 .map(|s| s.lines().filter_map(|l| serde_json::from_str(l).ok()).collect())
72 .unwrap_or_default();
73
74 let segments = read_segments_dir(dir);
75 let annotations = read_annotations(dir);
76 Ok(json!({ "session": session, "windows": windows, "segments": segments, "annotations": annotations }))
77}
78
79pub fn list_sessions(root: &Path) -> Vec<Value> {
81 let mut out = Vec::new();
82 if let Ok(entries) = fs::read_dir(root) {
83 for e in entries.flatten() {
84 if let Ok(meta) = fs::read_to_string(e.path().join("session.json")) {
85 if let Ok(v) = serde_json::from_str::<Value>(&meta) {
86 out.push(v);
87 }
88 }
89 }
90 }
91 out.sort_by(|a, b| b["startedAt"].as_i64().cmp(&a["startedAt"].as_i64()));
92 out
93}
94
95fn read_segments_dir(dir: &Path) -> Value {
97 let mut segments = serde_json::Map::new();
98 let seg_dir = dir.join("segments");
99 if let Ok(entries) = fs::read_dir(&seg_dir) {
100 for e in entries.flatten() {
101 let path = e.path();
102 let name = path.file_name().unwrap().to_string_lossy().to_string();
103 let seg_id = segment_id_from_filename(&name).to_string();
104 let events = read_segment_events(&path);
105 segments.insert(seg_id, Value::Array(events));
106 }
107 }
108 Value::Object(segments)
109}
110
111fn json_balanced(line: &str) -> bool {
117 let mut depth: i32 = 0;
118 let mut in_str = false;
119 let mut escaped = false;
120 for b in line.bytes() {
121 if in_str {
122 if escaped {
123 escaped = false;
124 } else if b == b'\\' {
125 escaped = true;
126 } else if b == b'"' {
127 in_str = false;
128 }
129 continue;
130 }
131 match b {
132 b'"' => in_str = true,
133 b'{' | b'[' => depth += 1,
134 b'}' | b']' => {
135 depth -= 1;
136 if depth < 0 {
137 return false;
138 }
139 }
140 _ => {}
141 }
142 }
143 depth == 0 && !in_str
144}
145
146fn segment_index(dir: &Path) -> Vec<Value> {
152 let mut out = Vec::new();
153 let Ok(entries) = fs::read_dir(dir.join("segments")) else {
154 return out;
155 };
156 for e in entries.flatten() {
157 let path = e.path();
158 let name = path.file_name().unwrap().to_string_lossy().to_string();
159 let seg_id = segment_id_from_filename(&name).to_string();
160 let bytes = e.metadata().map(|m| m.len()).unwrap_or(0);
161 let mut width = Value::Null;
162 let mut height = Value::Null;
163 let mut first_ts = Value::Null;
164 if let Some(line) = read_segment_first_line(&path) {
165 if let Ok(v) = serde_json::from_str::<Value>(&line) {
166 if let Some(w) = v["data"]["width"].as_u64() {
167 width = json!(w);
168 }
169 if let Some(h) = v["data"]["height"].as_u64() {
170 height = json!(h);
171 }
172 if let Some(t) = v["timestamp"].as_i64() {
173 first_ts = json!(t);
174 }
175 }
176 }
177 let mut last_ts = Value::Null;
180 if let Some(line) = read_segment_last_line(&path) {
181 if let Ok(v) = serde_json::from_str::<Value>(&line) {
182 if let Some(t) = v["timestamp"].as_i64() {
183 last_ts = json!(t);
184 }
185 }
186 }
187 out.push(json!({
188 "segmentId": seg_id,
189 "bytes": bytes,
190 "width": width,
191 "height": height,
192 "firstTs": first_ts,
193 "lastTs": last_ts,
194 }));
195 }
196 out.sort_by(|a, b| a["segmentId"].as_str().cmp(&b["segmentId"].as_str()));
197 out
198}
199
200pub fn read_session_meta(dir: &Path) -> Result<Value, String> {
203 let session = serde_json::from_str::<Value>(
204 &fs::read_to_string(dir.join("session.json")).map_err(|e| e.to_string())?,
205 )
206 .map_err(|e| e.to_string())?;
207 let windows: Vec<Value> = fs::read_to_string(dir.join("windows.jsonl"))
210 .ok()
211 .map(|s| s.lines().filter_map(|l| serde_json::from_str(l).ok()).collect())
212 .unwrap_or_default();
213 let annotations = read_annotations(dir);
214 Ok(json!({
215 "session": session,
216 "windows": windows,
217 "annotations": annotations,
218 "segments": segment_index(dir),
219 }))
220}
221
222pub fn read_segment_json(dir: &Path, segment_id: &str) -> Result<String, String> {
229 if !validate_segment_id(segment_id) {
230 return Err(format!("invalid segment id: {segment_id}"));
231 }
232 let path = segment_path(dir, segment_id)
233 .ok_or_else(|| format!("segment not found: {segment_id}"))?;
234 let raw = read_segment_text(&path).ok_or_else(|| format!("读取失败:{}", path.display()))?;
235 let mut out = String::with_capacity(raw.len() + 2);
236 out.push('[');
237 let mut first = true;
238 for line in raw.lines() {
239 let l = line.trim();
240 if !l.starts_with('{') || !l.ends_with('}') || !json_balanced(l) {
241 continue;
242 }
243 if !first {
244 out.push(',');
245 }
246 out.push_str(l);
247 first = false;
248 }
249 out.push(']');
250 Ok(out)
251}
252
253pub fn read_session_signals_json(dir: &Path) -> Result<String, String> {
260 let seg_dir = dir.join("segments");
261 let mut files: Vec<std::path::PathBuf> = Vec::new();
262 if let Ok(entries) = fs::read_dir(&seg_dir) {
263 for e in entries.flatten() {
264 let p = e.path();
265 if p.is_file() {
266 files.push(p);
267 }
268 }
269 }
270 files.sort();
271 let mut out = String::new();
272 out.push('[');
273 let mut first = true;
274 for path in files {
275 let Some(text) = read_segment_text(&path) else {
276 continue;
277 };
278 for line in text.lines() {
279 let l = line.trim();
280 if !l.starts_with('{')
281 || !l.ends_with('}')
282 || !l.contains("\"type\":6")
283 || !json_balanced(l)
284 {
285 continue;
286 }
287 if !first {
288 out.push(',');
289 }
290 out.push_str(l);
291 first = false;
292 }
293 }
294 out.push(']');
295 Ok(out)
296}
297
298pub fn build_export_bundle(dir: &Path) -> Result<Value, String> {
300 let session = serde_json::from_str::<Value>(
301 &fs::read_to_string(dir.join("session.json")).map_err(|e| e.to_string())?,
302 )
303 .map_err(|e| e.to_string())?;
304 let windows: Vec<Value> = fs::read_to_string(dir.join("windows.jsonl"))
305 .ok()
306 .map(|s| s.lines().filter_map(|l| serde_json::from_str(l).ok()).collect())
307 .unwrap_or_default();
308 let segments = read_segments_dir(dir);
309 let annotations = read_annotations(dir);
310 Ok(json!({
311 "format": BUNDLE_FORMAT,
312 "version": BUNDLE_VERSION,
313 "exportedAt": now_ms(),
314 "session": session,
315 "windows": windows,
316 "segments": segments,
317 "annotations": annotations,
318 }))
319}
320
321pub fn merge_session_meta(path: &Path, meta: &Value) -> Result<Value, String> {
323 let mut v = serde_json::from_str::<Value>(
324 &fs::read_to_string(path).map_err(|e| e.to_string())?,
325 )
326 .map_err(|e| e.to_string())?;
327 if let (Some(obj), Some(m)) = (v.as_object_mut(), meta.as_object()) {
328 for (k, val) in m {
329 if (val.is_string() && val.as_str() == Some("")) || val.is_null() {
331 obj.remove(k);
332 } else {
333 obj.insert(k.clone(), val.clone());
334 }
335 }
336 }
337 fs::write(path, v.to_string()).map_err(|e| e.to_string())?;
338 Ok(v)
339}
340
341pub fn write_import_bundle(dir: &Path, bundle: &Value, new_id: &str) -> Result<(), String> {
343 fs::create_dir_all(dir.join("segments")).map_err(|e| e.to_string())?;
344 let mut session = bundle["session"].clone();
346 if let Some(obj) = session.as_object_mut() {
347 obj.insert("id".into(), json!(new_id));
348 obj.insert("importedAt".into(), json!(now_ms()));
349 }
350 fs::write(dir.join("session.json"), session.to_string()).map_err(|e| e.to_string())?;
351 if let Some(wins) = bundle["windows"].as_array() {
353 let mut body = String::new();
354 for w in wins {
355 body.push_str(&w.to_string());
356 body.push('\n');
357 }
358 fs::write(dir.join("windows.jsonl"), body).map_err(|e| e.to_string())?;
359 }
360 if let Some(segs) = bundle["segments"].as_object() {
362 for (name, events) in segs {
363 if !validate_segment_id(name) {
364 return Err(format!("非法 segmentId(拒绝以防路径穿越):{name}"));
365 }
366 if let Some(arr) = events.as_array() {
367 let mut body = String::new();
368 for e in arr {
369 body.push_str(&e.to_string());
370 body.push('\n');
371 }
372 fs::write(dir.join("segments").join(format!("{name}.jsonl")), body)
373 .map_err(|e| e.to_string())?;
374 }
375 }
376 }
377 if let Some(annos) = bundle["annotations"].as_array() {
379 crate::annotations::write_annotations(dir, annos)?;
380 }
381 Ok(())
382}
383
384pub fn parse_bundle(content: &str) -> Result<Value, String> {
386 let v = serde_json::from_str::<Value>(content).map_err(|e| e.to_string())?;
387 if v["format"].as_str() != Some(BUNDLE_FORMAT) {
388 return Err(format!("不是有效的会话文件(缺少 format: {BUNDLE_FORMAT})"));
389 }
390 let version = v["version"].as_i64().unwrap_or(1);
391 if version > BUNDLE_VERSION {
392 return Err(format!(
393 "不支持的 bundle 版本:{version}(当前支持 ≤{BUNDLE_VERSION})"
394 ));
395 }
396 Ok(v)
397}
398
399pub fn import_bundle(root: &Path, bundle: &Value) -> Result<String, String> {
401 let new_id = unique_id(root);
402 let dir = root.join(&new_id);
403 let tmp = root.join(format!("{new_id}.tmp"));
404 if tmp.exists() {
406 let _ = fs::remove_dir_all(&tmp);
407 }
408 match write_import_bundle(&tmp, bundle, &new_id) {
409 Ok(()) => fs::rename(&tmp, &dir).map_err(|e| {
410 let _ = fs::remove_dir_all(&tmp);
411 e.to_string()
412 })?,
413 Err(e) => {
414 let _ = fs::remove_dir_all(&tmp);
415 return Err(e);
416 }
417 }
418 Ok(new_id)
419}
420
421pub fn import_bundle_content(root: &Path, content: &str) -> Result<String, String> {
424 let v = parse_bundle(content)?;
425 import_bundle(root, &v)
426}
427
428#[cfg(test)]
429mod tests {
430 use super::*;
431 use tempfile::tempdir;
432
433 fn write_jsonl(path: &Path, rows: &[Value]) {
434 let mut body = String::new();
435 for r in rows {
436 body.push_str(&r.to_string());
437 body.push('\n');
438 }
439 fs::write(path, body).unwrap();
440 }
441
442 #[test]
445 fn export_import_roundtrip() {
446 let root = tempdir().unwrap();
447 let src = root.path().join("session-001");
448 fs::create_dir_all(src.join("segments")).unwrap();
449 fs::write(
450 src.join("session.json"),
451 json!({"id":"session-001","source":"self","startedAt":1000,"endedAt":2000})
452 .to_string(),
453 )
454 .unwrap();
455 write_jsonl(
456 &src.join("windows.jsonl"),
457 &[
458 json!({"type":"shown","label":"main","segmentId":"main#0","t":1000}),
459 json!({"type":"hidden","label":"main","segmentId":"main#0","t":2000}),
460 ],
461 );
462 write_jsonl(
463 &src.join("segments/main#0.jsonl"),
464 &[
465 json!({"type":2,"timestamp":1000}),
466 json!({"type":6,"timestamp":1100,"data":{"plugin":"console"}}),
467 ],
468 );
469 crate::annotations::write_annotations(
470 &src,
471 &[json!({"id":"a1","t":500,"text":"note here","author":"local","createdAt":1200})],
472 )
473 .unwrap();
474
475 let bundle = build_export_bundle(&src).unwrap();
477 assert_eq!(bundle["format"], "prism-session");
478 assert_eq!(bundle["session"]["id"], "session-001");
479 assert_eq!(bundle["session"]["source"], "self");
480 assert_eq!(bundle["windows"].as_array().unwrap().len(), 2);
481 assert_eq!(bundle["segments"]["main#0"].as_array().unwrap().len(), 2);
482 assert_eq!(bundle["annotations"].as_array().unwrap().len(), 1);
483 assert_eq!(bundle["annotations"][0]["text"], "note here");
484
485 let dst = root.path().join("session-002");
487 write_import_bundle(&dst, &bundle, "session-002").unwrap();
488
489 let session: Value =
491 serde_json::from_str(&fs::read_to_string(dst.join("session.json")).unwrap()).unwrap();
492 assert_eq!(session["id"], "session-002");
493 assert_eq!(session["source"], "self");
494 assert_eq!(session["startedAt"], 1000);
495 assert_eq!(session["endedAt"], 2000);
496 assert!(session["importedAt"].as_i64().unwrap() > 0);
497
498 assert_eq!(
500 fs::read_to_string(dst.join("windows.jsonl"))
501 .unwrap()
502 .lines()
503 .count(),
504 2
505 );
506 assert_eq!(
507 fs::read_to_string(dst.join("segments/main#0.jsonl"))
508 .unwrap()
509 .lines()
510 .count(),
511 2
512 );
513 let annos = read_annotations(&dst);
514 assert_eq!(annos.len(), 1);
515 assert_eq!(annos[0]["text"], "note here");
516 assert_eq!(annos[0]["author"], "local");
517 }
518
519 #[test]
521 fn merge_session_meta_overrides_adds_and_clears() {
522 let root = tempdir().unwrap();
523 let path = root.path().join("session.json");
524 fs::write(
525 &path,
526 json!({"id":"s1","startedAt":100,"name":"old"}).to_string(),
527 )
528 .unwrap();
529
530 let v = merge_session_meta(
532 &path,
533 &json!({"name":"new name","note":"a note","tags":["bug","login"]}),
534 )
535 .unwrap();
536 assert_eq!(v["name"], "new name");
537 assert_eq!(v["note"], "a note");
538 assert_eq!(v["tags"].as_array().unwrap().len(), 2);
539 assert_eq!(v["startedAt"], 100); let v2 = merge_session_meta(&path, &json!({"name":""})).unwrap();
543 assert!(v2.get("name").is_none());
544 assert!(v2.get("note").is_some()); }
546
547 #[test]
549 fn segment_id_validation() {
550 assert!(validate_segment_id("main#0"));
551 assert!(validate_segment_id("web#1"));
552 assert!(validate_segment_id("settings#12"));
553 assert!(validate_segment_id("a-b_c#9"));
554 assert!(!validate_segment_id("../etc#0"));
556 assert!(!validate_segment_id("a/..#1"));
557 assert!(!validate_segment_id("a\\b#1"));
558 assert!(!validate_segment_id("a#b")); assert!(!validate_segment_id("#1")); assert!(!validate_segment_id("a#")); assert!(!validate_segment_id("a")); }
564
565 #[test]
567 fn session_id_validation() {
568 assert!(validate_session_id("1750000000000"));
569 assert!(validate_session_id("0"));
570 assert!(validate_session_id("123456789"));
571 assert!(!validate_session_id("../1"));
573 assert!(!validate_session_id("1/2"));
574 assert!(!validate_session_id(".."));
575 assert!(!validate_session_id("abc"));
577 assert!(!validate_session_id("1a"));
578 assert!(!validate_session_id(""));
579 assert!(!validate_session_id("1-2"));
580 }
581
582 #[test]
584 fn import_rejects_path_traversal() {
585 let root = tempdir().unwrap();
586 let dir = root.path().join("dst");
587 let bundle = json!({
588 "format": BUNDLE_FORMAT,
589 "version": BUNDLE_VERSION,
590 "session": {"id":"x","startedAt":1},
591 "windows": [],
592 "segments": { "../evil#0": [{"type":2,"timestamp":1}] },
593 "annotations": [],
594 });
595 let err = write_import_bundle(&dir, &bundle, "dst").unwrap_err();
596 assert!(err.contains("路径穿越"), "got: {err}");
597 assert!(
599 !dir.join("evil#0.jsonl").exists(),
600 "恶意 segment 不得落盘到段目录之外"
601 );
602 }
603
604 #[test]
606 fn parse_bundle_validates() {
607 let ok = json!({
608 "format": BUNDLE_FORMAT, "version": 1,
609 "session": {"id":"x","startedAt":1}, "windows": [], "segments": {}, "annotations": [],
610 })
611 .to_string();
612 assert!(parse_bundle(&ok).is_ok());
613
614 let no_format = json!({"version": 1, "session": {}}).to_string();
615 assert!(parse_bundle(&no_format).is_err());
616
617 let too_new = json!({"format": BUNDLE_FORMAT, "version": 99, "session": {}}).to_string();
618 let err = parse_bundle(&too_new).unwrap_err();
619 assert!(err.contains("版本"));
620 }
621
622 #[test]
624 fn import_bundle_content_roundtrip() {
625 let root = tempdir().unwrap();
626 let bundle = json!({
627 "format": BUNDLE_FORMAT, "version": 1,
628 "session": {"id":"orig","source":"web","startedAt":100},
629 "windows": [json!({"type":"shown","label":"web","segmentId":"web#1","t":100})],
630 "segments": {"web#1": [json!({"type":2,"timestamp":100})]},
631 "annotations": [],
632 })
633 .to_string();
634 let new_id = import_bundle_content(root.path(), &bundle).unwrap();
635 let dir = root.path().join(&new_id);
636 assert!(dir.join("session.json").exists());
637 let session: Value =
638 serde_json::from_str(&fs::read_to_string(dir.join("session.json")).unwrap()).unwrap();
639 assert_eq!(session["id"], new_id);
640 assert_eq!(session["source"], "web");
641 }
642
643 #[test]
645 fn read_session_with_gzip_segments() {
646 let root = tempdir().unwrap();
647 let dir = root.path().join("s1");
648 fs::create_dir_all(dir.join("segments")).unwrap();
649 fs::write(
650 &dir.join("session.json"),
651 json!({"id":"s1","startedAt":1}).to_string(),
652 )
653 .unwrap();
654 crate::storage::append_events_file(
656 &dir,
657 "plain#0",
658 &[json!({"type":2,"timestamp":1})],
659 )
660 .unwrap();
661 crate::storage::append_events_file_with(
662 &dir,
663 "gz#0",
664 &[json!({"type":2,"timestamp":2}), json!({"type":6,"timestamp":3})],
665 &crate::storage::WriteOpts::gzip(),
666 )
667 .unwrap();
668
669 let data = read_session(&dir).unwrap();
670 let segs = data["segments"].as_object().unwrap();
671 assert_eq!(segs.len(), 2);
672 assert_eq!(segs["plain#0"].as_array().unwrap().len(), 1);
673 assert_eq!(segs["gz#0"].as_array().unwrap().len(), 2);
674 }
675
676 #[test]
678 fn session_meta_index_reads_first_line() {
679 let root = tempdir().unwrap();
680 let dir = root.path().join("s1");
681 fs::create_dir_all(dir.join("segments")).unwrap();
682 fs::write(
683 &dir.join("session.json"),
684 json!({"id":"s1","startedAt":1}).to_string(),
685 )
686 .unwrap();
687 crate::storage::append_events_file(
688 &dir,
689 "main#0",
690 &[
691 json!({"data":{"width":1300,"height":800},"timestamp":1,"type":4}),
692 json!({"type":2,"timestamp":2}),
693 ],
694 )
695 .unwrap();
696
697 let meta = read_session_meta(&dir).unwrap();
698 assert_eq!(meta["session"]["id"], "s1");
699 let idx = meta["segments"].as_array().unwrap();
700 assert_eq!(idx.len(), 1);
701 assert_eq!(idx[0]["segmentId"], "main#0");
702 assert_eq!(idx[0]["width"], 1300);
703 assert_eq!(idx[0]["height"], 800);
704 assert_eq!(idx[0]["firstTs"], 1, "首行 Meta 的 timestamp");
705 assert_eq!(idx[0]["lastTs"], 2, "尾行事件的 timestamp(时长兜底)");
706 assert!(idx[0]["bytes"].as_u64().unwrap() > 0);
707 assert!(idx[0].get("events").is_none(), "段索引不含事件正文");
708 }
709
710 #[test]
712 fn segment_last_line_handles_trailing_newline() {
713 let root = tempdir().unwrap();
714 let dir = root.path().join("s1");
715 fs::create_dir_all(dir.join("segments")).unwrap();
716 crate::storage::append_events_file(
717 &dir,
718 "main#0",
719 &[json!({"type":4,"timestamp":11}), json!({"type":2,"timestamp":22})],
720 )
721 .unwrap();
722 let last = crate::storage::read_segment_last_line(
723 &dir.join("segments").join("main#0.jsonl"),
724 )
725 .unwrap();
726 assert_eq!(serde_json::from_str::<Value>(&last).unwrap()["timestamp"], 22);
727 }
728
729 #[test]
731 fn session_meta_index_covers_gzip_segments() {
732 let root = tempdir().unwrap();
733 let dir = root.path().join("s1");
734 fs::create_dir_all(dir.join("segments")).unwrap();
735 fs::write(
736 &dir.join("session.json"),
737 json!({"id":"s1","startedAt":1}).to_string(),
738 )
739 .unwrap();
740 crate::storage::append_events_file_with(
741 &dir,
742 "gz#0",
743 &[json!({"type":4,"timestamp":7}), json!({"type":2,"timestamp":9})],
744 &crate::storage::WriteOpts::gzip(),
745 )
746 .unwrap();
747
748 let idx = read_session_meta(&dir).unwrap()["segments"].clone();
749 assert_eq!(idx[0]["firstTs"], 7);
750 assert_eq!(idx[0]["lastTs"], 9);
751 }
752
753 #[test]
755 fn session_meta_index_tolerates_missing_meta() {
756 let root = tempdir().unwrap();
757 let dir = root.path().join("s1");
758 fs::create_dir_all(dir.join("segments")).unwrap();
759 fs::write(
760 &dir.join("session.json"),
761 json!({"id":"s1","startedAt":1}).to_string(),
762 )
763 .unwrap();
764 crate::storage::append_events_file(&dir, "main#0", &[json!({"type":2,"timestamp":2})])
765 .unwrap();
766
767 let idx = read_session_meta(&dir).unwrap()["segments"].clone();
768 assert_eq!(idx[0]["width"], Value::Null);
769 assert_eq!(idx[0]["height"], Value::Null);
770 }
771
772 #[test]
774 fn read_segment_json_roundtrips_plain_and_gzip() {
775 let root = tempdir().unwrap();
776 let dir = root.path().join("s1");
777 fs::create_dir_all(dir.join("segments")).unwrap();
778 let rows = [
779 json!({"data":{"width":800,"height":600},"timestamp":1,"type":4}),
780 json!({"type":3,"timestamp":2}),
781 ];
782 crate::storage::append_events_file(&dir, "main#0", &rows).unwrap();
783 crate::storage::append_events_file_with(
784 &dir,
785 "gz#0",
786 &rows,
787 &crate::storage::WriteOpts::gzip(),
788 )
789 .unwrap();
790
791 for seg in ["main#0", "gz#0"] {
792 let txt = read_segment_json(&dir, seg).unwrap();
793 let arr: Vec<Value> = serde_json::from_str(&txt).unwrap();
794 assert_eq!(arr.len(), 2, "{seg} 直拼结果应可被 JSON 解析且条数一致");
795 assert_eq!(arr[0]["data"]["width"], 800);
796 }
797 }
798
799 #[test]
801 fn read_segment_json_rejects_bad_id_and_missing() {
802 let root = tempdir().unwrap();
803 let dir = root.path().join("s1");
804 fs::create_dir_all(dir.join("segments")).unwrap();
805 assert!(read_segment_json(&dir, "../etc/passwd").is_err());
806 assert!(read_segment_json(&dir, "nope#9").is_err());
807 }
808
809 #[test]
811 fn session_signals_returns_only_plugin_events() {
812 let root = tempdir().unwrap();
813 let dir = root.path().join("s1");
814 fs::create_dir_all(dir.join("segments")).unwrap();
815 crate::storage::append_events_file(
816 &dir,
817 "main#0",
818 &[
819 json!({"data":{"width":800,"height":600},"timestamp":1,"type":4}),
820 json!({"data":{"plugin":"console","payload":{"level":"log","args":["hi"]}},
821 "timestamp":2,"type":6}),
822 json!({"data":{"source":0,"adds":[]},"timestamp":3,"type":3}),
823 ],
824 )
825 .unwrap();
826 crate::storage::append_events_file_with(
827 &dir,
828 "gz#0",
829 &[
830 json!({"data":{"plugin":"error","payload":{"message":"boom","kind":"error"}},
831 "timestamp":4,"type":6}),
832 json!({"data":{"source":1,"positions":[]},"timestamp":5,"type":3}),
833 ],
834 &crate::storage::WriteOpts::gzip(),
835 )
836 .unwrap();
837
838 let txt = read_session_signals_json(&dir).unwrap();
839 let arr: Vec<Value> = serde_json::from_str(&txt).unwrap();
840 assert_eq!(arr.len(), 2, "两段各一条 type:6");
841 assert!(arr.iter().all(|e| e["type"] == 6));
842 let plugins: Vec<&str> = arr
843 .iter()
844 .map(|e| e["data"]["plugin"].as_str().unwrap())
845 .collect();
846 assert!(plugins.contains(&"console") && plugins.contains(&"error"));
847 }
848
849 #[test]
851 fn json_balanced_tolerates_braces_in_strings() {
852 assert!(json_balanced(r#"{"a":"{ not a brace","b":1}"#));
853 assert!(json_balanced(r#"{"a":"}","b":[1,2]}"#));
854 assert!(json_balanced(r#"{"a":"esc \" quote {","b":1}"#));
855 assert!(!json_balanced(r#"{"a":{"b":1}"#), "缺右括号");
856 assert!(!json_balanced(r#"{"a":"unterminated}"#), "字符串未闭合");
857 assert!(!json_balanced(r#"{"a":1}}"#), "多余右括号");
858 }
859
860 #[test]
863 fn read_segment_json_skips_truncation_ending_with_brace() {
864 let root = tempdir().unwrap();
865 let dir = root.path().join("s1");
866 fs::create_dir_all(dir.join("segments")).unwrap();
867 fs::write(
868 dir.join("segments").join("main#0.jsonl"),
869 "{\"type\":4,\"timestamp\":1}\n{\"type\":6,\"timestamp\":2,\"data\":{\"plugin\":\"console\"}\n",
870 )
871 .unwrap();
872 let txt = read_segment_json(&dir, "main#0").unwrap();
873 let arr: Vec<Value> = serde_json::from_str(&txt).unwrap();
874 assert_eq!(arr.len(), 1, "括号不配平的残行被排除,其余仍可解析");
875 }
876
877 #[test]
880 fn session_windows_tolerates_bad_line() {
881 let root = tempdir().unwrap();
882 let dir = root.path().join("s1");
883 fs::create_dir_all(dir.join("segments")).unwrap();
884 fs::write(
885 &dir.join("session.json"),
886 json!({"id":"s1","startedAt":1}).to_string(),
887 )
888 .unwrap();
889 fs::write(
890 dir.join("windows.jsonl"),
891 "{not json}\n{\"type\":\"shown\",\"label\":\"main\",\"segmentId\":\"main#0\",\"t\":1}\n",
892 )
893 .unwrap();
894
895 for data in [read_session_meta(&dir).unwrap(), read_session(&dir).unwrap()] {
896 assert_eq!(data["windows"].as_array().unwrap().len(), 1);
897 }
898 }
899
900 #[test]
902 fn read_segment_json_skips_truncated_line() {
903 let root = tempdir().unwrap();
904 let dir = root.path().join("s1");
905 fs::create_dir_all(dir.join("segments")).unwrap();
906 fs::write(
907 dir.join("segments").join("main#0.jsonl"),
908 "{\"type\":4,\"timestamp\":1}\n{\"type\":2,\"timesta",
909 )
910 .unwrap();
911
912 let txt = read_segment_json(&dir, "main#0").unwrap();
913 let arr: Vec<Value> = serde_json::from_str(&txt).unwrap();
914 assert_eq!(arr.len(), 1, "残行被跳过,其余行可解析");
915 }
916}