Skip to main content

a3s_code_core/config/
editor.rs

1use super::acl_render::{render_labeled_section, render_single_section};
2use super::CodeConfig;
3use crate::error::{CodeError, Result};
4
5/// Logical portions of `config.acl` that can be rewritten independently.
6///
7/// The editor replaces only the requested top-level entries. Unrelated blocks,
8/// unknown configuration, comments outside a replaced block, and raw
9/// expressions such as `env("TOKEN")` remain intact.
10#[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
25/// Rewrite selected sections of an ACL document from a typed configuration.
26pub 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}