1use std::collections::{HashMap, HashSet};
2use std::path::PathBuf;
3
4use serde::{Deserialize, Serialize};
5
6pub(crate) const DEFAULT_SEMANTIC_QUERY_TIMEOUT_MS: u64 = 3_000;
7pub(crate) const MIN_SEMANTIC_QUERY_TIMEOUT_MS: u64 = 500;
8pub(crate) const MAX_SEMANTIC_QUERY_TIMEOUT_MS: u64 = 15_000;
9pub(crate) const DEFAULT_INSPECT_DIAGNOSTICS_TIMEOUT_MS: u64 = 120_000;
10pub(crate) const MIN_INSPECT_DIAGNOSTICS_TIMEOUT_MS: u64 = 10_000;
11pub(crate) const MAX_INSPECT_DIAGNOSTICS_TIMEOUT_MS: u64 = 600_000;
12
13const fn default_semantic_query_timeout_ms() -> u64 {
14 DEFAULT_SEMANTIC_QUERY_TIMEOUT_MS
15}
16
17const fn default_inspect_diagnostics_timeout_ms() -> u64 {
18 DEFAULT_INSPECT_DIAGNOSTICS_TIMEOUT_MS
19}
20
21const fn default_bash_detach_on_user_message() -> bool {
22 true
23}
24
25use crate::harness::Harness;
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
29#[serde(rename_all = "snake_case")]
30pub enum IndexKind {
31 Search,
32 Semantic,
33 Callgraph,
34}
35
36impl IndexKind {
37 pub const ALL: [Self; 3] = [Self::Search, Self::Semantic, Self::Callgraph];
38
39 pub const fn as_str(self) -> &'static str {
40 match self {
41 Self::Search => "search",
42 Self::Semantic => "semantic",
43 Self::Callgraph => "callgraph",
44 }
45 }
46
47 pub fn from_name(name: &str) -> Option<Self> {
48 match name {
49 "search" => Some(Self::Search),
50 "semantic" => Some(Self::Semantic),
51 "callgraph" => Some(Self::Callgraph),
52 _ => None,
53 }
54 }
55}
56
57#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
59pub struct IndexRootConfig {
60 pub path: String,
62 pub indexes: Vec<IndexKind>,
64}
65
66#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
68#[serde(default)]
69pub struct IndexConfig {
70 pub roots: Vec<IndexRootConfig>,
71}
72
73pub fn expand_index_root_path(
76 path: &str,
77 home: Option<&std::path::Path>,
78) -> Result<PathBuf, String> {
79 let expanded = if path == "~" {
80 home.ok_or_else(|| {
81 "index.roots path uses ~ but no home directory is available".to_string()
82 })?
83 .to_path_buf()
84 } else if let Some(remainder) = path.strip_prefix("~/").or_else(|| path.strip_prefix("~\\")) {
85 home.ok_or_else(|| {
86 "index.roots path uses ~ but no home directory is available".to_string()
87 })?
88 .join(remainder)
89 } else {
90 PathBuf::from(path)
91 };
92
93 if !expanded.is_absolute() {
94 return Err(format!(
95 "index.roots path must be absolute after ~ expansion: {path:?}"
96 ));
97 }
98 Ok(expanded)
99}
100
101#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
103#[serde(rename_all = "snake_case")]
104pub enum SemanticBackend {
105 Fastembed,
106 #[serde(rename = "openai_compatible")]
107 OpenAiCompatible,
108 Ollama,
109 Synapse,
110}
111
112impl SemanticBackend {
113 pub const fn as_str(&self) -> &'static str {
114 match self {
115 Self::Fastembed => "fastembed",
116 Self::OpenAiCompatible => "openai_compatible",
117 Self::Ollama => "ollama",
118 Self::Synapse => "synapse",
119 }
120 }
121
122 pub fn from_name(name: &str) -> Option<Self> {
123 match name {
124 "fastembed" => Some(Self::Fastembed),
125 "openai_compatible" => Some(Self::OpenAiCompatible),
126 "ollama" => Some(Self::Ollama),
127 "synapse" => Some(Self::Synapse),
128 _ => None,
129 }
130 }
131}
132
133#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
134pub struct SemanticBackendConfig {
135 pub backend: SemanticBackend,
136 pub model: String,
137 pub base_url: Option<String>,
138 pub api_key_env: Option<String>,
139 pub timeout_ms: u64,
140 #[serde(default = "default_semantic_query_timeout_ms")]
143 pub query_timeout_ms: u64,
144 pub max_batch_size: usize,
145 pub max_files: usize,
149 #[serde(skip)]
151 pub subc_connection_file: Option<PathBuf>,
152 #[serde(skip)]
155 pub route_project_root: Option<PathBuf>,
156 #[serde(skip)]
157 pub route_harness: Option<String>,
158}
159
160#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
161pub struct UserServerDef {
162 pub id: String,
163 pub extensions: Vec<String>,
164 pub binary: String,
165 pub args: Vec<String>,
166 pub root_markers: Vec<String>,
167 pub env: HashMap<String, String>,
168 pub initialization_options: Option<serde_json::Value>,
169 pub disabled: bool,
170}
171
172impl Default for SemanticBackendConfig {
173 fn default() -> Self {
174 Self {
175 backend: SemanticBackend::Fastembed,
176 model: DEFAULT_SEMANTIC_MODEL.to_string(),
177 base_url: None,
178 api_key_env: None,
179 timeout_ms: 25_000,
182 query_timeout_ms: DEFAULT_SEMANTIC_QUERY_TIMEOUT_MS,
183 max_batch_size: 64,
184 max_files: 20_000,
185 subc_connection_file: None,
186 route_project_root: None,
187 route_harness: None,
188 }
189 }
190}
191
192#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
193#[serde(default)]
194pub struct InspectConfig {
195 pub enabled: bool,
196 #[serde(default = "default_inspect_diagnostics_timeout_ms")]
198 pub diagnostics_timeout_ms: u64,
199 pub duplicates: InspectDuplicatesConfig,
200}
201
202#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
203#[serde(default)]
204pub struct InspectDuplicatesConfig {
205 pub expected_mirrors: Vec<[String; 2]>,
206}
207
208impl Default for InspectConfig {
209 fn default() -> Self {
210 Self {
211 enabled: true,
212 diagnostics_timeout_ms: default_inspect_diagnostics_timeout_ms(),
213 duplicates: InspectDuplicatesConfig::default(),
214 }
215 }
216}
217
218#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
219#[serde(default)]
220pub struct BackupConfig {
221 pub enabled: Option<bool>,
222 pub max_depth: Option<usize>,
223 pub max_file_size: Option<u64>,
224}
225
226impl Default for BackupConfig {
227 fn default() -> Self {
228 Self {
229 enabled: Some(true),
230 max_depth: Some(crate::backup::DEFAULT_MAX_UNDO_DEPTH),
231 max_file_size: None,
232 }
233 }
234}
235
236#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
238#[serde(default)]
239pub struct GhShimConfig {
240 pub enabled: bool,
244 pub binary_path: Option<PathBuf>,
247}
248
249impl Default for GhShimConfig {
250 fn default() -> Self {
251 Self {
252 enabled: true,
253 binary_path: None,
254 }
255 }
256}
257
258#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
266#[serde(default)]
267pub struct GhReadConfig {
268 pub enabled: bool,
271}
272
273impl Default for GhReadConfig {
274 fn default() -> Self {
275 Self { enabled: false }
276 }
277}
278
279#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
281#[serde(default)]
282pub struct GitConfig {
283 pub co_author: String,
285}
286
287impl Default for GitConfig {
288 fn default() -> Self {
289 Self {
290 co_author: "off".to_string(),
291 }
292 }
293}
294
295pub fn normalize_git_co_author(value: &str) -> Option<String> {
297 let value = value.trim();
298 if matches!(value, "off" | "auto") {
299 return Some(value.to_string());
300 }
301 if value.contains(['\n', '\r']) || !value.ends_with('>') {
302 return None;
303 }
304 let open = value.rfind('<')?;
305 if open == 0 || !value.as_bytes()[open - 1].is_ascii_whitespace() {
306 return None;
307 }
308 let name = value[..open].trim();
309 let email = value[open + 1..value.len() - 1].trim();
310 if name.is_empty()
311 || name.contains(['<', '>'])
312 || email.is_empty()
313 || !email.contains('@')
314 || email
315 .chars()
316 .any(|character| character.is_whitespace() || matches!(character, '<' | '>'))
317 {
318 return None;
319 }
320 Some(value.to_string())
321}
322
323#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
325#[serde(default)]
326pub struct WorktreeConfig {
327 pub ram_overlay: bool,
333}
334
335impl Default for WorktreeConfig {
336 fn default() -> Self {
337 Self { ram_overlay: false }
338 }
339}
340
341pub const DEFAULT_SEMANTIC_MODEL: &str = "all-MiniLM-L6-v2";
342
343impl Config {
344 pub fn semantic_backend_label(&self) -> &'static str {
345 self.semantic.backend.as_str()
346 }
347}
348
349#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
350#[serde(default)]
351pub struct SandboxConfig {
352 pub enabled: bool,
354 pub write_allow: Vec<PathBuf>,
356 pub read_deny: Vec<PathBuf>,
358}
359
360#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
361#[serde(default)]
362pub struct BashConfig {
363 pub host_fallback: bool,
366 #[serde(default = "default_bash_detach_on_user_message")]
369 pub detach_on_user_message: bool,
370 pub powershell_tool: bool,
373}
374
375impl Default for BashConfig {
376 fn default() -> Self {
377 Self {
378 host_fallback: false,
379 detach_on_user_message: default_bash_detach_on_user_message(),
380 powershell_tool: false,
381 }
382 }
383}
384
385#[derive(Debug, Clone, Serialize, Deserialize)]
386#[serde(default)]
387pub struct Config {
388 pub project_root: Option<PathBuf>,
390 pub validation_depth: u32,
392 pub checkpoint_ttl_hours: u32,
396 pub max_symbol_depth: u32,
398 pub formatter_timeout_secs: u32,
400 pub type_checker_timeout_secs: u32,
402 pub format_on_edit: bool,
404 pub hashline_enabled: bool,
407 pub validate_on_edit: Option<String>,
410 pub formatter: HashMap<String, String>,
413 pub checker: HashMap<String, String>,
416 pub restrict_to_project_root: bool,
419 pub search_index: bool,
421 pub index: IndexConfig,
423 pub semantic_search: bool,
425 pub aft_search_registered: bool,
431 pub callgraph_store: bool,
433 pub callgraph_chunk_size: usize,
437 pub experimental_bash_rewrite: bool,
439 pub experimental_bash_compress: bool,
441 pub experimental_bash_background: bool,
443 pub max_background_bash_tasks: usize,
445 pub bash_long_running_reminder_enabled: bool,
447 pub bash_long_running_reminder_interval_ms: u64,
449 #[serde(skip, default = "default_foreground_wait_window_ms")]
451 pub foreground_wait_window_ms: u64,
452 pub bash: BashConfig,
454 pub bash_permissions: bool,
456 pub sandbox: SandboxConfig,
458 pub search_index_max_file_size: u64,
460 pub semantic: SemanticBackendConfig,
461 pub inspect: InspectConfig,
462 pub backup: BackupConfig,
463 pub worktree: WorktreeConfig,
465 pub gh_shim: GhShimConfig,
467 pub gh_read: GhReadConfig,
469 pub git: GitConfig,
471 pub experimental_lsp_ty: bool,
473 pub lsp_servers: Vec<UserServerDef>,
475 pub disabled_lsp: HashSet<String>,
477 #[serde(skip)]
479 pub diagnostics_on_edit: bool,
480 pub lsp_paths_extra: Vec<PathBuf>,
488 pub lsp_auto_install_binaries: HashSet<String>,
494 pub lsp_inflight_installs: HashSet<String>,
500 pub storage_dir: Option<PathBuf>,
504 pub url_fetch_allow_private: bool,
507 pub hoist_builtin_tools: bool,
510 #[serde(default)]
512 pub harness: Option<Harness>,
513 pub diagnostic_cache_size: usize,
518}
519
520impl Default for Config {
521 fn default() -> Self {
522 Config {
523 project_root: None,
524 validation_depth: 1,
525 checkpoint_ttl_hours: 24,
526 max_symbol_depth: 10,
527 formatter_timeout_secs: 10,
528 type_checker_timeout_secs: 30,
529 format_on_edit: false,
534 hashline_enabled: false,
535 validate_on_edit: None,
536 formatter: HashMap::new(),
537 checker: HashMap::new(),
538 restrict_to_project_root: false,
541 search_index: false,
542 index: IndexConfig::default(),
543 semantic_search: false,
544 aft_search_registered: false,
545 callgraph_store: true,
546 callgraph_chunk_size: 100,
547 experimental_bash_rewrite: false,
548 experimental_bash_compress: false,
549 experimental_bash_background: false,
550 max_background_bash_tasks: 8,
551 bash_long_running_reminder_enabled: true,
552 bash_long_running_reminder_interval_ms: 600_000,
553 foreground_wait_window_ms: default_foreground_wait_window_ms(),
554 bash: BashConfig::default(),
555 bash_permissions: false,
556 sandbox: SandboxConfig::default(),
557 search_index_max_file_size: 1_048_576,
558 semantic: SemanticBackendConfig::default(),
559 inspect: InspectConfig::default(),
560 backup: BackupConfig::default(),
561 worktree: WorktreeConfig::default(),
562 gh_shim: GhShimConfig::default(),
563 gh_read: GhReadConfig::default(),
564 git: GitConfig::default(),
565 experimental_lsp_ty: false,
566 lsp_servers: Vec::new(),
567 disabled_lsp: HashSet::new(),
568 diagnostics_on_edit: false,
569 lsp_paths_extra: Vec::new(),
570 lsp_auto_install_binaries: HashSet::new(),
571 lsp_inflight_installs: HashSet::new(),
572 storage_dir: None,
573 url_fetch_allow_private: false,
574 hoist_builtin_tools: true,
575 harness: None,
576 diagnostic_cache_size: 5000,
577 }
578 }
579}
580
581fn default_foreground_wait_window_ms() -> u64 {
582 15_000
583}
584
585#[cfg(test)]
586mod tests {
587 use super::*;
588
589 #[test]
590 fn index_root_path_expands_tilde_before_absolute_validation() {
591 let home = std::env::temp_dir().join("aft-home");
592 assert_eq!(
593 expand_index_root_path("~/workspace", Some(&home)).unwrap(),
594 home.join("workspace")
595 );
596 assert!(expand_index_root_path("relative/root", Some(&home)).is_err());
597 assert!(expand_index_root_path("~", None).is_err());
598 }
599}