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}
315
316/// ACP server transport mode.
317#[derive(Debug, Clone, Default, Deserialize, Serialize)]
318#[serde(rename_all = "lowercase")]
319pub enum AcpTransport {
320    /// JSON-RPC over stdin/stdout (default, IDE embedding).
321    #[default]
322    Stdio,
323    /// JSON-RPC over HTTP+SSE and WebSocket.
324    Http,
325    /// Both stdio and HTTP transports active simultaneously.
326    Both,
327}
328
329/// Configuration for a named sub-agent preset in `[[acp.subagents.presets]]`.
330#[derive(Clone, Debug, Default, Deserialize, Serialize)]
331pub struct SubagentPresetConfig {
332    /// Identifier used to reference this preset by name.
333    pub name: String,
334    /// Shell command string to spawn the sub-agent (e.g. `"cargo run -- --acp"`).
335    pub command: String,
336    /// Optional working directory for the spawned subprocess.
337    #[serde(default, skip_serializing_if = "Option::is_none")]
338    pub cwd: Option<PathBuf>,
339    /// Timeout in seconds for the `initialize` + `session/new` handshake. Default: 30.
340    #[serde(default = "default_subagent_handshake_timeout_secs")]
341    pub handshake_timeout_secs: u64,
342    /// Timeout in seconds for a single prompt round-trip. Default: 600.
343    #[serde(default = "default_subagent_prompt_timeout_secs")]
344    pub prompt_timeout_secs: u64,
345}
346
347/// Configuration block for the `[acp.subagents]` TOML section.
348///
349/// # Example
350///
351/// ```toml
352/// [acp.subagents]
353/// enabled = true
354///
355/// [[acp.subagents.presets]]
356/// name = "inner"
357/// command = "cargo run --quiet -- --acp"
358/// ```
359#[derive(Clone, Debug, Default, Deserialize, Serialize)]
360pub struct AcpSubagentsConfig {
361    /// Whether sub-agent spawning is enabled at runtime. Default: `false`.
362    #[serde(default)]
363    pub enabled: bool,
364
365    /// Named presets available via CLI (`zeph acp subagent list`) and TUI palette.
366    #[serde(default)]
367    pub presets: Vec<SubagentPresetConfig>,
368}
369
370fn default_subagent_handshake_timeout_secs() -> u64 {
371    30
372}
373
374fn default_subagent_prompt_timeout_secs() -> u64 {
375    600
376}
377
378/// ACP (Agent Communication Protocol) server configuration, nested under `[acp]` in TOML.
379///
380/// When `enabled = true`, Zeph exposes an ACP endpoint that IDE integrations (e.g. Zed, VS Code)
381/// can connect to for conversational coding assistance. Supports stdio and HTTP transports.
382///
383/// # Example (TOML)
384///
385/// ```toml
386/// [acp]
387/// enabled = true
388/// transport = "stdio"
389/// agent_name = "zeph"
390/// max_sessions = 4
391/// ```
392#[derive(Clone, Deserialize, Serialize)]
393pub struct AcpConfig {
394    /// Enable the ACP server. Default: `false`.
395    #[serde(default)]
396    pub enabled: bool,
397    /// Agent name advertised in the ACP `initialize` response. Default: `"zeph"`.
398    #[serde(default = "default_acp_agent_name")]
399    pub agent_name: String,
400    /// Agent version advertised in the ACP `initialize` response. Default: crate version.
401    #[serde(default = "default_acp_agent_version")]
402    pub agent_version: String,
403    /// Maximum number of concurrent ACP sessions. Default: `4`.
404    #[serde(default = "default_acp_max_sessions")]
405    pub max_sessions: usize,
406    /// Seconds of inactivity before an idle session is closed. Default: `1800`.
407    #[serde(default = "default_acp_session_idle_timeout_secs")]
408    pub session_idle_timeout_secs: u64,
409    /// Broadcast channel capacity for streaming events. Default: `256`.
410    #[serde(default = "default_acp_broadcast_capacity")]
411    pub broadcast_capacity: usize,
412    /// Path to the ACP permission TOML file controlling per-session tool access.
413    #[serde(skip_serializing_if = "Option::is_none")]
414    pub permission_file: Option<std::path::PathBuf>,
415    /// List of `{provider}:{model}` identifiers advertised to the IDE for model switching.
416    /// Example: `["claude:claude-sonnet-4-5", "ollama:llama3"]`
417    #[serde(default)]
418    pub available_models: Vec<String>,
419    /// Transport mode: "stdio" (default), "http", or "both".
420    #[serde(default = "default_acp_transport")]
421    pub transport: AcpTransport,
422    /// Bind address for the HTTP transport.
423    #[serde(default = "default_acp_http_bind")]
424    pub http_bind: String,
425    /// Bearer token for HTTP and WebSocket transport authentication.
426    /// When set, all /acp and /acp/ws requests must include `Authorization: Bearer <token>`.
427    /// Omit for local unauthenticated access. TLS termination is assumed to be handled by a
428    /// reverse proxy.
429    #[serde(skip_serializing_if = "Option::is_none")]
430    pub auth_token: Option<String>,
431    /// Whether to serve the /.well-known/acp.json agent discovery manifest.
432    /// Only effective when transport is "http" or "both". Default: true.
433    #[serde(default = "default_acp_discovery_enabled")]
434    pub discovery_enabled: bool,
435    /// LSP extension configuration (`[acp.lsp]`).
436    #[serde(default)]
437    pub lsp: AcpLspConfig,
438    /// Allowlist of workspace directories that ACP clients may reference in session requests.
439    ///
440    /// Paths are canonicalized at config load; traversal (`..`) and reserved locations
441    /// (`/proc`, `/sys`, `~/.ssh`, `~/.gnupg`, `~/.aws`) are rejected with an error.
442    /// An empty list means clients may not request any additional directories beyond the
443    /// session `cwd`.
444    ///
445    /// This is a **policy** allowlist, not a protocol advertisement: the agent never returns
446    /// `additional_directories` in any response; instead it validates each session request's
447    /// `additional_directories` field against this list and rejects with `invalid_params`
448    /// on any violation.
449    #[serde(default)]
450    pub additional_directories: Vec<AdditionalDir>,
451    /// Auth methods advertised in the ACP `initialize` response.
452    ///
453    /// PR 4 MVP accepts only `"agent"`. Config load fails on any other value so drift
454    /// from the schema is detected at startup rather than silently ignored.
455    #[serde(default = "default_acp_auth_methods")]
456    pub auth_methods: Vec<AcpAuthMethod>,
457    /// Echo `PromptRequest.message_id` onto `PromptResponse.user_message_id` and every
458    /// streamed chunk, enabling IDE-side correlation.
459    ///
460    /// Requires the `unstable-message-id` feature. Default: `true`.
461    #[serde(default = "default_true")]
462    pub message_ids_enabled: bool,
463    /// Sub-agent delegation configuration (`[acp.subagents]`).
464    #[serde(default)]
465    pub subagents: AcpSubagentsConfig,
466}
467
468impl Default for AcpConfig {
469    fn default() -> Self {
470        Self {
471            enabled: false,
472            agent_name: default_acp_agent_name(),
473            agent_version: default_acp_agent_version(),
474            max_sessions: default_acp_max_sessions(),
475            session_idle_timeout_secs: default_acp_session_idle_timeout_secs(),
476            broadcast_capacity: default_acp_broadcast_capacity(),
477            permission_file: None,
478            available_models: Vec::new(),
479            transport: default_acp_transport(),
480            http_bind: default_acp_http_bind(),
481            auth_token: None,
482            discovery_enabled: default_acp_discovery_enabled(),
483            lsp: AcpLspConfig::default(),
484            additional_directories: Vec::new(),
485            auth_methods: default_acp_auth_methods(),
486            message_ids_enabled: true,
487            subagents: AcpSubagentsConfig::default(),
488        }
489    }
490}
491
492impl std::fmt::Debug for AcpConfig {
493    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
494        f.debug_struct("AcpConfig")
495            .field("enabled", &self.enabled)
496            .field("agent_name", &self.agent_name)
497            .field("agent_version", &self.agent_version)
498            .field("max_sessions", &self.max_sessions)
499            .field("session_idle_timeout_secs", &self.session_idle_timeout_secs)
500            .field("broadcast_capacity", &self.broadcast_capacity)
501            .field("permission_file", &self.permission_file)
502            .field("available_models", &self.available_models)
503            .field("transport", &self.transport)
504            .field("http_bind", &self.http_bind)
505            .field(
506                "auth_token",
507                &self.auth_token.as_ref().map(|_| "[REDACTED]"),
508            )
509            .field("discovery_enabled", &self.discovery_enabled)
510            .field("lsp", &self.lsp)
511            .field("additional_directories", &self.additional_directories)
512            .field("auth_methods", &self.auth_methods)
513            .field("message_ids_enabled", &self.message_ids_enabled)
514            .field("subagents", &self.subagents)
515            .finish()
516    }
517}
518
519/// Configuration for the ACP LSP extension.
520///
521/// Controls LSP code intelligence features when connected to an IDE that advertises
522/// `meta["lsp"]` capability during ACP `initialize`.
523#[derive(Debug, Clone, Deserialize, Serialize)]
524pub struct AcpLspConfig {
525    /// Enable LSP extension when the IDE supports it. Default: `true`.
526    #[serde(default = "default_true")]
527    pub enabled: bool,
528    /// Automatically fetch diagnostics when `lsp/didSave` notification is received.
529    #[serde(default = "default_true")]
530    pub auto_diagnostics_on_save: bool,
531    /// Maximum diagnostics to accept per file. Default: 20.
532    #[serde(default = "default_acp_lsp_max_diagnostics_per_file")]
533    pub max_diagnostics_per_file: usize,
534    /// Maximum files in `DiagnosticsCache` (LRU eviction). Default: 5.
535    #[serde(default = "default_acp_lsp_max_diagnostic_files")]
536    pub max_diagnostic_files: usize,
537    /// Maximum reference locations returned. Default: 100.
538    #[serde(default = "default_acp_lsp_max_references")]
539    pub max_references: usize,
540    /// Maximum workspace symbol search results. Default: 50.
541    #[serde(default = "default_acp_lsp_max_workspace_symbols")]
542    pub max_workspace_symbols: usize,
543    /// Timeout in seconds for LSP `ext_method` calls. Default: 10.
544    #[serde(default = "default_acp_lsp_request_timeout_secs")]
545    pub request_timeout_secs: u64,
546}
547
548impl Default for AcpLspConfig {
549    fn default() -> Self {
550        Self {
551            enabled: true,
552            auto_diagnostics_on_save: true,
553            max_diagnostics_per_file: default_acp_lsp_max_diagnostics_per_file(),
554            max_diagnostic_files: default_acp_lsp_max_diagnostic_files(),
555            max_references: default_acp_lsp_max_references(),
556            max_workspace_symbols: default_acp_lsp_max_workspace_symbols(),
557            request_timeout_secs: default_acp_lsp_request_timeout_secs(),
558        }
559    }
560}
561
562// ── LSP context injection ─────────────────────────────────────────────────────
563
564/// Minimum diagnostic severity to include in LSP context injection.
565#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
566#[serde(rename_all = "lowercase")]
567pub enum DiagnosticSeverity {
568    #[default]
569    Error,
570    Warning,
571    Info,
572    Hint,
573}
574
575/// Configuration for the diagnostics-on-save hook (`[agent.lsp.diagnostics]`).
576///
577/// Flood control relies on `token_budget` in [`LspConfig`], not a per-file count.
578#[derive(Debug, Clone, Deserialize, Serialize)]
579#[serde(default)]
580pub struct DiagnosticsConfig {
581    /// Enable automatic diagnostics fetching after the `write` tool.
582    pub enabled: bool,
583    /// Maximum diagnostics entries per file.
584    #[serde(default = "default_lsp_max_per_file")]
585    pub max_per_file: usize,
586    /// Minimum severity to include.
587    #[serde(default)]
588    pub min_severity: DiagnosticSeverity,
589}
590impl Default for DiagnosticsConfig {
591    fn default() -> Self {
592        Self {
593            enabled: true,
594            max_per_file: default_lsp_max_per_file(),
595            min_severity: DiagnosticSeverity::default(),
596        }
597    }
598}
599
600/// Configuration for the hover-on-read hook (`[agent.lsp.hover]`).
601#[derive(Debug, Clone, Deserialize, Serialize)]
602#[serde(default)]
603pub struct HoverConfig {
604    /// Enable hover info pre-fetch after the `read` tool. Disabled by default.
605    pub enabled: bool,
606    /// Maximum hover entries per file (Rust-only for MVP).
607    #[serde(default = "default_lsp_max_symbols")]
608    pub max_symbols: usize,
609}
610impl Default for HoverConfig {
611    fn default() -> Self {
612        Self {
613            enabled: false,
614            max_symbols: default_lsp_max_symbols(),
615        }
616    }
617}
618
619/// Top-level LSP context injection configuration (`[agent.lsp]` TOML section).
620#[derive(Debug, Clone, Deserialize, Serialize)]
621#[serde(default)]
622pub struct LspConfig {
623    /// Enable LSP context injection hooks.
624    pub enabled: bool,
625    /// MCP server ID to route LSP calls through (default: "mcpls").
626    #[serde(default = "default_lsp_mcp_server_id")]
627    pub mcp_server_id: String,
628    /// Maximum tokens to spend on injected LSP context per turn.
629    #[serde(default = "default_lsp_token_budget")]
630    pub token_budget: usize,
631    /// Timeout in seconds for each MCP LSP call.
632    #[serde(default = "default_lsp_call_timeout_secs")]
633    pub call_timeout_secs: u64,
634    /// Diagnostics-on-save hook configuration.
635    #[serde(default)]
636    pub diagnostics: DiagnosticsConfig,
637    /// Hover-on-read hook configuration.
638    #[serde(default)]
639    pub hover: HoverConfig,
640}
641impl Default for LspConfig {
642    fn default() -> Self {
643        Self {
644            enabled: false,
645            mcp_server_id: default_lsp_mcp_server_id(),
646            token_budget: default_lsp_token_budget(),
647            call_timeout_secs: default_lsp_call_timeout_secs(),
648            diagnostics: DiagnosticsConfig::default(),
649            hover: HoverConfig::default(),
650        }
651    }
652}
653
654#[cfg(test)]
655mod tests {
656    use super::*;
657
658    #[test]
659    fn acp_auth_method_unknown_variant_fails() {
660        assert!(serde_json::from_str::<AcpAuthMethod>(r#""bearer""#).is_err());
661        assert!(serde_json::from_str::<AcpAuthMethod>(r#""envvar""#).is_err());
662        assert!(serde_json::from_str::<AcpAuthMethod>(r#""Agent""#).is_err());
663    }
664
665    #[test]
666    fn acp_auth_method_known_variant_succeeds() {
667        let m = serde_json::from_str::<AcpAuthMethod>(r#""agent""#).unwrap();
668        assert_eq!(m, AcpAuthMethod::Agent);
669    }
670
671    #[test]
672    fn additional_dir_rejects_dotdot_traversal() {
673        let result = AdditionalDir::parse(std::path::PathBuf::from("/tmp/../etc"));
674        assert!(
675            matches!(result, Err(AdditionalDirError::Traversal(_))),
676            "expected Traversal, got {result:?}"
677        );
678    }
679
680    #[test]
681    fn additional_dir_rejects_proc() {
682        // /proc must exist on Linux CI; skip on macOS if not present.
683        if !std::path::Path::new("/proc").exists() {
684            return;
685        }
686        let result = AdditionalDir::parse(std::path::PathBuf::from("/proc/self"));
687        assert!(
688            matches!(result, Err(AdditionalDirError::Reserved(_))),
689            "expected Reserved, got {result:?}"
690        );
691    }
692
693    #[test]
694    fn additional_dir_rejects_ssh() {
695        let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_owned());
696        let ssh = std::path::PathBuf::from(format!("{home}/.ssh"));
697        if !ssh.exists() {
698            return;
699        }
700        let result = AdditionalDir::parse(ssh.clone());
701        assert!(
702            matches!(result, Err(AdditionalDirError::Reserved(_))),
703            "expected Reserved for {ssh:?}, got {result:?}"
704        );
705    }
706
707    #[test]
708    fn additional_dir_accepts_tmp() {
709        let tmp = std::env::temp_dir();
710        // tempdir always exists; /tmp is not reserved.
711        match AdditionalDir::parse(tmp.clone()) {
712            Ok(dir) => {
713                // canonicalized path stored correctly
714                assert!(dir.as_path().is_absolute());
715            }
716            Err(AdditionalDirError::Canonicalize { .. }) => {
717                // temp_dir may be a symlink that canonicalizes to something else — acceptable
718            }
719            Err(e) => panic!("unexpected error for {tmp:?}: {e:?}"),
720        }
721    }
722}