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
21use crate::harness::Harness;
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
25#[serde(rename_all = "snake_case")]
26pub enum IndexKind {
27 Search,
28 Semantic,
29 Callgraph,
30}
31
32impl IndexKind {
33 pub const ALL: [Self; 3] = [Self::Search, Self::Semantic, Self::Callgraph];
34
35 pub const fn as_str(self) -> &'static str {
36 match self {
37 Self::Search => "search",
38 Self::Semantic => "semantic",
39 Self::Callgraph => "callgraph",
40 }
41 }
42
43 pub fn from_name(name: &str) -> Option<Self> {
44 match name {
45 "search" => Some(Self::Search),
46 "semantic" => Some(Self::Semantic),
47 "callgraph" => Some(Self::Callgraph),
48 _ => None,
49 }
50 }
51}
52
53#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
55pub struct IndexRootConfig {
56 pub path: String,
58 pub indexes: Vec<IndexKind>,
60}
61
62#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
64#[serde(default)]
65pub struct IndexConfig {
66 pub roots: Vec<IndexRootConfig>,
67}
68
69pub fn expand_index_root_path(
72 path: &str,
73 home: Option<&std::path::Path>,
74) -> Result<PathBuf, String> {
75 let expanded = if path == "~" {
76 home.ok_or_else(|| {
77 "index.roots path uses ~ but no home directory is available".to_string()
78 })?
79 .to_path_buf()
80 } else if let Some(remainder) = path.strip_prefix("~/").or_else(|| path.strip_prefix("~\\")) {
81 home.ok_or_else(|| {
82 "index.roots path uses ~ but no home directory is available".to_string()
83 })?
84 .join(remainder)
85 } else {
86 PathBuf::from(path)
87 };
88
89 if !expanded.is_absolute() {
90 return Err(format!(
91 "index.roots path must be absolute after ~ expansion: {path:?}"
92 ));
93 }
94 Ok(expanded)
95}
96
97#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
99#[serde(rename_all = "snake_case")]
100pub enum SemanticBackend {
101 Fastembed,
102 #[serde(rename = "openai_compatible")]
103 OpenAiCompatible,
104 Ollama,
105}
106
107impl SemanticBackend {
108 pub const fn as_str(&self) -> &'static str {
109 match self {
110 Self::Fastembed => "fastembed",
111 Self::OpenAiCompatible => "openai_compatible",
112 Self::Ollama => "ollama",
113 }
114 }
115
116 pub fn from_name(name: &str) -> Option<Self> {
117 match name {
118 "fastembed" => Some(Self::Fastembed),
119 "openai_compatible" => Some(Self::OpenAiCompatible),
120 "ollama" => Some(Self::Ollama),
121 _ => None,
122 }
123 }
124}
125
126#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
127pub struct SemanticBackendConfig {
128 pub backend: SemanticBackend,
129 pub model: String,
130 pub base_url: Option<String>,
131 pub api_key_env: Option<String>,
132 pub timeout_ms: u64,
133 #[serde(default = "default_semantic_query_timeout_ms")]
136 pub query_timeout_ms: u64,
137 pub max_batch_size: usize,
138 pub max_files: usize,
142}
143
144#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
145pub struct UserServerDef {
146 pub id: String,
147 pub extensions: Vec<String>,
148 pub binary: String,
149 pub args: Vec<String>,
150 pub root_markers: Vec<String>,
151 pub env: HashMap<String, String>,
152 pub initialization_options: Option<serde_json::Value>,
153 pub disabled: bool,
154}
155
156impl Default for SemanticBackendConfig {
157 fn default() -> Self {
158 Self {
159 backend: SemanticBackend::Fastembed,
160 model: DEFAULT_SEMANTIC_MODEL.to_string(),
161 base_url: None,
162 api_key_env: None,
163 timeout_ms: 25_000,
166 query_timeout_ms: DEFAULT_SEMANTIC_QUERY_TIMEOUT_MS,
167 max_batch_size: 64,
168 max_files: 20_000,
169 }
170 }
171}
172
173#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
174#[serde(default)]
175pub struct InspectConfig {
176 pub enabled: bool,
177 #[serde(default = "default_inspect_diagnostics_timeout_ms")]
179 pub diagnostics_timeout_ms: u64,
180 pub duplicates: InspectDuplicatesConfig,
181}
182
183#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
184#[serde(default)]
185pub struct InspectDuplicatesConfig {
186 pub expected_mirrors: Vec<[String; 2]>,
187}
188
189impl Default for InspectConfig {
190 fn default() -> Self {
191 Self {
192 enabled: true,
193 diagnostics_timeout_ms: default_inspect_diagnostics_timeout_ms(),
194 duplicates: InspectDuplicatesConfig::default(),
195 }
196 }
197}
198
199#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
200#[serde(default)]
201pub struct BackupConfig {
202 pub enabled: Option<bool>,
203 pub max_depth: Option<usize>,
204 pub max_file_size: Option<u64>,
205}
206
207impl Default for BackupConfig {
208 fn default() -> Self {
209 Self {
210 enabled: Some(true),
211 max_depth: Some(crate::backup::DEFAULT_MAX_UNDO_DEPTH),
212 max_file_size: None,
213 }
214 }
215}
216
217#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
219#[serde(default)]
220pub struct GhShimConfig {
221 pub enabled: bool,
225 pub binary_path: Option<PathBuf>,
228}
229
230impl Default for GhShimConfig {
231 fn default() -> Self {
232 Self {
233 enabled: true,
234 binary_path: None,
235 }
236 }
237}
238
239#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
241#[serde(default)]
242pub struct GitConfig {
243 pub co_author: String,
245}
246
247impl Default for GitConfig {
248 fn default() -> Self {
249 Self {
250 co_author: "off".to_string(),
251 }
252 }
253}
254
255pub fn normalize_git_co_author(value: &str) -> Option<String> {
257 let value = value.trim();
258 if matches!(value, "off" | "auto") {
259 return Some(value.to_string());
260 }
261 if value.contains(['\n', '\r']) || !value.ends_with('>') {
262 return None;
263 }
264 let open = value.rfind('<')?;
265 if open == 0 || !value.as_bytes()[open - 1].is_ascii_whitespace() {
266 return None;
267 }
268 let name = value[..open].trim();
269 let email = value[open + 1..value.len() - 1].trim();
270 if name.is_empty()
271 || name.contains(['<', '>'])
272 || email.is_empty()
273 || !email.contains('@')
274 || email
275 .chars()
276 .any(|character| character.is_whitespace() || matches!(character, '<' | '>'))
277 {
278 return None;
279 }
280 Some(value.to_string())
281}
282
283#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
285#[serde(default)]
286pub struct WorktreeConfig {
287 pub ram_overlay: bool,
293}
294
295impl Default for WorktreeConfig {
296 fn default() -> Self {
297 Self { ram_overlay: false }
298 }
299}
300
301pub const DEFAULT_SEMANTIC_MODEL: &str = "all-MiniLM-L6-v2";
302
303impl Config {
304 pub fn semantic_backend_label(&self) -> &'static str {
305 self.semantic.backend.as_str()
306 }
307}
308
309#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
310#[serde(default)]
311pub struct SandboxConfig {
312 pub enabled: bool,
314 pub write_allow: Vec<PathBuf>,
316 pub read_deny: Vec<PathBuf>,
318}
319
320#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
321#[serde(default)]
322pub struct BashConfig {
323 pub host_fallback: bool,
326}
327
328#[derive(Debug, Clone, Serialize, Deserialize)]
329#[serde(default)]
330pub struct Config {
331 pub project_root: Option<PathBuf>,
333 pub validation_depth: u32,
335 pub checkpoint_ttl_hours: u32,
337 pub max_symbol_depth: u32,
339 pub formatter_timeout_secs: u32,
341 pub type_checker_timeout_secs: u32,
343 pub format_on_edit: bool,
345 pub hashline_enabled: bool,
348 pub validate_on_edit: Option<String>,
351 pub formatter: HashMap<String, String>,
354 pub checker: HashMap<String, String>,
357 pub restrict_to_project_root: bool,
360 pub search_index: bool,
362 pub index: IndexConfig,
364 pub semantic_search: bool,
366 pub aft_search_registered: bool,
372 pub callgraph_store: bool,
374 pub callgraph_chunk_size: usize,
378 pub experimental_bash_rewrite: bool,
380 pub experimental_bash_compress: bool,
382 pub experimental_bash_background: bool,
384 pub max_background_bash_tasks: usize,
386 pub bash_long_running_reminder_enabled: bool,
388 pub bash_long_running_reminder_interval_ms: u64,
390 #[serde(skip, default = "default_foreground_wait_window_ms")]
392 pub foreground_wait_window_ms: u64,
393 pub bash: BashConfig,
395 pub bash_permissions: bool,
397 pub sandbox: SandboxConfig,
399 pub search_index_max_file_size: u64,
401 pub semantic: SemanticBackendConfig,
402 pub inspect: InspectConfig,
403 pub backup: BackupConfig,
404 pub worktree: WorktreeConfig,
406 pub gh_shim: GhShimConfig,
408 pub git: GitConfig,
410 pub experimental_lsp_ty: bool,
412 pub lsp_servers: Vec<UserServerDef>,
414 pub disabled_lsp: HashSet<String>,
416 #[serde(skip)]
418 pub diagnostics_on_edit: bool,
419 pub lsp_paths_extra: Vec<PathBuf>,
427 pub lsp_auto_install_binaries: HashSet<String>,
433 pub lsp_inflight_installs: HashSet<String>,
439 pub storage_dir: Option<PathBuf>,
443 pub url_fetch_allow_private: bool,
446 #[serde(default)]
448 pub harness: Option<Harness>,
449 pub diagnostic_cache_size: usize,
454}
455
456impl Default for Config {
457 fn default() -> Self {
458 Config {
459 project_root: None,
460 validation_depth: 1,
461 checkpoint_ttl_hours: 24,
462 max_symbol_depth: 10,
463 formatter_timeout_secs: 10,
464 type_checker_timeout_secs: 30,
465 format_on_edit: false,
470 hashline_enabled: false,
471 validate_on_edit: None,
472 formatter: HashMap::new(),
473 checker: HashMap::new(),
474 restrict_to_project_root: false,
477 search_index: false,
478 index: IndexConfig::default(),
479 semantic_search: false,
480 aft_search_registered: false,
481 callgraph_store: true,
482 callgraph_chunk_size: 100,
483 experimental_bash_rewrite: false,
484 experimental_bash_compress: false,
485 experimental_bash_background: false,
486 max_background_bash_tasks: 8,
487 bash_long_running_reminder_enabled: true,
488 bash_long_running_reminder_interval_ms: 600_000,
489 foreground_wait_window_ms: default_foreground_wait_window_ms(),
490 bash: BashConfig::default(),
491 bash_permissions: false,
492 sandbox: SandboxConfig::default(),
493 search_index_max_file_size: 1_048_576,
494 semantic: SemanticBackendConfig::default(),
495 inspect: InspectConfig::default(),
496 backup: BackupConfig::default(),
497 worktree: WorktreeConfig::default(),
498 gh_shim: GhShimConfig::default(),
499 git: GitConfig::default(),
500 experimental_lsp_ty: false,
501 lsp_servers: Vec::new(),
502 disabled_lsp: HashSet::new(),
503 diagnostics_on_edit: false,
504 lsp_paths_extra: Vec::new(),
505 lsp_auto_install_binaries: HashSet::new(),
506 lsp_inflight_installs: HashSet::new(),
507 storage_dir: None,
508 url_fetch_allow_private: false,
509 harness: None,
510 diagnostic_cache_size: 5000,
511 }
512 }
513}
514
515fn default_foreground_wait_window_ms() -> u64 {
516 15_000
517}
518
519#[cfg(test)]
520mod tests {
521 use super::*;
522
523 #[test]
524 fn index_root_path_expands_tilde_before_absolute_validation() {
525 let home = std::env::temp_dir().join("aft-home");
526 assert_eq!(
527 expand_index_root_path("~/workspace", Some(&home)).unwrap(),
528 home.join("workspace")
529 );
530 assert!(expand_index_root_path("relative/root", Some(&home)).is_err());
531 assert!(expand_index_root_path("~", None).is_err());
532 }
533}