Skip to main content

zeph_config/
ui.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use std::path::{Component, Path, PathBuf};
5
6use serde::{Deserialize, Serialize};
7
8use crate::defaults::default_true;
9
10fn default_acp_agent_name() -> String {
11    "zeph".to_owned()
12}
13
14fn default_acp_agent_version() -> String {
15    env!("CARGO_PKG_VERSION").to_owned()
16}
17
18fn default_acp_max_sessions() -> usize {
19    4
20}
21
22fn default_acp_session_idle_timeout_secs() -> u64 {
23    1800
24}
25
26fn default_acp_broadcast_capacity() -> usize {
27    256
28}
29
30fn default_acp_transport() -> AcpTransport {
31    AcpTransport::Stdio
32}
33
34fn default_acp_http_bind() -> String {
35    "127.0.0.1:9800".to_owned()
36}
37
38fn default_acp_discovery_enabled() -> bool {
39    true
40}
41
42fn default_acp_lsp_max_diagnostics_per_file() -> usize {
43    20
44}
45
46fn default_acp_lsp_max_diagnostic_files() -> usize {
47    5
48}
49
50fn default_acp_lsp_max_references() -> usize {
51    100
52}
53
54fn default_acp_lsp_max_workspace_symbols() -> usize {
55    50
56}
57
58fn default_acp_lsp_request_timeout_secs() -> u64 {
59    10
60}
61fn default_lsp_mcp_server_id() -> String {
62    "mcpls".into()
63}
64fn default_lsp_token_budget() -> usize {
65    2000
66}
67fn default_lsp_max_per_file() -> usize {
68    20
69}
70fn default_lsp_max_symbols() -> usize {
71    5
72}
73fn default_lsp_call_timeout_secs() -> u64 {
74    5
75}
76
77/// Auth methods recognised by Zeph's ACP handler.
78///
79/// PR 4 MVP restricts this to `Agent` only. Future variants (`EnvVar`, `Terminal`) will
80/// be added in follow-up issues with their sub-struct payloads.
81///
82/// # Examples
83///
84/// ```rust
85/// use zeph_config::AcpAuthMethod;
86/// use serde_json;
87///
88/// let m: AcpAuthMethod = serde_json::from_str(r#""agent""#).unwrap();
89/// assert_eq!(m, AcpAuthMethod::Agent);
90/// assert!(serde_json::from_str::<AcpAuthMethod>(r#""envvar""#).is_err());
91/// ```
92#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
93#[serde(rename_all = "lowercase")]
94pub enum AcpAuthMethod {
95    /// Vault-backed agent auth — the sole supported method in PR 4.
96    Agent,
97}
98
99impl<'de> serde::Deserialize<'de> for AcpAuthMethod {
100    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
101        let s = String::deserialize(d)?;
102        match s.as_str() {
103            "agent" => Ok(Self::Agent),
104            other => Err(serde::de::Error::unknown_variant(other, &["agent"])),
105        }
106    }
107}
108
109impl std::fmt::Display for AcpAuthMethod {
110    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
111        match self {
112            Self::Agent => f.write_str("agent"),
113        }
114    }
115}
116
117fn default_acp_auth_methods() -> Vec<AcpAuthMethod> {
118    vec![AcpAuthMethod::Agent]
119}
120
121/// Error returned when parsing an [`AdditionalDir`] fails.
122#[derive(Debug, thiserror::Error)]
123pub enum AdditionalDirError {
124    /// The raw path contains a `..` component.
125    #[error("path `{0}` contains `..` traversal")]
126    Traversal(PathBuf),
127    /// The canonical path is a reserved system or credentials location.
128    #[error("path `{0}` is a reserved system or credentials directory")]
129    Reserved(PathBuf),
130    /// `std::fs::canonicalize` failed.
131    #[error("failed to canonicalize `{path}`: {source}")]
132    Canonicalize {
133        path: PathBuf,
134        #[source]
135        source: std::io::Error,
136    },
137}
138
139/// A single entry in the `acp.additional_directories` policy allowlist.
140///
141/// Constructed via [`Self::parse`], which:
142/// 1. Rejects any path containing a `..` component (component-aware check).
143/// 2. Expands a leading `~` to the user's home directory.
144/// 3. Calls `std::fs::canonicalize`.
145/// 4. Rejects paths prefixed by `/proc`, `/sys`, `{HOME}/.ssh`, `{HOME}/.gnupg`, or `{HOME}/.aws`.
146///
147/// # Examples
148///
149/// ```rust,no_run
150/// use zeph_config::AdditionalDir;
151///
152/// let dir = AdditionalDir::parse("/tmp/workspace").unwrap();
153/// assert!(dir.as_path().is_absolute());
154/// assert!(AdditionalDir::parse("/proc/self").is_err());
155/// ```
156#[derive(Clone, PartialEq, Eq)]
157pub struct AdditionalDir(PathBuf);
158
159impl AdditionalDir {
160    /// Parse and validate a raw path as a policy allowlist entry.
161    ///
162    /// # Errors
163    ///
164    /// Returns [`AdditionalDirError`] on traversal, reserved prefix, or canonicalization failure.
165    pub fn parse(raw: impl Into<PathBuf>) -> Result<Self, AdditionalDirError> {
166        let raw: PathBuf = raw.into();
167
168        // Expand leading `~`.
169        let expanded = if raw.starts_with("~") {
170            let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("/"));
171            home.join(raw.strip_prefix("~").unwrap_or(&raw))
172        } else {
173            raw.clone()
174        };
175
176        // Reject `..` components (component-aware, not string-based).
177        for component in expanded.components() {
178            if component == Component::ParentDir {
179                return Err(AdditionalDirError::Traversal(raw));
180            }
181        }
182
183        let canon =
184            std::fs::canonicalize(&expanded).map_err(|e| AdditionalDirError::Canonicalize {
185                path: raw.clone(),
186                source: e,
187            })?;
188
189        // Reject reserved locations.
190        let reserved = reserved_prefixes();
191        for prefix in &reserved {
192            if canon.starts_with(prefix) {
193                return Err(AdditionalDirError::Reserved(canon));
194            }
195        }
196
197        Ok(Self(canon))
198    }
199
200    /// Returns the canonicalized path.
201    #[must_use]
202    pub fn as_path(&self) -> &Path {
203        &self.0
204    }
205}
206
207fn reserved_prefixes() -> Vec<PathBuf> {
208    let mut prefixes = vec![PathBuf::from("/proc"), PathBuf::from("/sys")];
209    if let Some(home) = dirs::home_dir() {
210        prefixes.push(home.join(".ssh"));
211        prefixes.push(home.join(".gnupg"));
212        prefixes.push(home.join(".aws"));
213    }
214    prefixes
215}
216
217impl std::fmt::Debug for AdditionalDir {
218    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
219        write!(f, "AdditionalDir({:?})", self.0)
220    }
221}
222
223impl std::fmt::Display for AdditionalDir {
224    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
225        write!(f, "{}", self.0.display())
226    }
227}
228
229impl Serialize for AdditionalDir {
230    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
231        self.0.to_string_lossy().serialize(s)
232    }
233}
234
235impl<'de> serde::Deserialize<'de> for AdditionalDir {
236    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
237        let s = String::deserialize(d)?;
238        Self::parse(s).map_err(serde::de::Error::custom)
239    }
240}
241
242/// Controls how much detail is shown for tool-call messages in the chat view.
243///
244/// Cycled with the `c` key at runtime; persisted in `[tui].tool_density`.
245///
246/// # Examples
247///
248/// ```rust
249/// use zeph_config::ToolDensity;
250///
251/// let d = ToolDensity::default();
252/// assert_eq!(d, ToolDensity::Inline);
253/// assert_eq!(d.cycle(), ToolDensity::Block);
254/// assert_eq!(ToolDensity::Block.cycle(), ToolDensity::Compact);
255/// assert_eq!(ToolDensity::Compact.cycle(), ToolDensity::Inline);
256/// ```
257#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
258#[serde(rename_all = "lowercase")]
259pub enum ToolDensity {
260    /// Single-line summary only (tool name + line count, no output body).
261    Compact,
262    /// Command line + head/tail-truncated output (default).
263    #[default]
264    Inline,
265    /// Full output body without truncation.
266    Block,
267}
268
269impl ToolDensity {
270    /// Advance to the next density level, wrapping around.
271    ///
272    /// `Compact` → `Inline` → `Block` → `Compact`.
273    ///
274    /// # Examples
275    ///
276    /// ```rust
277    /// use zeph_config::ToolDensity;
278    ///
279    /// assert_eq!(ToolDensity::Compact.cycle(), ToolDensity::Inline);
280    /// assert_eq!(ToolDensity::Inline.cycle(), ToolDensity::Block);
281    /// assert_eq!(ToolDensity::Block.cycle(), ToolDensity::Compact);
282    /// ```
283    #[must_use]
284    pub fn cycle(self) -> Self {
285        match self {
286            Self::Compact => Self::Inline,
287            Self::Inline => Self::Block,
288            Self::Block => Self::Compact,
289        }
290    }
291}
292
293/// TUI (terminal user interface) configuration, nested under `[tui]` in TOML.
294///
295/// # Example (TOML)
296///
297/// ```toml
298/// [tui]
299/// show_source_labels = true
300/// tool_density = "inline"
301/// ```
302#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize)]
303pub struct TuiConfig {
304    /// Show memory source labels (episodic / semantic / graph) in the message view.
305    /// Default: `false`.
306    #[serde(default)]
307    pub show_source_labels: bool,
308    /// Default tool-output density applied at startup.
309    ///
310    /// Runtime changes via the `c` key are not persisted back to config.
311    /// Default: `inline`.
312    #[serde(default)]
313    pub tool_density: ToolDensity,
314    /// Fleet panel configuration (auto-refresh interval and max sessions displayed).
315    #[serde(default)]
316    pub fleet: FleetConfig,
317}
318
319/// Configuration for the TUI fleet panel (#3884).
320#[derive(Debug, Clone, Copy, Deserialize, Serialize)]
321#[serde(default)]
322pub struct FleetConfig {
323    /// How often the fleet panel polls the database for updated session data (seconds).
324    pub refresh_interval_secs: u64,
325    /// Maximum number of sessions to display in the fleet panel.
326    pub max_sessions: u32,
327}
328
329impl Default for FleetConfig {
330    fn default() -> Self {
331        Self {
332            refresh_interval_secs: 5,
333            max_sessions: 50,
334        }
335    }
336}
337
338/// ACP server transport mode.
339#[derive(Debug, Clone, Default, Deserialize, Serialize)]
340#[serde(rename_all = "lowercase")]
341pub enum AcpTransport {
342    /// JSON-RPC over stdin/stdout (default, IDE embedding).
343    #[default]
344    Stdio,
345    /// JSON-RPC over HTTP+SSE and WebSocket.
346    Http,
347    /// Both stdio and HTTP transports active simultaneously.
348    Both,
349}
350
351/// Configuration for a named sub-agent preset in `[[acp.subagents.presets]]`.
352#[derive(Clone, Debug, Default, Deserialize, Serialize)]
353pub struct SubagentPresetConfig {
354    /// Identifier used to reference this preset by name.
355    pub name: String,
356    /// Shell command string to spawn the sub-agent (e.g. `"cargo run -- --acp"`).
357    pub command: String,
358    /// Optional working directory for the spawned subprocess.
359    #[serde(default, skip_serializing_if = "Option::is_none")]
360    pub cwd: Option<PathBuf>,
361    /// Timeout in seconds for the `initialize` + `session/new` handshake. Default: 30.
362    #[serde(default = "default_subagent_handshake_timeout_secs")]
363    pub handshake_timeout_secs: u64,
364    /// Timeout in seconds for a single prompt round-trip. Default: 600.
365    #[serde(default = "default_subagent_prompt_timeout_secs")]
366    pub prompt_timeout_secs: u64,
367}
368
369/// Configuration block for the `[acp.subagents]` TOML section.
370///
371/// # Example
372///
373/// ```toml
374/// [acp.subagents]
375/// enabled = true
376///
377/// [[acp.subagents.presets]]
378/// name = "inner"
379/// command = "cargo run --quiet -- --acp"
380/// ```
381#[derive(Clone, Debug, Default, Deserialize, Serialize)]
382pub struct AcpSubagentsConfig {
383    /// Whether sub-agent spawning is enabled at runtime. Default: `false`.
384    #[serde(default)]
385    pub enabled: bool,
386
387    /// Named presets available via CLI (`zeph acp subagent list`) and TUI palette.
388    #[serde(default)]
389    pub presets: Vec<SubagentPresetConfig>,
390}
391
392fn default_subagent_handshake_timeout_secs() -> u64 {
393    30
394}
395
396fn default_subagent_prompt_timeout_secs() -> u64 {
397    600
398}
399
400/// ACP (Agent Communication Protocol) server configuration, nested under `[acp]` in TOML.
401///
402/// When `enabled = true`, Zeph exposes an ACP endpoint that IDE integrations (e.g. Zed, VS Code)
403/// can connect to for conversational coding assistance. Supports stdio and HTTP transports.
404///
405/// # Example (TOML)
406///
407/// ```toml
408/// [acp]
409/// enabled = true
410/// transport = "stdio"
411/// agent_name = "zeph"
412/// max_sessions = 4
413/// ```
414#[derive(Clone, Deserialize, Serialize)]
415pub struct AcpConfig {
416    /// Enable the ACP server. Default: `false`.
417    #[serde(default)]
418    pub enabled: bool,
419    /// Agent name advertised in the ACP `initialize` response. Default: `"zeph"`.
420    #[serde(default = "default_acp_agent_name")]
421    pub agent_name: String,
422    /// Agent version advertised in the ACP `initialize` response. Default: crate version.
423    #[serde(default = "default_acp_agent_version")]
424    pub agent_version: String,
425    /// Maximum number of concurrent ACP sessions. Default: `4`.
426    #[serde(default = "default_acp_max_sessions")]
427    pub max_sessions: usize,
428    /// Seconds of inactivity before an idle session is closed. Default: `1800`.
429    #[serde(default = "default_acp_session_idle_timeout_secs")]
430    pub session_idle_timeout_secs: u64,
431    /// Broadcast channel capacity for streaming events. Default: `256`.
432    #[serde(default = "default_acp_broadcast_capacity")]
433    pub broadcast_capacity: usize,
434    /// Path to the ACP permission TOML file controlling per-session tool access.
435    #[serde(skip_serializing_if = "Option::is_none")]
436    pub permission_file: Option<std::path::PathBuf>,
437    /// List of `{provider}:{model}` identifiers advertised to the IDE for model switching.
438    /// Example: `["claude:claude-sonnet-4-5", "ollama:llama3"]`
439    #[serde(default)]
440    pub available_models: Vec<String>,
441    /// Transport mode: "stdio" (default), "http", or "both".
442    #[serde(default = "default_acp_transport")]
443    pub transport: AcpTransport,
444    /// Bind address for the HTTP transport.
445    #[serde(default = "default_acp_http_bind")]
446    pub http_bind: String,
447    /// Bearer token for HTTP and WebSocket transport authentication.
448    /// When set, all /acp and /acp/ws requests must include `Authorization: Bearer <token>`.
449    /// Omit for local unauthenticated access. TLS termination is assumed to be handled by a
450    /// reverse proxy.
451    #[serde(skip_serializing_if = "Option::is_none")]
452    pub auth_token: Option<String>,
453    /// Whether to serve the /.well-known/acp.json agent discovery manifest.
454    /// Only effective when transport is "http" or "both". Default: true.
455    #[serde(default = "default_acp_discovery_enabled")]
456    pub discovery_enabled: bool,
457    /// LSP extension configuration (`[acp.lsp]`).
458    #[serde(default)]
459    pub lsp: AcpLspConfig,
460    /// Allowlist of workspace directories that ACP clients may reference in session requests.
461    ///
462    /// Paths are canonicalized at config load; traversal (`..`) and reserved locations
463    /// (`/proc`, `/sys`, `~/.ssh`, `~/.gnupg`, `~/.aws`) are rejected with an error.
464    /// An empty list means clients may not request any additional directories beyond the
465    /// session `cwd`.
466    ///
467    /// This is a **policy** allowlist, not a protocol advertisement: the agent never returns
468    /// `additional_directories` in any response; instead it validates each session request's
469    /// `additional_directories` field against this list and rejects with `invalid_params`
470    /// on any violation.
471    #[serde(default)]
472    pub additional_directories: Vec<AdditionalDir>,
473    /// Auth methods advertised in the ACP `initialize` response.
474    ///
475    /// PR 4 MVP accepts only `"agent"`. Config load fails on any other value so drift
476    /// from the schema is detected at startup rather than silently ignored.
477    #[serde(default = "default_acp_auth_methods")]
478    pub auth_methods: Vec<AcpAuthMethod>,
479    /// Echo `PromptRequest.message_id` onto `PromptResponse.user_message_id` and every
480    /// streamed chunk, enabling IDE-side correlation.
481    ///
482    /// Requires the `unstable-message-id` feature. Default: `true`.
483    #[serde(default = "default_true")]
484    pub message_ids_enabled: bool,
485    /// Sub-agent delegation configuration (`[acp.subagents]`).
486    #[serde(default)]
487    pub subagents: AcpSubagentsConfig,
488}
489
490impl Default for AcpConfig {
491    fn default() -> Self {
492        Self {
493            enabled: false,
494            agent_name: default_acp_agent_name(),
495            agent_version: default_acp_agent_version(),
496            max_sessions: default_acp_max_sessions(),
497            session_idle_timeout_secs: default_acp_session_idle_timeout_secs(),
498            broadcast_capacity: default_acp_broadcast_capacity(),
499            permission_file: None,
500            available_models: Vec::new(),
501            transport: default_acp_transport(),
502            http_bind: default_acp_http_bind(),
503            auth_token: None,
504            discovery_enabled: default_acp_discovery_enabled(),
505            lsp: AcpLspConfig::default(),
506            additional_directories: Vec::new(),
507            auth_methods: default_acp_auth_methods(),
508            message_ids_enabled: true,
509            subagents: AcpSubagentsConfig::default(),
510        }
511    }
512}
513
514impl std::fmt::Debug for AcpConfig {
515    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
516        f.debug_struct("AcpConfig")
517            .field("enabled", &self.enabled)
518            .field("agent_name", &self.agent_name)
519            .field("agent_version", &self.agent_version)
520            .field("max_sessions", &self.max_sessions)
521            .field("session_idle_timeout_secs", &self.session_idle_timeout_secs)
522            .field("broadcast_capacity", &self.broadcast_capacity)
523            .field("permission_file", &self.permission_file)
524            .field("available_models", &self.available_models)
525            .field("transport", &self.transport)
526            .field("http_bind", &self.http_bind)
527            .field(
528                "auth_token",
529                &self.auth_token.as_ref().map(|_| "[REDACTED]"),
530            )
531            .field("discovery_enabled", &self.discovery_enabled)
532            .field("lsp", &self.lsp)
533            .field("additional_directories", &self.additional_directories)
534            .field("auth_methods", &self.auth_methods)
535            .field("message_ids_enabled", &self.message_ids_enabled)
536            .field("subagents", &self.subagents)
537            .finish()
538    }
539}
540
541/// Configuration for the ACP LSP extension.
542///
543/// Controls LSP code intelligence features when connected to an IDE that advertises
544/// `meta["lsp"]` capability during ACP `initialize`.
545#[derive(Debug, Clone, Deserialize, Serialize)]
546pub struct AcpLspConfig {
547    /// Enable LSP extension when the IDE supports it. Default: `true`.
548    #[serde(default = "default_true")]
549    pub enabled: bool,
550    /// Automatically fetch diagnostics when `lsp/didSave` notification is received.
551    #[serde(default = "default_true")]
552    pub auto_diagnostics_on_save: bool,
553    /// Maximum diagnostics to accept per file. Default: 20.
554    #[serde(default = "default_acp_lsp_max_diagnostics_per_file")]
555    pub max_diagnostics_per_file: usize,
556    /// Maximum files in `DiagnosticsCache` (LRU eviction). Default: 5.
557    #[serde(default = "default_acp_lsp_max_diagnostic_files")]
558    pub max_diagnostic_files: usize,
559    /// Maximum reference locations returned. Default: 100.
560    #[serde(default = "default_acp_lsp_max_references")]
561    pub max_references: usize,
562    /// Maximum workspace symbol search results. Default: 50.
563    #[serde(default = "default_acp_lsp_max_workspace_symbols")]
564    pub max_workspace_symbols: usize,
565    /// Timeout in seconds for LSP `ext_method` calls. Default: 10.
566    #[serde(default = "default_acp_lsp_request_timeout_secs")]
567    pub request_timeout_secs: u64,
568}
569
570impl Default for AcpLspConfig {
571    fn default() -> Self {
572        Self {
573            enabled: true,
574            auto_diagnostics_on_save: true,
575            max_diagnostics_per_file: default_acp_lsp_max_diagnostics_per_file(),
576            max_diagnostic_files: default_acp_lsp_max_diagnostic_files(),
577            max_references: default_acp_lsp_max_references(),
578            max_workspace_symbols: default_acp_lsp_max_workspace_symbols(),
579            request_timeout_secs: default_acp_lsp_request_timeout_secs(),
580        }
581    }
582}
583
584// ── LSP context injection ─────────────────────────────────────────────────────
585
586/// Minimum diagnostic severity to include in LSP context injection.
587#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
588#[serde(rename_all = "lowercase")]
589pub enum DiagnosticSeverity {
590    #[default]
591    Error,
592    Warning,
593    Info,
594    Hint,
595}
596
597/// Configuration for the diagnostics-on-save hook (`[agent.lsp.diagnostics]`).
598///
599/// Flood control relies on `token_budget` in [`LspConfig`], not a per-file count.
600#[derive(Debug, Clone, Deserialize, Serialize)]
601#[serde(default)]
602pub struct DiagnosticsConfig {
603    /// Enable automatic diagnostics fetching after the `write` tool.
604    pub enabled: bool,
605    /// Maximum diagnostics entries per file.
606    #[serde(default = "default_lsp_max_per_file")]
607    pub max_per_file: usize,
608    /// Minimum severity to include.
609    #[serde(default)]
610    pub min_severity: DiagnosticSeverity,
611}
612impl Default for DiagnosticsConfig {
613    fn default() -> Self {
614        Self {
615            enabled: true,
616            max_per_file: default_lsp_max_per_file(),
617            min_severity: DiagnosticSeverity::default(),
618        }
619    }
620}
621
622/// Configuration for the hover-on-read hook (`[agent.lsp.hover]`).
623#[derive(Debug, Clone, Deserialize, Serialize)]
624#[serde(default)]
625pub struct HoverConfig {
626    /// Enable hover info pre-fetch after the `read` tool. Disabled by default.
627    pub enabled: bool,
628    /// Maximum hover entries per file (Rust-only for MVP).
629    #[serde(default = "default_lsp_max_symbols")]
630    pub max_symbols: usize,
631}
632impl Default for HoverConfig {
633    fn default() -> Self {
634        Self {
635            enabled: false,
636            max_symbols: default_lsp_max_symbols(),
637        }
638    }
639}
640
641/// Top-level LSP context injection configuration (`[agent.lsp]` TOML section).
642#[derive(Debug, Clone, Deserialize, Serialize)]
643#[serde(default)]
644pub struct LspConfig {
645    /// Enable LSP context injection hooks.
646    pub enabled: bool,
647    /// MCP server ID to route LSP calls through (default: "mcpls").
648    #[serde(default = "default_lsp_mcp_server_id")]
649    pub mcp_server_id: String,
650    /// Maximum tokens to spend on injected LSP context per turn.
651    #[serde(default = "default_lsp_token_budget")]
652    pub token_budget: usize,
653    /// Timeout in seconds for each MCP LSP call.
654    #[serde(default = "default_lsp_call_timeout_secs")]
655    pub call_timeout_secs: u64,
656    /// Diagnostics-on-save hook configuration.
657    #[serde(default)]
658    pub diagnostics: DiagnosticsConfig,
659    /// Hover-on-read hook configuration.
660    #[serde(default)]
661    pub hover: HoverConfig,
662}
663impl Default for LspConfig {
664    fn default() -> Self {
665        Self {
666            enabled: false,
667            mcp_server_id: default_lsp_mcp_server_id(),
668            token_budget: default_lsp_token_budget(),
669            call_timeout_secs: default_lsp_call_timeout_secs(),
670            diagnostics: DiagnosticsConfig::default(),
671            hover: HoverConfig::default(),
672        }
673    }
674}
675
676#[cfg(test)]
677mod tests {
678    use super::*;
679
680    #[test]
681    fn acp_auth_method_unknown_variant_fails() {
682        assert!(serde_json::from_str::<AcpAuthMethod>(r#""bearer""#).is_err());
683        assert!(serde_json::from_str::<AcpAuthMethod>(r#""envvar""#).is_err());
684        assert!(serde_json::from_str::<AcpAuthMethod>(r#""Agent""#).is_err());
685    }
686
687    #[test]
688    fn acp_auth_method_known_variant_succeeds() {
689        let m = serde_json::from_str::<AcpAuthMethod>(r#""agent""#).unwrap();
690        assert_eq!(m, AcpAuthMethod::Agent);
691    }
692
693    #[test]
694    fn additional_dir_rejects_dotdot_traversal() {
695        let result = AdditionalDir::parse(std::path::PathBuf::from("/tmp/../etc"));
696        assert!(
697            matches!(result, Err(AdditionalDirError::Traversal(_))),
698            "expected Traversal, got {result:?}"
699        );
700    }
701
702    #[test]
703    fn additional_dir_rejects_proc() {
704        // /proc must exist on Linux CI; skip on macOS if not present.
705        if !std::path::Path::new("/proc").exists() {
706            return;
707        }
708        let result = AdditionalDir::parse(std::path::PathBuf::from("/proc/self"));
709        assert!(
710            matches!(result, Err(AdditionalDirError::Reserved(_))),
711            "expected Reserved, got {result:?}"
712        );
713    }
714
715    #[test]
716    fn additional_dir_rejects_ssh() {
717        let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_owned());
718        let ssh = std::path::PathBuf::from(format!("{home}/.ssh"));
719        if !ssh.exists() {
720            return;
721        }
722        let result = AdditionalDir::parse(ssh.clone());
723        assert!(
724            matches!(result, Err(AdditionalDirError::Reserved(_))),
725            "expected Reserved for {ssh:?}, got {result:?}"
726        );
727    }
728
729    #[test]
730    fn additional_dir_accepts_tmp() {
731        let tmp = std::env::temp_dir();
732        // tempdir always exists; /tmp is not reserved.
733        match AdditionalDir::parse(tmp.clone()) {
734            Ok(dir) => {
735                // canonicalized path stored correctly
736                assert!(dir.as_path().is_absolute());
737            }
738            Err(AdditionalDirError::Canonicalize { .. }) => {
739                // temp_dir may be a symlink that canonicalizes to something else — acceptable
740            }
741            Err(e) => panic!("unexpected error for {tmp:?}: {e:?}"),
742        }
743    }
744}