1use std::collections::BTreeMap;
4
5#[derive(Debug, PartialEq, Eq)]
7pub struct EditDiff {
8 pub added: BTreeMap<String, String>,
10 pub updated: BTreeMap<String, String>,
12 pub removed: Vec<String>,
14}
15
16impl EditDiff {
17 pub fn is_empty(&self) -> bool {
19 self.added.is_empty() && self.updated.is_empty() && self.removed.is_empty()
20 }
21}
22
23#[derive(Debug, PartialEq, Eq)]
25pub struct ParseWarning {
26 pub line: String,
27 pub reason: &'static str,
28}
29
30pub fn parse_edit_buffer(
33 content: &str,
34 validate_key: fn(&str) -> bool,
35) -> (BTreeMap<String, String>, Vec<ParseWarning>) {
36 let mut entries = BTreeMap::new();
37 let mut warnings = Vec::new();
38
39 for line in content.lines() {
40 let trimmed = line.trim();
41 if trimmed.is_empty() || trimmed.starts_with('#') {
42 continue;
43 }
44 let Some((k, v)) = trimmed.split_once('=') else {
45 warnings.push(ParseWarning {
46 line: trimmed.to_string(),
47 reason: "malformed (no = sign)",
48 });
49 continue;
50 };
51 let k = k.trim();
52 if !validate_key(k) {
53 warnings.push(ParseWarning {
54 line: trimmed.to_string(),
55 reason: "invalid key name",
56 });
57 continue;
58 }
59 entries.insert(k.to_string(), v.to_string());
60 }
61
62 (entries, warnings)
63}
64
65pub fn diff_edits(
67 original: &BTreeMap<String, String>,
68 edited: &BTreeMap<String, String>,
69) -> EditDiff {
70 let mut added = BTreeMap::new();
71 let mut updated = BTreeMap::new();
72 let mut removed = Vec::new();
73
74 for (k, v) in edited {
75 match original.get(k) {
76 Some(old_v) if old_v == v => {} Some(_) => {
78 updated.insert(k.clone(), v.clone());
79 }
80 None => {
81 added.insert(k.clone(), v.clone());
82 }
83 }
84 }
85
86 for k in original.keys() {
87 if !edited.contains_key(k) {
88 removed.push(k.clone());
89 }
90 }
91
92 EditDiff {
93 added,
94 updated,
95 removed,
96 }
97}
98
99pub fn parse_single_value(content: &str) -> String {
101 content
102 .lines()
103 .filter(|l| !l.starts_with('#'))
104 .collect::<Vec<_>>()
105 .join("\n")
106 .trim_end_matches('\n')
107 .to_string()
108}
109
110#[cfg(test)]
111mod tests {
112 use super::*;
113
114 fn always_valid(_: &str) -> bool {
115 true
116 }
117
118 fn alpha_only(k: &str) -> bool {
119 k.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
120 }
121
122 #[test]
123 fn parse_basic() {
124 let (entries, warnings) = parse_edit_buffer("FOO=bar\nBAZ=qux\n", always_valid);
125 assert_eq!(entries.len(), 2);
126 assert_eq!(entries["FOO"], "bar");
127 assert_eq!(entries["BAZ"], "qux");
128 assert!(warnings.is_empty());
129 }
130
131 #[test]
132 fn parse_skips_comments_and_blanks() {
133 let input = "# comment\n\nFOO=bar\n# another\n";
134 let (entries, _) = parse_edit_buffer(input, always_valid);
135 assert_eq!(entries.len(), 1);
136 assert_eq!(entries["FOO"], "bar");
137 }
138
139 #[test]
140 fn parse_warns_on_malformed() {
141 let input = "FOO=bar\nbad line\nBAZ=qux\n";
142 let (entries, warnings) = parse_edit_buffer(input, always_valid);
143 assert_eq!(entries.len(), 2);
144 assert_eq!(warnings.len(), 1);
145 assert_eq!(warnings[0].line, "bad line");
146 }
147
148 #[test]
149 fn parse_warns_on_invalid_key() {
150 let input = "GOOD=yes\nbad-key=no\n";
151 let (entries, warnings) = parse_edit_buffer(input, alpha_only);
152 assert_eq!(entries.len(), 1);
153 assert!(entries.contains_key("GOOD"));
154 assert_eq!(warnings.len(), 1);
155 }
156
157 #[test]
158 fn parse_value_with_equals() {
159 let input = "URL=postgres://host:5432/db?sslmode=require\n";
160 let (entries, _) = parse_edit_buffer(input, always_valid);
161 assert_eq!(entries["URL"], "postgres://host:5432/db?sslmode=require");
162 }
163
164 #[test]
165 fn diff_no_changes() {
166 let orig: BTreeMap<_, _> = [("A".into(), "1".into())].into();
167 let edited = orig.clone();
168 let diff = diff_edits(&orig, &edited);
169 assert!(diff.is_empty());
170 }
171
172 #[test]
173 fn diff_added() {
174 let orig: BTreeMap<String, String> = BTreeMap::new();
175 let edited: BTreeMap<_, _> = [("NEW".into(), "val".into())].into();
176 let diff = diff_edits(&orig, &edited);
177 assert_eq!(diff.added.len(), 1);
178 assert!(diff.updated.is_empty());
179 assert!(diff.removed.is_empty());
180 }
181
182 #[test]
183 fn diff_updated() {
184 let orig: BTreeMap<_, _> = [("KEY".into(), "old".into())].into();
185 let edited: BTreeMap<_, _> = [("KEY".into(), "new".into())].into();
186 let diff = diff_edits(&orig, &edited);
187 assert!(diff.added.is_empty());
188 assert_eq!(diff.updated.len(), 1);
189 assert_eq!(diff.updated["KEY"], "new");
190 assert!(diff.removed.is_empty());
191 }
192
193 #[test]
194 fn diff_removed() {
195 let orig: BTreeMap<_, _> = [("GONE".into(), "val".into())].into();
196 let edited: BTreeMap<String, String> = BTreeMap::new();
197 let diff = diff_edits(&orig, &edited);
198 assert!(diff.added.is_empty());
199 assert!(diff.updated.is_empty());
200 assert_eq!(diff.removed, vec!["GONE"]);
201 }
202
203 #[test]
204 fn diff_mixed() {
205 let orig: BTreeMap<_, _> = [
206 ("KEEP".into(), "same".into()),
207 ("CHANGE".into(), "old".into()),
208 ("DELETE".into(), "gone".into()),
209 ]
210 .into();
211 let edited: BTreeMap<_, _> = [
212 ("KEEP".into(), "same".into()),
213 ("CHANGE".into(), "new".into()),
214 ("ADD".into(), "fresh".into()),
215 ]
216 .into();
217 let diff = diff_edits(&orig, &edited);
218 assert_eq!(diff.added.len(), 1);
219 assert_eq!(diff.updated.len(), 1);
220 assert_eq!(diff.removed, vec!["DELETE"]);
221 }
222
223 #[test]
224 fn parse_single_value_strips_comments() {
225 let input = "# Editing KEY\n# Save and quit.\nsecret_value";
226 assert_eq!(parse_single_value(input), "secret_value");
227 }
228
229 #[test]
230 fn parse_single_value_empty() {
231 let input = "# comment only\n";
232 assert_eq!(parse_single_value(input), "");
233 }
234
235 #[test]
236 fn parse_single_value_multiline() {
237 let input = "# header\nline1\nline2";
238 assert_eq!(parse_single_value(input), "line1\nline2");
239 }
240}