1use std::collections::HashMap;
13use std::fs;
14use std::path::{Path, PathBuf};
15use std::time::SystemTime;
16
17use serde_json::Value;
18
19use crate::snapshot::SmartDevice;
20
21const NVME_DATA_UNIT_BYTES: u64 = 512_000;
23
24const EXIT_FATAL_MASK: u64 = 0b11;
27
28#[derive(Debug)]
30pub struct SmartReader {
31 dir: PathBuf,
32 cache: HashMap<PathBuf, (SystemTime, SmartDevice)>,
34}
35
36impl SmartReader {
37 pub fn new(dir: impl Into<PathBuf>) -> Self {
40 Self {
41 dir: dir.into(),
42 cache: HashMap::new(),
43 }
44 }
45
46 pub fn read(&mut self) -> Vec<SmartDevice> {
48 let mut paths: Vec<PathBuf> = match fs::read_dir(&self.dir) {
49 Ok(entries) => entries
50 .filter_map(|e| e.ok())
51 .map(|e| e.path())
52 .filter(|p| p.extension().is_some_and(|ext| ext == "json"))
53 .collect(),
54 Err(_) => Vec::new(),
55 };
56 paths.sort();
57
58 self.cache.retain(|path, _| paths.contains(path));
60
61 paths
62 .into_iter()
63 .filter_map(|path| {
64 let mtime = fs::metadata(&path).and_then(|m| m.modified()).ok()?;
65 if let Some((cached_mtime, device)) = self.cache.get(&path)
66 && *cached_mtime == mtime
67 {
68 return Some(device.clone());
69 }
70 let device = parse_file(&path);
71 self.cache.insert(path, (mtime, device.clone()));
72 Some(device)
73 })
74 .collect()
75 }
76}
77
78fn parse_file(path: &Path) -> SmartDevice {
81 let name = path
85 .file_stem()
86 .map(|s| s.to_string_lossy().into_owned())
87 .unwrap_or_default();
88 let parsed = fs::read_to_string(path)
89 .map_err(|e| e.to_string())
90 .and_then(|text| serde_json::from_str::<Value>(&text).map_err(|e| e.to_string()));
91 match parsed {
92 Ok(json) => parse_json(&json, &name),
93 Err(err) => SmartDevice {
94 device: name,
95 error: Some(format!("cannot read SMART result: {err}")),
96 ..Default::default()
97 },
98 }
99}
100
101pub fn parse_json(json: &Value, device: &str) -> SmartDevice {
106 let u64_at = |pointer: &str| json.pointer(pointer).and_then(Value::as_u64);
107 let u32_at = |pointer: &str| u64_at(pointer).and_then(|v| u32::try_from(v).ok());
108 let str_at = |pointer: &str| json.pointer(pointer).and_then(Value::as_str);
109
110 let device = if device.is_empty() {
111 str_at("/device/name")
112 .and_then(|name| name.rsplit('/').next())
113 .unwrap_or_default()
114 .to_owned()
115 } else {
116 device.to_owned()
117 };
118
119 let exit_status = u64_at("/smartctl/exit_status").unwrap_or(0);
120 let error = (exit_status & EXIT_FATAL_MASK != 0).then(|| {
121 json.pointer("/smartctl/messages")
123 .and_then(Value::as_array)
124 .and_then(|messages| {
125 messages
126 .iter()
127 .find(|m| m.get("severity").and_then(Value::as_str) == Some("error"))
128 .and_then(|m| m.get("string").and_then(Value::as_str))
129 })
130 .map(str::to_owned)
131 .unwrap_or_else(|| format!("smartctl failed (exit status {exit_status})"))
132 });
133
134 let ata_raw = |id: u64| {
136 json.pointer("/ata_smart_attributes/table")
137 .and_then(Value::as_array)?
138 .iter()
139 .find(|attr| attr.get("id").and_then(Value::as_u64) == Some(id))?
140 .pointer("/raw/value")
141 .and_then(Value::as_u64)
142 };
143
144 let nvme = "/nvme_smart_health_information_log";
145 SmartDevice {
146 device,
147 protocol: str_at("/device/protocol").unwrap_or_default().to_owned(),
148 model: str_at("/model_name").unwrap_or_default().trim().to_owned(),
149 passed: json
150 .pointer("/smart_status/passed")
151 .and_then(Value::as_bool),
152 temperature_c: u64_at("/temperature/current").map(|t| t as f32),
153 power_on_hours: u64_at("/power_on_time/hours"),
154 percentage_used: u32_at(&format!("{nvme}/percentage_used")),
155 available_spare_percent: u32_at(&format!("{nvme}/available_spare")),
156 available_spare_threshold_percent: u32_at(&format!("{nvme}/available_spare_threshold")),
157 critical_warning: u32_at(&format!("{nvme}/critical_warning")),
158 media_errors: u64_at(&format!("{nvme}/media_errors")),
159 reallocated_sectors: ata_raw(5),
160 pending_sectors: ata_raw(197),
161 offline_uncorrectable: ata_raw(198),
162 data_written_bytes: u64_at(&format!("{nvme}/data_units_written"))
163 .map(|units| units.saturating_mul(NVME_DATA_UNIT_BYTES)),
164 collected_at_ms: u64_at("/local_time/time_t").map(|t| t.saturating_mul(1000)),
165 error,
166 }
167}
168
169#[cfg(test)]
170mod tests {
171 use super::*;
172
173 const NVME_JSON: &str = r#"{
175 "json_format_version": [1, 0],
176 "smartctl": {"version": [7, 5], "exit_status": 0},
177 "local_time": {"time_t": 1792775225, "asctime": "Sat Oct 24 02:07:05 2026 JST"},
178 "device": {"name": "/dev/nvme0", "info_name": "/dev/nvme0", "type": "nvme", "protocol": "NVMe"},
179 "model_name": "Samsung SSD 990 PRO 2TB",
180 "serial_number": "S000000000000",
181 "smart_status": {"passed": true, "nvme": {"value": 0}},
182 "nvme_smart_health_information_log": {
183 "critical_warning": 0, "temperature": 45, "available_spare": 100,
184 "available_spare_threshold": 10, "percentage_used": 2,
185 "data_units_read": 12345678, "data_units_written": 2000000,
186 "power_on_hours": 3210, "unsafe_shutdowns": 12, "media_errors": 0,
187 "num_err_log_entries": 0
188 },
189 "temperature": {"current": 45},
190 "power_on_time": {"hours": 3210}
191 }"#;
192
193 const ATA_JSON: &str = r#"{
195 "smartctl": {"exit_status": 64},
196 "local_time": {"time_t": 1792775225},
197 "device": {"name": "/dev/sda", "type": "sat", "protocol": "ATA"},
198 "model_name": "WDC WD40EFRX-68N32N0",
199 "smart_status": {"passed": true},
200 "ata_smart_attributes": {"revision": 16, "table": [
201 {"id": 5, "name": "Reallocated_Sector_Ct", "raw": {"value": 8, "string": "8"}},
202 {"id": 9, "name": "Power_On_Hours", "raw": {"value": 40000, "string": "40000"}},
203 {"id": 197, "name": "Current_Pending_Sector", "raw": {"value": 0, "string": "0"}},
204 {"id": 198, "name": "Offline_Uncorrectable", "raw": {"value": 0, "string": "0"}}
205 ]},
206 "temperature": {"current": 38},
207 "power_on_time": {"hours": 40000}
208 }"#;
209
210 const DENIED_JSON: &str = r#"{
212 "smartctl": {"messages": [{"string": "Smartctl open device: /dev/nvme0 failed: Permission denied", "severity": "error"}], "exit_status": 2},
213 "local_time": {"time_t": 1792775225},
214 "device": {"name": "/dev/nvme0", "type": "nvme", "protocol": "NVMe"}
215 }"#;
216
217 fn parse(text: &str) -> SmartDevice {
218 parse_json(&serde_json::from_str(text).unwrap(), "")
219 }
220
221 #[test]
222 fn parses_nvme_health() {
223 let d = parse(NVME_JSON);
224 assert_eq!(d.device, "nvme0");
225 assert_eq!(d.protocol, "NVMe");
226 assert_eq!(d.model, "Samsung SSD 990 PRO 2TB");
227 assert_eq!(d.passed, Some(true));
228 assert_eq!(d.temperature_c, Some(45.0));
229 assert_eq!(d.percentage_used, Some(2));
230 assert_eq!(d.available_spare_percent, Some(100));
231 assert_eq!(d.media_errors, Some(0));
232 assert_eq!(d.data_written_bytes, Some(2_000_000 * 512_000));
233 assert_eq!(d.collected_at_ms, Some(1_792_775_225_000));
234 assert_eq!(d.reallocated_sectors, None);
235 assert!(d.error.is_none());
236 assert!(!d.needs_attention());
237 }
238
239 #[test]
240 fn parses_ata_attributes() {
241 let d = parse(ATA_JSON);
242 assert_eq!(d.device, "sda");
243 assert_eq!(d.reallocated_sectors, Some(8));
244 assert_eq!(d.pending_sectors, Some(0));
245 assert_eq!(d.power_on_hours, Some(40000));
246 assert!(d.error.is_none());
248 assert!(d.needs_attention());
249 }
250
251 #[test]
252 fn open_failure_is_reported_as_error() {
253 let d = parse(DENIED_JSON);
254 assert_eq!(d.passed, None);
255 assert!(d.error.as_deref().unwrap().contains("Permission denied"));
256 assert!(d.needs_attention());
257 }
258
259 #[test]
260 fn reader_caches_by_mtime_and_reports_broken_files() {
261 let tmp = tempfile::tempdir().unwrap();
262 let dir = tmp.path();
263 fs::write(dir.join("nvme0.json"), NVME_JSON).unwrap();
264 fs::write(dir.join("sdb.json"), "{ truncated").unwrap();
265 fs::write(dir.join("0_megaraid_3.json"), DENIED_JSON).unwrap();
266 fs::write(dir.join("README"), "not a result").unwrap();
267
268 let mut reader = SmartReader::new(dir);
269 let devices = reader.read();
270 let names: Vec<&str> = devices.iter().map(|d| d.device.as_str()).collect();
271 assert_eq!(names, vec!["0_megaraid_3", "nvme0", "sdb"]);
273 assert!(devices[2].error.is_some());
275
276 fs::remove_file(dir.join("sdb.json")).unwrap();
278 let devices = reader.read();
279 assert_eq!(devices.len(), 2);
280 assert_eq!(reader.cache.len(), 2);
281 }
282
283 #[test]
284 fn missing_directory_yields_nothing() {
285 let tmp = tempfile::tempdir().unwrap();
286 let mut reader = SmartReader::new(tmp.path().join("absent"));
287 assert!(reader.read().is_empty());
288 }
289}