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_prefix('[').and_then(|v| v.strip_suffix(']')) 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_prefix('[').and_then(|v| v.strip_suffix(']')) {
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    Ok(output)
214}
215
216/// Replace an existing INI scalar without reordering sections or entries.
217pub fn set_preserving(content: &str, path: &str, value: &Value) -> DocumentResult<String> {
218    let Value::String(value) = value else {
219        return Err(unsupported("set", "INI values are strings"));
220    };
221    edit_entry(content, path, Some(value))
222}
223
224/// Remove an existing INI entry without rewriting the document.
225pub fn unset_preserving(content: &str, path: &str) -> DocumentResult<String> {
226    edit_entry(content, path, None)
227}
228
229/// The section name a line declares, or `None` when it is not a header.
230fn section_header(line: &str) -> Option<String> {
231    let trimmed = line.trim_end_matches(['\n', '\r']).trim();
232    (trimmed.starts_with('[') && trimmed.ends_with(']'))
233        .then(|| trimmed[1..trimmed.len() - 1].trim().to_string())
234}
235
236/// End `output` with a line terminator so the next line starts on its own.
237fn ensure_terminated(output: &mut String, newline: &str) {
238    if !output.is_empty() && !output.ends_with('\n') {
239        output.push_str(newline);
240    }
241}
242
243fn edit_entry(content: &str, path: &str, replacement: Option<&str>) -> DocumentResult<String> {
244    let segments = crate::document::parse_path(path)?;
245    // One segment is a root entry, two are `section.key`. Deeper has no INI
246    // spelling: a section cannot nest.
247    let (section, key) = match segments.as_slice() {
248        [key] => (ROOT.to_string(), key.clone()),
249        [section, key] => (section.clone(), key.clone()),
250        _ => return Err(unsupported("edit", "INI paths must be key or section.key")),
251    };
252    let document = IniDocument::parse(content).map_err(|error| with_path(error, path))?;
253    let section = &section;
254    let key = &key;
255    let found = document.has_entry(section, key);
256    let newline = if content.contains("\r\n") {
257        "\r\n"
258    } else {
259        "\n"
260    };
261    let lines: Vec<&str> = content.split_inclusive('\n').collect();
262
263    if found {
264        let mut current = ROOT.to_string();
265        let mut output = String::with_capacity(content.len());
266        for line in &lines {
267            if let Some(name) = section_header(line) {
268                current = name;
269            }
270            let body = line.strip_suffix('\n').unwrap_or(line);
271            let bare = body.strip_suffix('\r').unwrap_or(body);
272            let matches = current == section.as_str()
273                && bare
274                    .trim()
275                    .split_once('=')
276                    .is_some_and(|(name, _)| name.trim() == key);
277            if !matches {
278                output.push_str(line);
279                continue;
280            }
281            // `unset` drops the line entirely; `set` rewrites only the value,
282            // keeping the key's own spacing.
283            let Some(value) = replacement else { continue };
284            let eq = bare.find('=').unwrap_or(bare.len());
285            output.push_str(&bare[..eq + 1]);
286            output.push_str(
287                &bare[eq + 1..]
288                    .chars()
289                    .take_while(|character| character.is_whitespace())
290                    .collect::<String>(),
291            );
292            output.push_str(value);
293            if body.ends_with('\r') {
294                output.push('\r');
295            }
296            if line.ends_with('\n') {
297                output.push('\n');
298            }
299        }
300        return Ok(output);
301    }
302
303    let Some(value) = replacement else {
304        return Err(DocumentError::PathNotFound {
305            path: path.to_string(),
306        });
307    };
308    if section == ROOT && document.has_section(key) {
309        return Err(unsupported(
310            "edit",
311            "an INI root key cannot replace a section of the same name",
312        ));
313    }
314    if section != ROOT && document.has_entry(ROOT, section) {
315        return Err(unsupported(
316            "edit",
317            "an INI section cannot replace a root key of the same name",
318        ));
319    }
320
321    // A new key belongs at the end of its own section's block. Appending it at
322    // end of file instead re-opens a section that already closed, and the
323    // duplicate header leaves the document unparseable by the same parser that
324    // just wrote it — a successful `set` that destroys the file.
325    let mut section_seen = false;
326    let mut next_header = None;
327    for (index, line) in lines.iter().enumerate() {
328        let Some(name) = section_header(line) else {
329            continue;
330        };
331        if section_seen {
332            next_header = Some(index);
333            break;
334        }
335        if name == *section {
336            section_seen = true;
337        }
338    }
339
340    let entry = format!("{key}={value}{newline}");
341    let mut output = String::with_capacity(content.len() + entry.len());
342
343    // A new root entry goes before the first header. At end of file it would
344    // land inside whatever section closes the document — the same silent move
345    // `save` avoids, and here it would be a `set` that writes to the wrong key.
346    if section == ROOT {
347        let first_header = lines
348            .iter()
349            .position(|line| section_header(line).is_some())
350            .unwrap_or(lines.len());
351        for line in &lines[..first_header] {
352            output.push_str(line);
353        }
354        ensure_terminated(&mut output, newline);
355        output.push_str(&entry);
356        for line in &lines[first_header..] {
357            output.push_str(line);
358        }
359        return Ok(output);
360    }
361
362    match (section_seen, next_header) {
363        (true, Some(mut at)) => {
364            // Step back over the blank lines separating the two sections, so
365            // the entry lands inside its own block rather than after the gap.
366            while at > 0 && lines[at - 1].trim().is_empty() {
367                at -= 1;
368            }
369            for line in &lines[..at] {
370                output.push_str(line);
371            }
372            ensure_terminated(&mut output, newline);
373            output.push_str(&entry);
374            for line in &lines[at..] {
375                output.push_str(line);
376            }
377        }
378        (true, None) => {
379            output.push_str(content);
380            ensure_terminated(&mut output, newline);
381            output.push_str(&entry);
382        }
383        (false, _) => {
384            output.push_str(content);
385            if !output.is_empty() {
386                ensure_terminated(&mut output, newline);
387                output.push_str(newline);
388            }
389            output.push_str(&format!("[{section}]{newline}"));
390            output.push_str(&entry);
391        }
392    }
393    Ok(output)
394}
395
396fn unsupported(operation: &str, detail: &str) -> DocumentError {
397    DocumentError::UnsupportedOperation {
398        format: "INI".to_string(),
399        operation: operation.to_string(),
400        detail: detail.to_string(),
401    }
402}
403
404fn parse_error<T>(line: usize, column: usize, detail: &str) -> DocumentResult<T> {
405    Err(DocumentError::ParseError {
406        format: "INI Core v1".to_string(),
407        detail: format!("line {line}, column {column}: {detail}"),
408    })
409}
410
411fn with_path(error: DocumentError, path: &str) -> DocumentError {
412    match error {
413        DocumentError::ParseError { format, detail } => DocumentError::ParseError {
414            format,
415            detail: format!("path `{path}`: {detail}"),
416        },
417        other => other,
418    }
419}