Skip to main content

parley/domain/
config.rs

1use crate::domain::ai::{AiProvider, AiSessionMode};
2use serde::{Deserialize, Serialize};
3use std::path::Path;
4
5#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
6#[serde(default)]
7pub struct AppConfig {
8    #[serde(alias = "name", default = "default_user_name")]
9    pub user_name: String,
10    pub theme: String,
11    pub diff_view: DiffViewMode,
12    #[serde(default = "default_ignore_parley_dir")]
13    pub ignore_parley_dir: bool,
14    #[serde(default = "default_log_level")]
15    pub log_level: String,
16    pub ai: AiConfig,
17}
18
19#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
20#[serde(rename_all = "snake_case")]
21#[derive(Default)]
22pub enum DiffViewMode {
23    #[default]
24    SideBySide,
25    Unified,
26}
27
28impl DiffViewMode {
29    #[must_use]
30    pub fn is_side_by_side(&self) -> bool {
31        matches!(self, Self::SideBySide)
32    }
33}
34
35#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
36#[serde(rename_all = "snake_case")]
37#[derive(Default)]
38pub enum AgentTransport {
39    #[default]
40    Acp,
41    Cli,
42}
43
44impl AgentTransport {
45    #[must_use]
46    pub fn as_str(&self) -> &'static str {
47        match self {
48            Self::Acp => "acp",
49            Self::Cli => "cli",
50        }
51    }
52}
53
54#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
55#[serde(rename_all = "snake_case")]
56#[derive(Default)]
57pub enum ProviderTransport {
58    #[default]
59    Acp,
60    Cli,
61    PiRpc,
62}
63
64impl ProviderTransport {
65    #[must_use]
66    pub fn as_str(&self) -> &'static str {
67        match self {
68            Self::Acp => "acp",
69            Self::Cli => "cli",
70            Self::PiRpc => "pi_rpc",
71        }
72    }
73
74    #[must_use]
75    pub fn as_agent_transport(&self) -> Option<AgentTransport> {
76        match self {
77            Self::Acp => Some(AgentTransport::Acp),
78            Self::Cli => Some(AgentTransport::Cli),
79            Self::PiRpc => None,
80        }
81    }
82}
83
84impl From<AgentTransport> for ProviderTransport {
85    fn from(value: AgentTransport) -> Self {
86        match value {
87            AgentTransport::Acp => Self::Acp,
88            AgentTransport::Cli => Self::Cli,
89        }
90    }
91}
92
93#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
94#[serde(default)]
95pub struct AiProviderConfig {
96    pub transport: ProviderTransport,
97    #[serde(alias = "program")]
98    pub client: String,
99    pub model: Option<String>,
100    pub model_arg: Option<String>,
101    pub args: Vec<String>,
102}
103
104#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
105#[serde(default)]
106pub struct AiConfig {
107    pub timeout_seconds: u64,
108    pub default_provider: AiProvider,
109    pub default_transport: Option<AgentTransport>,
110    pub prompt_path: Option<String>,
111    pub reply_prompt_path: Option<String>,
112    pub refactor_prompt_path: Option<String>,
113    pub codex: AiProviderConfig,
114    pub claude: AiProviderConfig,
115    pub opencode: AiProviderConfig,
116    pub pi: AiProviderConfig,
117}
118
119#[must_use]
120pub fn default_user_name() -> String {
121    std::env::var("PARLEY_USER_NAME")
122        .ok()
123        .or_else(|| std::env::var("USER").ok())
124        .or_else(|| std::env::var("USERNAME").ok())
125        .filter(|value| !value.trim().is_empty())
126        .unwrap_or_else(|| "User".to_string())
127}
128
129#[must_use]
130pub fn default_log_level() -> String {
131    "info".to_string()
132}
133
134#[must_use]
135pub fn default_ignore_parley_dir() -> bool {
136    true
137}
138
139impl Default for AppConfig {
140    fn default() -> Self {
141        Self {
142            user_name: default_user_name(),
143            theme: "default".to_string(),
144            diff_view: DiffViewMode::default(),
145            ignore_parley_dir: default_ignore_parley_dir(),
146            log_level: default_log_level(),
147            ai: AiConfig::default(),
148        }
149    }
150}
151
152impl Default for AiProviderConfig {
153    fn default() -> Self {
154        Self {
155            transport: ProviderTransport::Acp,
156            client: String::new(),
157            model: None,
158            model_arg: Some("--model".to_string()),
159            args: Vec::new(),
160        }
161    }
162}
163
164impl AiProviderConfig {
165    #[must_use]
166    pub fn with_client(client: &str) -> Self {
167        Self {
168            client: client.to_string(),
169            model: None,
170            ..Self::default()
171        }
172    }
173
174    #[must_use]
175    pub fn command_label(&self) -> String {
176        let mut parts = Vec::with_capacity(self.args.len().saturating_add(1));
177        parts.push(self.client.as_str());
178        parts.extend(self.args.iter().map(String::as_str));
179        parts.join(" ")
180    }
181}
182
183impl Default for AiConfig {
184    fn default() -> Self {
185        Self {
186            timeout_seconds: 120,
187            default_provider: AiProvider::Opencode,
188            default_transport: Some(AgentTransport::Acp),
189            prompt_path: None,
190            reply_prompt_path: None,
191            refactor_prompt_path: None,
192            codex: default_provider_config_for_provider_transport(
193                AiProvider::Codex,
194                ProviderTransport::Acp,
195            )
196            .expect("codex acp profile should exist"),
197            claude: default_provider_config_for_provider_transport(
198                AiProvider::Claude,
199                ProviderTransport::Acp,
200            )
201            .expect("claude acp profile should exist"),
202            opencode: default_provider_config_for_provider_transport(
203                AiProvider::Opencode,
204                ProviderTransport::Acp,
205            )
206            .expect("opencode acp profile should exist"),
207            pi: default_provider_config_for_provider_transport(
208                AiProvider::Pi,
209                ProviderTransport::PiRpc,
210            )
211            .expect("pi rpc profile should exist"),
212        }
213    }
214}
215
216impl AiConfig {
217    #[must_use]
218    pub fn provider_config(&self, provider: AiProvider) -> &AiProviderConfig {
219        match provider {
220            AiProvider::Codex => &self.codex,
221            AiProvider::Claude => &self.claude,
222            AiProvider::Opencode => &self.opencode,
223            AiProvider::Pi => &self.pi,
224        }
225    }
226
227    #[must_use]
228    pub fn provider_config_for_transport(
229        &self,
230        provider: AiProvider,
231        transport: Option<AgentTransport>,
232    ) -> AiProviderConfig {
233        let configured = self.provider_config(provider);
234        if provider == AiProvider::Pi {
235            return pi_rpc_provider_config(configured);
236        }
237        match transport {
238            Some(AgentTransport::Acp)
239                if configured.transport != ProviderTransport::Acp
240                    || is_cli_command_for_acp_transport(provider, configured) =>
241            {
242                default_provider_config_for_agent_transport(provider, AgentTransport::Acp)
243                    .unwrap_or_else(|| configured.clone())
244            }
245            Some(AgentTransport::Cli) if configured.transport != ProviderTransport::Cli => {
246                default_provider_config_for_agent_transport(provider, AgentTransport::Cli)
247                    .unwrap_or_else(|| configured.clone())
248            }
249            _ => configured.clone(),
250        }
251    }
252
253    #[must_use]
254    pub fn prompt_path_for_mode(&self, mode: AiSessionMode) -> Option<&str> {
255        let mode_path = match mode {
256            AiSessionMode::Reply => self.reply_prompt_path.as_deref(),
257            AiSessionMode::Refactor => self.refactor_prompt_path.as_deref(),
258        };
259        mode_path
260            .or(self.prompt_path.as_deref())
261            .map(str::trim)
262            .filter(|path| !path.is_empty())
263    }
264}
265
266#[derive(Debug, Clone, Copy)]
267struct ProviderCommandProfile {
268    transport: ProviderTransport,
269    client: &'static str,
270    args: &'static [&'static str],
271    model_arg: Option<&'static str>,
272}
273
274impl ProviderCommandProfile {
275    fn to_config(self) -> AiProviderConfig {
276        let mut config = AiProviderConfig::with_client(self.client);
277        config.transport = self.transport;
278        config.args = self.args.iter().map(|value| (*value).to_string()).collect();
279        config.model_arg = self.model_arg.map(str::to_string);
280        config
281    }
282
283    fn command_label(self) -> String {
284        let mut parts = Vec::with_capacity(self.args.len().saturating_add(1));
285        parts.push(self.client);
286        parts.extend(self.args);
287        parts.join(" ")
288    }
289}
290
291fn provider_command_profile(
292    provider: AiProvider,
293    transport: ProviderTransport,
294) -> Option<ProviderCommandProfile> {
295    match (provider, transport) {
296        (AiProvider::Codex, ProviderTransport::Acp) => Some(ProviderCommandProfile {
297            transport: ProviderTransport::Acp,
298            client: "codex-acp",
299            args: &[],
300            model_arg: Some("--model"),
301        }),
302        (AiProvider::Codex, ProviderTransport::Cli) => Some(ProviderCommandProfile {
303            transport: ProviderTransport::Cli,
304            client: "codex",
305            args: &["exec"],
306            model_arg: Some("--model"),
307        }),
308        (AiProvider::Claude, ProviderTransport::Acp) => Some(ProviderCommandProfile {
309            transport: ProviderTransport::Acp,
310            client: "claude-agent-acp",
311            args: &[],
312            model_arg: Some("--model"),
313        }),
314        (AiProvider::Claude, ProviderTransport::Cli) => Some(ProviderCommandProfile {
315            transport: ProviderTransport::Cli,
316            client: "claude",
317            args: &["-p"],
318            model_arg: Some("--model"),
319        }),
320        (AiProvider::Opencode, ProviderTransport::Acp) => Some(ProviderCommandProfile {
321            transport: ProviderTransport::Acp,
322            client: "opencode",
323            args: &["acp"],
324            model_arg: Some("-m"),
325        }),
326        (AiProvider::Opencode, ProviderTransport::Cli) => Some(ProviderCommandProfile {
327            transport: ProviderTransport::Cli,
328            client: "opencode",
329            args: &["run"],
330            model_arg: Some("-m"),
331        }),
332        (AiProvider::Pi, ProviderTransport::PiRpc) => Some(ProviderCommandProfile {
333            transport: ProviderTransport::PiRpc,
334            client: "pi",
335            args: &["--mode", "rpc", "--no-session"],
336            model_arg: Some("--model"),
337        }),
338        _ => None,
339    }
340}
341
342fn default_provider_config_for_provider_transport(
343    provider: AiProvider,
344    transport: ProviderTransport,
345) -> Option<AiProviderConfig> {
346    provider_command_profile(provider, transport).map(ProviderCommandProfile::to_config)
347}
348
349fn default_provider_config_for_agent_transport(
350    provider: AiProvider,
351    transport: AgentTransport,
352) -> Option<AiProviderConfig> {
353    default_provider_config_for_provider_transport(provider, ProviderTransport::from(transport))
354}
355
356fn pi_rpc_provider_config(configured: &AiProviderConfig) -> AiProviderConfig {
357    let default =
358        default_provider_config_for_provider_transport(AiProvider::Pi, ProviderTransport::PiRpc)
359            .expect("pi rpc profile should exist");
360    let mut config = configured.clone();
361    config.transport = ProviderTransport::PiRpc;
362    if config.client.trim().is_empty() {
363        config.client = default.client;
364    }
365    if config.args.is_empty() {
366        config.args = default.args;
367    }
368    if config
369        .model_arg
370        .as_deref()
371        .is_none_or(|value| value.trim().is_empty())
372    {
373        config.model_arg = default.model_arg;
374    }
375    config
376}
377
378#[must_use]
379pub fn acp_command_replacement(provider: AiProvider, config: &AiProviderConfig) -> Option<String> {
380    if is_cli_command_for_acp_transport(provider, config) {
381        provider_command_profile(provider, ProviderTransport::Acp)
382            .map(ProviderCommandProfile::command_label)
383    } else {
384        None
385    }
386}
387
388fn is_cli_command_for_acp_transport(provider: AiProvider, config: &AiProviderConfig) -> bool {
389    if config.transport != ProviderTransport::Acp {
390        return false;
391    }
392    let client = Path::new(&config.client)
393        .file_name()
394        .and_then(|value| value.to_str())
395        .unwrap_or(config.client.as_str());
396    match provider {
397        AiProvider::Codex => client == "codex",
398        AiProvider::Claude => client == "claude" || client == "claude-code",
399        AiProvider::Opencode => {
400            client == "opencode" && config.args.first().map(String::as_str) != Some("acp")
401        }
402        AiProvider::Pi => false,
403    }
404}
405
406#[cfg(test)]
407mod tests {
408    use super::{AgentTransport, AiConfig, AiProviderConfig, AppConfig, ProviderTransport};
409    use crate::domain::ai::{AiProvider, AiSessionMode};
410    use anyhow::Result;
411
412    #[test]
413    fn default_config_ignores_parley_dir() {
414        let config = AppConfig::default();
415
416        assert!(config.ignore_parley_dir);
417    }
418
419    #[test]
420    fn ai_prompt_path_for_mode_prefers_mode_specific_path() {
421        let config = AiConfig {
422            prompt_path: Some("prompts/default.md".to_string()),
423            reply_prompt_path: Some("prompts/reply.md".to_string()),
424            refactor_prompt_path: None,
425            ..AiConfig::default()
426        };
427
428        assert_eq!(
429            config.prompt_path_for_mode(AiSessionMode::Reply),
430            Some("prompts/reply.md")
431        );
432        assert_eq!(
433            config.prompt_path_for_mode(AiSessionMode::Refactor),
434            Some("prompts/default.md")
435        );
436    }
437
438    #[test]
439    fn default_ai_config_uses_persistent_agent_transports() {
440        let config = AiConfig::default();
441
442        assert_eq!(config.codex.transport, ProviderTransport::Acp);
443        assert_eq!(config.default_transport, Some(AgentTransport::Acp));
444        assert_eq!(config.claude.transport, ProviderTransport::Acp);
445        assert_eq!(config.opencode.transport, ProviderTransport::Acp);
446        assert_eq!(config.pi.transport, ProviderTransport::PiRpc);
447        assert_eq!(config.opencode.args, vec!["acp"]);
448        assert_eq!(config.pi.args, vec!["--mode", "rpc", "--no-session"]);
449    }
450
451    #[test]
452    fn provider_config_for_transport_uses_builtin_cli_profiles() {
453        let config = AiConfig::default();
454
455        let codex =
456            config.provider_config_for_transport(AiProvider::Codex, Some(AgentTransport::Cli));
457        let opencode =
458            config.provider_config_for_transport(AiProvider::Opencode, Some(AgentTransport::Cli));
459
460        assert_eq!(codex.transport, ProviderTransport::Cli);
461        assert_eq!(codex.client, "codex");
462        assert_eq!(codex.args, vec!["exec"]);
463        assert_eq!(opencode.transport, ProviderTransport::Cli);
464        assert_eq!(opencode.client, "opencode");
465        assert_eq!(opencode.args, vec!["run"]);
466    }
467
468    #[test]
469    fn provider_config_for_transport_repairs_cli_command_for_acp_transport() {
470        let mut config = AiConfig::default();
471        config.opencode.transport = ProviderTransport::Acp;
472        config.opencode.client = "opencode".to_string();
473        config.opencode.args = vec!["run".to_string()];
474
475        let opencode =
476            config.provider_config_for_transport(AiProvider::Opencode, Some(AgentTransport::Acp));
477
478        assert_eq!(opencode.transport, ProviderTransport::Acp);
479        assert_eq!(opencode.client, "opencode");
480        assert_eq!(opencode.args, vec!["acp"]);
481    }
482
483    #[test]
484    fn provider_config_for_transport_keeps_pi_rpc_provider_specific() {
485        let mut config = AiConfig {
486            default_transport: Some(AgentTransport::Cli),
487            ..AiConfig::default()
488        };
489        config.pi = AiProviderConfig::with_client("/custom/pi");
490
491        let pi = config.provider_config_for_transport(AiProvider::Pi, config.default_transport);
492
493        assert_eq!(pi.transport, ProviderTransport::PiRpc);
494        assert_eq!(pi.client, "/custom/pi");
495        assert_eq!(pi.args, vec!["--mode", "rpc", "--no-session"]);
496    }
497
498    #[test]
499    fn app_config_deserializes_custom_prompt_paths() -> Result<()> {
500        let config: AppConfig = toml::from_str(
501            r#"
502            [ai]
503            prompt_path = "prompts/shared.md"
504            reply_prompt_path = "prompts/reply.md"
505            refactor_prompt_path = "prompts/refactor.md"
506            "#,
507        )?;
508
509        assert_eq!(config.ai.prompt_path.as_deref(), Some("prompts/shared.md"));
510        assert_eq!(
511            config.ai.reply_prompt_path.as_deref(),
512            Some("prompts/reply.md")
513        );
514        assert_eq!(
515            config.ai.refactor_prompt_path.as_deref(),
516            Some("prompts/refactor.md")
517        );
518        Ok(())
519    }
520}