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