buswatch_tui/source/
file.rs

1//! File-based data source.
2//!
3//! Polls a JSON file for monitor snapshots.
4
5use std::fs;
6use std::path::{Path, PathBuf};
7use std::time::SystemTime;
8
9use super::{DataSource, Snapshot};
10
11/// A data source that reads monitor snapshots from a JSON file.
12///
13/// This is the traditional mode of operation where caryatid's Monitor
14/// writes snapshots to a file, and this source polls that file.
15///
16/// The source tracks the file's modification time and only returns
17/// new data when the file has been updated.
18#[derive(Debug)]
19pub struct FileSource {
20    path: PathBuf,
21    description: String,
22    last_error: Option<String>,
23    last_modified: Option<SystemTime>,
24    /// Cached snapshot to return on first poll
25    cached_snapshot: Option<Snapshot>,
26}
27
28impl FileSource {
29    /// Create a new file source for the given path.
30    pub fn new<P: AsRef<Path>>(path: P) -> Self {
31        let path = path.as_ref().to_path_buf();
32        let description = format!("file: {}", path.display());
33        Self {
34            path,
35            description,
36            last_error: None,
37            last_modified: None,
38            cached_snapshot: None,
39        }
40    }
41
42    /// Returns the path being monitored.
43    pub fn path(&self) -> &Path {
44        &self.path
45    }
46
47    /// Get the file's modification time.
48    fn get_modified_time(&self) -> Option<SystemTime> {
49        fs::metadata(&self.path).ok()?.modified().ok()
50    }
51
52    /// Read and parse the file.
53    fn read_file(&mut self) -> Option<Snapshot> {
54        match fs::read_to_string(&self.path) {
55            Ok(content) => match serde_json::from_str(&content) {
56                Ok(snapshot) => {
57                    self.last_error = None;
58                    Some(snapshot)
59                }
60                Err(e) => {
61                    self.last_error = Some(format!("Parse error: {}", e));
62                    None
63                }
64            },
65            Err(e) => {
66                self.last_error = Some(format!("Read error: {}", e));
67                None
68            }
69        }
70    }
71}
72
73impl DataSource for FileSource {
74    fn poll(&mut self) -> Option<Snapshot> {
75        let current_modified = self.get_modified_time();
76
77        // Check if file has been modified since last read
78        let file_changed = match (&self.last_modified, &current_modified) {
79            (None, _) => true,        // First poll, always read
80            (Some(_), None) => false, // File disappeared, don't update
81            (Some(last), Some(current)) => current > last,
82        };
83
84        if file_changed {
85            if let Some(snapshot) = self.read_file() {
86                self.last_modified = current_modified;
87                self.cached_snapshot = Some(snapshot.clone());
88                return Some(snapshot);
89            }
90        }
91
92        None
93    }
94
95    fn description(&self) -> &str {
96        &self.description
97    }
98
99    fn error(&self) -> Option<&str> {
100        self.last_error.as_deref()
101    }
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107    use std::io::{Seek, Write};
108    use tempfile::NamedTempFile;
109
110    fn sample_json() -> &'static str {
111        r#"{
112            "version": { "major": 1, "minor": 0 },
113            "timestamp_ms": 1703160000000,
114            "modules": {
115                "TestModule": {
116                    "reads": {
117                        "input": { "count": 100, "backlog": 5 }
118                    },
119                    "writes": {
120                        "output": { "count": 50 }
121                    }
122                }
123            }
124        }"#
125    }
126
127    #[test]
128    fn test_file_source_new() {
129        let source = FileSource::new("/tmp/test.json");
130        assert_eq!(source.path(), Path::new("/tmp/test.json"));
131        assert_eq!(source.description(), "file: /tmp/test.json");
132        assert!(source.error().is_none());
133    }
134
135    #[test]
136    fn test_file_source_poll_reads_file() {
137        let mut file = NamedTempFile::new().unwrap();
138        writeln!(file, "{}", sample_json()).unwrap();
139
140        let mut source = FileSource::new(file.path());
141
142        // First poll should return data
143        let snapshot = source.poll();
144        assert!(snapshot.is_some());
145        let snapshot = snapshot.unwrap();
146        assert!(snapshot.modules.contains_key("TestModule"));
147
148        // Second poll without file change should return None
149        let snapshot2 = source.poll();
150        assert!(snapshot2.is_none());
151    }
152
153    #[test]
154    fn test_file_source_detects_changes() {
155        let mut file = NamedTempFile::new().unwrap();
156        writeln!(file, "{}", sample_json()).unwrap();
157
158        let mut source = FileSource::new(file.path());
159
160        // First poll
161        let _ = source.poll();
162
163        // Modify the file (need to wait a bit for mtime to change)
164        std::thread::sleep(std::time::Duration::from_millis(10));
165        file.rewind().unwrap();
166        writeln!(
167            file,
168            r#"{{
169            "version": {{ "major": 1, "minor": 0 }},
170            "timestamp_ms": 1703160000000,
171            "modules": {{
172                "ModifiedModule": {{
173                    "reads": {{}},
174                    "writes": {{}}
175                }}
176            }}
177        }}"#
178        )
179        .unwrap();
180        file.flush().unwrap();
181
182        // Force mtime update by touching the file
183        let _ = std::fs::File::open(file.path());
184
185        // Poll again - should detect change
186        // Note: This test may be flaky on some filesystems with low mtime resolution
187        let snapshot = source.poll();
188        if let Some(s) = snapshot {
189            assert!(s.modules.contains_key("ModifiedModule"));
190        }
191    }
192
193    #[test]
194    fn test_file_source_missing_file() {
195        let mut source = FileSource::new("/nonexistent/path/monitor.json");
196
197        let snapshot = source.poll();
198        assert!(snapshot.is_none());
199        assert!(source.error().is_some());
200        assert!(source.error().unwrap().contains("Read error"));
201    }
202
203    #[test]
204    fn test_file_source_invalid_json() {
205        let mut file = NamedTempFile::new().unwrap();
206        writeln!(file, "not valid json").unwrap();
207
208        let mut source = FileSource::new(file.path());
209
210        let snapshot = source.poll();
211        assert!(snapshot.is_none());
212        assert!(source.error().is_some());
213        assert!(source.error().unwrap().contains("Parse error"));
214    }
215}