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 const DEFAULT_SEMANTIC_QUERY_INSTRUCTION: &str = "auto";
13pub const QWEN3_EMBEDDING_MODEL_CARD_RETRIEVAL_TASK: &str =
16 "Given a web search query, retrieve relevant passages that answer the query";
17pub const QWEN3_EMBEDDING_CODE_SEARCH_TASK: &str =
18 "Given a code search query, retrieve relevant source code, symbols, and documentation";
19pub(crate) const MIN_SEMANTIC_QUERY_TIMEOUT_MS: u64 = 500;
20pub(crate) const MAX_SEMANTIC_QUERY_TIMEOUT_MS: u64 = 15_000;
21pub(crate) const DEFAULT_INSPECT_DIAGNOSTICS_TIMEOUT_MS: u64 = 120_000;
22pub(crate) const MIN_INSPECT_DIAGNOSTICS_TIMEOUT_MS: u64 = 10_000;
23pub(crate) const MAX_INSPECT_DIAGNOSTICS_TIMEOUT_MS: u64 = 600_000;
24pub const DEFAULT_INSPECT_TIER2_PASS_TIMEOUT_MS: u64 = 600_000;
25pub const DEFAULT_BASH_WATCH_SYNC_MAX_MS: u64 = 120_000;
26pub const MIN_BASH_WATCH_SYNC_MAX_MS: u64 = 1_000;
27pub const MAX_BASH_WATCH_SYNC_MAX_MS: u64 = 1_800_000;
28
29pub const DEFAULT_IDLE_ROOT_TTL_MINUTES: u32 = 30;
31pub const MIN_IDLE_ROOT_TTL_MINUTES: u32 = 5;
32pub const MAX_IDLE_ROOT_TTL_MINUTES: u32 = 30;
33pub const DEFAULT_IDLE_LSP_TTL_MINUTES: u32 = 10;
35pub const MIN_IDLE_LSP_TTL_MINUTES: u32 = 1;
36pub const MAX_IDLE_LSP_TTL_MINUTES: u32 = 10;
37
38const fn default_semantic_query_timeout_ms() -> u64 {
39 DEFAULT_SEMANTIC_QUERY_TIMEOUT_MS
40}
41
42const fn default_inspect_diagnostics_timeout_ms() -> u64 {
43 DEFAULT_INSPECT_DIAGNOSTICS_TIMEOUT_MS
44}
45
46const fn default_inspect_tier2_pass_timeout_ms() -> u64 {
47 DEFAULT_INSPECT_TIER2_PASS_TIMEOUT_MS
48}
49
50const fn default_bash_detach_on_user_message() -> bool {
51 true
52}
53
54pub(crate) const fn default_bash_watch_sync_max_ms() -> u64 {
55 DEFAULT_BASH_WATCH_SYNC_MAX_MS
56}
57
58use crate::harness::Harness;
59
60#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
66#[serde(default)]
67pub struct IdleConfig {
68 pub root_ttl_minutes: u32,
69 pub lsp_ttl_minutes: u32,
70}
71
72impl Default for IdleConfig {
73 fn default() -> Self {
74 Self {
75 root_ttl_minutes: DEFAULT_IDLE_ROOT_TTL_MINUTES,
76 lsp_ttl_minutes: DEFAULT_IDLE_LSP_TTL_MINUTES,
77 }
78 }
79}
80
81impl IdleConfig {
82 pub fn root_ttl(&self) -> std::time::Duration {
83 std::time::Duration::from_secs(u64::from(self.root_ttl_minutes) * 60)
84 }
85
86 pub fn lsp_ttl(&self) -> std::time::Duration {
87 std::time::Duration::from_secs(u64::from(self.lsp_ttl_minutes) * 60)
88 }
89}
90
91#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
93#[serde(rename_all = "snake_case")]
94pub enum IndexKind {
95 Search,
96 Semantic,
97 Callgraph,
98}
99
100impl IndexKind {
101 pub const ALL: [Self; 3] = [Self::Search, Self::Semantic, Self::Callgraph];
102
103 pub const fn as_str(self) -> &'static str {
104 match self {
105 Self::Search => "search",
106 Self::Semantic => "semantic",
107 Self::Callgraph => "callgraph",
108 }
109 }
110
111 pub fn from_name(name: &str) -> Option<Self> {
112 match name {
113 "search" => Some(Self::Search),
114 "semantic" => Some(Self::Semantic),
115 "callgraph" => Some(Self::Callgraph),
116 _ => None,
117 }
118 }
119}
120
121#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
123pub struct IndexRootConfig {
124 pub path: String,
126 pub indexes: Vec<IndexKind>,
128}
129
130#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
132#[serde(default)]
133pub struct IndexConfig {
134 pub roots: Vec<IndexRootConfig>,
135}
136
137pub fn expand_index_root_path(
140 path: &str,
141 home: Option<&std::path::Path>,
142) -> Result<PathBuf, String> {
143 let expanded = if path == "~" {
144 home.ok_or_else(|| {
145 "index.roots path uses ~ but no home directory is available".to_string()
146 })?
147 .to_path_buf()
148 } else if let Some(remainder) = path.strip_prefix("~/").or_else(|| path.strip_prefix("~\\")) {
149 home.ok_or_else(|| {
150 "index.roots path uses ~ but no home directory is available".to_string()
151 })?
152 .join(remainder)
153 } else {
154 PathBuf::from(path)
155 };
156
157 if !expanded.is_absolute() {
158 return Err(format!(
159 "index.roots path must be absolute after ~ expansion: {path:?}"
160 ));
161 }
162 Ok(expanded)
163}
164
165#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
167#[serde(rename_all = "snake_case")]
168pub enum SemanticBackend {
169 Fastembed,
170 #[serde(rename = "openai_compatible")]
171 OpenAiCompatible,
172 Ollama,
173 Synapse,
174}
175
176impl SemanticBackend {
177 pub const fn as_str(&self) -> &'static str {
178 match self {
179 Self::Fastembed => "fastembed",
180 Self::OpenAiCompatible => "openai_compatible",
181 Self::Ollama => "ollama",
182 Self::Synapse => "synapse",
183 }
184 }
185
186 pub fn from_name(name: &str) -> Option<Self> {
187 match name {
188 "fastembed" => Some(Self::Fastembed),
189 "openai_compatible" => Some(Self::OpenAiCompatible),
190 "ollama" => Some(Self::Ollama),
191 "synapse" => Some(Self::Synapse),
192 _ => None,
193 }
194 }
195}
196
197#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
198pub struct SemanticBackendConfig {
199 pub backend: SemanticBackend,
200 pub model: String,
201 pub base_url: Option<String>,
202 pub api_key_env: Option<String>,
203 pub timeout_ms: u64,
207 #[serde(default = "default_semantic_query_timeout_ms")]
210 pub query_timeout_ms: u64,
211 #[serde(default = "default_semantic_query_instruction")]
214 pub query_instruction: String,
215 pub max_batch_size: usize,
216 #[serde(default, skip_serializing_if = "Option::is_none")]
219 pub max_input_tokens: Option<usize>,
220 pub max_files: usize,
224 #[serde(skip)]
226 pub subc_connection_file: Option<PathBuf>,
227 #[serde(skip)]
230 pub route_project_root: Option<PathBuf>,
231 #[serde(skip)]
232 pub route_harness: Option<String>,
233}
234
235#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
236pub struct UserServerDef {
237 pub id: String,
238 pub extensions: Vec<String>,
239 pub binary: String,
240 pub args: Vec<String>,
241 pub root_markers: Vec<String>,
242 pub env: HashMap<String, String>,
243 pub initialization_options: Option<serde_json::Value>,
244 pub disabled: bool,
245}
246
247fn default_semantic_query_instruction() -> String {
248 DEFAULT_SEMANTIC_QUERY_INSTRUCTION.to_string()
249}
250
251impl SemanticBackendConfig {
252 pub fn resolved_query_instruction(&self) -> Option<&str> {
253 if self.backend == SemanticBackend::Fastembed {
254 return None;
255 }
256 match self.query_instruction.as_str() {
257 "off" => None,
258 "auto" if self.model.to_ascii_lowercase().contains("qwen3-embedding") => {
259 Some(QWEN3_EMBEDDING_MODEL_CARD_RETRIEVAL_TASK)
260 }
261 "auto" => None,
262 literal => Some(literal),
263 }
264 }
265}
266
267impl Default for SemanticBackendConfig {
268 fn default() -> Self {
269 Self {
270 backend: SemanticBackend::Fastembed,
271 model: DEFAULT_SEMANTIC_MODEL.to_string(),
272 base_url: None,
273 api_key_env: None,
274 timeout_ms: 25_000,
278 query_timeout_ms: DEFAULT_SEMANTIC_QUERY_TIMEOUT_MS,
279 query_instruction: default_semantic_query_instruction(),
280 max_batch_size: 64,
281 max_input_tokens: None,
282 max_files: 20_000,
283 subc_connection_file: None,
284 route_project_root: None,
285 route_harness: None,
286 }
287 }
288}
289
290#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
291#[serde(default)]
292pub struct InspectConfig {
293 pub enabled: bool,
294 #[serde(default = "default_inspect_diagnostics_timeout_ms")]
296 pub diagnostics_timeout_ms: u64,
297 #[serde(default = "default_inspect_tier2_pass_timeout_ms")]
299 pub tier2_pass_timeout_ms: u64,
300 pub duplicates: InspectDuplicatesConfig,
301}
302
303#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
304#[serde(default)]
305pub struct InspectDuplicatesConfig {
306 pub expected_mirrors: Vec<[String; 2]>,
307}
308
309impl Default for InspectConfig {
310 fn default() -> Self {
311 Self {
312 enabled: true,
313 diagnostics_timeout_ms: default_inspect_diagnostics_timeout_ms(),
314 tier2_pass_timeout_ms: default_inspect_tier2_pass_timeout_ms(),
315 duplicates: InspectDuplicatesConfig::default(),
316 }
317 }
318}
319
320#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
321#[serde(default)]
322pub struct BackupConfig {
323 pub enabled: Option<bool>,
324 pub max_depth: Option<usize>,
325 pub max_file_size: Option<u64>,
326}
327
328impl Default for BackupConfig {
329 fn default() -> Self {
330 Self {
331 enabled: Some(true),
332 max_depth: Some(crate::backup::DEFAULT_MAX_UNDO_DEPTH),
333 max_file_size: Some(crate::backup::DEFAULT_MAX_BACKUP_FILE_SIZE),
334 }
335 }
336}
337
338#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
340#[serde(default)]
341pub struct GhShimConfig {
342 pub enabled: bool,
346 pub binary_path: Option<PathBuf>,
349}
350
351impl Default for GhShimConfig {
352 fn default() -> Self {
353 Self {
354 enabled: true,
355 binary_path: None,
356 }
357 }
358}
359
360#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
362#[serde(default)]
363pub struct GithubConfig {
364 pub enabled: bool,
366 pub shim: bool,
368 pub read: bool,
370 pub write: bool,
372}
373
374impl Default for GithubConfig {
375 fn default() -> Self {
376 Self {
377 enabled: true,
378 shim: true,
379 read: false,
380 write: false,
381 }
382 }
383}
384
385#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
387#[serde(default)]
388pub struct GhReadConfig {
389 pub enabled: bool,
390}
391
392impl Default for GhReadConfig {
393 fn default() -> Self {
394 Self { enabled: false }
395 }
396}
397
398#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
400#[serde(default)]
401pub struct GitConfig {
402 pub co_author: String,
404}
405
406impl Default for GitConfig {
407 fn default() -> Self {
408 Self {
409 co_author: "off".to_string(),
410 }
411 }
412}
413
414pub fn normalize_git_co_author(value: &str) -> Option<String> {
416 let value = value.trim();
417 if matches!(value, "off" | "auto") {
418 return Some(value.to_string());
419 }
420 if value.contains(['\n', '\r']) || !value.ends_with('>') {
421 return None;
422 }
423 let open = value.rfind('<')?;
424 if open == 0 || !value.as_bytes()[open - 1].is_ascii_whitespace() {
425 return None;
426 }
427 let name = value[..open].trim();
428 let email = value[open + 1..value.len() - 1].trim();
429 if name.is_empty()
430 || name.contains(['<', '>'])
431 || email.is_empty()
432 || !email.contains('@')
433 || email
434 .chars()
435 .any(|character| character.is_whitespace() || matches!(character, '<' | '>'))
436 {
437 return None;
438 }
439 Some(value.to_string())
440}
441
442#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
444#[serde(default)]
445pub struct ViewsConfig {
446 pub enabled: bool,
448}
449
450#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
452#[serde(default)]
453pub struct WorktreeConfig {
454 pub ram_overlay: bool,
460}
461
462impl Default for WorktreeConfig {
463 fn default() -> Self {
464 Self { ram_overlay: false }
465 }
466}
467
468pub const DEFAULT_SEMANTIC_MODEL: &str = "all-MiniLM-L6-v2";
469
470impl Config {
471 pub fn semantic_backend_label(&self) -> &'static str {
472 self.semantic.backend.as_str()
473 }
474}
475
476#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
477#[serde(default)]
478pub struct SandboxConfig {
479 pub enabled: bool,
481 pub write_allow: Vec<PathBuf>,
483 pub read_deny: Vec<PathBuf>,
485}
486
487#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
488#[serde(default)]
489pub struct BashConfig {
490 pub host_fallback: bool,
493 #[serde(default = "default_bash_detach_on_user_message")]
496 pub detach_on_user_message: bool,
497 #[serde(default = "default_bash_watch_sync_max_ms")]
500 pub watch_sync_max_ms: u64,
501 pub linux_scope: bool,
503 pub powershell_tool: bool,
506}
507
508impl Default for BashConfig {
509 fn default() -> Self {
510 Self {
511 host_fallback: false,
512 detach_on_user_message: default_bash_detach_on_user_message(),
513 watch_sync_max_ms: default_bash_watch_sync_max_ms(),
514 linux_scope: false,
515 powershell_tool: false,
516 }
517 }
518}
519
520#[derive(Debug, Clone, Serialize, Deserialize)]
521#[serde(default)]
522pub struct Config {
523 pub project_root: Option<PathBuf>,
525 pub validation_depth: u32,
527 pub checkpoint_ttl_hours: u32,
531 pub max_symbol_depth: u32,
533 pub formatter_timeout_secs: u32,
535 pub type_checker_timeout_secs: u32,
537 pub format_on_edit: bool,
539 pub hashline_enabled: bool,
542 pub validate_on_edit: Option<String>,
545 pub formatter: HashMap<String, String>,
548 pub checker: HashMap<String, String>,
551 pub restrict_to_project_root: bool,
554 pub search_index: bool,
556 pub index: IndexConfig,
558 pub semantic_search: bool,
560 pub views: ViewsConfig,
562 pub aft_search_registered: bool,
568 pub callgraph_store: bool,
570 pub callgraph_chunk_size: usize,
574 pub experimental_bash_rewrite: bool,
576 pub experimental_bash_compress: bool,
578 pub experimental_bash_background: bool,
580 pub max_background_bash_tasks: usize,
582 pub bash_long_running_reminder_enabled: bool,
584 pub bash_long_running_reminder_interval_ms: u64,
586 #[serde(skip, default = "default_foreground_wait_window_ms")]
588 pub foreground_wait_window_ms: u64,
589 pub bash: BashConfig,
591 pub bash_permissions: bool,
593 pub sandbox: SandboxConfig,
595 pub search_index_max_file_size: u64,
597 pub semantic: SemanticBackendConfig,
598 pub inspect: InspectConfig,
599 pub backup: BackupConfig,
600 pub worktree: WorktreeConfig,
602 pub github: GithubConfig,
604 pub gh_shim: GhShimConfig,
606 pub gh_read: GhReadConfig,
608 pub git: GitConfig,
610 pub experimental_lsp_ty: bool,
612 pub lsp_servers: Vec<UserServerDef>,
614 pub disabled_lsp: HashSet<String>,
616 #[serde(skip)]
618 pub diagnostics_on_edit: bool,
619 pub lsp_paths_extra: Vec<PathBuf>,
627 pub lsp_auto_install_binaries: HashSet<String>,
633 pub lsp_inflight_installs: HashSet<String>,
639 pub storage_dir: Option<PathBuf>,
643 pub url_fetch_allow_private: bool,
646 pub hoist_builtin_tools: bool,
649 pub tool_surface: String,
653 pub disabled_tools: Vec<String>,
657 #[serde(default)]
659 pub harness: Option<Harness>,
660 pub diagnostic_cache_size: usize,
665 pub idle: IdleConfig,
667}
668
669impl Default for Config {
670 fn default() -> Self {
671 Config {
672 project_root: None,
673 validation_depth: 1,
674 checkpoint_ttl_hours: 24,
675 max_symbol_depth: 10,
676 formatter_timeout_secs: 10,
677 type_checker_timeout_secs: 30,
678 format_on_edit: false,
683 hashline_enabled: false,
684 validate_on_edit: None,
685 formatter: HashMap::new(),
686 checker: HashMap::new(),
687 restrict_to_project_root: false,
690 search_index: false,
691 index: IndexConfig::default(),
692 semantic_search: false,
693 views: ViewsConfig::default(),
694 aft_search_registered: false,
695 callgraph_store: true,
696 callgraph_chunk_size: 100,
697 experimental_bash_rewrite: false,
698 experimental_bash_compress: false,
699 experimental_bash_background: false,
700 max_background_bash_tasks: 8,
701 bash_long_running_reminder_enabled: true,
702 bash_long_running_reminder_interval_ms: 600_000,
703 foreground_wait_window_ms: default_foreground_wait_window_ms(),
704 bash: BashConfig::default(),
705 bash_permissions: false,
706 sandbox: SandboxConfig::default(),
707 search_index_max_file_size: 1_048_576,
708 semantic: SemanticBackendConfig::default(),
709 inspect: InspectConfig::default(),
710 backup: BackupConfig::default(),
711 worktree: WorktreeConfig::default(),
712 github: GithubConfig::default(),
713 gh_shim: GhShimConfig::default(),
714 gh_read: GhReadConfig::default(),
715 git: GitConfig::default(),
716 experimental_lsp_ty: false,
717 lsp_servers: Vec::new(),
718 disabled_lsp: HashSet::new(),
719 diagnostics_on_edit: false,
720 lsp_paths_extra: Vec::new(),
721 lsp_auto_install_binaries: HashSet::new(),
722 lsp_inflight_installs: HashSet::new(),
723 storage_dir: None,
724 url_fetch_allow_private: false,
725 hoist_builtin_tools: true,
726 tool_surface: "recommended".to_string(),
727 disabled_tools: Vec::new(),
728 harness: None,
729 diagnostic_cache_size: 5000,
730 idle: IdleConfig::default(),
731 }
732 }
733}
734
735impl Config {
736 pub fn read_slot_survives(&self) -> bool {
745 self.tool_surface != "minimal"
746 && self.hoist_builtin_tools
747 && !self.disabled_tools.iter().any(|name| name == "read")
748 }
749}
750
751fn default_foreground_wait_window_ms() -> u64 {
752 15_000
753}
754
755#[cfg(test)]
756mod tests {
757 use super::*;
758
759 #[test]
760 fn read_slot_survival_matches_the_plugin_registration_rule() {
761 let base = Config::default();
762 assert!(base.read_slot_survives());
763
764 let disabled = Config {
765 disabled_tools: vec!["read".to_string()],
766 ..Config::default()
767 };
768 assert!(!disabled.read_slot_survives());
769
770 let unrelated = Config {
772 disabled_tools: vec!["aft_zoom".to_string()],
773 ..Config::default()
774 };
775 assert!(unrelated.read_slot_survives());
776
777 let minimal = Config {
778 tool_surface: "minimal".to_string(),
779 ..Config::default()
780 };
781 assert!(!minimal.read_slot_survives());
782
783 let unhoisted = Config {
784 hoist_builtin_tools: false,
785 ..Config::default()
786 };
787 assert!(!unhoisted.read_slot_survives());
788 }
789
790 #[test]
791 fn semantic_query_instruction_resolves_by_backend_model_and_override() {
792 let mut config = SemanticBackendConfig::default();
793 config.model = "QWEN/Qwen3-Embedding-0.6B".to_string();
794 config.query_instruction = "auto".to_string();
795 assert_eq!(config.resolved_query_instruction(), None);
796
797 config.backend = SemanticBackend::OpenAiCompatible;
798 assert_eq!(
799 config.resolved_query_instruction(),
800 Some(QWEN3_EMBEDDING_MODEL_CARD_RETRIEVAL_TASK)
801 );
802
803 config.model = "text-embedding-3-small".to_string();
804 assert_eq!(config.resolved_query_instruction(), None);
805
806 config.query_instruction = "custom code retrieval task".to_string();
807 assert_eq!(
808 config.resolved_query_instruction(),
809 Some("custom code retrieval task")
810 );
811
812 config.query_instruction = "off".to_string();
813 assert_eq!(config.resolved_query_instruction(), None);
814 }
815
816 #[test]
817 fn bash_watch_sync_max_defaults_to_two_minutes_when_deserialized() {
818 let parsed: BashConfig = serde_json::from_str("{}").unwrap();
819 assert_eq!(parsed.watch_sync_max_ms, DEFAULT_BASH_WATCH_SYNC_MAX_MS);
820 assert_eq!(BashConfig::default().watch_sync_max_ms, 120_000);
821 }
822
823 #[test]
824 fn index_root_path_expands_tilde_before_absolute_validation() {
825 let home = std::env::temp_dir().join("aft-home");
826 assert_eq!(
827 expand_index_root_path("~/workspace", Some(&home)).unwrap(),
828 home.join("workspace")
829 );
830 assert!(expand_index_root_path("relative/root", Some(&home)).is_err());
831 assert!(expand_index_root_path("~", None).is_err());
832 }
833}