1use super::acl_render::{render_labeled_section, render_single_section};
2use super::CodeConfig;
3use crate::error::{CodeError, Result};
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
11pub enum ConfigSection {
12 DefaultModel,
13 Providers,
14 ModelRuntime,
15 Execution,
16 Storage,
17 Memory,
18 Queue,
19 Search,
20 Os,
21 DocumentParser,
22 McpServers,
23}
24
25pub fn rewrite_acl_sections(
27 source: &str,
28 config: &CodeConfig,
29 sections: &[ConfigSection],
30) -> Result<String> {
31 let document = a3s_acl::parse_acl(source)
32 .map_err(|error| CodeError::Config(format!("Failed to parse ACL: {error}")))?;
33 let original = CodeConfig::from_acl(source)?;
34 let mut output = source.to_string();
35
36 for section in deduplicated_sections(sections) {
37 match section {
38 ConfigSection::Providers | ConfigSection::McpServers => {
39 let rendered = render_labeled_section(section, config, &original, &document);
40 let names = section_names(section);
41 output = replace_labeled_entries(&output, names, rendered);
42 }
43 _ => {
44 for entry in render_single_section(section, config, &original, &document) {
45 output = replace_single_entry(&output, entry.names, entry.text);
46 }
47 }
48 }
49 }
50
51 CodeConfig::from_acl(&output).map_err(|error| {
52 CodeError::Config(format!("Generated ACL did not pass validation: {error}"))
53 })?;
54 Ok(normalize_final_newline(output))
55}
56
57fn deduplicated_sections(sections: &[ConfigSection]) -> Vec<ConfigSection> {
58 let mut result = Vec::new();
59 for section in sections {
60 if !result.contains(section) {
61 result.push(*section);
62 }
63 }
64 result
65}
66
67fn section_names(section: ConfigSection) -> &'static [&'static str] {
68 match section {
69 ConfigSection::Providers => &["providers"],
70 ConfigSection::McpServers => &["mcp_servers", "mcpServers", "mcp_server"],
71 _ => &[],
72 }
73}
74
75#[derive(Debug, Clone, PartialEq, Eq)]
76struct TopLevelEntry {
77 name: String,
78 label: Option<String>,
79 start: usize,
80 end: usize,
81}
82
83fn replace_single_entry(source: &str, names: &[&str], rendered: Option<String>) -> String {
84 let entries = scan_top_level_entries(source);
85 let matching = entries
86 .iter()
87 .filter(|entry| names.contains(&entry.name.as_str()))
88 .collect::<Vec<_>>();
89
90 if matching.is_empty() {
91 return rendered
92 .map(|value| append_entry(source, &value))
93 .unwrap_or_else(|| source.to_string());
94 }
95
96 let mut edits = Vec::new();
97 for (index, entry) in matching.into_iter().enumerate() {
98 let replacement = if index == 0 {
99 rendered.clone().unwrap_or_default()
100 } else {
101 String::new()
102 };
103 edits.push((entry.start, entry.end, replacement));
104 }
105 apply_edits(source, edits)
106}
107
108fn replace_labeled_entries(
109 source: &str,
110 names: &[&str],
111 rendered: Vec<(String, String)>,
112) -> String {
113 let entries = scan_top_level_entries(source);
114 let matching = entries
115 .iter()
116 .filter(|entry| names.contains(&entry.name.as_str()))
117 .collect::<Vec<_>>();
118 let mut remaining = rendered;
119 let mut edits = Vec::new();
120
121 for entry in matching {
122 let replacement = entry.label.as_ref().and_then(|label| {
123 remaining
124 .iter()
125 .position(|(candidate, _)| candidate == label)
126 .map(|index| remaining.remove(index).1)
127 });
128 edits.push((entry.start, entry.end, replacement.unwrap_or_default()));
129 }
130
131 let mut output = apply_edits(source, edits);
132 for (_, entry) in remaining {
133 output = append_entry(&output, &entry);
134 }
135 output
136}
137
138fn append_entry(source: &str, entry: &str) -> String {
139 let mut output = source.trim_end().to_string();
140 if !output.is_empty() {
141 output.push_str("\n\n");
142 }
143 output.push_str(entry.trim());
144 output.push('\n');
145 output
146}
147
148fn apply_edits(source: &str, mut edits: Vec<(usize, usize, String)>) -> String {
149 edits.sort_by_key(|edit| std::cmp::Reverse(edit.0));
150 let mut output = source.to_string();
151 for (start, end, replacement) in edits {
152 let replacement = if replacement.trim().is_empty() {
153 String::new()
154 } else {
155 format!("{}\n", replacement.trim_end())
156 };
157 output.replace_range(start..end, &replacement);
158 }
159 output
160}
161
162fn normalize_final_newline(mut source: String) -> String {
163 while source.ends_with("\n\n\n") {
164 source.pop();
165 }
166 if !source.ends_with('\n') {
167 source.push('\n');
168 }
169 source
170}
171
172fn scan_top_level_entries(source: &str) -> Vec<TopLevelEntry> {
173 let bytes = source.as_bytes();
174 let mut entries = Vec::new();
175 let mut index = 0;
176
177 while index < bytes.len() {
178 index = skip_space_and_comments(bytes, index);
179 if index >= bytes.len() {
180 break;
181 }
182 if !is_ident_start(bytes[index]) {
183 index += 1;
184 continue;
185 }
186
187 let start = index;
188 index += 1;
189 while index < bytes.len() && is_ident_part(bytes[index]) {
190 index += 1;
191 }
192 let name = source[start..index].to_string();
193 let header_start = index;
194 let mut cursor = index;
195 let mut braces = 0usize;
196 let mut brackets = 0usize;
197 let mut parens = 0usize;
198 let mut quote = None;
199 let mut escaped = false;
200 let mut saw_delimiter = false;
201
202 while cursor < bytes.len() {
203 let byte = bytes[cursor];
204 if let Some(active_quote) = quote {
205 if escaped {
206 escaped = false;
207 } else if byte == b'\\' {
208 escaped = true;
209 } else if byte == active_quote {
210 quote = None;
211 }
212 cursor += 1;
213 continue;
214 }
215 match byte {
216 b'"' | b'\'' => {
217 quote = Some(byte);
218 cursor += 1;
219 }
220 b'#' => cursor = skip_line(bytes, cursor),
221 b'/' if bytes.get(cursor + 1) == Some(&b'/') => cursor = skip_line(bytes, cursor),
222 b'{' => {
223 saw_delimiter = true;
224 braces += 1;
225 cursor += 1;
226 }
227 b'}' => {
228 braces = braces.saturating_sub(1);
229 cursor += 1;
230 }
231 b'[' => {
232 saw_delimiter = true;
233 brackets += 1;
234 cursor += 1;
235 }
236 b']' => {
237 brackets = brackets.saturating_sub(1);
238 cursor += 1;
239 }
240 b'(' => {
241 saw_delimiter = true;
242 parens += 1;
243 cursor += 1;
244 }
245 b')' => {
246 parens = parens.saturating_sub(1);
247 cursor += 1;
248 }
249 b'=' | b':' => {
250 saw_delimiter = true;
251 cursor += 1;
252 }
253 b'\n' if saw_delimiter && braces == 0 && brackets == 0 && parens == 0 => {
254 cursor += 1;
255 break;
256 }
257 _ => cursor += 1,
258 }
259 }
260
261 let header_end = source[header_start..cursor]
262 .find('{')
263 .or_else(|| source[header_start..cursor].find('='))
264 .map(|offset| header_start + offset)
265 .unwrap_or(cursor);
266 entries.push(TopLevelEntry {
267 name,
268 label: parse_first_quoted(&source[header_start..header_end]),
269 start,
270 end: cursor,
271 });
272 index = cursor.max(start + 1);
273 }
274
275 entries
276}
277
278fn skip_space_and_comments(bytes: &[u8], mut index: usize) -> usize {
279 loop {
280 while index < bytes.len() && bytes[index].is_ascii_whitespace() {
281 index += 1;
282 }
283 if index < bytes.len() && bytes[index] == b'#' {
284 index = skip_line(bytes, index);
285 continue;
286 }
287 if bytes.get(index) == Some(&b'/') && bytes.get(index + 1) == Some(&b'/') {
288 index = skip_line(bytes, index);
289 continue;
290 }
291 return index;
292 }
293}
294
295fn skip_line(bytes: &[u8], mut index: usize) -> usize {
296 while index < bytes.len() && bytes[index] != b'\n' {
297 index += 1;
298 }
299 index
300}
301
302fn parse_first_quoted(value: &str) -> Option<String> {
303 let bytes = value.as_bytes();
304 let start = bytes
305 .iter()
306 .position(|byte| *byte == b'"' || *byte == b'\'')?;
307 let quote = bytes[start];
308 let mut result = String::new();
309 let mut escaped = false;
310 for byte in &bytes[start + 1..] {
311 if escaped {
312 result.push(*byte as char);
313 escaped = false;
314 } else if *byte == b'\\' {
315 escaped = true;
316 } else if *byte == quote {
317 return Some(result);
318 } else {
319 result.push(*byte as char);
320 }
321 }
322 None
323}
324
325fn is_ident_start(byte: u8) -> bool {
326 byte.is_ascii_alphabetic() || byte == b'_'
327}
328
329fn is_ident_part(byte: u8) -> bool {
330 byte.is_ascii_alphanumeric() || byte == b'_'
331}
332
333#[cfg(test)]
334mod tests {
335 use super::{rewrite_acl_sections, scan_top_level_entries, ConfigSection};
336 use crate::config::CodeConfig;
337
338 const COMPLETE_ACL: &str = r#"# keep this document comment
339default_model = "openai/gpt-test"
340thinking_budget = 12000
341llm_api_timeout_ms = 45000
342storage_backend = "custom"
343sessions_dir = "./sessions"
344memory_dir = "./memory"
345storage_url = env("A3S_EDITOR_STORAGE_URL")
346skill_dirs = ["./skills"]
347agent_dirs = ["./agents"]
348max_tool_rounds = 42
349max_parallel_tasks = 6
350auto_parallel = false
351os = "https://os.example.test"
352
353auto_delegation {
354 enabled = true
355 auto_parallel = false
356 allow_manual_delegation = false
357 min_confidence = 0.81
358 max_tasks = 7
359}
360
361providers "openai" {
362 api_key = env("A3S_EDITOR_PROVIDER_KEY")
363 base_url = "https://llm.example.test/v1"
364 headers = { Authorization = env("A3S_EDITOR_PROVIDER_TOKEN"), X_Tenant = "a3s" }
365
366 models "gpt-test" {
367 name = "GPT Test"
368 family = "gpt"
369 api_key = env("A3S_EDITOR_MODEL_KEY")
370 attachment = true
371 reasoning = true
372 tool_call = true
373 temperature = false
374 modalities = { input = ["text", "image"], output = ["text"] }
375 cost = { input = 1.25, output = 4.5, cache_read = 0.2, cache_write = 0.4 }
376 limit = { context = 128000, output = 8192 }
377 }
378}
379
380memory {
381 max_short_term = 120
382 max_working = 16
383 prune_interval_secs = 1800
384 llm_extraction = true
385 llm_extraction_max_items = 9
386 llm_extraction_max_input_chars = 12000
387 relevance {
388 decay_days = 45
389 importance_weight = 0.8
390 recency_weight = 0.2
391 }
392 prune_policy {
393 max_age_days = 120
394 min_importance_to_keep = 0.6
395 max_items = 5000
396 }
397}
398
399queue {
400 control_max_concurrency = 3
401 query_max_concurrency = 9
402 execute_max_concurrency = 5
403 generate_max_concurrency = 2
404 enable_dlq = true
405 dlq_max_size = 250
406 enable_metrics = true
407 enable_alerts = true
408 default_timeout_ms = 65000
409 storage_path = "./queue"
410 pressure_threshold = 30
411 lane_handlers {
412 query {
413 mode = "external"
414 timeout_ms = 20000
415 }
416 }
417 lane_timeouts = { query = 30000, execute = 120000 }
418 retry_policy {
419 strategy = "exponential"
420 max_retries = 5
421 initial_delay_ms = 250
422 }
423 rate_limit {
424 limit_type = "per_minute"
425 max_operations = 600
426 }
427 priority_boost {
428 strategy = "aggressive"
429 deadline_ms = 10000
430 }
431}
432
433search {
434 timeout = 18
435 health {
436 max_failures = 4
437 suspend_seconds = 90
438 }
439 headless {
440 backend = "lightpanda"
441 max_tabs = 6
442 launch_args = ["--disable-gpu"]
443 }
444 engine {
445 duckduckgo {
446 enabled = true
447 weight = 1.3
448 timeout = 12
449 }
450 }
451}
452
453document_parser {
454 enabled = true
455 max_file_size_mb = 80
456 cache {
457 enabled = true
458 directory = "./document-cache"
459 }
460 ocr {
461 enabled = true
462 model = "openai/gpt-test"
463 max_images = 12
464 dpi = 192
465 provider = "vision"
466 api_key = env("A3S_EDITOR_OCR_KEY")
467 }
468}
469
470mcp_servers "remote" {
471 transport = "streamable-http"
472 url = "https://mcp.example.test"
473 headers = { Authorization = env("A3S_EDITOR_MCP_HEADER") }
474 enabled = true
475 env = { MCP_TOKEN = env("A3S_EDITOR_MCP_TOKEN") }
476 tool_timeout_secs = 75
477 oauth {
478 auth_url = "https://auth.example.test"
479 token_url = "https://token.example.test"
480 client_id = "client"
481 client_secret = env("A3S_EDITOR_OAUTH_SECRET")
482 scopes = ["tools.read"]
483 redirect_uri = "http://127.0.0.1/callback"
484 access_token = env("A3S_EDITOR_OAUTH_TOKEN")
485 }
486}
487
488# unknown top-level configuration must survive every settings save
489future_feature "keep-me" {
490 enabled = true
491 nested {
492 value = "untouched"
493 }
494}
495"#;
496
497 #[test]
498 fn scanner_keeps_comments_and_finds_labeled_blocks() {
499 let source = r#"# before
500default_model = "openai/a"
501
502providers "openai" {
503 api_key = env("OPENAI_API_KEY")
504 models "a" { name = "A" }
505}
506
507unknown { nested { enabled = true } }
508"#;
509 let entries = scan_top_level_entries(source);
510 assert_eq!(entries.len(), 3);
511 assert_eq!(entries[0].name, "default_model");
512 assert_eq!(entries[1].label.as_deref(), Some("openai"));
513 assert_eq!(entries[2].name, "unknown");
514 assert!(source[..entries[0].start].contains("# before"));
515 }
516
517 #[test]
518 fn every_config_section_round_trips_without_losing_unmanaged_content_or_env_calls() {
519 let mut config = CodeConfig::from_acl(COMPLETE_ACL).expect("complete config");
520 config.default_model = Some("openai/gpt-test".to_string());
521 config.thinking_budget = Some(16000);
522 config.max_tool_rounds = Some(64);
523 config.auto_delegation.max_tasks = 9;
524 config.memory.as_mut().expect("memory").max_working = 24;
525 config.queue.as_mut().expect("queue").query_max_concurrency = 11;
526 config.search.as_mut().expect("search").timeout = 22;
527 config
528 .document_parser
529 .as_mut()
530 .expect("document parser")
531 .max_file_size_mb = 96;
532
533 let output = rewrite_acl_sections(
534 COMPLETE_ACL,
535 &config,
536 &[
537 ConfigSection::DefaultModel,
538 ConfigSection::Providers,
539 ConfigSection::ModelRuntime,
540 ConfigSection::Execution,
541 ConfigSection::Storage,
542 ConfigSection::Memory,
543 ConfigSection::Queue,
544 ConfigSection::Search,
545 ConfigSection::Os,
546 ConfigSection::DocumentParser,
547 ConfigSection::McpServers,
548 ],
549 )
550 .expect("rewrite every section");
551
552 assert!(output.contains("# keep this document comment"));
553 assert!(
554 output.contains("# unknown top-level configuration must survive every settings save")
555 );
556 assert!(output.contains("future_feature \"keep-me\""));
557 assert!(output.contains("value = \"untouched\""));
558 for expression in [
559 "env(\"A3S_EDITOR_STORAGE_URL\")",
560 "env(\"A3S_EDITOR_PROVIDER_KEY\")",
561 "env(\"A3S_EDITOR_PROVIDER_TOKEN\")",
562 "env(\"A3S_EDITOR_MODEL_KEY\")",
563 "env(\"A3S_EDITOR_OCR_KEY\")",
564 "env(\"A3S_EDITOR_MCP_HEADER\")",
565 "env(\"A3S_EDITOR_MCP_TOKEN\")",
566 "env(\"A3S_EDITOR_OAUTH_SECRET\")",
567 "env(\"A3S_EDITOR_OAUTH_TOKEN\")",
568 ] {
569 assert!(
570 output.contains(expression),
571 "missing {expression}:\n{output}"
572 );
573 }
574
575 let round_trip = CodeConfig::from_acl(&output).expect("rewritten ACL parses");
576 assert_eq!(round_trip.thinking_budget, Some(16000));
577 assert_eq!(round_trip.max_tool_rounds, Some(64));
578 assert_eq!(round_trip.auto_delegation.max_tasks, 9);
579 assert_eq!(round_trip.memory.expect("memory").max_working, 24);
580 assert_eq!(round_trip.queue.expect("queue").query_max_concurrency, 11);
581 assert_eq!(round_trip.search.expect("search").timeout, 22);
582 assert_eq!(
583 round_trip
584 .document_parser
585 .expect("document parser")
586 .max_file_size_mb,
587 96
588 );
589 }
590
591 #[test]
592 fn labeled_sections_remove_missing_entries_and_append_new_entries() {
593 let mut config = CodeConfig::from_acl(COMPLETE_ACL).expect("complete config");
594 let mut provider = config.providers[0].clone();
595 provider.name = "replacement".to_string();
596 provider.models[0].id = "replacement-model".to_string();
597 provider.models[0].name = "Replacement Model".to_string();
598 provider.api_key = Some("replacement-key".to_string());
599 config.providers = vec![provider];
600
601 let mut server = config.mcp_servers[0].clone();
602 server.name = "replacement-mcp".to_string();
603 server.env.insert("VISIBLE".to_string(), "yes".to_string());
604 config.mcp_servers = vec![server];
605
606 let output = rewrite_acl_sections(
607 COMPLETE_ACL,
608 &config,
609 &[ConfigSection::Providers, ConfigSection::McpServers],
610 )
611 .expect("rewrite labeled sections");
612
613 assert!(!output.contains("providers \"openai\""));
614 assert!(!output.contains("mcp_servers \"remote\""));
615 assert!(output.contains("providers \"replacement\""));
616 assert!(output.contains("models \"replacement-model\""));
617 assert!(output.contains("mcp_servers \"replacement-mcp\""));
618
619 let round_trip = CodeConfig::from_acl(&output).expect("rewritten ACL parses");
620 assert_eq!(round_trip.providers.len(), 1);
621 assert_eq!(round_trip.providers[0].name, "replacement");
622 assert_eq!(round_trip.mcp_servers.len(), 1);
623 assert_eq!(round_trip.mcp_servers[0].name, "replacement-mcp");
624 }
625}