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