agent_first_data/document/format/
ini.rs1use 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#[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
156pub 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
164pub fn unset_preserving(content: &str, path: &str) -> DocumentResult<String> {
166 edit_entry(content, path, None)
167}
168
169fn 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
176fn 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 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 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 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}