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
21const fn default_bash_detach_on_user_message() -> bool {
22    true
23}
24
25use crate::harness::Harness;
26
27/// The durable index families that a standing root may maintain.
28#[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/// One user-configured root whose literal path spelling is its durable identity.
58#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
59pub struct IndexRootConfig {
60    /// The unmodified Unicode spelling supplied in `index.roots[].path`.
61    pub path: String,
62    /// Normalized selected index families, in fixed [`IndexKind::ALL`] order.
63    pub indexes: Vec<IndexKind>,
64}
65
66/// User-tier standing-index configuration.
67#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
68#[serde(default)]
69pub struct IndexConfig {
70    pub roots: Vec<IndexRootConfig>,
71}
72
73/// Expand the supported `~` forms before validating that a configured root is
74/// absolute. The literal string is retained separately for durable identity.
75pub 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/// Semantic backend selected by the currently resolved runtime configuration.
102#[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    /// Deadline for one interactive query embedding request. Unlike `timeout_ms`,
141    /// this budget never controls background index builds.
142    #[serde(default = "default_semantic_query_timeout_ms")]
143    pub query_timeout_ms: u64,
144    pub max_batch_size: usize,
145    /// Maximum number of project files to semantically index. Guards local
146    /// fastembed memory (model + embeddings + batch buffers) on huge project
147    /// roots; remote backends that embed server-side can raise it freely.
148    pub max_files: usize,
149    /// User-tier SubC connection file used only by the Synapse embedding backend.
150    #[serde(skip)]
151    pub subc_connection_file: Option<PathBuf>,
152    /// Project-root and harness identity used to route Synapse management calls
153    /// to the correct project and execution environment.
154    #[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            // Keep the default below the plugin bridge timeout to avoid bridge-killed
180            // semantic_search requests when callers do not set an explicit timeout.
181            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    /// Deadline for the blocking LSP diagnostics phase of `aft_inspect`.
197    #[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/// `gh` routing shim operator hard-off.
237#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
238#[serde(default)]
239pub struct GhShimConfig {
240    /// When false, the `gh` routing shim passes bytes through before any
241    /// daemon or catalog probe, so a disabled shim produces no subc traffic.
242    /// Default true. This is an operator hard-off for fleet rollout safety.
243    pub enabled: bool,
244    /// Optional deployed or development AFT image used by managed shim entries.
245    /// The running executable is used when this user-tier field is absent.
246    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/// Git behavior applied only to AFT-spawned agent children.
259#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
260#[serde(default)]
261pub struct GitConfig {
262    /// `off` (default), `auto`, or one explicit `Name <email>` identity.
263    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
274/// Normalize configuration values into `off`, `auto`, or `Name <email>`.
275pub 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/// Linked-worktree behavior that never writes shared on-disk artifacts.
303#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
304#[serde(default)]
305pub struct WorktreeConfig {
306    /// When true, a borrow-only (linked worktree) root applies its own
307    /// file-watcher events to the in-RAM trigram delta and invalidates the
308    /// symbol cache so search reflects local edits. Default false. Semantic
309    /// search and the callgraph stay frozen. Never persists to the shared
310    /// `cache.bin`.
311    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    /// Route first-party bash commands through the native platform sandbox.
332    pub enabled: bool,
333    /// User-approved writable roots in addition to projects, task artifacts, and caches.
334    pub write_allow: Vec<PathBuf>,
335    /// Extra paths that native backends should deny reading.
336    pub read_deny: Vec<PathBuf>,
337}
338
339#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
340#[serde(default)]
341pub struct BashConfig {
342    /// Permit plugin-side break-glass execution when its AFT transport is unavailable.
343    /// Rust accepts this for cross-language config parity but never acts on it.
344    pub host_fallback: bool,
345    /// Whether the hosting plugin detaches wait:true bash calls on user messages.
346    /// Rust accepts this for cross-language config parity but never acts on it.
347    #[serde(default = "default_bash_detach_on_user_message")]
348    pub detach_on_user_message: bool,
349    /// Pi-only fallback gate for its optional PowerShell default tool. The Rust
350    /// executor accepts this solely to keep shared config parsing in parity.
351    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    /// Root directory of the project being analyzed. `None` if not scoped.
368    pub project_root: Option<PathBuf>,
369    /// How many levels of call-graph edges to follow during validation (default: 1).
370    pub validation_depth: u32,
371    /// Hours before legacy backup-session maintenance may collect inactive history
372    /// (default: 24). Named checkpoint retention is intentionally fixed at fourteen
373    /// days and is not controlled by configuration.
374    pub checkpoint_ttl_hours: u32,
375    /// Maximum depth for recursive symbol resolution (default: 10).
376    pub max_symbol_depth: u32,
377    /// Seconds before killing a formatter subprocess (default: 10).
378    pub formatter_timeout_secs: u32,
379    /// Seconds before killing a type-checker subprocess (default: 30).
380    pub type_checker_timeout_secs: u32,
381    /// Whether to auto-format files after edits (default: false).
382    pub format_on_edit: bool,
383    /// Whether the hashline edit/read surface is enabled for eligible sessions.
384    /// Resolved from the public `edit_mode` enum in aft.jsonc.
385    pub hashline_enabled: bool,
386    /// Whether to auto-validate files after edits (default: false).
387    /// When "syntax", only tree-sitter parse check. When "full", runs type checker.
388    pub validate_on_edit: Option<String>,
389    /// Per-language formatter overrides. Keys: "typescript", "python", "rust", "go".
390    /// Values: "biome", "oxfmt", "prettier", "deno", "ruff", "black", "rustfmt", "goimports", "gofmt", "none".
391    pub formatter: HashMap<String, String>,
392    /// Per-language type checker overrides. Keys: "typescript", "python", "rust", "go".
393    /// Values: "tsc", "tsgo", "biome", "pyright", "ruff", "cargo", "go", "staticcheck", "none".
394    pub checker: HashMap<String, String>,
395    /// Whether to restrict file operations to within `project_root` (default: false).
396    /// When true, write-capable commands reject paths outside the project root.
397    pub restrict_to_project_root: bool,
398    /// Enable the trigram search index (default: false).
399    pub search_index: bool,
400    /// User-tier standing roots. Empty by default, so normal session indexing is unchanged.
401    pub index: IndexConfig,
402    /// Enable semantic search (default: false).
403    pub semantic_search: bool,
404    /// Whether the plugin registered the `aft_search` tool for this surface
405    /// (default: false). Forwarded by the plugin's resolved registration
406    /// predicate (semantic on + not minimal + not disabled). Used only to pick
407    /// the grep-rewrite footer: when true the footer steers to `aft_search`,
408    /// otherwise to the `grep` tool. Not a capability gate.
409    pub aft_search_registered: bool,
410    /// Enable the persisted callgraph store substrate (default: true).
411    pub callgraph_store: bool,
412    /// Number of files to parse in a single batch during callgraph store cold build (default: 100).
413    /// Lower values reduce peak memory during cold build.
414    /// Set to 0 to disable chunking and parse all files at once.
415    pub callgraph_chunk_size: usize,
416    /// Enable experimental bash command rewriting (default: false).
417    pub experimental_bash_rewrite: bool,
418    /// Enable experimental bash command compression (default: false).
419    pub experimental_bash_compress: bool,
420    /// Enable experimental bash background execution (default: false).
421    pub experimental_bash_background: bool,
422    /// Maximum number of background bash tasks allowed to run concurrently (default: 8).
423    pub max_background_bash_tasks: usize,
424    /// Emit reminders for long-running bash tasks (default: true).
425    pub bash_long_running_reminder_enabled: bool,
426    /// Milliseconds between long-running bash reminders (default: 10 minutes).
427    pub bash_long_running_reminder_interval_ms: u64,
428    /// Milliseconds to wait before a foreground bash task is promoted to background handling.
429    #[serde(skip, default = "default_foreground_wait_window_ms")]
430    pub foreground_wait_window_ms: u64,
431    /// Plugin-owned bash settings accepted by configure but inert in the engine.
432    pub bash: BashConfig,
433    /// Enable OpenCode-style bash permission prompts (default: false).
434    pub bash_permissions: bool,
435    /// Native sandbox policy for first-party bash and PTY processes.
436    pub sandbox: SandboxConfig,
437    /// Maximum file size to fully index in bytes (default: 1MB).
438    pub search_index_max_file_size: u64,
439    pub semantic: SemanticBackendConfig,
440    pub inspect: InspectConfig,
441    pub backup: BackupConfig,
442    /// Linked-worktree RAM overlay. Default off; see [`WorktreeConfig`].
443    pub worktree: WorktreeConfig,
444    /// `gh` routing shim operator gate. Default on; see [`GhShimConfig`].
445    pub gh_shim: GhShimConfig,
446    /// Git attribution for AFT-spawned agent children. Default off.
447    pub git: GitConfig,
448    /// Enable Astral ty as an experimental Python LSP server (default: false).
449    pub experimental_lsp_ty: bool,
450    /// User-defined LSP servers registered by the OpenCode plugin.
451    pub lsp_servers: Vec<UserServerDef>,
452    /// Lowercase LSP server IDs disabled by user config.
453    pub disabled_lsp: HashSet<String>,
454    /// Whether the system should request inline diagnostics after a tool call edits or writes a file.
455    #[serde(skip)]
456    pub diagnostics_on_edit: bool,
457    /// Extra directories to search when resolving LSP binaries.
458    /// The plugin populates these from its own auto-install cache (e.g.
459    /// `~/.cache/aft/lsp-packages/<pkg>/node_modules/.bin/`) so an LSP binary
460    /// installed by AFT is discoverable without needing it on PATH.
461    /// Resolution order: `<project_root>/node_modules/.bin/<bin>` →
462    /// `lsp_paths_extra/<bin>` (in order) → PATH via `which`. Python-family
463    /// servers additionally probe the selected workspace's `.venv`/`venv` first.
464    pub lsp_paths_extra: Vec<PathBuf>,
465    /// Binary names the hosting plugin knows how to auto-install.
466    ///
467    /// Built-in LSPs discovered from files only emit missing-binary warnings
468    /// when their binary is in this set. User-configured `lsp_servers` keep
469    /// warning unconditionally.
470    pub lsp_auto_install_binaries: HashSet<String>,
471    /// Binary names with plugin-managed auto-installs currently in flight.
472    ///
473    /// Missing-binary warnings are suppressed while the install is actively
474    /// running; install failure reporting is handled by the plugin after the
475    /// background work settles.
476    pub lsp_inflight_installs: HashSet<String>,
477    /// Persistent storage directory for indexes (trigram, semantic).
478    /// Set by the plugin to the XDG-compliant path (e.g. ~/.local/share/opencode/storage/plugin/aft/).
479    /// Falls back to ~/.cache/aft/ if not set.
480    pub storage_dir: Option<PathBuf>,
481    /// Allow URL-fetch commands to access private network hosts.
482    /// Default false; hosting plugins only forward this from user-level config.
483    pub url_fetch_allow_private: bool,
484    /// Resolved host-tool registration preference. The Rust core retains this
485    /// value for cross-harness config parity; the hosting plugin owns registration.
486    pub hoist_builtin_tools: bool,
487    /// Hosting harness identity supplied by configure.
488    #[serde(default)]
489    pub harness: Option<Harness>,
490    /// Maximum number of (server, file) entries kept in the in-memory
491    /// diagnostic cache. Older entries are evicted in LRU order when the
492    /// cap is exceeded. Set to 0 to disable the cap entirely.
493    /// Default: 5000 (covers very large monorepos with bounded memory).
494    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            // Default OFF: formatting after an edit can silently reflow the file
507            // under the agent (a formatter splitting/joining lines), staling the
508            // context for the next edit/patch. Agents that want formatting opt in
509            // via `format_on_edit: true`.
510            format_on_edit: false,
511            hashline_enabled: false,
512            validate_on_edit: None,
513            formatter: HashMap::new(),
514            checker: HashMap::new(),
515            // Default to false to match OpenCode's existing permission-based model.
516            // The plugin opts into root restriction explicitly when desired.
517            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}