Skip to main content

nuviz_cli/data/
scenes.rs

1use serde::Deserialize;
2use std::collections::HashMap;
3use std::fs::File;
4use std::io::{BufRead, BufReader};
5use std::path::Path;
6
7/// A per-scene metrics record from scenes.jsonl.
8#[derive(Debug, Clone, Deserialize)]
9pub struct SceneRecord {
10    pub scene: String,
11    pub metrics: HashMap<String, f64>,
12    pub timestamp: f64,
13}
14
15/// Read all scene records from a scenes.jsonl file, skipping malformed lines.
16pub fn read_scenes(path: &Path) -> Vec<SceneRecord> {
17    let file = match File::open(path) {
18        Ok(f) => f,
19        Err(_) => return Vec::new(),
20    };
21
22    let reader = BufReader::new(file);
23    let mut records = Vec::new();
24
25    for line in reader.lines() {
26        let line = match line {
27            Ok(l) => l,
28            Err(_) => continue,
29        };
30
31        let trimmed = line.trim();
32        if trimmed.is_empty() {
33            continue;
34        }
35
36        match serde_json::from_str::<SceneRecord>(trimmed) {
37            Ok(record) => records.push(record),
38            Err(e) => {
39                eprintln!("[nuviz] Warning: skipping malformed scene line: {e}");
40            }
41        }
42    }
43
44    records
45}
46
47/// Group scene records by scene name, keeping the latest record per scene.
48pub fn scenes_by_name(records: &[SceneRecord]) -> HashMap<String, &SceneRecord> {
49    let mut map: HashMap<String, &SceneRecord> = HashMap::new();
50    for record in records {
51        map.entry(record.scene.clone())
52            .and_modify(|existing| {
53                if record.timestamp > existing.timestamp {
54                    *existing = record;
55                }
56            })
57            .or_insert(record);
58    }
59    map
60}
61
62#[cfg(test)]
63mod tests {
64    use super::*;
65    use std::io::Write;
66    use tempfile::NamedTempFile;
67
68    fn write_jsonl(lines: &[&str]) -> NamedTempFile {
69        let mut f = NamedTempFile::new().unwrap();
70        for line in lines {
71            writeln!(f, "{line}").unwrap();
72        }
73        f
74    }
75
76    #[test]
77    fn test_read_valid_scenes() {
78        let f = write_jsonl(&[
79            r#"{"scene":"garden","metrics":{"psnr":27.41,"ssim":0.945},"timestamp":1.0}"#,
80            r#"{"scene":"bicycle","metrics":{"psnr":25.12,"ssim":0.912},"timestamp":2.0}"#,
81        ]);
82        let records = read_scenes(f.path());
83        assert_eq!(records.len(), 2);
84        assert_eq!(records[0].scene, "garden");
85        assert!((records[0].metrics["psnr"] - 27.41).abs() < f64::EPSILON);
86    }
87
88    #[test]
89    fn test_skip_malformed() {
90        let f = write_jsonl(&[
91            r#"{"scene":"garden","metrics":{"psnr":27.0},"timestamp":1.0}"#,
92            "invalid json",
93            r#"{"scene":"stump","metrics":{"psnr":26.0},"timestamp":3.0}"#,
94        ]);
95        let records = read_scenes(f.path());
96        assert_eq!(records.len(), 2);
97    }
98
99    #[test]
100    fn test_missing_file() {
101        let records = read_scenes(Path::new("/nonexistent/scenes.jsonl"));
102        assert!(records.is_empty());
103    }
104
105    #[test]
106    fn test_scenes_by_name() {
107        let records = vec![
108            SceneRecord {
109                scene: "garden".into(),
110                metrics: HashMap::from([("psnr".into(), 25.0)]),
111                timestamp: 1.0,
112            },
113            SceneRecord {
114                scene: "garden".into(),
115                metrics: HashMap::from([("psnr".into(), 27.0)]),
116                timestamp: 2.0,
117            },
118            SceneRecord {
119                scene: "bicycle".into(),
120                metrics: HashMap::from([("psnr".into(), 24.0)]),
121                timestamp: 1.0,
122            },
123        ];
124        let by_name = scenes_by_name(&records);
125        assert_eq!(by_name.len(), 2);
126        // Should have the latest garden record
127        assert!((by_name["garden"].metrics["psnr"] - 27.0).abs() < f64::EPSILON);
128    }
129}