agentmux_cef\commands/
providers.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3//
4// Provider management commands for the CEF host.
5// Ported from src-tauri/src/commands/providers.rs and cli_installer.rs.
6//
7// Uses JSON file storage instead of tauri-plugin-store.
8
9use 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
17/// Helper to extract the version-specific config dir from AppState.
18fn 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
26/// Helper to extract the version-specific data dir from AppState.
27fn 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// ---- Types ----
36
37#[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
111// ---- File-based config storage (replaces tauri-plugin-store) ----
112
113fn 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
139// ---- CLI detection helpers ----
140
141fn 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
189// ---- CLI installer helpers ----
190
191const 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
238// ---- Command handlers ----
239
240/// Detect installed CLI tools.
241pub 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
264/// Get the persisted provider configuration.
265pub 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
270/// Save the provider configuration.
271pub 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
286/// Get install info for a provider.
287pub 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
315/// Store an auth token for a provider.
316pub 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    // For Claude (and any CLI-based provider), deliver the auth code directly
327    // to the running login process via its stdin. The CLI prints the
328    // OAuth URL, then waits for the user to paste the device code on stdin.
329    // The stdin is either a piped tokio::process::ChildStdin (typical) or a
330    // portable_pty master writer (for TTY-required providers like OpenClaw)
331    // — `CliLoginStdin::write_line` dispatches on the variant.
332    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        // Don't put stdin back — it's single-use (one code per login flow).
340        return Ok(serde_json::Value::Null);
341    }
342
343    // Fallback for providers that use AgentMux's own config-file auth
344    // (non-CLI providers, or when no login process is running).
345    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
367/// Clear auth token for a provider.
368pub 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
387/// Get auth status for a provider.
388pub 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
409/// Check CLI authentication status.
410pub 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
513/// Get CLI path from isolated install directory.
514pub 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
530/// Install a provider CLI via npm.
531pub 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        // Pre-flight: verify npm is available
561        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
618/// Check if Node.js and npm are available.
619pub 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
684/// Copy a file to a directory.
685pub 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
722// ---- File operation helpers ----
723
724fn 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}