agent_first_data/document/format/
ini.rs1use crate::document::{DocumentError, DocumentResult, Value};
15use std::collections::BTreeMap;
16
17const MAX_INI_VALUE_BYTES: usize = 1024 * 1024;
18
19const ROOT: &str = "";
22
23#[derive(Debug, Clone, Copy)]
24struct IniEntry<'a> {
25 section: &'a str,
26 key: &'a str,
27}
28
29#[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 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 Some(section) => {
144 if let Some(Value::Object(entries)) = root.get_mut(section) {
145 entries.insert(key, value);
146 }
147 }
148 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 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 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
217pub 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
234fn 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
247pub fn unset_preserving(content: &str, path: &str) -> DocumentResult<String> {
249 edit_entry(content, path, None)
250}
251
252fn section_header(line: &str) -> Option<String> {
254 let trimmed = line.trim_end_matches(['\n', '\r']).trim();
255 (trimmed.starts_with('[') && trimmed.ends_with(']'))
256 .then(|| trimmed[1..trimmed.len() - 1].trim().to_string())
257}
258
259fn ensure_terminated(output: &mut String, newline: &str) {
261 if !output.is_empty() && !output.ends_with('\n') {
262 output.push_str(newline);
263 }
264}
265
266fn edit_entry(content: &str, path: &str, replacement: Option<&str>) -> DocumentResult<String> {
267 let segments = crate::document::parse_path(path)?;
268 let (section, key) = match segments.as_slice() {
271 [key] => (ROOT.to_string(), key.clone()),
272 [section, key] => (section.clone(), key.clone()),
273 _ => return Err(unsupported("edit", "INI paths must be key or section.key")),
274 };
275 let document = IniDocument::parse(content).map_err(|error| with_path(error, path))?;
276 let section = §ion;
277 let key = &key;
278 let found = document.has_entry(section, key);
279 let newline = if content.contains("\r\n") {
280 "\r\n"
281 } else {
282 "\n"
283 };
284 let lines: Vec<&str> = content.split_inclusive('\n').collect();
285
286 if found {
287 let mut current = ROOT.to_string();
288 let mut output = String::with_capacity(content.len());
289 for line in &lines {
290 if let Some(name) = section_header(line) {
291 current = name;
292 }
293 let body = line.strip_suffix('\n').unwrap_or(line);
294 let bare = body.strip_suffix('\r').unwrap_or(body);
295 let matches = current == section.as_str()
296 && bare
297 .trim()
298 .split_once('=')
299 .is_some_and(|(name, _)| name.trim() == key);
300 if !matches {
301 output.push_str(line);
302 continue;
303 }
304 let Some(value) = replacement else { continue };
307 let eq = bare.find('=').unwrap_or(bare.len());
308 output.push_str(&bare[..eq + 1]);
309 output.push_str(
310 &bare[eq + 1..]
311 .chars()
312 .take_while(|character| character.is_whitespace())
313 .collect::<String>(),
314 );
315 output.push_str(value);
316 if body.ends_with('\r') {
317 output.push('\r');
318 }
319 if line.ends_with('\n') {
320 output.push('\n');
321 }
322 }
323 return Ok(output);
324 }
325
326 let Some(value) = replacement else {
327 return Err(DocumentError::PathNotFound {
328 path: path.to_string(),
329 });
330 };
331 if section == ROOT && document.has_section(key) {
332 return Err(unsupported(
333 "edit",
334 "an INI root key cannot replace a section of the same name",
335 ));
336 }
337 if section != ROOT && document.has_entry(ROOT, section) {
338 return Err(unsupported(
339 "edit",
340 "an INI section cannot replace a root key of the same name",
341 ));
342 }
343
344 let mut section_seen = false;
349 let mut next_header = None;
350 for (index, line) in lines.iter().enumerate() {
351 let Some(name) = section_header(line) else {
352 continue;
353 };
354 if section_seen {
355 next_header = Some(index);
356 break;
357 }
358 if name == *section {
359 section_seen = true;
360 }
361 }
362
363 let entry = format!("{key}={value}{newline}");
364 let mut output = String::with_capacity(content.len() + entry.len());
365
366 if section == ROOT {
370 let first_header = lines
371 .iter()
372 .position(|line| section_header(line).is_some())
373 .unwrap_or(lines.len());
374 for line in &lines[..first_header] {
375 output.push_str(line);
376 }
377 ensure_terminated(&mut output, newline);
378 output.push_str(&entry);
379 for line in &lines[first_header..] {
380 output.push_str(line);
381 }
382 return Ok(output);
383 }
384
385 match (section_seen, next_header) {
386 (true, Some(mut at)) => {
387 while at > 0 && lines[at - 1].trim().is_empty() {
390 at -= 1;
391 }
392 for line in &lines[..at] {
393 output.push_str(line);
394 }
395 ensure_terminated(&mut output, newline);
396 output.push_str(&entry);
397 for line in &lines[at..] {
398 output.push_str(line);
399 }
400 }
401 (true, None) => {
402 output.push_str(content);
403 ensure_terminated(&mut output, newline);
404 output.push_str(&entry);
405 }
406 (false, _) => {
407 output.push_str(content);
408 if !output.is_empty() {
409 ensure_terminated(&mut output, newline);
410 output.push_str(newline);
411 }
412 output.push_str(&format!("[{section}]{newline}"));
413 output.push_str(&entry);
414 }
415 }
416 Ok(output)
417}
418
419fn unsupported(operation: &str, detail: &str) -> DocumentError {
420 DocumentError::UnsupportedOperation {
421 format: "INI".to_string(),
422 operation: operation.to_string(),
423 detail: detail.to_string(),
424 }
425}
426
427fn parse_error<T>(line: usize, column: usize, detail: &str) -> DocumentResult<T> {
428 Err(DocumentError::ParseError {
429 format: "INI Core v1".to_string(),
430 detail: format!("line {line}, column {column}: {detail}"),
431 })
432}
433
434fn with_path(error: DocumentError, path: &str) -> DocumentError {
435 match error {
436 DocumentError::ParseError { format, detail } => DocumentError::ParseError {
437 format,
438 detail: format!("path `{path}`: {detail}"),
439 },
440 other => other,
441 }
442}
443
444#[cfg(test)]
445mod tests {
446 use super::*;
447 use crate::document::{Document, Format};
448
449 #[test]
450 fn unrepresentable_edits_leave_the_document_unchanged() {
451 let source = "; keep\r\n[section]\r\nkey = old\r\n";
452 for (path, value) in [
453 ("section.key", "new\nextra=injected"),
454 ("section.key", " padded "),
455 ("section.new=key", "value"),
456 ("section.#hidden", "value"),
457 ("section. leading", "value"),
458 ("new[section.key", "value"),
459 ] {
460 let mut document = Document::parse(source, Format::Ini).unwrap();
461 let original = document.value().clone();
462 assert!(
463 document.set(path, Value::String(value.into())).is_err(),
464 "{path}"
465 );
466 assert_eq!(document.source(), source);
467 assert_eq!(document.value(), &original);
468 }
469 }
470
471 #[test]
472 fn save_refuses_names_and_values_that_change_when_reparsed() {
473 for source in [
474 r##"{"key":"value\nextra=injected"}"##,
475 r##"{"key":" padded "}"##,
476 r##"{"#hidden":"value"}"##,
477 r##"{"section":{"key=other":"value"}}"##,
478 r##"{"bad]section":{"key":"value"}}"##,
479 ] {
480 let value = Format::Json.load(source).unwrap();
481 assert!(save(&value).is_err());
482 }
483 }
484
485 #[test]
486 fn literal_delimiters_inside_values_still_round_trip() {
487 let value = Value::String("new=literal # ; value".into());
488 let source = "; keep\r\n[section]\r\nkey = old\r\n";
489 let edited = set_preserving(source, "section.key", &value).unwrap();
490 assert_eq!(
491 edited,
492 "; keep\r\n[section]\r\nkey = new=literal # ; value\r\n"
493 );
494 let parsed = load(&edited).unwrap();
495 assert_eq!(load(&save(&parsed).unwrap()).unwrap(), parsed);
496 }
497}