bijux_cli/features/config/
storage.rs1#![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
22pub 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}