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}