Skip to main content

bijux_cli/features/config/
storage.rs

1#![forbid(unsafe_code)]
2
3use std::collections::BTreeMap;
4use std::fs;
5use std::path::Path;
6
7use crate::infrastructure::fs_store::atomic_write_text;
8
9use super::error::ConfigError;
10use super::serialization::{decode_quoted_value, render_env};
11use super::validation::{normalize_key, validate_value};
12
13pub(crate) trait ConfigRepository {
14    fn load(&self, path: &Path) -> Result<BTreeMap<String, String>, ConfigError>;
15    fn save(&self, path: &Path, values: &BTreeMap<String, String>) -> Result<(), ConfigError>;
16    fn remove(&self, path: &Path) -> Result<bool, ConfigError>;
17}
18
19#[derive(Debug, Default, Clone, Copy)]
20pub(crate) struct FileConfigRepository;
21
22/// Validate and parse a config file using runtime config parsing rules.
23pub fn validate_config_file(path: &Path) -> Result<(), String> {
24    let repository = FileConfigRepository;
25    repository.load(path).map(|_| ()).map_err(|err| err.to_string())
26}
27
28impl ConfigRepository for FileConfigRepository {
29    fn load(&self, path: &Path) -> Result<BTreeMap<String, String>, ConfigError> {
30        if !path.exists() {
31            return Ok(BTreeMap::new());
32        }
33        let text =
34            fs::read_to_string(path).map_err(|err| ConfigError::persistence(err.to_string()))?;
35        let mut out = BTreeMap::new();
36        for (index, raw_line) in text.lines().enumerate() {
37            let line_no = index + 1;
38            let trimmed = raw_line.trim();
39            if trimmed.is_empty() || trimmed.starts_with('#') {
40                continue;
41            }
42            let Some((raw_key, raw_value)) = raw_line.split_once('=') else {
43                return Err(ConfigError::parse(format!("Malformed line {line_no}: {raw_line}")));
44            };
45            let key = normalize_key(raw_key)?;
46            let value = decode_quoted_value(raw_value.trim());
47            validate_value(&value)?;
48            if out.contains_key(&key) {
49                return Err(ConfigError::parse(format!("Duplicate key `{key}` at line {line_no}")));
50            }
51            out.insert(key, value);
52        }
53        Ok(out)
54    }
55
56    fn save(&self, path: &Path, values: &BTreeMap<String, String>) -> Result<(), ConfigError> {
57        let rendered = render_env(values);
58        atomic_write_text(path, &rendered)
59            .map_err(|err| ConfigError::persistence(err.to_string()))?;
60        Ok(())
61    }
62
63    fn remove(&self, path: &Path) -> Result<bool, ConfigError> {
64        if !path.exists() {
65            return Ok(false);
66        }
67        fs::remove_file(path).map_err(|err| ConfigError::persistence(err.to_string()))?;
68        Ok(true)
69    }
70}
71
72#[cfg(test)]
73mod tests {
74    use std::collections::BTreeMap;
75    use std::fs;
76    use std::path::PathBuf;
77    use std::time::{SystemTime, UNIX_EPOCH};
78
79    use super::{ConfigRepository, FileConfigRepository};
80
81    fn make_temp_dir(name: &str) -> PathBuf {
82        let nanos = SystemTime::now().duration_since(UNIX_EPOCH).expect("clock").as_nanos();
83        let path = std::env::temp_dir().join(format!("bijux-storage-{name}-{nanos}"));
84        fs::create_dir_all(&path).expect("mkdir");
85        path
86    }
87
88    #[test]
89    fn parser_handles_empty_and_missing_files() {
90        let repo = FileConfigRepository;
91        let temp = make_temp_dir("missing");
92        let missing = temp.join("missing.env");
93        let loaded = repo.load(&missing).expect("missing treated as empty");
94        assert!(loaded.is_empty());
95        assert!(!missing.exists(), "load should not materialize missing file");
96
97        let empty = temp.join("empty.env");
98        fs::write(&empty, "").expect("write empty");
99        let loaded_empty = repo.load(&empty).expect("empty parse");
100        assert!(loaded_empty.is_empty());
101    }
102
103    #[test]
104    fn parser_rejects_malformed_lines() {
105        let repo = FileConfigRepository;
106        let temp = make_temp_dir("malformed");
107        let malformed = temp.join("malformed.env");
108        fs::write(&malformed, "BIJUXCLI_OK=1\nMALFORMED\n").expect("write malformed");
109        let err = repo.load(&malformed).expect_err("must fail");
110        assert!(err.to_string().contains("Malformed line 2"));
111    }
112
113    #[test]
114    fn parser_rejects_duplicate_keys() {
115        let repo = FileConfigRepository;
116        let temp = make_temp_dir("dupes");
117        let path = temp.join("dupes.env");
118        fs::write(
119            &path,
120            "BIJUXCLI_ALPHA=1\nBIJUXCLI_ALPHA=1\nBIJUXCLI_BETA=old\nBIJUXCLI_BETA=new\n",
121        )
122        .expect("write dupes");
123
124        let err = repo.load(&path).expect_err("duplicate keys must be rejected");
125        assert!(err.to_string().contains("Duplicate key `alpha`"));
126    }
127
128    #[test]
129    fn parser_ignores_blank_and_comment_lines() {
130        let repo = FileConfigRepository;
131        let temp = make_temp_dir("comments");
132        let path = temp.join("comments.env");
133        fs::write(
134            &path,
135            "\n# top comment\nBIJUXCLI_ALPHA=1\n\n   # indented comment\nBIJUXCLI_BETA=2\n# inline = comment\n",
136        )
137        .expect("write comments");
138
139        let loaded = repo.load(&path).expect("parse");
140        assert_eq!(loaded.len(), 2);
141        assert_eq!(loaded.get("alpha").map(String::as_str), Some("1"));
142        assert_eq!(loaded.get("beta").map(String::as_str), Some("2"));
143    }
144
145    #[test]
146    fn parser_trims_trailing_whitespace_in_values() {
147        let repo = FileConfigRepository;
148        let temp = make_temp_dir("whitespace");
149        let path = temp.join("whitespace.env");
150        fs::write(&path, "BIJUXCLI_ALPHA=value   \nBIJUXCLI_BETA=\"quoted value\"   \n")
151            .expect("write");
152
153        let loaded = repo.load(&path).expect("parse");
154        assert_eq!(loaded.get("alpha").map(String::as_str), Some("value"));
155        assert_eq!(
156            loaded.get("beta").map(String::as_str),
157            Some("\"quoted value\""),
158            "quoted input should preserve surrounding quotes while trimming trailing whitespace"
159        );
160    }
161
162    #[test]
163    fn writer_creates_parent_and_uses_deterministic_order() {
164        let repo = FileConfigRepository;
165        let temp = make_temp_dir("writer");
166        let path = temp.join("nested").join("config.env");
167        let mut map = BTreeMap::new();
168        map.insert("beta".to_string(), "2".to_string());
169        map.insert("alpha".to_string(), "1".to_string());
170
171        repo.save(&path, &map).expect("save");
172        let written = fs::read_to_string(&path).expect("read");
173        assert_eq!(
174            written, "BIJUXCLI_ALPHA=1\nBIJUXCLI_BETA=2\n",
175            "BTreeMap-backed rendering should stay stable"
176        );
177        let reloaded = repo.load(&path).expect("reload");
178        assert_eq!(reloaded, map, "saved config must round-trip through parser");
179    }
180
181    #[test]
182    fn writer_drops_comments_and_formatting_by_design() {
183        let repo = FileConfigRepository;
184        let temp = make_temp_dir("rewrite");
185        let path = temp.join("rewrite.env");
186        fs::write(&path, "# comment\nBIJUXCLI_ALPHA=1\n").expect("seed");
187
188        let loaded = repo.load(&path).expect("load");
189        repo.save(&path, &loaded).expect("save");
190        let rewritten = fs::read_to_string(&path).expect("read rewritten");
191        assert_eq!(rewritten, "BIJUXCLI_ALPHA=1\n");
192    }
193}