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/// Operator opt-in for structured GitHub resource reads.
259///
260/// This gate is user-tier only because it changes the globally registered read
261/// surface. Most users leave it disabled, so advertising issue and pull-request
262/// spellings that can only return a refusal would waste prompt tokens and confuse
263/// tool steering. Project-specific surfaces would also destabilize prompt-prefix
264/// caches within one host.
265#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
266#[serde(default)]
267pub struct GhReadConfig {
268    /// When false, `issue://` and `pr://` reads refuse before any cache, `gh`,
269    /// or network activity. Default false keeps the in-flight feature opt-in.
270    pub enabled: bool,
271}
272
273impl Default for GhReadConfig {
274    fn default() -> Self {
275        Self { enabled: false }
276    }
277}
278
279/// Git behavior applied only to AFT-spawned agent children.
280#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
281#[serde(default)]
282pub struct GitConfig {
283    /// `off` (default), `auto`, or one explicit `Name <email>` identity.
284    pub co_author: String,
285}
286
287impl Default for GitConfig {
288    fn default() -> Self {
289        Self {
290            co_author: "off".to_string(),
291        }
292    }
293}
294
295/// Normalize configuration values into `off`, `auto`, or `Name <email>`.
296pub fn normalize_git_co_author(value: &str) -> Option<String> {
297    let value = value.trim();
298    if matches!(value, "off" | "auto") {
299        return Some(value.to_string());
300    }
301    if value.contains(['\n', '\r']) || !value.ends_with('>') {
302        return None;
303    }
304    let open = value.rfind('<')?;
305    if open == 0 || !value.as_bytes()[open - 1].is_ascii_whitespace() {
306        return None;
307    }
308    let name = value[..open].trim();
309    let email = value[open + 1..value.len() - 1].trim();
310    if name.is_empty()
311        || name.contains(['<', '>'])
312        || email.is_empty()
313        || !email.contains('@')
314        || email
315            .chars()
316            .any(|character| character.is_whitespace() || matches!(character, '<' | '>'))
317    {
318        return None;
319    }
320    Some(value.to_string())
321}
322
323/// Linked-worktree behavior that never writes shared on-disk artifacts.
324#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
325#[serde(default)]
326pub struct WorktreeConfig {
327    /// When true, a borrow-only (linked worktree) root applies its own
328    /// file-watcher events to the in-RAM trigram delta and invalidates the
329    /// symbol cache so search reflects local edits. Default false. Semantic
330    /// search and the callgraph stay frozen. Never persists to the shared
331    /// `cache.bin`.
332    pub ram_overlay: bool,
333}
334
335impl Default for WorktreeConfig {
336    fn default() -> Self {
337        Self { ram_overlay: false }
338    }
339}
340
341pub const DEFAULT_SEMANTIC_MODEL: &str = "all-MiniLM-L6-v2";
342
343impl Config {
344    pub fn semantic_backend_label(&self) -> &'static str {
345        self.semantic.backend.as_str()
346    }
347}
348
349#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
350#[serde(default)]
351pub struct SandboxConfig {
352    /// Route first-party bash commands through the native platform sandbox.
353    pub enabled: bool,
354    /// User-approved writable roots in addition to projects, task artifacts, and caches.
355    pub write_allow: Vec<PathBuf>,
356    /// Extra paths that native backends should deny reading.
357    pub read_deny: Vec<PathBuf>,
358}
359
360#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
361#[serde(default)]
362pub struct BashConfig {
363    /// Permit plugin-side break-glass execution when its AFT transport is unavailable.
364    /// Rust accepts this for cross-language config parity but never acts on it.
365    pub host_fallback: bool,
366    /// Whether the hosting plugin detaches wait:true bash calls on user messages.
367    /// Rust accepts this for cross-language config parity but never acts on it.
368    #[serde(default = "default_bash_detach_on_user_message")]
369    pub detach_on_user_message: bool,
370    /// Pi-only fallback gate for its optional PowerShell default tool. The Rust
371    /// executor accepts this solely to keep shared config parsing in parity.
372    pub powershell_tool: bool,
373}
374
375impl Default for BashConfig {
376    fn default() -> Self {
377        Self {
378            host_fallback: false,
379            detach_on_user_message: default_bash_detach_on_user_message(),
380            powershell_tool: false,
381        }
382    }
383}
384
385#[derive(Debug, Clone, Serialize, Deserialize)]
386#[serde(default)]
387pub struct Config {
388    /// Root directory of the project being analyzed. `None` if not scoped.
389    pub project_root: Option<PathBuf>,
390    /// How many levels of call-graph edges to follow during validation (default: 1).
391    pub validation_depth: u32,
392    /// Hours before legacy backup-session maintenance may collect inactive history
393    /// (default: 24). Named checkpoint retention is intentionally fixed at fourteen
394    /// days and is not controlled by configuration.
395    pub checkpoint_ttl_hours: u32,
396    /// Maximum depth for recursive symbol resolution (default: 10).
397    pub max_symbol_depth: u32,
398    /// Seconds before killing a formatter subprocess (default: 10).
399    pub formatter_timeout_secs: u32,
400    /// Seconds before killing a type-checker subprocess (default: 30).
401    pub type_checker_timeout_secs: u32,
402    /// Whether to auto-format files after edits (default: false).
403    pub format_on_edit: bool,
404    /// Whether the hashline edit/read surface is enabled for eligible sessions.
405    /// Resolved from the public `edit_mode` enum in aft.jsonc.
406    pub hashline_enabled: bool,
407    /// Whether to auto-validate files after edits (default: false).
408    /// When "syntax", only tree-sitter parse check. When "full", runs type checker.
409    pub validate_on_edit: Option<String>,
410    /// Per-language formatter overrides. Keys: "typescript", "python", "rust", "go".
411    /// Values: "biome", "oxfmt", "prettier", "deno", "ruff", "black", "rustfmt", "goimports", "gofmt", "none".
412    pub formatter: HashMap<String, String>,
413    /// Per-language type checker overrides. Keys: "typescript", "python", "rust", "go".
414    /// Values: "tsc", "tsgo", "biome", "pyright", "ruff", "cargo", "go", "staticcheck", "none".
415    pub checker: HashMap<String, String>,
416    /// Whether to restrict file operations to within `project_root` (default: false).
417    /// When true, write-capable commands reject paths outside the project root.
418    pub restrict_to_project_root: bool,
419    /// Enable the trigram search index (default: false).
420    pub search_index: bool,
421    /// User-tier standing roots. Empty by default, so normal session indexing is unchanged.
422    pub index: IndexConfig,
423    /// Enable semantic search (default: false).
424    pub semantic_search: bool,
425    /// Whether the plugin registered the `aft_search` tool for this surface
426    /// (default: false). Forwarded by the plugin's resolved registration
427    /// predicate (semantic on + not minimal + not disabled). Used only to pick
428    /// the grep-rewrite footer: when true the footer steers to `aft_search`,
429    /// otherwise to the `grep` tool. Not a capability gate.
430    pub aft_search_registered: bool,
431    /// Enable the persisted callgraph store substrate (default: true).
432    pub callgraph_store: bool,
433    /// Number of files to parse in a single batch during callgraph store cold build (default: 100).
434    /// Lower values reduce peak memory during cold build.
435    /// Set to 0 to disable chunking and parse all files at once.
436    pub callgraph_chunk_size: usize,
437    /// Enable experimental bash command rewriting (default: false).
438    pub experimental_bash_rewrite: bool,
439    /// Enable experimental bash command compression (default: false).
440    pub experimental_bash_compress: bool,
441    /// Enable experimental bash background execution (default: false).
442    pub experimental_bash_background: bool,
443    /// Maximum number of background bash tasks allowed to run concurrently (default: 8).
444    pub max_background_bash_tasks: usize,
445    /// Emit reminders for long-running bash tasks (default: true).
446    pub bash_long_running_reminder_enabled: bool,
447    /// Milliseconds between long-running bash reminders (default: 10 minutes).
448    pub bash_long_running_reminder_interval_ms: u64,
449    /// Milliseconds to wait before a foreground bash task is promoted to background handling.
450    #[serde(skip, default = "default_foreground_wait_window_ms")]
451    pub foreground_wait_window_ms: u64,
452    /// Plugin-owned bash settings accepted by configure but inert in the engine.
453    pub bash: BashConfig,
454    /// Enable OpenCode-style bash permission prompts (default: false).
455    pub bash_permissions: bool,
456    /// Native sandbox policy for first-party bash and PTY processes.
457    pub sandbox: SandboxConfig,
458    /// Maximum file size to fully index in bytes (default: 1MB).
459    pub search_index_max_file_size: u64,
460    pub semantic: SemanticBackendConfig,
461    pub inspect: InspectConfig,
462    pub backup: BackupConfig,
463    /// Linked-worktree RAM overlay. Default off; see [`WorktreeConfig`].
464    pub worktree: WorktreeConfig,
465    /// `gh` routing shim operator gate. Default on; see [`GhShimConfig`].
466    pub gh_shim: GhShimConfig,
467    /// Structured GitHub resource read gate. Default off; see [`GhReadConfig`].
468    pub gh_read: GhReadConfig,
469    /// Git attribution for AFT-spawned agent children. Default off.
470    pub git: GitConfig,
471    /// Enable Astral ty as an experimental Python LSP server (default: false).
472    pub experimental_lsp_ty: bool,
473    /// User-defined LSP servers registered by the OpenCode plugin.
474    pub lsp_servers: Vec<UserServerDef>,
475    /// Lowercase LSP server IDs disabled by user config.
476    pub disabled_lsp: HashSet<String>,
477    /// Whether the system should request inline diagnostics after a tool call edits or writes a file.
478    #[serde(skip)]
479    pub diagnostics_on_edit: bool,
480    /// Extra directories to search when resolving LSP binaries.
481    /// The plugin populates these from its own auto-install cache (e.g.
482    /// `~/.cache/aft/lsp-packages/<pkg>/node_modules/.bin/`) so an LSP binary
483    /// installed by AFT is discoverable without needing it on PATH.
484    /// Resolution order: `<project_root>/node_modules/.bin/<bin>` →
485    /// `lsp_paths_extra/<bin>` (in order) → PATH via `which`. Python-family
486    /// servers additionally probe the selected workspace's `.venv`/`venv` first.
487    pub lsp_paths_extra: Vec<PathBuf>,
488    /// Binary names the hosting plugin knows how to auto-install.
489    ///
490    /// Built-in LSPs discovered from files only emit missing-binary warnings
491    /// when their binary is in this set. User-configured `lsp_servers` keep
492    /// warning unconditionally.
493    pub lsp_auto_install_binaries: HashSet<String>,
494    /// Binary names with plugin-managed auto-installs currently in flight.
495    ///
496    /// Missing-binary warnings are suppressed while the install is actively
497    /// running; install failure reporting is handled by the plugin after the
498    /// background work settles.
499    pub lsp_inflight_installs: HashSet<String>,
500    /// Persistent storage directory for indexes (trigram, semantic).
501    /// Set by the plugin to the XDG-compliant path (e.g. ~/.local/share/opencode/storage/plugin/aft/).
502    /// Falls back to ~/.cache/aft/ if not set.
503    pub storage_dir: Option<PathBuf>,
504    /// Allow URL-fetch commands to access private network hosts.
505    /// Default false; hosting plugins only forward this from user-level config.
506    pub url_fetch_allow_private: bool,
507    /// Resolved host-tool registration preference. The Rust core retains this
508    /// value for cross-harness config parity; the hosting plugin owns registration.
509    pub hoist_builtin_tools: bool,
510    /// Hosting harness identity supplied by configure.
511    #[serde(default)]
512    pub harness: Option<Harness>,
513    /// Maximum number of (server, file) entries kept in the in-memory
514    /// diagnostic cache. Older entries are evicted in LRU order when the
515    /// cap is exceeded. Set to 0 to disable the cap entirely.
516    /// Default: 5000 (covers very large monorepos with bounded memory).
517    pub diagnostic_cache_size: usize,
518}
519
520impl Default for Config {
521    fn default() -> Self {
522        Config {
523            project_root: None,
524            validation_depth: 1,
525            checkpoint_ttl_hours: 24,
526            max_symbol_depth: 10,
527            formatter_timeout_secs: 10,
528            type_checker_timeout_secs: 30,
529            // Default OFF: formatting after an edit can silently reflow the file
530            // under the agent (a formatter splitting/joining lines), staling the
531            // context for the next edit/patch. Agents that want formatting opt in
532            // via `format_on_edit: true`.
533            format_on_edit: false,
534            hashline_enabled: false,
535            validate_on_edit: None,
536            formatter: HashMap::new(),
537            checker: HashMap::new(),
538            // Default to false to match OpenCode's existing permission-based model.
539            // The plugin opts into root restriction explicitly when desired.
540            restrict_to_project_root: false,
541            search_index: false,
542            index: IndexConfig::default(),
543            semantic_search: false,
544            aft_search_registered: false,
545            callgraph_store: true,
546            callgraph_chunk_size: 100,
547            experimental_bash_rewrite: false,
548            experimental_bash_compress: false,
549            experimental_bash_background: false,
550            max_background_bash_tasks: 8,
551            bash_long_running_reminder_enabled: true,
552            bash_long_running_reminder_interval_ms: 600_000,
553            foreground_wait_window_ms: default_foreground_wait_window_ms(),
554            bash: BashConfig::default(),
555            bash_permissions: false,
556            sandbox: SandboxConfig::default(),
557            search_index_max_file_size: 1_048_576,
558            semantic: SemanticBackendConfig::default(),
559            inspect: InspectConfig::default(),
560            backup: BackupConfig::default(),
561            worktree: WorktreeConfig::default(),
562            gh_shim: GhShimConfig::default(),
563            gh_read: GhReadConfig::default(),
564            git: GitConfig::default(),
565            experimental_lsp_ty: false,
566            lsp_servers: Vec::new(),
567            disabled_lsp: HashSet::new(),
568            diagnostics_on_edit: false,
569            lsp_paths_extra: Vec::new(),
570            lsp_auto_install_binaries: HashSet::new(),
571            lsp_inflight_installs: HashSet::new(),
572            storage_dir: None,
573            url_fetch_allow_private: false,
574            hoist_builtin_tools: true,
575            harness: None,
576            diagnostic_cache_size: 5000,
577        }
578    }
579}
580
581fn default_foreground_wait_window_ms() -> u64 {
582    15_000
583}
584
585#[cfg(test)]
586mod tests {
587    use super::*;
588
589    #[test]
590    fn index_root_path_expands_tilde_before_absolute_validation() {
591        let home = std::env::temp_dir().join("aft-home");
592        assert_eq!(
593            expand_index_root_path("~/workspace", Some(&home)).unwrap(),
594            home.join("workspace")
595        );
596        assert!(expand_index_root_path("relative/root", Some(&home)).is_err());
597        assert!(expand_index_root_path("~", None).is_err());
598    }
599}