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