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)]
260#[serde(default)]
261pub struct GitConfig {
262 pub co_author: String,
264}
265
266impl Default for GitConfig {
267 fn default() -> Self {
268 Self {
269 co_author: "off".to_string(),
270 }
271 }
272}
273
274pub fn normalize_git_co_author(value: &str) -> Option<String> {
276 let value = value.trim();
277 if matches!(value, "off" | "auto") {
278 return Some(value.to_string());
279 }
280 if value.contains(['\n', '\r']) || !value.ends_with('>') {
281 return None;
282 }
283 let open = value.rfind('<')?;
284 if open == 0 || !value.as_bytes()[open - 1].is_ascii_whitespace() {
285 return None;
286 }
287 let name = value[..open].trim();
288 let email = value[open + 1..value.len() - 1].trim();
289 if name.is_empty()
290 || name.contains(['<', '>'])
291 || email.is_empty()
292 || !email.contains('@')
293 || email
294 .chars()
295 .any(|character| character.is_whitespace() || matches!(character, '<' | '>'))
296 {
297 return None;
298 }
299 Some(value.to_string())
300}
301
302#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
304#[serde(default)]
305pub struct WorktreeConfig {
306 pub ram_overlay: bool,
312}
313
314impl Default for WorktreeConfig {
315 fn default() -> Self {
316 Self { ram_overlay: false }
317 }
318}
319
320pub const DEFAULT_SEMANTIC_MODEL: &str = "all-MiniLM-L6-v2";
321
322impl Config {
323 pub fn semantic_backend_label(&self) -> &'static str {
324 self.semantic.backend.as_str()
325 }
326}
327
328#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
329#[serde(default)]
330pub struct SandboxConfig {
331 pub enabled: bool,
333 pub write_allow: Vec<PathBuf>,
335 pub read_deny: Vec<PathBuf>,
337}
338
339#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
340#[serde(default)]
341pub struct BashConfig {
342 pub host_fallback: bool,
345 #[serde(default = "default_bash_detach_on_user_message")]
348 pub detach_on_user_message: bool,
349 pub powershell_tool: bool,
352}
353
354impl Default for BashConfig {
355 fn default() -> Self {
356 Self {
357 host_fallback: false,
358 detach_on_user_message: default_bash_detach_on_user_message(),
359 powershell_tool: false,
360 }
361 }
362}
363
364#[derive(Debug, Clone, Serialize, Deserialize)]
365#[serde(default)]
366pub struct Config {
367 pub project_root: Option<PathBuf>,
369 pub validation_depth: u32,
371 pub checkpoint_ttl_hours: u32,
375 pub max_symbol_depth: u32,
377 pub formatter_timeout_secs: u32,
379 pub type_checker_timeout_secs: u32,
381 pub format_on_edit: bool,
383 pub hashline_enabled: bool,
386 pub validate_on_edit: Option<String>,
389 pub formatter: HashMap<String, String>,
392 pub checker: HashMap<String, String>,
395 pub restrict_to_project_root: bool,
398 pub search_index: bool,
400 pub index: IndexConfig,
402 pub semantic_search: bool,
404 pub aft_search_registered: bool,
410 pub callgraph_store: bool,
412 pub callgraph_chunk_size: usize,
416 pub experimental_bash_rewrite: bool,
418 pub experimental_bash_compress: bool,
420 pub experimental_bash_background: bool,
422 pub max_background_bash_tasks: usize,
424 pub bash_long_running_reminder_enabled: bool,
426 pub bash_long_running_reminder_interval_ms: u64,
428 #[serde(skip, default = "default_foreground_wait_window_ms")]
430 pub foreground_wait_window_ms: u64,
431 pub bash: BashConfig,
433 pub bash_permissions: bool,
435 pub sandbox: SandboxConfig,
437 pub search_index_max_file_size: u64,
439 pub semantic: SemanticBackendConfig,
440 pub inspect: InspectConfig,
441 pub backup: BackupConfig,
442 pub worktree: WorktreeConfig,
444 pub gh_shim: GhShimConfig,
446 pub git: GitConfig,
448 pub experimental_lsp_ty: bool,
450 pub lsp_servers: Vec<UserServerDef>,
452 pub disabled_lsp: HashSet<String>,
454 #[serde(skip)]
456 pub diagnostics_on_edit: bool,
457 pub lsp_paths_extra: Vec<PathBuf>,
465 pub lsp_auto_install_binaries: HashSet<String>,
471 pub lsp_inflight_installs: HashSet<String>,
477 pub storage_dir: Option<PathBuf>,
481 pub url_fetch_allow_private: bool,
484 pub hoist_builtin_tools: bool,
487 #[serde(default)]
489 pub harness: Option<Harness>,
490 pub diagnostic_cache_size: usize,
495}
496
497impl Default for Config {
498 fn default() -> Self {
499 Config {
500 project_root: None,
501 validation_depth: 1,
502 checkpoint_ttl_hours: 24,
503 max_symbol_depth: 10,
504 formatter_timeout_secs: 10,
505 type_checker_timeout_secs: 30,
506 format_on_edit: false,
511 hashline_enabled: false,
512 validate_on_edit: None,
513 formatter: HashMap::new(),
514 checker: HashMap::new(),
515 restrict_to_project_root: false,
518 search_index: false,
519 index: IndexConfig::default(),
520 semantic_search: false,
521 aft_search_registered: false,
522 callgraph_store: true,
523 callgraph_chunk_size: 100,
524 experimental_bash_rewrite: false,
525 experimental_bash_compress: false,
526 experimental_bash_background: false,
527 max_background_bash_tasks: 8,
528 bash_long_running_reminder_enabled: true,
529 bash_long_running_reminder_interval_ms: 600_000,
530 foreground_wait_window_ms: default_foreground_wait_window_ms(),
531 bash: BashConfig::default(),
532 bash_permissions: false,
533 sandbox: SandboxConfig::default(),
534 search_index_max_file_size: 1_048_576,
535 semantic: SemanticBackendConfig::default(),
536 inspect: InspectConfig::default(),
537 backup: BackupConfig::default(),
538 worktree: WorktreeConfig::default(),
539 gh_shim: GhShimConfig::default(),
540 git: GitConfig::default(),
541 experimental_lsp_ty: false,
542 lsp_servers: Vec::new(),
543 disabled_lsp: HashSet::new(),
544 diagnostics_on_edit: false,
545 lsp_paths_extra: Vec::new(),
546 lsp_auto_install_binaries: HashSet::new(),
547 lsp_inflight_installs: HashSet::new(),
548 storage_dir: None,
549 url_fetch_allow_private: false,
550 hoist_builtin_tools: true,
551 harness: None,
552 diagnostic_cache_size: 5000,
553 }
554 }
555}
556
557fn default_foreground_wait_window_ms() -> u64 {
558 15_000
559}
560
561#[cfg(test)]
562mod tests {
563 use super::*;
564
565 #[test]
566 fn index_root_path_expands_tilde_before_absolute_validation() {
567 let home = std::env::temp_dir().join("aft-home");
568 assert_eq!(
569 expand_index_root_path("~/workspace", Some(&home)).unwrap(),
570 home.join("workspace")
571 );
572 assert!(expand_index_root_path("relative/root", Some(&home)).is_err());
573 assert!(expand_index_root_path("~", None).is_err());
574 }
575}