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_BASH_WATCH_SYNC_MAX_MS: u64 = 120_000;
25pub const MIN_BASH_WATCH_SYNC_MAX_MS: u64 = 1_000;
26pub const MAX_BASH_WATCH_SYNC_MAX_MS: u64 = 1_800_000;
27
28pub const DEFAULT_IDLE_ROOT_TTL_MINUTES: u32 = 30;
30pub const MIN_IDLE_ROOT_TTL_MINUTES: u32 = 5;
31pub const MAX_IDLE_ROOT_TTL_MINUTES: u32 = 30;
32pub const DEFAULT_IDLE_LSP_TTL_MINUTES: u32 = 10;
34pub const MIN_IDLE_LSP_TTL_MINUTES: u32 = 1;
35pub const MAX_IDLE_LSP_TTL_MINUTES: u32 = 10;
36
37const fn default_semantic_query_timeout_ms() -> u64 {
38 DEFAULT_SEMANTIC_QUERY_TIMEOUT_MS
39}
40
41const fn default_inspect_diagnostics_timeout_ms() -> u64 {
42 DEFAULT_INSPECT_DIAGNOSTICS_TIMEOUT_MS
43}
44
45const fn default_bash_detach_on_user_message() -> bool {
46 true
47}
48
49pub(crate) const fn default_bash_watch_sync_max_ms() -> u64 {
50 DEFAULT_BASH_WATCH_SYNC_MAX_MS
51}
52
53use crate::harness::Harness;
54
55#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
61#[serde(default)]
62pub struct IdleConfig {
63 pub root_ttl_minutes: u32,
64 pub lsp_ttl_minutes: u32,
65}
66
67impl Default for IdleConfig {
68 fn default() -> Self {
69 Self {
70 root_ttl_minutes: DEFAULT_IDLE_ROOT_TTL_MINUTES,
71 lsp_ttl_minutes: DEFAULT_IDLE_LSP_TTL_MINUTES,
72 }
73 }
74}
75
76impl IdleConfig {
77 pub fn root_ttl(&self) -> std::time::Duration {
78 std::time::Duration::from_secs(u64::from(self.root_ttl_minutes) * 60)
79 }
80
81 pub fn lsp_ttl(&self) -> std::time::Duration {
82 std::time::Duration::from_secs(u64::from(self.lsp_ttl_minutes) * 60)
83 }
84}
85
86#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
88#[serde(rename_all = "snake_case")]
89pub enum IndexKind {
90 Search,
91 Semantic,
92 Callgraph,
93}
94
95impl IndexKind {
96 pub const ALL: [Self; 3] = [Self::Search, Self::Semantic, Self::Callgraph];
97
98 pub const fn as_str(self) -> &'static str {
99 match self {
100 Self::Search => "search",
101 Self::Semantic => "semantic",
102 Self::Callgraph => "callgraph",
103 }
104 }
105
106 pub fn from_name(name: &str) -> Option<Self> {
107 match name {
108 "search" => Some(Self::Search),
109 "semantic" => Some(Self::Semantic),
110 "callgraph" => Some(Self::Callgraph),
111 _ => None,
112 }
113 }
114}
115
116#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
118pub struct IndexRootConfig {
119 pub path: String,
121 pub indexes: Vec<IndexKind>,
123}
124
125#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
127#[serde(default)]
128pub struct IndexConfig {
129 pub roots: Vec<IndexRootConfig>,
130}
131
132pub fn expand_index_root_path(
135 path: &str,
136 home: Option<&std::path::Path>,
137) -> Result<PathBuf, String> {
138 let expanded = if path == "~" {
139 home.ok_or_else(|| {
140 "index.roots path uses ~ but no home directory is available".to_string()
141 })?
142 .to_path_buf()
143 } else if let Some(remainder) = path.strip_prefix("~/").or_else(|| path.strip_prefix("~\\")) {
144 home.ok_or_else(|| {
145 "index.roots path uses ~ but no home directory is available".to_string()
146 })?
147 .join(remainder)
148 } else {
149 PathBuf::from(path)
150 };
151
152 if !expanded.is_absolute() {
153 return Err(format!(
154 "index.roots path must be absolute after ~ expansion: {path:?}"
155 ));
156 }
157 Ok(expanded)
158}
159
160#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
162#[serde(rename_all = "snake_case")]
163pub enum SemanticBackend {
164 Fastembed,
165 #[serde(rename = "openai_compatible")]
166 OpenAiCompatible,
167 Ollama,
168 Synapse,
169}
170
171impl SemanticBackend {
172 pub const fn as_str(&self) -> &'static str {
173 match self {
174 Self::Fastembed => "fastembed",
175 Self::OpenAiCompatible => "openai_compatible",
176 Self::Ollama => "ollama",
177 Self::Synapse => "synapse",
178 }
179 }
180
181 pub fn from_name(name: &str) -> Option<Self> {
182 match name {
183 "fastembed" => Some(Self::Fastembed),
184 "openai_compatible" => Some(Self::OpenAiCompatible),
185 "ollama" => Some(Self::Ollama),
186 "synapse" => Some(Self::Synapse),
187 _ => None,
188 }
189 }
190}
191
192#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
193pub struct SemanticBackendConfig {
194 pub backend: SemanticBackend,
195 pub model: String,
196 pub base_url: Option<String>,
197 pub api_key_env: Option<String>,
198 pub timeout_ms: u64,
202 #[serde(default = "default_semantic_query_timeout_ms")]
205 pub query_timeout_ms: u64,
206 #[serde(default = "default_semantic_query_instruction")]
209 pub query_instruction: String,
210 pub max_batch_size: usize,
211 #[serde(default, skip_serializing_if = "Option::is_none")]
214 pub max_input_tokens: Option<usize>,
215 pub max_files: usize,
219 #[serde(skip)]
221 pub subc_connection_file: Option<PathBuf>,
222 #[serde(skip)]
225 pub route_project_root: Option<PathBuf>,
226 #[serde(skip)]
227 pub route_harness: Option<String>,
228}
229
230#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
231pub struct UserServerDef {
232 pub id: String,
233 pub extensions: Vec<String>,
234 pub binary: String,
235 pub args: Vec<String>,
236 pub root_markers: Vec<String>,
237 pub env: HashMap<String, String>,
238 pub initialization_options: Option<serde_json::Value>,
239 pub disabled: bool,
240}
241
242fn default_semantic_query_instruction() -> String {
243 DEFAULT_SEMANTIC_QUERY_INSTRUCTION.to_string()
244}
245
246impl SemanticBackendConfig {
247 pub fn resolved_query_instruction(&self) -> Option<&str> {
248 if self.backend == SemanticBackend::Fastembed {
249 return None;
250 }
251 match self.query_instruction.as_str() {
252 "off" => None,
253 "auto" if self.model.to_ascii_lowercase().contains("qwen3-embedding") => {
254 Some(QWEN3_EMBEDDING_MODEL_CARD_RETRIEVAL_TASK)
255 }
256 "auto" => None,
257 literal => Some(literal),
258 }
259 }
260}
261
262impl Default for SemanticBackendConfig {
263 fn default() -> Self {
264 Self {
265 backend: SemanticBackend::Fastembed,
266 model: DEFAULT_SEMANTIC_MODEL.to_string(),
267 base_url: None,
268 api_key_env: None,
269 timeout_ms: 25_000,
273 query_timeout_ms: DEFAULT_SEMANTIC_QUERY_TIMEOUT_MS,
274 query_instruction: default_semantic_query_instruction(),
275 max_batch_size: 64,
276 max_input_tokens: None,
277 max_files: 20_000,
278 subc_connection_file: None,
279 route_project_root: None,
280 route_harness: None,
281 }
282 }
283}
284
285#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
286#[serde(default)]
287pub struct InspectConfig {
288 pub enabled: bool,
289 #[serde(default = "default_inspect_diagnostics_timeout_ms")]
291 pub diagnostics_timeout_ms: u64,
292 pub duplicates: InspectDuplicatesConfig,
293}
294
295#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
296#[serde(default)]
297pub struct InspectDuplicatesConfig {
298 pub expected_mirrors: Vec<[String; 2]>,
299}
300
301impl Default for InspectConfig {
302 fn default() -> Self {
303 Self {
304 enabled: true,
305 diagnostics_timeout_ms: default_inspect_diagnostics_timeout_ms(),
306 duplicates: InspectDuplicatesConfig::default(),
307 }
308 }
309}
310
311#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
312#[serde(default)]
313pub struct BackupConfig {
314 pub enabled: Option<bool>,
315 pub max_depth: Option<usize>,
316 pub max_file_size: Option<u64>,
317}
318
319impl Default for BackupConfig {
320 fn default() -> Self {
321 Self {
322 enabled: Some(true),
323 max_depth: Some(crate::backup::DEFAULT_MAX_UNDO_DEPTH),
324 max_file_size: Some(crate::backup::DEFAULT_MAX_BACKUP_FILE_SIZE),
325 }
326 }
327}
328
329#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
331#[serde(default)]
332pub struct GhShimConfig {
333 pub enabled: bool,
337 pub binary_path: Option<PathBuf>,
340}
341
342impl Default for GhShimConfig {
343 fn default() -> Self {
344 Self {
345 enabled: true,
346 binary_path: None,
347 }
348 }
349}
350
351#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
353#[serde(default)]
354pub struct GithubConfig {
355 pub enabled: bool,
357 pub shim: bool,
359 pub read: bool,
361 pub write: bool,
363}
364
365impl Default for GithubConfig {
366 fn default() -> Self {
367 Self {
368 enabled: true,
369 shim: true,
370 read: false,
371 write: false,
372 }
373 }
374}
375
376#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
378#[serde(default)]
379pub struct GhReadConfig {
380 pub enabled: bool,
381}
382
383impl Default for GhReadConfig {
384 fn default() -> Self {
385 Self { enabled: false }
386 }
387}
388
389#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
391#[serde(default)]
392pub struct GitConfig {
393 pub co_author: String,
395}
396
397impl Default for GitConfig {
398 fn default() -> Self {
399 Self {
400 co_author: "off".to_string(),
401 }
402 }
403}
404
405pub fn normalize_git_co_author(value: &str) -> Option<String> {
407 let value = value.trim();
408 if matches!(value, "off" | "auto") {
409 return Some(value.to_string());
410 }
411 if value.contains(['\n', '\r']) || !value.ends_with('>') {
412 return None;
413 }
414 let open = value.rfind('<')?;
415 if open == 0 || !value.as_bytes()[open - 1].is_ascii_whitespace() {
416 return None;
417 }
418 let name = value[..open].trim();
419 let email = value[open + 1..value.len() - 1].trim();
420 if name.is_empty()
421 || name.contains(['<', '>'])
422 || email.is_empty()
423 || !email.contains('@')
424 || email
425 .chars()
426 .any(|character| character.is_whitespace() || matches!(character, '<' | '>'))
427 {
428 return None;
429 }
430 Some(value.to_string())
431}
432
433#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
435#[serde(default)]
436pub struct ViewsConfig {
437 pub enabled: bool,
439}
440
441#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
443#[serde(default)]
444pub struct WorktreeConfig {
445 pub ram_overlay: bool,
451}
452
453impl Default for WorktreeConfig {
454 fn default() -> Self {
455 Self { ram_overlay: false }
456 }
457}
458
459pub const DEFAULT_SEMANTIC_MODEL: &str = "all-MiniLM-L6-v2";
460
461impl Config {
462 pub fn semantic_backend_label(&self) -> &'static str {
463 self.semantic.backend.as_str()
464 }
465}
466
467#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
468#[serde(default)]
469pub struct SandboxConfig {
470 pub enabled: bool,
472 pub write_allow: Vec<PathBuf>,
474 pub read_deny: Vec<PathBuf>,
476}
477
478#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
479#[serde(default)]
480pub struct BashConfig {
481 pub host_fallback: bool,
484 #[serde(default = "default_bash_detach_on_user_message")]
487 pub detach_on_user_message: bool,
488 #[serde(default = "default_bash_watch_sync_max_ms")]
491 pub watch_sync_max_ms: u64,
492 pub linux_scope: bool,
494 pub powershell_tool: bool,
497}
498
499impl Default for BashConfig {
500 fn default() -> Self {
501 Self {
502 host_fallback: false,
503 detach_on_user_message: default_bash_detach_on_user_message(),
504 watch_sync_max_ms: default_bash_watch_sync_max_ms(),
505 linux_scope: false,
506 powershell_tool: false,
507 }
508 }
509}
510
511#[derive(Debug, Clone, Serialize, Deserialize)]
512#[serde(default)]
513pub struct Config {
514 pub project_root: Option<PathBuf>,
516 pub validation_depth: u32,
518 pub checkpoint_ttl_hours: u32,
522 pub max_symbol_depth: u32,
524 pub formatter_timeout_secs: u32,
526 pub type_checker_timeout_secs: u32,
528 pub format_on_edit: bool,
530 pub hashline_enabled: bool,
533 pub validate_on_edit: Option<String>,
536 pub formatter: HashMap<String, String>,
539 pub checker: HashMap<String, String>,
542 pub restrict_to_project_root: bool,
545 pub search_index: bool,
547 pub index: IndexConfig,
549 pub semantic_search: bool,
551 pub views: ViewsConfig,
553 pub aft_search_registered: bool,
559 pub callgraph_store: bool,
561 pub callgraph_chunk_size: usize,
565 pub experimental_bash_rewrite: bool,
567 pub experimental_bash_compress: bool,
569 pub experimental_bash_background: bool,
571 pub max_background_bash_tasks: usize,
573 pub bash_long_running_reminder_enabled: bool,
575 pub bash_long_running_reminder_interval_ms: u64,
577 #[serde(skip, default = "default_foreground_wait_window_ms")]
579 pub foreground_wait_window_ms: u64,
580 pub bash: BashConfig,
582 pub bash_permissions: bool,
584 pub sandbox: SandboxConfig,
586 pub search_index_max_file_size: u64,
588 pub semantic: SemanticBackendConfig,
589 pub inspect: InspectConfig,
590 pub backup: BackupConfig,
591 pub worktree: WorktreeConfig,
593 pub github: GithubConfig,
595 pub gh_shim: GhShimConfig,
597 pub gh_read: GhReadConfig,
599 pub git: GitConfig,
601 pub experimental_lsp_ty: bool,
603 pub lsp_servers: Vec<UserServerDef>,
605 pub disabled_lsp: HashSet<String>,
607 #[serde(skip)]
609 pub diagnostics_on_edit: bool,
610 pub lsp_paths_extra: Vec<PathBuf>,
618 pub lsp_auto_install_binaries: HashSet<String>,
624 pub lsp_inflight_installs: HashSet<String>,
630 pub storage_dir: Option<PathBuf>,
634 pub url_fetch_allow_private: bool,
637 pub hoist_builtin_tools: bool,
640 pub tool_surface: String,
644 pub disabled_tools: Vec<String>,
648 #[serde(default)]
650 pub harness: Option<Harness>,
651 pub diagnostic_cache_size: usize,
656 pub idle: IdleConfig,
658}
659
660impl Default for Config {
661 fn default() -> Self {
662 Config {
663 project_root: None,
664 validation_depth: 1,
665 checkpoint_ttl_hours: 24,
666 max_symbol_depth: 10,
667 formatter_timeout_secs: 10,
668 type_checker_timeout_secs: 30,
669 format_on_edit: false,
674 hashline_enabled: false,
675 validate_on_edit: None,
676 formatter: HashMap::new(),
677 checker: HashMap::new(),
678 restrict_to_project_root: false,
681 search_index: false,
682 index: IndexConfig::default(),
683 semantic_search: false,
684 views: ViewsConfig::default(),
685 aft_search_registered: false,
686 callgraph_store: true,
687 callgraph_chunk_size: 100,
688 experimental_bash_rewrite: false,
689 experimental_bash_compress: false,
690 experimental_bash_background: false,
691 max_background_bash_tasks: 8,
692 bash_long_running_reminder_enabled: true,
693 bash_long_running_reminder_interval_ms: 600_000,
694 foreground_wait_window_ms: default_foreground_wait_window_ms(),
695 bash: BashConfig::default(),
696 bash_permissions: false,
697 sandbox: SandboxConfig::default(),
698 search_index_max_file_size: 1_048_576,
699 semantic: SemanticBackendConfig::default(),
700 inspect: InspectConfig::default(),
701 backup: BackupConfig::default(),
702 worktree: WorktreeConfig::default(),
703 github: GithubConfig::default(),
704 gh_shim: GhShimConfig::default(),
705 gh_read: GhReadConfig::default(),
706 git: GitConfig::default(),
707 experimental_lsp_ty: false,
708 lsp_servers: Vec::new(),
709 disabled_lsp: HashSet::new(),
710 diagnostics_on_edit: false,
711 lsp_paths_extra: Vec::new(),
712 lsp_auto_install_binaries: HashSet::new(),
713 lsp_inflight_installs: HashSet::new(),
714 storage_dir: None,
715 url_fetch_allow_private: false,
716 hoist_builtin_tools: true,
717 tool_surface: "recommended".to_string(),
718 disabled_tools: Vec::new(),
719 harness: None,
720 diagnostic_cache_size: 5000,
721 idle: IdleConfig::default(),
722 }
723 }
724}
725
726impl Config {
727 pub fn read_slot_survives(&self) -> bool {
736 self.tool_surface != "minimal"
737 && self.hoist_builtin_tools
738 && !self.disabled_tools.iter().any(|name| name == "read")
739 }
740}
741
742fn default_foreground_wait_window_ms() -> u64 {
743 15_000
744}
745
746#[cfg(test)]
747mod tests {
748 use super::*;
749
750 #[test]
751 fn read_slot_survival_matches_the_plugin_registration_rule() {
752 let base = Config::default();
753 assert!(base.read_slot_survives());
754
755 let disabled = Config {
756 disabled_tools: vec!["read".to_string()],
757 ..Config::default()
758 };
759 assert!(!disabled.read_slot_survives());
760
761 let unrelated = Config {
763 disabled_tools: vec!["aft_zoom".to_string()],
764 ..Config::default()
765 };
766 assert!(unrelated.read_slot_survives());
767
768 let minimal = Config {
769 tool_surface: "minimal".to_string(),
770 ..Config::default()
771 };
772 assert!(!minimal.read_slot_survives());
773
774 let unhoisted = Config {
775 hoist_builtin_tools: false,
776 ..Config::default()
777 };
778 assert!(!unhoisted.read_slot_survives());
779 }
780
781 #[test]
782 fn semantic_query_instruction_resolves_by_backend_model_and_override() {
783 let mut config = SemanticBackendConfig::default();
784 config.model = "QWEN/Qwen3-Embedding-0.6B".to_string();
785 config.query_instruction = "auto".to_string();
786 assert_eq!(config.resolved_query_instruction(), None);
787
788 config.backend = SemanticBackend::OpenAiCompatible;
789 assert_eq!(
790 config.resolved_query_instruction(),
791 Some(QWEN3_EMBEDDING_MODEL_CARD_RETRIEVAL_TASK)
792 );
793
794 config.model = "text-embedding-3-small".to_string();
795 assert_eq!(config.resolved_query_instruction(), None);
796
797 config.query_instruction = "custom code retrieval task".to_string();
798 assert_eq!(
799 config.resolved_query_instruction(),
800 Some("custom code retrieval task")
801 );
802
803 config.query_instruction = "off".to_string();
804 assert_eq!(config.resolved_query_instruction(), None);
805 }
806
807 #[test]
808 fn bash_watch_sync_max_defaults_to_two_minutes_when_deserialized() {
809 let parsed: BashConfig = serde_json::from_str("{}").unwrap();
810 assert_eq!(parsed.watch_sync_max_ms, DEFAULT_BASH_WATCH_SYNC_MAX_MS);
811 assert_eq!(BashConfig::default().watch_sync_max_ms, 120_000);
812 }
813
814 #[test]
815 fn index_root_path_expands_tilde_before_absolute_validation() {
816 let home = std::env::temp_dir().join("aft-home");
817 assert_eq!(
818 expand_index_root_path("~/workspace", Some(&home)).unwrap(),
819 home.join("workspace")
820 );
821 assert!(expand_index_root_path("relative/root", Some(&home)).is_err());
822 assert!(expand_index_root_path("~", None).is_err());
823 }
824}