Skip to main content

aft/
config.rs

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/// The durable index families that a standing root may maintain.
24#[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/// One user-configured root whose literal path spelling is its durable identity.
54#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
55pub struct IndexRootConfig {
56    /// The unmodified Unicode spelling supplied in `index.roots[].path`.
57    pub path: String,
58    /// Normalized selected index families, in fixed [`IndexKind::ALL`] order.
59    pub indexes: Vec<IndexKind>,
60}
61
62/// User-tier standing-index configuration.
63#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
64#[serde(default)]
65pub struct IndexConfig {
66    pub roots: Vec<IndexRootConfig>,
67}
68
69/// Expand the supported `~` forms before validating that a configured root is
70/// absolute. The literal string is retained separately for durable identity.
71pub 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/// Semantic backend selected by the currently resolved runtime configuration.
98#[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    /// Deadline for one interactive query embedding request. Unlike `timeout_ms`,
134    /// this budget never controls background index builds.
135    #[serde(default = "default_semantic_query_timeout_ms")]
136    pub query_timeout_ms: u64,
137    pub max_batch_size: usize,
138    /// Maximum number of project files to semantically index. Guards local
139    /// fastembed memory (model + embeddings + batch buffers) on huge project
140    /// roots; remote backends that embed server-side can raise it freely.
141    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            // Keep the default below the plugin bridge timeout to avoid bridge-killed
164            // semantic_search requests when callers do not set an explicit timeout.
165            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    /// Deadline for the blocking LSP diagnostics phase of `aft_inspect`.
178    #[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/// `gh` routing shim operator hard-off.
218#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
219#[serde(default)]
220pub struct GhShimConfig {
221    /// When false, the `gh` routing shim passes bytes through before any
222    /// daemon or catalog probe, so a disabled shim produces no subc traffic.
223    /// Default true. This is an operator hard-off for fleet rollout safety.
224    pub enabled: bool,
225    /// Optional deployed or development AFT image used by managed shim entries.
226    /// The running executable is used when this user-tier field is absent.
227    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/// Git behavior applied only to AFT-spawned agent children.
240#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
241#[serde(default)]
242pub struct GitConfig {
243    /// `off` (default), `auto`, or one explicit `Name <email>` identity.
244    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
255/// Normalize configuration values into `off`, `auto`, or `Name <email>`.
256pub 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/// Linked-worktree behavior that never writes shared on-disk artifacts.
284#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
285#[serde(default)]
286pub struct WorktreeConfig {
287    /// When true, a borrow-only (linked worktree) root applies its own
288    /// file-watcher events to the in-RAM trigram delta and invalidates the
289    /// symbol cache so search reflects local edits. Default false. Semantic
290    /// search and the callgraph stay frozen. Never persists to the shared
291    /// `cache.bin`.
292    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    /// Route first-party bash commands through the native platform sandbox.
313    pub enabled: bool,
314    /// User-approved writable roots in addition to projects, task artifacts, and caches.
315    pub write_allow: Vec<PathBuf>,
316    /// Extra paths that native backends should deny reading.
317    pub read_deny: Vec<PathBuf>,
318}
319
320#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
321#[serde(default)]
322pub struct BashConfig {
323    /// Permit plugin-side break-glass execution when its AFT transport is unavailable.
324    /// Rust accepts this for cross-language config parity but never acts on it.
325    pub host_fallback: bool,
326}
327
328#[derive(Debug, Clone, Serialize, Deserialize)]
329#[serde(default)]
330pub struct Config {
331    /// Root directory of the project being analyzed. `None` if not scoped.
332    pub project_root: Option<PathBuf>,
333    /// How many levels of call-graph edges to follow during validation (default: 1).
334    pub validation_depth: u32,
335    /// Hours before a checkpoint expires and is eligible for cleanup (default: 24).
336    pub checkpoint_ttl_hours: u32,
337    /// Maximum depth for recursive symbol resolution (default: 10).
338    pub max_symbol_depth: u32,
339    /// Seconds before killing a formatter subprocess (default: 10).
340    pub formatter_timeout_secs: u32,
341    /// Seconds before killing a type-checker subprocess (default: 30).
342    pub type_checker_timeout_secs: u32,
343    /// Whether to auto-format files after edits (default: false).
344    pub format_on_edit: bool,
345    /// Whether the hashline edit/read surface is enabled for eligible sessions.
346    /// Resolved from the public `edit_mode` enum in aft.jsonc.
347    pub hashline_enabled: bool,
348    /// Whether to auto-validate files after edits (default: false).
349    /// When "syntax", only tree-sitter parse check. When "full", runs type checker.
350    pub validate_on_edit: Option<String>,
351    /// Per-language formatter overrides. Keys: "typescript", "python", "rust", "go".
352    /// Values: "biome", "oxfmt", "prettier", "deno", "ruff", "black", "rustfmt", "goimports", "gofmt", "none".
353    pub formatter: HashMap<String, String>,
354    /// Per-language type checker overrides. Keys: "typescript", "python", "rust", "go".
355    /// Values: "tsc", "tsgo", "biome", "pyright", "ruff", "cargo", "go", "staticcheck", "none".
356    pub checker: HashMap<String, String>,
357    /// Whether to restrict file operations to within `project_root` (default: false).
358    /// When true, write-capable commands reject paths outside the project root.
359    pub restrict_to_project_root: bool,
360    /// Enable the trigram search index (default: false).
361    pub search_index: bool,
362    /// User-tier standing roots. Empty by default, so normal session indexing is unchanged.
363    pub index: IndexConfig,
364    /// Enable semantic search (default: false).
365    pub semantic_search: bool,
366    /// Whether the plugin registered the `aft_search` tool for this surface
367    /// (default: false). Forwarded by the plugin's resolved registration
368    /// predicate (semantic on + not minimal + not disabled). Used only to pick
369    /// the grep-rewrite footer: when true the footer steers to `aft_search`,
370    /// otherwise to the `grep` tool. Not a capability gate.
371    pub aft_search_registered: bool,
372    /// Enable the persisted callgraph store substrate (default: true).
373    pub callgraph_store: bool,
374    /// Number of files to parse in a single batch during callgraph store cold build (default: 100).
375    /// Lower values reduce peak memory during cold build.
376    /// Set to 0 to disable chunking and parse all files at once.
377    pub callgraph_chunk_size: usize,
378    /// Enable experimental bash command rewriting (default: false).
379    pub experimental_bash_rewrite: bool,
380    /// Enable experimental bash command compression (default: false).
381    pub experimental_bash_compress: bool,
382    /// Enable experimental bash background execution (default: false).
383    pub experimental_bash_background: bool,
384    /// Maximum number of background bash tasks allowed to run concurrently (default: 8).
385    pub max_background_bash_tasks: usize,
386    /// Emit reminders for long-running bash tasks (default: true).
387    pub bash_long_running_reminder_enabled: bool,
388    /// Milliseconds between long-running bash reminders (default: 10 minutes).
389    pub bash_long_running_reminder_interval_ms: u64,
390    /// Milliseconds to wait before a foreground bash task is promoted to background handling.
391    #[serde(skip, default = "default_foreground_wait_window_ms")]
392    pub foreground_wait_window_ms: u64,
393    /// Plugin-owned bash settings accepted by configure but inert in the engine.
394    pub bash: BashConfig,
395    /// Enable OpenCode-style bash permission prompts (default: false).
396    pub bash_permissions: bool,
397    /// Native sandbox policy for first-party bash and PTY processes.
398    pub sandbox: SandboxConfig,
399    /// Maximum file size to fully index in bytes (default: 1MB).
400    pub search_index_max_file_size: u64,
401    pub semantic: SemanticBackendConfig,
402    pub inspect: InspectConfig,
403    pub backup: BackupConfig,
404    /// Linked-worktree RAM overlay. Default off; see [`WorktreeConfig`].
405    pub worktree: WorktreeConfig,
406    /// `gh` routing shim operator gate. Default on; see [`GhShimConfig`].
407    pub gh_shim: GhShimConfig,
408    /// Git attribution for AFT-spawned agent children. Default off.
409    pub git: GitConfig,
410    /// Enable Astral ty as an experimental Python LSP server (default: false).
411    pub experimental_lsp_ty: bool,
412    /// User-defined LSP servers registered by the OpenCode plugin.
413    pub lsp_servers: Vec<UserServerDef>,
414    /// Lowercase LSP server IDs disabled by user config.
415    pub disabled_lsp: HashSet<String>,
416    /// Whether the system should request inline diagnostics after a tool call edits or writes a file.
417    #[serde(skip)]
418    pub diagnostics_on_edit: bool,
419    /// Extra directories to search when resolving LSP binaries.
420    /// The plugin populates these from its own auto-install cache (e.g.
421    /// `~/.cache/aft/lsp-packages/<pkg>/node_modules/.bin/`) so an LSP binary
422    /// installed by AFT is discoverable without needing it on PATH.
423    /// Resolution order: `<project_root>/node_modules/.bin/<bin>` →
424    /// `lsp_paths_extra/<bin>` (in order) → PATH via `which`. Python-family
425    /// servers additionally probe the selected workspace's `.venv`/`venv` first.
426    pub lsp_paths_extra: Vec<PathBuf>,
427    /// Binary names the hosting plugin knows how to auto-install.
428    ///
429    /// Built-in LSPs discovered from files only emit missing-binary warnings
430    /// when their binary is in this set. User-configured `lsp_servers` keep
431    /// warning unconditionally.
432    pub lsp_auto_install_binaries: HashSet<String>,
433    /// Binary names with plugin-managed auto-installs currently in flight.
434    ///
435    /// Missing-binary warnings are suppressed while the install is actively
436    /// running; install failure reporting is handled by the plugin after the
437    /// background work settles.
438    pub lsp_inflight_installs: HashSet<String>,
439    /// Persistent storage directory for indexes (trigram, semantic).
440    /// Set by the plugin to the XDG-compliant path (e.g. ~/.local/share/opencode/storage/plugin/aft/).
441    /// Falls back to ~/.cache/aft/ if not set.
442    pub storage_dir: Option<PathBuf>,
443    /// Allow URL-fetch commands to access private network hosts.
444    /// Default false; hosting plugins only forward this from user-level config.
445    pub url_fetch_allow_private: bool,
446    /// Hosting harness identity supplied by configure.
447    #[serde(default)]
448    pub harness: Option<Harness>,
449    /// Maximum number of (server, file) entries kept in the in-memory
450    /// diagnostic cache. Older entries are evicted in LRU order when the
451    /// cap is exceeded. Set to 0 to disable the cap entirely.
452    /// Default: 5000 (covers very large monorepos with bounded memory).
453    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            // Default OFF: formatting after an edit can silently reflow the file
466            // under the agent (a formatter splitting/joining lines), staling the
467            // context for the next edit/patch. Agents that want formatting opt in
468            // via `format_on_edit: true`.
469            format_on_edit: false,
470            hashline_enabled: false,
471            validate_on_edit: None,
472            formatter: HashMap::new(),
473            checker: HashMap::new(),
474            // Default to false to match OpenCode's existing permission-based model.
475            // The plugin opts into root restriction explicitly when desired.
476            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}