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_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 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 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 Ok(output)
214}
215
216pub 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
224pub fn unset_preserving(content: &str, path: &str) -> DocumentResult<String> {
226 edit_entry(content, path, None)
227}
228
229fn 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
236fn 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 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 = §ion;
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 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 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 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 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}