Skip to main content

horto_os_ui_shared/kits/
envfile.rs

1//! `KEY=value` env file parse / write helpers.
2//!
3//! # Examples
4//!
5//! ```
6//! use horto_os_ui_shared::kits::envfile;
7//!
8//! let map = envfile::parse("FOO=bar\n# c\nBAZ=\"x y\"\n");
9//! assert_eq!(map.get("FOO").map(String::as_str), Some("bar"));
10//! assert_eq!(map.get("BAZ").map(String::as_str), Some("x y"));
11//! ```
12
13use crate::error::{HortoError, Result};
14use std::collections::BTreeMap;
15use std::fs;
16use std::path::Path;
17
18/// Parse KEY=value / KEY="value" lines into an ordered map.
19#[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
68/// Write KEY="value" lines.
69///
70/// # Errors
71///
72/// Returns [`HortoError`] when parent dirs cannot be created or the file cannot be written.
73pub 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
88/// Load KEY=VALUE pairs from `path` into a map.
89///
90/// # Errors
91///
92/// Returns [`HortoError::Message`] when the file cannot be read.
93pub 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
99/// Insert or replace `key` in `map`.
100pub fn set_key(map: &mut BTreeMap<String, String>, key: &str, value: impl Into<String>) {
101    map.insert(key.to_string(), value.into());
102}
103
104/// Ensure each of `keys` is present and non-empty in `map`.
105///
106/// # Errors
107///
108/// Returns [`HortoError::Message`] naming the first missing or empty key.
109pub 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/// True when `WIFI_INTERFACE` names a real `WiFi` AP iface (not `none` / empty / `n`).
124#[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/// True when the `WiFi` interface value should drive hostapd / SSID prompts.
131#[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/// Normalize optional `WiFi` iface: disabled answers become `none`.
138#[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}