1use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11use std::sync::Arc;
12#[cfg(windows)]
13use std::os::windows::process::CommandExt;
14
15use crate::state::AppState;
16
17fn get_config_dir(state: &Arc<AppState>) -> Result<String, String> {
19 state
20 .version_config_dir
21 .lock()
22 .clone()
23 .ok_or_else(|| "Config dir not initialized yet".to_string())
24}
25
26fn get_data_dir(state: &Arc<AppState>) -> Result<String, String> {
28 state
29 .version_data_dir
30 .lock()
31 .clone()
32 .ok_or_else(|| "Data dir not initialized yet".to_string())
33}
34
35#[derive(Debug, Serialize, Deserialize, Clone)]
38pub struct CliDetectionResult {
39 pub provider: String,
40 pub installed: bool,
41 pub path: Option<String>,
42 pub version: Option<String>,
43}
44
45#[derive(Debug, Serialize, Deserialize, Clone)]
46pub struct ProviderConfig {
47 pub default_provider: String,
48 pub providers: HashMap<String, ProviderSettings>,
49 pub setup_complete: bool,
50}
51
52#[derive(Debug, Serialize, Deserialize, Clone)]
53pub struct ProviderSettings {
54 pub cli_path: Option<String>,
55 pub auth_token: Option<String>,
56 pub auth_status: String,
57 pub output_format: String,
58 pub extra_args: Vec<String>,
59}
60
61#[derive(Debug, Serialize, Deserialize, Clone)]
62pub struct ProviderInstallInfo {
63 pub provider: String,
64 pub install_command: String,
65 pub docs_url: String,
66}
67
68#[derive(Debug, Serialize, Deserialize, Clone)]
69pub struct ProviderAuthStatus {
70 pub provider: String,
71 pub status: String,
72 pub error: Option<String>,
73}
74
75#[derive(Debug, Serialize, Deserialize, Clone)]
76pub struct CliAuthStatus {
77 pub logged_in: bool,
78 pub auth_method: Option<String>,
79 pub api_provider: Option<String>,
80 pub email: Option<String>,
81 pub subscription_type: Option<String>,
82}
83
84#[derive(Debug, Serialize, Deserialize, Clone)]
85pub struct CliInstallResult {
86 pub provider: String,
87 pub cli_path: String,
88 pub version: String,
89 pub already_installed: bool,
90}
91
92#[derive(Debug, Serialize, Deserialize, Clone)]
93pub struct NodejsStatus {
94 pub available: bool,
95 pub version: Option<String>,
96 pub npm_available: bool,
97 pub npm_version: Option<String>,
98 pub path: Option<String>,
99}
100
101impl Default for ProviderConfig {
102 fn default() -> Self {
103 Self {
104 default_provider: String::new(),
105 providers: HashMap::new(),
106 setup_complete: false,
107 }
108 }
109}
110
111fn config_path(config_dir: &str) -> Result<std::path::PathBuf, String> {
114 let dir = std::path::PathBuf::from(config_dir);
115 std::fs::create_dir_all(&dir)
116 .map_err(|e| format!("Failed to create config dir: {e}"))?;
117 Ok(dir.join("provider-config.json"))
118}
119
120fn load_config(config_dir: &str) -> Result<ProviderConfig, String> {
121 let path = config_path(config_dir)?;
122 if !path.exists() {
123 return Ok(ProviderConfig::default());
124 }
125 let content = std::fs::read_to_string(&path)
126 .map_err(|e| format!("Failed to read provider config: {e}"))?;
127 serde_json::from_str(&content)
128 .map_err(|e| format!("Failed to parse provider config: {e}"))
129}
130
131fn save_config(config_dir: &str, config: &ProviderConfig) -> Result<(), String> {
132 let path = config_path(config_dir)?;
133 let content = serde_json::to_string_pretty(config)
134 .map_err(|e| format!("Failed to serialize provider config: {e}"))?;
135 std::fs::write(&path, content)
136 .map_err(|e| format!("Failed to write provider config: {e}"))
137}
138
139fn detect_cli(name: &str) -> CliDetectionResult {
142 let find_cmd = if cfg!(windows) { "where" } else { "which" };
143
144 let mut find = std::process::Command::new(find_cmd);
145 find.arg(name);
146 #[cfg(windows)]
147 find.creation_flags(0x08000000);
148
149 let path = find
150 .output()
151 .ok()
152 .and_then(|output| {
153 if output.status.success() {
154 let stdout = String::from_utf8_lossy(&output.stdout);
155 stdout.lines().next().map(|s| s.trim().to_string())
156 } else {
157 None
158 }
159 });
160
161 let version = if path.is_some() {
162 let mut ver = std::process::Command::new(name);
163 ver.arg("--version");
164 #[cfg(windows)]
165 ver.creation_flags(0x08000000);
166
167 ver.output()
168 .ok()
169 .and_then(|output| {
170 if output.status.success() {
171 let stdout = String::from_utf8_lossy(&output.stdout);
172 Some(stdout.lines().next().unwrap_or("").trim().to_string())
173 } else {
174 None
175 }
176 })
177 } else {
178 None
179 };
180
181 CliDetectionResult {
182 provider: name.to_string(),
183 installed: path.is_some(),
184 path,
185 version,
186 }
187}
188
189const CLAUDE_VERSION: &str = "latest";
192const CODEX_VERSION: &str = "0.107.0";
193const GEMINI_VERSION: &str = "0.31.0";
194
195fn get_provider_install_dir(data_dir: &str, provider: &str) -> Result<std::path::PathBuf, String> {
196 Ok(std::path::PathBuf::from(data_dir)
197 .join("cli")
198 .join(provider))
199}
200
201fn get_local_cli_bin_path(data_dir: &str, provider: &str) -> Result<std::path::PathBuf, String> {
202 let install_dir = get_provider_install_dir(data_dir, provider)?;
203 let bin_name = match provider {
204 "claude" => "claude",
205 "codex" => "codex",
206 "gemini" => "gemini",
207 _ => return Err(format!("Unknown provider: {provider}")),
208 };
209
210 if cfg!(windows) {
211 Ok(install_dir
212 .join("node_modules")
213 .join(".bin")
214 .join(format!("{bin_name}.cmd")))
215 } else {
216 Ok(install_dir.join("node_modules").join(".bin").join(bin_name))
217 }
218}
219
220fn get_npm_package(provider: &str) -> Result<&'static str, String> {
221 match provider {
222 "claude" => Ok("@anthropic-ai/claude-code"),
223 "codex" => Ok("@openai/codex"),
224 "gemini" => Ok("@google/gemini-cli"),
225 _ => Err(format!("Unknown provider: {provider}")),
226 }
227}
228
229fn get_pinned_version(provider: &str) -> Result<&'static str, String> {
230 match provider {
231 "claude" => Ok(CLAUDE_VERSION),
232 "codex" => Ok(CODEX_VERSION),
233 "gemini" => Ok(GEMINI_VERSION),
234 _ => Err(format!("No pinned version for provider: {provider}")),
235 }
236}
237
238pub async fn detect_installed_clis() -> Result<serde_json::Value, String> {
242 let results = tokio::task::spawn_blocking(|| {
243 vec![
244 detect_cli("claude"),
245 detect_cli("gemini"),
246 detect_cli("codex"),
247 ]
248 })
249 .await
250 .map_err(|e| format!("Detection task failed: {e}"))?;
251
252 tracing::info!(
253 "CLI detection: {}",
254 results
255 .iter()
256 .map(|r| format!("{}={}", r.provider, r.installed))
257 .collect::<Vec<_>>()
258 .join(", ")
259 );
260
261 serde_json::to_value(&results).map_err(|e| format!("Serialize error: {e}"))
262}
263
264pub fn get_provider_config(state: &Arc<AppState>) -> Result<serde_json::Value, String> {
266 let config = load_config(&get_config_dir(state)?)?;
267 serde_json::to_value(&config).map_err(|e| format!("Serialize error: {e}"))
268}
269
270pub fn save_provider_config(state: &Arc<AppState>, args: &serde_json::Value) -> Result<serde_json::Value, String> {
272 let config: ProviderConfig = serde_json::from_value(
273 args.get("config").cloned().unwrap_or(args.clone()),
274 )
275 .map_err(|e| format!("Failed to parse config: {e}"))?;
276
277 tracing::info!(
278 "Saving provider config: default={}, setup_complete={}",
279 config.default_provider,
280 config.setup_complete
281 );
282 save_config(&get_config_dir(state)?, &config)?;
283 Ok(serde_json::Value::Null)
284}
285
286pub fn get_provider_install_info(args: &serde_json::Value) -> Result<serde_json::Value, String> {
288 let provider = args
289 .get("provider")
290 .and_then(|v| v.as_str())
291 .ok_or_else(|| "Missing provider".to_string())?;
292
293 let info = match provider {
294 "claude" => ProviderInstallInfo {
295 provider: "claude".to_string(),
296 install_command: "npm install -g @anthropic-ai/claude-code".to_string(),
297 docs_url: "https://docs.anthropic.com/claude-code".to_string(),
298 },
299 "gemini" => ProviderInstallInfo {
300 provider: "gemini".to_string(),
301 install_command: "npm install -g @google/gemini-cli".to_string(),
302 docs_url: "https://ai.google.dev/gemini-cli".to_string(),
303 },
304 "codex" => ProviderInstallInfo {
305 provider: "codex".to_string(),
306 install_command: "npm install -g @openai/codex".to_string(),
307 docs_url: "https://platform.openai.com/docs/codex".to_string(),
308 },
309 _ => return Err(format!("Unknown provider: {provider}")),
310 };
311
312 serde_json::to_value(&info).map_err(|e| format!("Serialize error: {e}"))
313}
314
315pub async fn set_provider_auth(state: &Arc<AppState>, args: &serde_json::Value) -> Result<serde_json::Value, String> {
317 let provider = args
318 .get("provider")
319 .and_then(|v| v.as_str())
320 .ok_or_else(|| "Missing provider".to_string())?;
321 let token = args
322 .get("token")
323 .and_then(|v| v.as_str())
324 .ok_or_else(|| "Missing token".to_string())?;
325
326 let maybe_stdin = state.cli_login_stdin.lock().take();
333 if let Some(mut child_stdin) = maybe_stdin {
334 tracing::info!(provider = %provider, "set_provider_auth: delivering code to CLI stdin");
335 if let Err(e) = child_stdin.write_line(&token).await {
336 tracing::warn!(error = %e, "set_provider_auth: failed to write to CLI stdin");
337 return Err(format!("Failed to deliver auth code to CLI: {e}"));
338 }
339 return Ok(serde_json::Value::Null);
341 }
342
343 tracing::info!("Setting auth token for provider: {}", provider);
346 let cfg_dir = get_config_dir(state)?;
347 let mut config = load_config(&cfg_dir)?;
348
349 let settings = config
350 .providers
351 .entry(provider.to_string())
352 .or_insert_with(|| ProviderSettings {
353 cli_path: None,
354 auth_token: None,
355 auth_status: "none".to_string(),
356 output_format: String::new(),
357 extra_args: vec![],
358 });
359
360 settings.auth_token = Some(token.to_string());
361 settings.auth_status = "authenticated".to_string();
362
363 save_config(&cfg_dir, &config)?;
364 Ok(serde_json::Value::Null)
365}
366
367pub fn clear_provider_auth(state: &Arc<AppState>, args: &serde_json::Value) -> Result<serde_json::Value, String> {
369 let provider = args
370 .get("provider")
371 .and_then(|v| v.as_str())
372 .ok_or_else(|| "Missing provider".to_string())?;
373
374 tracing::info!("Clearing auth token for provider: {}", provider);
375 let cfg_dir = get_config_dir(state)?;
376 let mut config = load_config(&cfg_dir)?;
377
378 if let Some(settings) = config.providers.get_mut(provider) {
379 settings.auth_token = None;
380 settings.auth_status = "none".to_string();
381 }
382
383 save_config(&cfg_dir, &config)?;
384 Ok(serde_json::Value::Null)
385}
386
387pub fn get_provider_auth_status(state: &Arc<AppState>, args: &serde_json::Value) -> Result<serde_json::Value, String> {
389 let provider = args
390 .get("provider")
391 .and_then(|v| v.as_str())
392 .ok_or_else(|| "Missing provider".to_string())?;
393
394 let config = load_config(&get_config_dir(state)?)?;
395 let status = config
396 .providers
397 .get(provider)
398 .map(|s| s.auth_status.clone())
399 .unwrap_or_else(|| "none".to_string());
400
401 let result = ProviderAuthStatus {
402 provider: provider.to_string(),
403 status,
404 error: None,
405 };
406 serde_json::to_value(&result).map_err(|e| format!("Serialize error: {e}"))
407}
408
409pub async fn check_cli_auth_status(args: &serde_json::Value) -> Result<serde_json::Value, String> {
411 let provider = args
412 .get("provider")
413 .and_then(|v| v.as_str())
414 .ok_or_else(|| "Missing provider".to_string())?
415 .to_string();
416
417 let cli_path = args
418 .get("cli_path")
419 .or_else(|| args.get("cliPath"))
420 .and_then(|v| v.as_str())
421 .map(|s| s.to_string());
422
423 let cli_cmd = cli_path.unwrap_or_else(|| provider.clone());
424
425 let provider_clone = provider.clone();
426 let result = tokio::task::spawn_blocking(move || {
427 match provider_clone.as_str() {
428 "claude" => check_claude_auth(&cli_cmd),
429 "codex" => check_codex_auth(&cli_cmd),
430 "gemini" => check_gemini_auth(&cli_cmd),
431 _ => Err(format!("Unknown provider: {provider_clone}")),
432 }
433 })
434 .await
435 .map_err(|e| format!("Auth check task failed: {e}"))??;
436
437 serde_json::to_value(&result).map_err(|e| format!("Serialize error: {e}"))
438}
439
440fn check_claude_auth(cli_cmd: &str) -> Result<CliAuthStatus, String> {
441 let mut cmd = std::process::Command::new(cli_cmd);
442 cmd.args(["auth", "status", "--json"]);
443 #[cfg(windows)]
444 cmd.creation_flags(0x08000000);
445
446 let output = cmd
447 .output()
448 .map_err(|e| format!("Failed to run `{cli_cmd} auth status`: {e}"))?;
449
450 let stdout = String::from_utf8_lossy(&output.stdout);
451 let trimmed = stdout.trim();
452
453 if trimmed.is_empty() {
454 return Ok(CliAuthStatus {
455 logged_in: false,
456 auth_method: None,
457 api_provider: None,
458 email: None,
459 subscription_type: None,
460 });
461 }
462
463 let json: serde_json::Value = serde_json::from_str(trimmed)
464 .map_err(|e| format!("Failed to parse auth status JSON: {e}"))?;
465
466 Ok(CliAuthStatus {
467 logged_in: json.get("loggedIn").and_then(|v| v.as_bool()).unwrap_or(false),
468 auth_method: json.get("authMethod").and_then(|v| v.as_str()).map(|s| s.to_string()),
469 api_provider: json.get("apiProvider").and_then(|v| v.as_str()).map(|s| s.to_string()),
470 email: json.get("email").and_then(|v| v.as_str()).map(|s| s.to_string()),
471 subscription_type: json.get("subscriptionType").and_then(|v| v.as_str()).map(|s| s.to_string()),
472 })
473}
474
475fn check_codex_auth(cli_cmd: &str) -> Result<CliAuthStatus, String> {
476 let mut cmd = std::process::Command::new(cli_cmd);
477 cmd.args(["login", "status"]);
478 #[cfg(windows)]
479 cmd.creation_flags(0x08000000);
480
481 let output = cmd
482 .output()
483 .map_err(|e| format!("Failed to run `{cli_cmd} login status`: {e}"))?;
484
485 Ok(CliAuthStatus {
486 logged_in: output.status.success(),
487 auth_method: if output.status.success() { Some("oauth".to_string()) } else { None },
488 api_provider: None,
489 email: None,
490 subscription_type: None,
491 })
492}
493
494fn check_gemini_auth(cli_cmd: &str) -> Result<CliAuthStatus, String> {
495 let mut cmd = std::process::Command::new(cli_cmd);
496 cmd.args(["auth", "status"]);
497 #[cfg(windows)]
498 cmd.creation_flags(0x08000000);
499
500 let output = cmd
501 .output()
502 .map_err(|e| format!("Failed to run `{cli_cmd} auth status`: {e}"))?;
503
504 Ok(CliAuthStatus {
505 logged_in: output.status.success(),
506 auth_method: if output.status.success() { Some("oauth".to_string()) } else { None },
507 api_provider: None,
508 email: None,
509 subscription_type: None,
510 })
511}
512
513pub fn get_cli_path(state: &Arc<AppState>, args: &serde_json::Value) -> Result<serde_json::Value, String> {
515 let provider = args
516 .get("provider")
517 .and_then(|v| v.as_str())
518 .ok_or_else(|| "Missing provider".to_string())?;
519
520 let local_path = get_local_cli_bin_path(&get_data_dir(state)?, provider)?;
521 if local_path.exists() {
522 tracing::info!("Found {} in isolated install: {}", provider, local_path.display());
523 return Ok(serde_json::json!(local_path.to_string_lossy()));
524 }
525
526 tracing::info!("{} CLI not found in isolated install", provider);
527 Ok(serde_json::Value::Null)
528}
529
530pub async fn install_cli(state: &Arc<AppState>, args: &serde_json::Value) -> Result<serde_json::Value, String> {
532 let provider = args
533 .get("provider")
534 .and_then(|v| v.as_str())
535 .ok_or_else(|| "Missing provider".to_string())?
536 .to_string();
537
538 let data_dir = get_data_dir(state)?;
539 let local_path = get_local_cli_bin_path(&data_dir, &provider)?;
540 if local_path.exists() {
541 tracing::info!("CLI already installed for {}: {}", provider, local_path.display());
542 let result = CliInstallResult {
543 provider,
544 cli_path: local_path.to_string_lossy().to_string(),
545 version: "installed".to_string(),
546 already_installed: true,
547 };
548 return serde_json::to_value(&result).map_err(|e| format!("Serialize error: {e}"));
549 }
550
551 let provider_clone = provider.clone();
552 let data_dir_clone = data_dir.clone();
553 let result = tokio::task::spawn_blocking(move || {
554 let npm_package = get_npm_package(&provider_clone)?;
555 let pinned_version = get_pinned_version(&provider_clone)?;
556 let install_dir = get_provider_install_dir(&data_dir_clone, &provider_clone)?;
557
558 let npm_cmd = if cfg!(windows) { "npm.cmd" } else { "npm" };
559
560 let mut check = std::process::Command::new(npm_cmd);
562 check.arg("--version");
563 #[cfg(windows)]
564 check.creation_flags(0x08000000);
565 match check.output() {
566 Ok(output) if output.status.success() => {}
567 _ => {
568 return Err(
569 "NODEJS_NOT_FOUND: Node.js/npm is not installed.".to_string(),
570 );
571 }
572 }
573
574 std::fs::create_dir_all(&install_dir)
575 .map_err(|e| format!("Failed to create install dir: {e}"))?;
576
577 let package_spec = format!("{npm_package}@{pinned_version}");
578 let mut cmd = std::process::Command::new(npm_cmd);
579 cmd.args([
580 "install",
581 "--prefix",
582 &install_dir.to_string_lossy(),
583 &package_spec,
584 ]);
585 #[cfg(windows)]
586 cmd.creation_flags(0x08000000);
587
588 let output = cmd
589 .output()
590 .map_err(|e| format!("Failed to run npm install: {e}"))?;
591
592 if !output.status.success() {
593 let stderr = String::from_utf8_lossy(&output.stderr);
594 return Err(format!("npm install failed: {stderr}"));
595 }
596
597 let cli_path = get_local_cli_bin_path(&data_dir_clone, &provider_clone)?;
598 if !cli_path.exists() {
599 return Err(format!(
600 "Installation completed but CLI binary not found at {}",
601 cli_path.display()
602 ));
603 }
604
605 Ok(CliInstallResult {
606 provider: provider_clone,
607 cli_path: cli_path.to_string_lossy().to_string(),
608 version: "installed".to_string(),
609 already_installed: false,
610 })
611 })
612 .await
613 .map_err(|e| format!("Install task failed: {e}"))??;
614
615 serde_json::to_value(&result).map_err(|e| format!("Serialize error: {e}"))
616}
617
618pub async fn check_nodejs_available() -> Result<serde_json::Value, String> {
620 let result = tokio::task::spawn_blocking(|| {
621 let node_cmd = if cfg!(windows) { "node.exe" } else { "node" };
622 let npm_cmd = if cfg!(windows) { "npm.cmd" } else { "npm" };
623
624 let mut status = NodejsStatus {
625 available: false,
626 version: None,
627 npm_available: false,
628 npm_version: None,
629 path: None,
630 };
631
632 let mut cmd = std::process::Command::new(node_cmd);
633 cmd.arg("--version");
634 #[cfg(windows)]
635 cmd.creation_flags(0x08000000);
636 if let Ok(output) = cmd.output() {
637 if output.status.success() {
638 status.available = true;
639 status.version = Some(
640 String::from_utf8_lossy(&output.stdout).trim().to_string(),
641 );
642
643 let which_cmd = if cfg!(windows) { "where" } else { "which" };
644 let mut wcmd = std::process::Command::new(which_cmd);
645 wcmd.arg(node_cmd);
646 #[cfg(windows)]
647 wcmd.creation_flags(0x08000000);
648 if let Ok(path_out) = wcmd.output() {
649 if path_out.status.success() {
650 status.path = Some(
651 String::from_utf8_lossy(&path_out.stdout)
652 .lines()
653 .next()
654 .unwrap_or("")
655 .trim()
656 .to_string(),
657 );
658 }
659 }
660 }
661 }
662
663 let mut cmd = std::process::Command::new(npm_cmd);
664 cmd.arg("--version");
665 #[cfg(windows)]
666 cmd.creation_flags(0x08000000);
667 if let Ok(output) = cmd.output() {
668 if output.status.success() {
669 status.npm_available = true;
670 status.npm_version = Some(
671 String::from_utf8_lossy(&output.stdout).trim().to_string(),
672 );
673 }
674 }
675
676 status
677 })
678 .await
679 .map_err(|e| format!("Failed to check Node.js: {e}"))?;
680
681 serde_json::to_value(&result).map_err(|e| format!("Serialize error: {e}"))
682}
683
684pub fn copy_file_to_dir(args: &serde_json::Value) -> Result<serde_json::Value, String> {
686 let source_path = args
687 .get("source_path")
688 .or_else(|| args.get("sourcePath"))
689 .and_then(|v| v.as_str())
690 .ok_or_else(|| "Missing source_path".to_string())?;
691
692 let target_dir = args
693 .get("target_dir")
694 .or_else(|| args.get("targetDir"))
695 .and_then(|v| v.as_str())
696 .ok_or_else(|| "Missing target_dir".to_string())?;
697
698 let source = std::path::Path::new(source_path);
699 let target_dir_norm = normalize_path_for_platform(target_dir);
700 let target_dir = std::path::Path::new(&target_dir_norm);
701
702 if !source.exists() {
703 return Err(format!("Source not found: {}", source.display()));
704 }
705 if !target_dir.exists() {
706 return Err(format!("Target directory not found: {}", target_dir.display()));
707 }
708 if !target_dir.is_dir() {
709 return Err(format!("Target path is not a directory: {}", target_dir.display()));
710 }
711
712 let name = source
713 .file_name()
714 .ok_or_else(|| "Invalid source path".to_string())?;
715
716 let target = deconflict_path(target_dir, name)?;
717 copy_recursive(source, &target)?;
718
719 Ok(serde_json::json!(target.display().to_string()))
720}
721
722fn normalize_path_for_platform(path: &str) -> String {
725 #[cfg(windows)]
726 {
727 if let Some(rest) = path.strip_prefix('/') {
728 let mut chars = rest.chars();
729 if let Some(drive) = chars.next() {
730 if drive.is_ascii_alphabetic() {
731 let after_drive = chars.as_str();
732 if after_drive.is_empty() || after_drive.starts_with('/') {
733 let tail = after_drive.replace('/', "\\");
734 return format!("{}:{}", drive.to_ascii_uppercase(), tail);
735 }
736 }
737 }
738 }
739 path.replace('/', "\\")
740 }
741 #[cfg(not(windows))]
742 path.to_string()
743}
744
745fn copy_recursive(src: &std::path::Path, dst: &std::path::Path) -> Result<(), String> {
746 if src.is_file() {
747 std::fs::copy(src, dst).map_err(|e| format!("Copy failed: {}", e))?;
748 } else if src.is_dir() {
749 std::fs::create_dir_all(dst).map_err(|e| format!("Create dir failed: {}", e))?;
750 for entry in std::fs::read_dir(src).map_err(|e| format!("Read dir failed: {}", e))? {
751 let entry = entry.map_err(|e| format!("Dir entry error: {}", e))?;
752 let name = entry.file_name();
753 copy_recursive(&entry.path(), &dst.join(&name))?;
754 }
755 }
756 Ok(())
757}
758
759fn deconflict_path(
760 dir: &std::path::Path,
761 name: &std::ffi::OsStr,
762) -> Result<std::path::PathBuf, String> {
763 let candidate = dir.join(name);
764 if !candidate.exists() {
765 return Ok(candidate);
766 }
767
768 let name_str = name.to_string_lossy();
769 let (stem, ext) = match name_str.rfind('.') {
770 Some(dot) => (&name_str[..dot], &name_str[dot..]),
771 None => (name_str.as_ref(), ""),
772 };
773
774 for n in 1..=99 {
775 let new_name = format!("{stem}_{n}{ext}");
776 let candidate = dir.join(&new_name);
777 if !candidate.exists() {
778 return Ok(candidate);
779 }
780 }
781
782 Err(format!(
783 "Could not find a free filename for '{}' in '{}'",
784 name_str,
785 dir.display()
786 ))
787}