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> {
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 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
261fn 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
276fn 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
283fn 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 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 = §ion;
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 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 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 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 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}