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_scalar_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 edit_entry(content: &str, path: &str, replacement: Option<&str>) -> DocumentResult<String> {
170 let segments = crate::document::parse_path(path)?;
171 if segments.len() != 2 {
172 return Err(unsupported("edit", "INI paths must be section.key"));
173 }
174 let document = IniDocument::parse(content).map_err(|error| with_path(error, path))?;
175 let section = &segments[0];
176 let key = &segments[1];
177 let mut current = String::new();
178 let found = document.has_entry(section, key);
179 let mut output = String::with_capacity(content.len());
180 for line in content.split_inclusive('\n') {
181 let body = line.strip_suffix('\n').unwrap_or(line);
182 let bare = body.strip_suffix('\r').unwrap_or(body);
183 let trimmed = bare.trim();
184 if trimmed.starts_with('[') && trimmed.ends_with(']') {
185 current = trimmed[1..trimmed.len() - 1].trim().to_string();
186 }
187 let matches = current == section.as_str()
188 && trimmed
189 .split_once('=')
190 .is_some_and(|(name, _)| name.trim() == key);
191 if matches {
192 if let Some(value) = replacement {
193 let eq = bare.find('=').unwrap_or(bare.len());
194 output.push_str(&bare[..eq + 1]);
195 output.push_str(
196 &bare[eq + 1..]
197 .chars()
198 .take_while(|character| character.is_whitespace())
199 .collect::<String>(),
200 );
201 output.push_str(value);
202 if body.ends_with('\r') {
203 output.push('\r');
204 }
205 if line.ends_with('\n') {
206 output.push('\n');
207 }
208 }
209 } else {
210 output.push_str(line);
211 }
212 }
213 if !found {
214 let section_header = format!("[{section}]");
215 if replacement.is_some() {
216 if current == section.as_str() && !output.ends_with('\n') {
217 output.push('\n');
218 }
219 if !output.is_empty() && !output.ends_with('\n') {
220 output.push('\n');
221 }
222 if current != section.as_str() {
223 if !output.is_empty() && !output.ends_with("\n\n") {
224 output.push('\n');
225 }
226 output.push_str(§ion_header);
227 output.push('\n');
228 }
229 output.push_str(key);
230 output.push('=');
231 output.push_str(replacement.unwrap_or_default());
232 if content.ends_with('\n') || !output.ends_with('\n') {
233 output.push('\n');
234 }
235 return Ok(output);
236 }
237 return Err(DocumentError::PathNotFound {
238 path: path.to_string(),
239 });
240 }
241 Ok(output)
242}
243
244fn unsupported(operation: &str, detail: &str) -> DocumentError {
245 DocumentError::UnsupportedOperation {
246 format: "INI".to_string(),
247 operation: operation.to_string(),
248 detail: detail.to_string(),
249 }
250}
251
252fn parse_error<T>(line: usize, column: usize, detail: &str) -> DocumentResult<T> {
253 Err(DocumentError::ParseError {
254 format: "INI Core v1".to_string(),
255 detail: format!("line {line}, column {column}: {detail}"),
256 })
257}
258
259fn with_path(error: DocumentError, path: &str) -> DocumentError {
260 match error {
261 DocumentError::ParseError { format, detail } => DocumentError::ParseError {
262 format,
263 detail: format!("path `{path}`: {detail}"),
264 },
265 other => other,
266 }
267}