1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
93#[serde(rename_all = "lowercase")]
94pub enum AcpAuthMethod {
95 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#[derive(Debug, thiserror::Error)]
123pub enum AdditionalDirError {
124 #[error("path `{0}` contains `..` traversal")]
126 Traversal(PathBuf),
127 #[error("path `{0}` is a reserved system or credentials directory")]
129 Reserved(PathBuf),
130 #[error("failed to canonicalize `{path}`: {source}")]
132 Canonicalize {
133 path: PathBuf,
134 #[source]
135 source: std::io::Error,
136 },
137}
138
139#[derive(Clone, PartialEq, Eq)]
157pub struct AdditionalDir(PathBuf);
158
159impl AdditionalDir {
160 pub fn parse(raw: impl Into<PathBuf>) -> Result<Self, AdditionalDirError> {
166 let raw: PathBuf = raw.into();
167
168 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 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 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 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
258#[serde(rename_all = "lowercase")]
259pub enum ToolDensity {
260 Compact,
262 #[default]
264 Inline,
265 Block,
267}
268
269impl ToolDensity {
270 #[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#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize)]
303pub struct TuiConfig {
304 #[serde(default)]
307 pub show_source_labels: bool,
308 #[serde(default)]
313 pub tool_density: ToolDensity,
314}
315
316#[derive(Debug, Clone, Default, Deserialize, Serialize)]
318#[serde(rename_all = "lowercase")]
319pub enum AcpTransport {
320 #[default]
322 Stdio,
323 Http,
325 Both,
327}
328
329#[derive(Clone, Debug, Default, Deserialize, Serialize)]
331pub struct SubagentPresetConfig {
332 pub name: String,
334 pub command: String,
336 #[serde(default, skip_serializing_if = "Option::is_none")]
338 pub cwd: Option<PathBuf>,
339 #[serde(default = "default_subagent_handshake_timeout_secs")]
341 pub handshake_timeout_secs: u64,
342 #[serde(default = "default_subagent_prompt_timeout_secs")]
344 pub prompt_timeout_secs: u64,
345}
346
347#[derive(Clone, Debug, Default, Deserialize, Serialize)]
360pub struct AcpSubagentsConfig {
361 #[serde(default)]
363 pub enabled: bool,
364
365 #[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#[derive(Clone, Deserialize, Serialize)]
393pub struct AcpConfig {
394 #[serde(default)]
396 pub enabled: bool,
397 #[serde(default = "default_acp_agent_name")]
399 pub agent_name: String,
400 #[serde(default = "default_acp_agent_version")]
402 pub agent_version: String,
403 #[serde(default = "default_acp_max_sessions")]
405 pub max_sessions: usize,
406 #[serde(default = "default_acp_session_idle_timeout_secs")]
408 pub session_idle_timeout_secs: u64,
409 #[serde(default = "default_acp_broadcast_capacity")]
411 pub broadcast_capacity: usize,
412 #[serde(skip_serializing_if = "Option::is_none")]
414 pub permission_file: Option<std::path::PathBuf>,
415 #[serde(default)]
418 pub available_models: Vec<String>,
419 #[serde(default = "default_acp_transport")]
421 pub transport: AcpTransport,
422 #[serde(default = "default_acp_http_bind")]
424 pub http_bind: String,
425 #[serde(skip_serializing_if = "Option::is_none")]
430 pub auth_token: Option<String>,
431 #[serde(default = "default_acp_discovery_enabled")]
434 pub discovery_enabled: bool,
435 #[serde(default)]
437 pub lsp: AcpLspConfig,
438 #[serde(default)]
450 pub additional_directories: Vec<AdditionalDir>,
451 #[serde(default = "default_acp_auth_methods")]
456 pub auth_methods: Vec<AcpAuthMethod>,
457 #[serde(default = "default_true")]
462 pub message_ids_enabled: bool,
463 #[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#[derive(Debug, Clone, Deserialize, Serialize)]
524pub struct AcpLspConfig {
525 #[serde(default = "default_true")]
527 pub enabled: bool,
528 #[serde(default = "default_true")]
530 pub auto_diagnostics_on_save: bool,
531 #[serde(default = "default_acp_lsp_max_diagnostics_per_file")]
533 pub max_diagnostics_per_file: usize,
534 #[serde(default = "default_acp_lsp_max_diagnostic_files")]
536 pub max_diagnostic_files: usize,
537 #[serde(default = "default_acp_lsp_max_references")]
539 pub max_references: usize,
540 #[serde(default = "default_acp_lsp_max_workspace_symbols")]
542 pub max_workspace_symbols: usize,
543 #[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#[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#[derive(Debug, Clone, Deserialize, Serialize)]
579#[serde(default)]
580pub struct DiagnosticsConfig {
581 pub enabled: bool,
583 #[serde(default = "default_lsp_max_per_file")]
585 pub max_per_file: usize,
586 #[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#[derive(Debug, Clone, Deserialize, Serialize)]
602#[serde(default)]
603pub struct HoverConfig {
604 pub enabled: bool,
606 #[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#[derive(Debug, Clone, Deserialize, Serialize)]
621#[serde(default)]
622pub struct LspConfig {
623 pub enabled: bool,
625 #[serde(default = "default_lsp_mcp_server_id")]
627 pub mcp_server_id: String,
628 #[serde(default = "default_lsp_token_budget")]
630 pub token_budget: usize,
631 #[serde(default = "default_lsp_call_timeout_secs")]
633 pub call_timeout_secs: u64,
634 #[serde(default)]
636 pub diagnostics: DiagnosticsConfig,
637 #[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 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 match AdditionalDir::parse(tmp.clone()) {
712 Ok(dir) => {
713 assert!(dir.as_path().is_absolute());
715 }
716 Err(AdditionalDirError::Canonicalize { .. }) => {
717 }
719 Err(e) => panic!("unexpected error for {tmp:?}: {e:?}"),
720 }
721 }
722}