horto_os_ui_shared/kits/
envfile.rs1use crate::error::{HortoError, Result};
14use std::collections::BTreeMap;
15use std::fs;
16use std::path::Path;
17
18#[must_use]
20pub fn parse(content: &str) -> BTreeMap<String, String> {
21 let mut map = BTreeMap::new();
22 for line in content.lines() {
23 let trimmed = line.trim();
24 if trimmed.is_empty() || trimmed.starts_with('#') {
25 continue;
26 }
27 let Some((key, raw)) = trimmed.split_once('=') else {
28 continue;
29 };
30 let key = key.trim().to_string();
31 let value = unquote(raw.trim());
32 map.insert(key, value);
33 }
34 map
35}
36
37fn unquote(s: &str) -> String {
38 let s = s.trim();
39 let bytes = s.as_bytes();
40 if bytes.is_empty() {
41 return String::new();
42 }
43 let quote = bytes[0];
44 if quote == b'"' || quote == b'\'' {
45 let mut out = String::new();
46 let mut i = 1;
47 while i < bytes.len() {
48 if bytes[i] == b'\\' && i + 1 < bytes.len() {
49 out.push(bytes[i + 1] as char);
50 i += 2;
51 continue;
52 }
53 if bytes[i] == quote {
54 return out;
55 }
56 out.push(bytes[i] as char);
57 i += 1;
58 }
59 return out;
60 }
61 s.split_once('#').map_or(s, |(v, _)| v.trim()).to_string()
62}
63
64fn escape_double(s: &str) -> String {
65 s.replace('\\', "\\\\").replace('"', "\\\"")
66}
67
68pub fn write(path: &Path, map: &BTreeMap<String, String>) -> Result<()> {
74 let mut out = String::new();
75 for (k, v) in map {
76 out.push_str(k);
77 out.push_str("=\"");
78 out.push_str(&escape_double(v));
79 out.push_str("\"\n");
80 }
81 if let Some(parent) = path.parent() {
82 fs::create_dir_all(parent)?;
83 }
84 fs::write(path, out)?;
85 Ok(())
86}
87
88pub fn load(path: &Path) -> Result<BTreeMap<String, String>> {
94 let content = fs::read_to_string(path)
95 .map_err(|e| HortoError::msg(format!("cannot read {}: {e}", path.display())))?;
96 Ok(parse(&content))
97}
98
99pub fn set_key(map: &mut BTreeMap<String, String>, key: &str, value: impl Into<String>) {
101 map.insert(key.to_string(), value.into());
102}
103
104pub fn require_keys(map: &BTreeMap<String, String>, keys: &[&str]) -> Result<()> {
110 for key in keys {
111 match map.get(*key) {
112 Some(v) if !v.is_empty() => {}
113 _ => {
114 return Err(HortoError::msg(format!(
115 "required variable {key} is empty or missing"
116 )));
117 }
118 }
119 }
120 Ok(())
121}
122
123#[must_use]
125pub fn wifi_ap_enabled(map: &BTreeMap<String, String>) -> bool {
126 map.get("WIFI_INTERFACE")
127 .is_some_and(|v| wifi_iface_enabled(v))
128}
129
130#[must_use]
132pub fn wifi_iface_enabled(raw: &str) -> bool {
133 let t = raw.trim().to_ascii_lowercase();
134 !(t.is_empty() || matches!(t.as_str(), "none" | "-" | "n" | "no"))
135}
136
137#[must_use]
139pub fn normalize_wifi_iface(raw: &str) -> String {
140 let trimmed = raw.trim();
141 if wifi_iface_enabled(trimmed) {
142 trimmed.to_string()
143 } else {
144 "none".into()
145 }
146}
147
148#[cfg(test)]
149mod tests {
150 use super::*;
151 use std::collections::BTreeMap;
152
153 #[test]
154 fn parse_inline_comments() {
155 let map = parse("NPU_TYPE=\"rk3588\" # comment\nOS_TYPE=debian # other\n");
156 assert_eq!(map.get("NPU_TYPE").map(String::as_str), Some("rk3588"));
157 assert_eq!(map.get("OS_TYPE").map(String::as_str), Some("debian"));
158 }
159
160 #[test]
161 fn write_roundtrip() {
162 let dir = tempfile::tempdir().unwrap();
163 let path = dir.path().join("vars.env");
164 let mut map = BTreeMap::new();
165 map.insert("A".into(), "one".into());
166 map.insert("B".into(), "has \"quote\"".into());
167 write(&path, &map).unwrap();
168 let loaded = load(&path).unwrap();
169 assert_eq!(loaded.get("A").map(String::as_str), Some("one"));
170 assert_eq!(loaded.get("B").map(String::as_str), Some("has \"quote\""));
171 }
172
173 #[test]
174 fn wifi_iface_none_disables_ap() {
175 assert!(!wifi_iface_enabled("none"));
176 assert!(!wifi_iface_enabled("NONE"));
177 assert!(!wifi_iface_enabled("n"));
178 assert!(!wifi_iface_enabled(""));
179 assert!(wifi_iface_enabled("wlan0"));
180 assert!(wifi_iface_enabled("wlx00aabb"));
181 assert_eq!(normalize_wifi_iface("n"), "none");
182 assert_eq!(normalize_wifi_iface(" wlan0 "), "wlan0");
183
184 let mut map = BTreeMap::new();
185 map.insert("WIFI_INTERFACE".into(), "none".into());
186 assert!(!wifi_ap_enabled(&map));
187 map.insert("WIFI_INTERFACE".into(), "wlan0".into());
188 assert!(wifi_ap_enabled(&map));
189 }
190}