Skip to main content

agent_first_data/document/format/
ini.rs

1//! INI Core v1: a deliberately small, deterministic INI dialect.
2
3use crate::document::{DocumentError, DocumentResult, Value};
4use std::collections::BTreeMap;
5
6const MAX_INI_VALUE_BYTES: usize = 1024 * 1024;
7
8#[derive(Debug, Clone, Copy)]
9struct IniEntry<'a> {
10    section: &'a str,
11    key: &'a str,
12}
13
14/// Parsed INI Core v1 source document. The lexer used for semantic loading and
15/// source editing is intentionally shared so both paths enforce the same
16/// section/key/duplicate rules.
17#[derive(Debug)]
18pub struct IniDocument<'a> {
19    source: &'a str,
20    entries: Vec<IniEntry<'a>>,
21}
22
23impl<'a> IniDocument<'a> {
24    pub fn parse(source: &'a str) -> DocumentResult<Self> {
25        let mut entries = Vec::new();
26        let mut current: Option<&str> = None;
27        let mut sections = BTreeMap::<&str, usize>::new();
28        for (index, raw) in source.lines().enumerate() {
29            let line_number = index + 1;
30            let line = raw.strip_suffix('\r').unwrap_or(raw);
31            let trimmed = line.trim();
32            if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with(';') {
33                continue;
34            }
35            if trimmed.starts_with('[') {
36                let Some(name) = trimmed.strip_prefix('[').and_then(|v| v.strip_suffix(']')) else {
37                    return parse_error(line_number, 1, "invalid section header");
38                };
39                let name = name.trim();
40                if name.is_empty() || name.contains(['[', ']']) {
41                    return parse_error(line_number, 1, "section name must be non-empty");
42                }
43                if sections.insert(name, line_number).is_some() {
44                    return parse_error(line_number, 1, "duplicate section");
45                }
46                current = Some(name);
47                continue;
48            }
49            let Some(section) = current else {
50                return parse_error(line_number, 1, "root entries are not supported");
51            };
52            let Some((key, value)) = line.split_once('=') else {
53                return parse_error(line_number, 1, "expected key=value entry");
54            };
55            let key = key.trim();
56            if key.is_empty() || key.contains(['[', ']']) {
57                return parse_error(line_number, 1, "key must be non-empty");
58            }
59            if value.trim().len() > MAX_INI_VALUE_BYTES {
60                return parse_error(
61                    line_number,
62                    line.find('=').unwrap_or(0) + 2,
63                    "value exceeds 1 MiB",
64                );
65            }
66            if entries
67                .iter()
68                .any(|entry: &IniEntry<'_>| entry.section == section && entry.key == key)
69            {
70                return parse_error(
71                    line_number,
72                    line.find(key).unwrap_or(0) + 1,
73                    "duplicate key",
74                );
75            }
76            entries.push(IniEntry { section, key });
77        }
78        Ok(Self { source, entries })
79    }
80
81    fn has_entry(&self, section: &str, key: &str) -> bool {
82        self.entries
83            .iter()
84            .any(|entry| entry.section == section && entry.key == key)
85    }
86
87    fn to_value(&self) -> Value {
88        let mut sections = BTreeMap::<String, Value>::new();
89        let mut current: Option<String> = None;
90        for raw in self.source.lines() {
91            let line = raw.strip_suffix('\r').unwrap_or(raw);
92            let trimmed = line.trim();
93            if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with(';') {
94                continue;
95            }
96            if let Some(name) = trimmed.strip_prefix('[').and_then(|v| v.strip_suffix(']')) {
97                let name = name.trim().to_string();
98                sections.insert(name.clone(), Value::Object(BTreeMap::new()));
99                current = Some(name);
100                continue;
101            }
102            if let (Some(section), Some((key, value))) = (current.as_ref(), line.split_once('='))
103                && let Some(Value::Object(entries)) = sections.get_mut(section)
104            {
105                entries.insert(
106                    key.trim().to_string(),
107                    Value::String(value.trim().to_string()),
108                );
109            }
110        }
111        Value::Object(sections)
112    }
113}
114
115pub fn load(content: &str) -> DocumentResult<Value> {
116    Ok(IniDocument::parse(content)?.to_value())
117}
118
119pub fn save(value: &Value) -> DocumentResult<String> {
120    let Value::Object(sections) = value else {
121        return Err(DocumentError::UnsupportedOperation {
122            format: "INI".to_string(),
123            operation: "save".to_string(),
124            detail: "INI requires a section object".to_string(),
125        });
126    };
127    let mut output = String::new();
128    for (section, value) in sections {
129        let Value::Object(entries) = value else {
130            return Err(DocumentError::UnsupportedOperation {
131                format: "INI".to_string(),
132                operation: "save".to_string(),
133                detail: format!("section `{section}` must be an object"),
134            });
135        };
136        output.push('[');
137        output.push_str(section);
138        output.push_str("]\n");
139        for (key, value) in entries {
140            let Value::String(value) = value else {
141                return Err(DocumentError::UnsupportedOperation {
142                    format: "INI".to_string(),
143                    operation: "save".to_string(),
144                    detail: format!("entry `{section}.{key}` must remain a string"),
145                });
146            };
147            output.push_str(key);
148            output.push('=');
149            output.push_str(value);
150            output.push('\n');
151        }
152    }
153    Ok(output)
154}
155
156/// Replace an existing INI scalar without reordering sections or entries.
157pub fn set_preserving(content: &str, path: &str, value: &Value) -> DocumentResult<String> {
158    let Value::String(value) = value else {
159        return Err(unsupported("set", "INI values are strings"));
160    };
161    edit_entry(content, path, Some(value))
162}
163
164/// Remove an existing INI entry without rewriting the document.
165pub fn unset_preserving(content: &str, path: &str) -> DocumentResult<String> {
166    edit_entry(content, path, None)
167}
168
169/// The section name a line declares, or `None` when it is not a header.
170fn section_header(line: &str) -> Option<String> {
171    let trimmed = line.trim_end_matches(['\n', '\r']).trim();
172    (trimmed.starts_with('[') && trimmed.ends_with(']'))
173        .then(|| trimmed[1..trimmed.len() - 1].trim().to_string())
174}
175
176/// End `output` with a line terminator so the next line starts on its own.
177fn ensure_terminated(output: &mut String, newline: &str) {
178    if !output.is_empty() && !output.ends_with('\n') {
179        output.push_str(newline);
180    }
181}
182
183fn edit_entry(content: &str, path: &str, replacement: Option<&str>) -> DocumentResult<String> {
184    let segments = crate::document::parse_path(path)?;
185    if segments.len() != 2 {
186        return Err(unsupported("edit", "INI paths must be section.key"));
187    }
188    let document = IniDocument::parse(content).map_err(|error| with_path(error, path))?;
189    let section = &segments[0];
190    let key = &segments[1];
191    let found = document.has_entry(section, key);
192    let newline = if content.contains("\r\n") {
193        "\r\n"
194    } else {
195        "\n"
196    };
197    let lines: Vec<&str> = content.split_inclusive('\n').collect();
198
199    if found {
200        let mut current = String::new();
201        let mut output = String::with_capacity(content.len());
202        for line in &lines {
203            if let Some(name) = section_header(line) {
204                current = name;
205            }
206            let body = line.strip_suffix('\n').unwrap_or(line);
207            let bare = body.strip_suffix('\r').unwrap_or(body);
208            let matches = current == section.as_str()
209                && bare
210                    .trim()
211                    .split_once('=')
212                    .is_some_and(|(name, _)| name.trim() == key);
213            if !matches {
214                output.push_str(line);
215                continue;
216            }
217            // `unset` drops the line entirely; `set` rewrites only the value,
218            // keeping the key's own spacing.
219            let Some(value) = replacement else { continue };
220            let eq = bare.find('=').unwrap_or(bare.len());
221            output.push_str(&bare[..eq + 1]);
222            output.push_str(
223                &bare[eq + 1..]
224                    .chars()
225                    .take_while(|character| character.is_whitespace())
226                    .collect::<String>(),
227            );
228            output.push_str(value);
229            if body.ends_with('\r') {
230                output.push('\r');
231            }
232            if line.ends_with('\n') {
233                output.push('\n');
234            }
235        }
236        return Ok(output);
237    }
238
239    let Some(value) = replacement else {
240        return Err(DocumentError::PathNotFound {
241            path: path.to_string(),
242        });
243    };
244
245    // A new key belongs at the end of its own section's block. Appending it at
246    // end of file instead re-opens a section that already closed, and the
247    // duplicate header leaves the document unparseable by the same parser that
248    // just wrote it — a successful `set` that destroys the file.
249    let mut section_seen = false;
250    let mut next_header = None;
251    for (index, line) in lines.iter().enumerate() {
252        let Some(name) = section_header(line) else {
253            continue;
254        };
255        if section_seen {
256            next_header = Some(index);
257            break;
258        }
259        if name == *section {
260            section_seen = true;
261        }
262    }
263
264    let entry = format!("{key}={value}{newline}");
265    let mut output = String::with_capacity(content.len() + entry.len());
266    match (section_seen, next_header) {
267        (true, Some(mut at)) => {
268            // Step back over the blank lines separating the two sections, so
269            // the entry lands inside its own block rather than after the gap.
270            while at > 0 && lines[at - 1].trim().is_empty() {
271                at -= 1;
272            }
273            for line in &lines[..at] {
274                output.push_str(line);
275            }
276            ensure_terminated(&mut output, newline);
277            output.push_str(&entry);
278            for line in &lines[at..] {
279                output.push_str(line);
280            }
281        }
282        (true, None) => {
283            output.push_str(content);
284            ensure_terminated(&mut output, newline);
285            output.push_str(&entry);
286        }
287        (false, _) => {
288            output.push_str(content);
289            if !output.is_empty() {
290                ensure_terminated(&mut output, newline);
291                output.push_str(newline);
292            }
293            output.push_str(&format!("[{section}]{newline}"));
294            output.push_str(&entry);
295        }
296    }
297    Ok(output)
298}
299
300fn unsupported(operation: &str, detail: &str) -> DocumentError {
301    DocumentError::UnsupportedOperation {
302        format: "INI".to_string(),
303        operation: operation.to_string(),
304        detail: detail.to_string(),
305    }
306}
307
308fn parse_error<T>(line: usize, column: usize, detail: &str) -> DocumentResult<T> {
309    Err(DocumentError::ParseError {
310        format: "INI Core v1".to_string(),
311        detail: format!("line {line}, column {column}: {detail}"),
312    })
313}
314
315fn with_path(error: DocumentError, path: &str) -> DocumentError {
316    match error {
317        DocumentError::ParseError { format, detail } => DocumentError::ParseError {
318            format,
319            detail: format!("path `{path}`: {detail}"),
320        },
321        other => other,
322    }
323}