Skip to main content

agent_first_data/document/format/
ini.rs

1//! INI Core v1: a deliberately small, deterministic INI dialect.
2//!
3//! Entries before the first `[section]` header belong to the document root and
4//! are addressed by their bare key. Refusing them was a smaller dialect but not
5//! a truthful one: a flat `key=value` file — phoenixd's `phoenix.conf`, most of
6//! `/etc/*.conf`, php.ini's preamble — is the shape people most often need one
7//! value out of, and having no reader for it pushed callers back to `grep | cut`,
8//! which is what this module exists to replace.
9//!
10//! The cost is one ambiguity, and it is refused rather than resolved: a root key
11//! and a section of the same name would both address the same top-level name, so
12//! a document containing both is a parse error.
13
14use crate::document::{DocumentError, DocumentResult, Value};
15use std::collections::BTreeMap;
16
17const MAX_INI_VALUE_BYTES: usize = 1024 * 1024;
18
19/// The section name entries before the first header carry. Not a real section:
20/// it is how "at the document root" is spelled inside the lexer.
21const ROOT: &str = "";
22
23#[derive(Debug, Clone, Copy)]
24struct IniEntry<'a> {
25    section: &'a str,
26    key: &'a str,
27}
28
29/// Parsed INI Core v1 source document. The lexer used for semantic loading and
30/// source editing is intentionally shared so both paths enforce the same
31/// section/key/duplicate rules.
32#[derive(Debug)]
33pub struct IniDocument<'a> {
34    source: &'a str,
35    entries: Vec<IniEntry<'a>>,
36    sections: BTreeMap<&'a str, usize>,
37}
38
39impl<'a> IniDocument<'a> {
40    pub fn parse(source: &'a str) -> DocumentResult<Self> {
41        let mut entries = Vec::new();
42        let mut current: Option<&str> = None;
43        let mut sections = BTreeMap::<&str, usize>::new();
44        for (index, raw) in source.lines().enumerate() {
45            let line_number = index + 1;
46            let line = raw.strip_suffix('\r').unwrap_or(raw);
47            let trimmed = line.trim();
48            if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with(';') {
49                continue;
50            }
51            if trimmed.starts_with('[') {
52                let Some(name) = trimmed.strip_circumfix('[', ']') else {
53                    return parse_error(line_number, 1, "invalid section header");
54                };
55                let name = name.trim();
56                if name.is_empty() || name.contains(['[', ']']) {
57                    return parse_error(line_number, 1, "section name must be non-empty");
58                }
59                if sections.insert(name, line_number).is_some() {
60                    return parse_error(line_number, 1, "duplicate section");
61                }
62                if entries
63                    .iter()
64                    .any(|entry: &IniEntry<'_>| entry.section == ROOT && entry.key == name)
65                {
66                    return parse_error(
67                        line_number,
68                        1,
69                        "section name collides with a root entry of the same name",
70                    );
71                }
72                current = Some(name);
73                continue;
74            }
75            // `""` is the root: entries before the first header. Addressed by
76            // bare key, which is why a section may not take a root key's name.
77            let section = current.unwrap_or(ROOT);
78            let Some((key, value)) = line.split_once('=') else {
79                return parse_error(line_number, 1, "expected key=value entry");
80            };
81            let key = key.trim();
82            if key.is_empty() || key.contains(['[', ']']) {
83                return parse_error(line_number, 1, "key must be non-empty");
84            }
85            if value.trim().len() > MAX_INI_VALUE_BYTES {
86                return parse_error(
87                    line_number,
88                    line.find('=').unwrap_or(0) + 2,
89                    "value exceeds 1 MiB",
90                );
91            }
92            if entries
93                .iter()
94                .any(|entry: &IniEntry<'_>| entry.section == section && entry.key == key)
95            {
96                return parse_error(
97                    line_number,
98                    line.find(key).unwrap_or(0) + 1,
99                    "duplicate key",
100                );
101            }
102            entries.push(IniEntry { section, key });
103        }
104        Ok(Self {
105            source,
106            entries,
107            sections,
108        })
109    }
110
111    fn has_entry(&self, section: &str, key: &str) -> bool {
112        self.entries
113            .iter()
114            .any(|entry| entry.section == section && entry.key == key)
115    }
116
117    fn has_section(&self, section: &str) -> bool {
118        self.sections.contains_key(section)
119    }
120
121    fn to_value(&self) -> Value {
122        let mut root = BTreeMap::<String, Value>::new();
123        let mut current: Option<String> = None;
124        for raw in self.source.lines() {
125            let line = raw.strip_suffix('\r').unwrap_or(raw);
126            let trimmed = line.trim();
127            if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with(';') {
128                continue;
129            }
130            if let Some(name) = trimmed.strip_circumfix('[', ']') {
131                let name = name.trim().to_string();
132                root.insert(name.clone(), Value::Object(BTreeMap::new()));
133                current = Some(name);
134                continue;
135            }
136            let Some((key, value)) = line.split_once('=') else {
137                continue;
138            };
139            let key = key.trim().to_string();
140            let value = Value::String(value.trim().to_string());
141            match current.as_ref() {
142                // Inside a section, the entry is a member of that object.
143                Some(section) => {
144                    if let Some(Value::Object(entries)) = root.get_mut(section) {
145                        entries.insert(key, value);
146                    }
147                }
148                // Before any header, the entry *is* a top-level member.
149                None => {
150                    root.insert(key, value);
151                }
152            }
153        }
154        Value::Object(root)
155    }
156}
157
158pub fn load(content: &str) -> DocumentResult<Value> {
159    Ok(IniDocument::parse(content)?.to_value())
160}
161
162pub fn save(value: &Value) -> DocumentResult<String> {
163    let Value::Object(sections) = value else {
164        return Err(DocumentError::UnsupportedOperation {
165            format: "INI".to_string(),
166            operation: "save".to_string(),
167            detail: "INI requires a section object".to_string(),
168        });
169    };
170    let mut output = String::new();
171    // Root entries first, and not by preference: a `key=value` written after a
172    // header belongs to that section, so emitting them later would silently
173    // move them.
174    for (key, value) in sections {
175        let Value::String(value) = value else {
176            continue;
177        };
178        output.push_str(key);
179        output.push('=');
180        output.push_str(value);
181        output.push('\n');
182    }
183    for (section, value) in sections {
184        let Value::Object(entries) = value else {
185            // Already emitted above as a root entry; anything else has no INI
186            // spelling at all.
187            if matches!(value, Value::String(_)) {
188                continue;
189            }
190            return Err(DocumentError::UnsupportedOperation {
191                format: "INI".to_string(),
192                operation: "save".to_string(),
193                detail: format!("`{section}` must be a section object or a root string"),
194            });
195        };
196        output.push('[');
197        output.push_str(section);
198        output.push_str("]\n");
199        for (key, value) in entries {
200            let Value::String(value) = value else {
201                return Err(DocumentError::UnsupportedOperation {
202                    format: "INI".to_string(),
203                    operation: "save".to_string(),
204                    detail: format!("entry `{section}.{key}` must remain a string"),
205                });
206            };
207            output.push_str(key);
208            output.push('=');
209            output.push_str(value);
210            output.push('\n');
211        }
212    }
213    validate_round_trip(&output, value, "save")?;
214    Ok(output)
215}
216
217/// Replace an existing INI scalar without reordering sections or entries.
218pub fn set_preserving(content: &str, path: &str, value: &Value) -> DocumentResult<String> {
219    let Value::String(text) = value else {
220        return Err(unsupported("set", "INI values are strings"));
221    };
222    let mut expected = load(content)?;
223    crate::document::set_path(
224        &mut expected,
225        path,
226        value,
227        crate::document::Addressing::INDEX_ONLY,
228    )?;
229    let output = edit_entry(content, path, Some(text))?;
230    validate_round_trip(&output, &expected, "set")?;
231    Ok(output)
232}
233
234/// INI has no quoting or escaping for structural delimiters or edge spaces.
235/// Refuse values the writer cannot express instead of silently changing their
236/// meaning, dropping a key, or introducing an additional entry.
237fn validate_round_trip(output: &str, expected: &Value, operation: &str) -> DocumentResult<()> {
238    if load(output).is_ok_and(|parsed| &parsed == expected) {
239        return Ok(());
240    }
241    Err(unsupported(
242        operation,
243        "INI names and values must round-trip without changing structure or whitespace",
244    ))
245}
246
247/// Remove an existing INI entry, or a whole section named by one segment,
248/// without rewriting the rest of the document.
249pub fn unset_preserving(content: &str, path: &str) -> DocumentResult<String> {
250    if let [name] = crate::document::parse_path(path)?.as_slice() {
251        let document = IniDocument::parse(content).map_err(|error| with_path(error, path))?;
252        // A read of `name` returns the section, so unset must remove it
253        // rather than report a path that reads as present not found.
254        if !document.has_entry(ROOT, name) && document.has_section(name) {
255            return Ok(remove_section(content, name));
256        }
257    }
258    edit_entry(content, path, None)
259}
260
261/// Drop `name`'s header line and every line up to the next header.
262fn remove_section(content: &str, name: &str) -> String {
263    let mut output = String::with_capacity(content.len());
264    let mut inside = false;
265    for line in content.split_inclusive('\n') {
266        if let Some(header) = section_header(line) {
267            inside = header == name;
268        }
269        if !inside {
270            output.push_str(line);
271        }
272    }
273    output
274}
275
276/// The section name a line declares, or `None` when it is not a header.
277fn section_header(line: &str) -> Option<String> {
278    let trimmed = line.trim_end_matches(['\n', '\r']).trim();
279    (trimmed.starts_with('[') && trimmed.ends_with(']'))
280        .then(|| trimmed[1..trimmed.len() - 1].trim().to_string())
281}
282
283/// End `output` with a line terminator so the next line starts on its own.
284fn ensure_terminated(output: &mut String, newline: &str) {
285    if !output.is_empty() && !output.ends_with('\n') {
286        output.push_str(newline);
287    }
288}
289
290fn edit_entry(content: &str, path: &str, replacement: Option<&str>) -> DocumentResult<String> {
291    let segments = crate::document::parse_path(path)?;
292    // One segment is a root entry, two are `section.key`. Deeper has no INI
293    // spelling: a section cannot nest.
294    let (section, key) = match segments.as_slice() {
295        [key] => (ROOT.to_string(), key.clone()),
296        [section, key] => (section.clone(), key.clone()),
297        _ => return Err(unsupported("edit", "INI paths must be key or section.key")),
298    };
299    let document = IniDocument::parse(content).map_err(|error| with_path(error, path))?;
300    let section = &section;
301    let key = &key;
302    let found = document.has_entry(section, key);
303    let newline = if content.contains("\r\n") {
304        "\r\n"
305    } else {
306        "\n"
307    };
308    let lines: Vec<&str> = content.split_inclusive('\n').collect();
309
310    if found {
311        let mut current = ROOT.to_string();
312        let mut output = String::with_capacity(content.len());
313        for line in &lines {
314            if let Some(name) = section_header(line) {
315                current = name;
316            }
317            let body = line.strip_suffix('\n').unwrap_or(line);
318            let bare = body.strip_suffix('\r').unwrap_or(body);
319            let matches = current == section.as_str()
320                && bare
321                    .trim()
322                    .split_once('=')
323                    .is_some_and(|(name, _)| name.trim() == key);
324            if !matches {
325                output.push_str(line);
326                continue;
327            }
328            // `unset` drops the line entirely; `set` rewrites only the value,
329            // keeping the key's own spacing.
330            let Some(value) = replacement else { continue };
331            let eq = bare.find('=').unwrap_or(bare.len());
332            output.push_str(&bare[..eq + 1]);
333            output.push_str(
334                &bare[eq + 1..]
335                    .chars()
336                    .take_while(|character| character.is_whitespace())
337                    .collect::<String>(),
338            );
339            output.push_str(value);
340            if body.ends_with('\r') {
341                output.push('\r');
342            }
343            if line.ends_with('\n') {
344                output.push('\n');
345            }
346        }
347        return Ok(output);
348    }
349
350    let Some(value) = replacement else {
351        return Err(DocumentError::PathNotFound {
352            path: path.to_string(),
353        });
354    };
355    if section == ROOT && document.has_section(key) {
356        return Err(unsupported(
357            "edit",
358            "an INI root key cannot replace a section of the same name",
359        ));
360    }
361    if section != ROOT && document.has_entry(ROOT, section) {
362        return Err(unsupported(
363            "edit",
364            "an INI section cannot replace a root key of the same name",
365        ));
366    }
367
368    // A new key belongs at the end of its own section's block. Appending it at
369    // end of file instead re-opens a section that already closed, and the
370    // duplicate header leaves the document unparseable by the same parser that
371    // just wrote it — a successful `set` that destroys the file.
372    let mut section_seen = false;
373    let mut next_header = None;
374    for (index, line) in lines.iter().enumerate() {
375        let Some(name) = section_header(line) else {
376            continue;
377        };
378        if section_seen {
379            next_header = Some(index);
380            break;
381        }
382        if name == *section {
383            section_seen = true;
384        }
385    }
386
387    let entry = format!("{key}={value}{newline}");
388    let mut output = String::with_capacity(content.len() + entry.len());
389
390    // A new root entry goes before the first header. At end of file it would
391    // land inside whatever section closes the document — the same silent move
392    // `save` avoids, and here it would be a `set` that writes to the wrong key.
393    if section == ROOT {
394        let first_header = lines
395            .iter()
396            .position(|line| section_header(line).is_some())
397            .unwrap_or(lines.len());
398        for line in &lines[..first_header] {
399            output.push_str(line);
400        }
401        ensure_terminated(&mut output, newline);
402        output.push_str(&entry);
403        for line in &lines[first_header..] {
404            output.push_str(line);
405        }
406        return Ok(output);
407    }
408
409    match (section_seen, next_header) {
410        (true, Some(mut at)) => {
411            // Step back over the blank lines separating the two sections, so
412            // the entry lands inside its own block rather than after the gap.
413            while at > 0 && lines[at - 1].trim().is_empty() {
414                at -= 1;
415            }
416            for line in &lines[..at] {
417                output.push_str(line);
418            }
419            ensure_terminated(&mut output, newline);
420            output.push_str(&entry);
421            for line in &lines[at..] {
422                output.push_str(line);
423            }
424        }
425        (true, None) => {
426            output.push_str(content);
427            ensure_terminated(&mut output, newline);
428            output.push_str(&entry);
429        }
430        (false, _) => {
431            output.push_str(content);
432            if !output.is_empty() {
433                ensure_terminated(&mut output, newline);
434                output.push_str(newline);
435            }
436            output.push_str(&format!("[{section}]{newline}"));
437            output.push_str(&entry);
438        }
439    }
440    Ok(output)
441}
442
443fn unsupported(operation: &str, detail: &str) -> DocumentError {
444    DocumentError::UnsupportedOperation {
445        format: "INI".to_string(),
446        operation: operation.to_string(),
447        detail: detail.to_string(),
448    }
449}
450
451fn parse_error<T>(line: usize, column: usize, detail: &str) -> DocumentResult<T> {
452    Err(DocumentError::ParseError {
453        format: "INI Core v1".to_string(),
454        detail: format!("line {line}, column {column}: {detail}"),
455    })
456}
457
458fn with_path(error: DocumentError, path: &str) -> DocumentError {
459    match error {
460        DocumentError::ParseError { format, detail } => DocumentError::ParseError {
461            format,
462            detail: format!("path `{path}`: {detail}"),
463        },
464        other => other,
465    }
466}
467
468#[cfg(test)]
469mod tests {
470    use super::*;
471    use crate::document::{Document, Format};
472
473    #[test]
474    fn unrepresentable_edits_leave_the_document_unchanged() {
475        let source = "; keep\r\n[section]\r\nkey = old\r\n";
476        for (path, value) in [
477            ("section.key", "new\nextra=injected"),
478            ("section.key", " padded "),
479            ("section.new=key", "value"),
480            ("section.#hidden", "value"),
481            ("section. leading", "value"),
482            ("new[section.key", "value"),
483        ] {
484            let mut document = Document::parse(source, Format::Ini).unwrap();
485            let original = document.value().clone();
486            assert!(
487                document.set(path, Value::String(value.into())).is_err(),
488                "{path}"
489            );
490            assert_eq!(document.source(), source);
491            assert_eq!(document.value(), &original);
492        }
493    }
494
495    #[test]
496    fn unset_removes_a_whole_section_and_keeps_the_rest() {
497        let source = "top = 1\n[a]\nk = 1\n\n[b]\nk = 2\n";
498        let mut document = Document::parse(source, Format::Ini).unwrap();
499        assert!(document.unset("a").unwrap());
500        assert_eq!(document.source(), "top = 1\n[b]\nk = 2\n");
501        assert!(document.value().as_object().unwrap().get("a").is_none());
502    }
503
504    #[test]
505    fn save_refuses_names_and_values_that_change_when_reparsed() {
506        for source in [
507            r##"{"key":"value\nextra=injected"}"##,
508            r##"{"key":" padded "}"##,
509            r##"{"#hidden":"value"}"##,
510            r##"{"section":{"key=other":"value"}}"##,
511            r##"{"bad]section":{"key":"value"}}"##,
512        ] {
513            let value = Format::Json.load(source).unwrap();
514            assert!(save(&value).is_err());
515        }
516    }
517
518    #[test]
519    fn literal_delimiters_inside_values_still_round_trip() {
520        let value = Value::String("new=literal # ; value".into());
521        let source = "; keep\r\n[section]\r\nkey = old\r\n";
522        let edited = set_preserving(source, "section.key", &value).unwrap();
523        assert_eq!(
524            edited,
525            "; keep\r\n[section]\r\nkey = new=literal # ; value\r\n"
526        );
527        let parsed = load(&edited).unwrap();
528        assert_eq!(load(&save(&parsed).unwrap()).unwrap(), parsed);
529    }
530}