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}