Skip to main content

murk_cli/
edit.rs

1//! Edit buffer parsing and diff logic for `murk edit`.
2
3use std::collections::BTreeMap;
4
5/// Result of parsing an edited buffer and diffing against the original.
6#[derive(Debug, PartialEq, Eq)]
7pub struct EditDiff {
8    /// Keys that were added (not in original).
9    pub added: BTreeMap<String, String>,
10    /// Keys that were updated (value changed).
11    pub updated: BTreeMap<String, String>,
12    /// Keys that were removed (in original but not in edited).
13    pub removed: Vec<String>,
14}
15
16impl EditDiff {
17    /// True if nothing changed.
18    pub fn is_empty(&self) -> bool {
19        self.added.is_empty() && self.updated.is_empty() && self.removed.is_empty()
20    }
21}
22
23/// Malformed lines encountered during parsing.
24#[derive(Debug, PartialEq, Eq)]
25pub struct ParseWarning {
26    pub line: String,
27    pub reason: &'static str,
28}
29
30/// Parse a KEY=VALUE edit buffer, filtering comments and blank lines.
31/// Returns parsed entries and any warnings for malformed lines.
32pub 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
65/// Diff edited entries against the original set.
66pub 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 => {} // unchanged
77            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
99/// Parse a single-key edit result, stripping comment lines.
100pub 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}