1use serde::{Deserialize, Serialize};
5use thiserror::Error;
6
7use crate::SensorId;
8
9#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)]
10pub enum HardwareCategory {
11 Gpu,
12 Storage,
13 Network,
14 Cpu,
15 Other,
16}
17
18impl HardwareCategory {
19 pub fn as_str(self) -> &'static str {
20 match self {
21 Self::Gpu => "gpu",
22 Self::Storage => "storage",
23 Self::Network => "network",
24 Self::Cpu => "cpu",
25 Self::Other => "other",
26 }
27 }
28}
29
30#[derive(Debug, Error, PartialEq)]
31pub enum KeyError {
32 #[error("hardware device key is empty")]
33 Empty,
34 #[error("hardware device key too long ({0} bytes, max 140)")]
35 TooLong(usize),
36 #[error(
37 "hardware device key missing scheme prefix (expected one of: pci, nvml, nvme, net, cpu, plugin)"
38 )]
39 NoScheme,
40 #[error("hardware device key has unknown scheme '{0}'")]
41 UnknownScheme(String),
42 #[error("hardware device key payload empty after '{0}:'")]
43 EmptyPayload(String),
44 #[error("hardware device key contains invalid character {0:?}")]
45 BadChar(char),
46}
47
48const ALLOWED_SCHEMES: &[&str] = &[
49 "pci", "nvml", "nvme", "net", "cpu", "system", "block", "hwmon", "fs", "amdgpu", "zram",
50 "i915", "plugin",
51];
52const KEY_MAX_LEN: usize = 140;
53
54#[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)]
55pub struct HardwareDeviceKey(String);
56
57impl HardwareDeviceKey {
58 pub fn try_new(s: impl Into<String>) -> Result<Self, KeyError> {
59 let s = s.into();
60 if s.is_empty() {
61 return Err(KeyError::Empty);
62 }
63 if s.len() > KEY_MAX_LEN {
64 return Err(KeyError::TooLong(s.len()));
65 }
66 let (scheme, rest) = s.split_once(':').ok_or(KeyError::NoScheme)?;
67 if !ALLOWED_SCHEMES.contains(&scheme) {
68 return Err(KeyError::UnknownScheme(scheme.to_owned()));
69 }
70 if rest.is_empty() {
71 return Err(KeyError::EmptyPayload(scheme.to_owned()));
72 }
73 for c in rest.chars() {
74 let ok = c.is_ascii_alphanumeric() || matches!(c, '_' | ':' | '.' | '-');
75 if !ok {
76 return Err(KeyError::BadChar(c));
77 }
78 }
79 Ok(Self(s))
80 }
81
82 pub fn as_str(&self) -> &str {
83 &self.0
84 }
85
86 pub fn scheme(&self) -> &str {
87 self.0.split(':').next().unwrap_or("")
88 }
89}
90
91impl std::fmt::Display for HardwareDeviceKey {
92 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
93 f.write_str(&self.0)
94 }
95}
96
97#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
98pub struct HardwareDevice {
99 pub key: HardwareDeviceKey,
100 pub category: HardwareCategory,
101 pub model: String,
102 pub vendor: Option<String>,
103 pub location: Option<String>,
104 pub plugin_id: String,
105 pub plugin_device_id: String,
106 pub sensor_ids: Vec<SensorId>,
107}
108
109#[derive(Debug, Error, PartialEq)]
110pub enum NicknameError {
111 #[error("nickname too long ({0} chars, max 64)")]
112 TooLong(usize),
113 #[error("nickname contains control char {0:?}")]
114 ControlChar(char),
115}
116
117pub const NICKNAME_MAX_CHARS: usize = 64;
118
119pub fn validate_nickname(input: &str) -> Result<Option<String>, NicknameError> {
124 let trimmed = input.trim();
125 if trimmed.is_empty() {
126 return Ok(None);
127 }
128 let char_count = trimmed.chars().count();
129 if char_count > NICKNAME_MAX_CHARS {
130 return Err(NicknameError::TooLong(char_count));
131 }
132 if let Some(c) = trimmed.chars().find(|c| c.is_control()) {
133 return Err(NicknameError::ControlChar(c));
134 }
135 Ok(Some(trimmed.to_owned()))
136}
137
138pub fn parse_sysfs_pci_id(path: &std::path::Path) -> Option<u16> {
147 let raw = std::fs::read_to_string(path).ok()?;
148 let trimmed = raw.trim().strip_prefix("0x").unwrap_or(raw.trim());
149 u16::from_str_radix(trimmed, 16).ok()
150}
151
152pub fn compute_device_label(
165 target: &HardwareDevice,
166 all_devices: &[HardwareDevice],
167 nicknames: &std::collections::HashMap<String, String>,
168) -> String {
169 if let Some(nick) = nicknames.get(target.key.as_str()) {
170 return nick.clone();
171 }
172 let needs_disambig = all_devices.iter().any(|d| {
173 d.key != target.key && d.model == target.model && !nicknames.contains_key(d.key.as_str())
174 });
175 if needs_disambig {
176 let suffix =
177 target.key.as_str().split_once(':').map(|(_, p)| p).unwrap_or(target.key.as_str());
178 format!("{} ({})", target.model, suffix)
179 } else {
180 target.model.clone()
181 }
182}
183
184#[cfg(test)]
185mod tests {
186 use super::*;
187
188 #[test]
189 fn category_as_str_is_stable() {
190 assert_eq!(HardwareCategory::Gpu.as_str(), "gpu");
191 assert_eq!(HardwareCategory::Storage.as_str(), "storage");
192 assert_eq!(HardwareCategory::Network.as_str(), "network");
193 assert_eq!(HardwareCategory::Cpu.as_str(), "cpu");
194 assert_eq!(HardwareCategory::Other.as_str(), "other");
195 }
196
197 #[test]
198 fn key_accepts_valid_schemes() {
199 for s in [
200 "pci:0000:06:00.0",
201 "nvml:uuid:gpu-abc123-456",
202 "nvme:eui.001b448b41234567",
203 "nvme:nvme0",
204 "net:enp4s0",
205 "net:wg0",
206 "cpu:0",
207 "plugin:com.visorcraft.linsight.echo:demo",
208 ] {
209 assert!(HardwareDeviceKey::try_new(s).is_ok(), "should accept: {s}");
210 }
211 }
212
213 #[test]
214 fn key_rejects_invalid_forms() {
215 let long = "x".repeat(200);
216 for s in [
217 "",
218 "pci",
219 "pci:",
220 "FOO:bar",
221 "pci:0000:06:00.0 ",
222 "PCI:0000:06:00.0",
223 "pci:0000:06:00.0/extra",
224 long.as_str(),
225 ] {
226 assert!(HardwareDeviceKey::try_new(s).is_err(), "should reject: {s}");
227 }
228 }
229
230 #[test]
231 fn key_accepts_uppercase_in_payload() {
232 for s in
238 ["hwmon:BAT0", "hwmon:ACAD", "fs:tmp_.mount_S3DrivweY1lv", "nvml:uuid:GPU-abc123-456"]
239 {
240 assert!(HardwareDeviceKey::try_new(s).is_ok(), "should accept: {s}");
241 }
242 }
243
244 #[test]
245 fn key_scheme_extraction() {
246 let k = HardwareDeviceKey::try_new("pci:0000:06:00.0").unwrap();
247 assert_eq!(k.scheme(), "pci");
248 let k = HardwareDeviceKey::try_new("nvml:uuid:gpu-abc").unwrap();
249 assert_eq!(k.scheme(), "nvml");
250 }
251
252 #[test]
253 fn nickname_validation_accepts_normal_text() {
254 assert_eq!(validate_nickname("Battlemage"), Ok(Some("Battlemage".into())));
255 assert_eq!(validate_nickname("OS drive"), Ok(Some("OS drive".into())));
256 assert_eq!(validate_nickname("RTX 5080 \u{1F525}"), Ok(Some("RTX 5080 \u{1F525}".into())));
257 }
258
259 #[test]
260 fn nickname_empty_means_delete() {
261 assert_eq!(validate_nickname(""), Ok(None));
262 assert_eq!(validate_nickname(" "), Ok(None));
263 assert_eq!(validate_nickname("\t\t"), Ok(None));
264 }
265
266 #[test]
267 fn nickname_trims_outer_whitespace() {
268 assert_eq!(validate_nickname(" hello "), Ok(Some("hello".into())));
269 }
270
271 #[test]
272 fn nickname_rejects_control_chars() {
273 assert!(matches!(validate_nickname("ab\nc"), Err(NicknameError::ControlChar('\n'))));
274 assert!(matches!(validate_nickname("ab\x00c"), Err(NicknameError::ControlChar('\x00'))));
275 assert!(matches!(validate_nickname("ab\x7fc"), Err(NicknameError::ControlChar('\x7f'))));
276 }
277
278 #[test]
279 fn nickname_rejects_too_long() {
280 let s = "x".repeat(65);
281 assert!(matches!(validate_nickname(&s), Err(NicknameError::TooLong(65))));
282 let s = "x".repeat(64);
283 assert!(validate_nickname(&s).is_ok());
284 }
285
286 #[test]
287 fn device_serde_round_trip() {
288 let dev = HardwareDevice {
289 key: HardwareDeviceKey::try_new("pci:0000:06:00.0").unwrap(),
290 category: HardwareCategory::Gpu,
291 model: "Intel Arc B-series".into(),
292 vendor: Some("Intel Corporation".into()),
293 location: Some("PCI 0000:06:00.0".into()),
294 plugin_id: "com.visorcraft.linsight.xe".into(),
295 plugin_device_id: "gpu0".into(),
296 sensor_ids: vec![SensorId::new("xe.gpu0.util")],
297 };
298 let s = serde_json::to_string(&dev).unwrap();
299 let back: HardwareDevice = serde_json::from_str(&s).unwrap();
300 assert_eq!(back, dev);
301 }
302}