Skip to main content

gitee_cli_rs/
extension.rs

1use std::ffi::OsString;
2use std::path::{Path, PathBuf};
3use std::process::Command;
4
5use crate::cli::BuildKind;
6use crate::config::Config;
7use crate::error::{GiteeError, Result};
8
9const PREFIX: &str = "gitee-";
10
11#[cfg(test)]
12static TEST_MANAGED_DIR: std::sync::Mutex<Option<PathBuf>> = std::sync::Mutex::new(None);
13
14#[cfg(test)]
15pub fn set_test_managed_dir(dir: Option<PathBuf>) {
16    *TEST_MANAGED_DIR.lock().unwrap_or_else(|e| e.into_inner()) = dir;
17}
18
19#[cfg(test)]
20pub fn test_managed_env_lock() -> std::sync::MutexGuard<'static, ()> {
21    static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
22    LOCK.lock().unwrap_or_else(|e| e.into_inner())
23}
24
25/// Split `PATH` into directories (empty when unset).
26pub fn path_dirs() -> Vec<PathBuf> {
27    std::env::var_os("PATH")
28        .map(|p| std::env::split_paths(&p).collect())
29        .unwrap_or_default()
30}
31
32/// Managed extensions live under `<data_dir>/gitee/extensions`. Matches `gh`'s
33/// choice of `dirs::data_dir()` (NOT `data_local_dir`).
34pub fn managed_dir() -> Result<PathBuf> {
35    #[cfg(test)]
36    if let Some(p) = TEST_MANAGED_DIR.lock().unwrap_or_else(|e| e.into_inner()).clone() {
37        return Ok(p);
38    }
39    let base = dirs::data_dir().ok_or_else(|| {
40        GiteeError::Usage("no data directory available for managed extensions".into())
41    })?;
42    Ok(base.join("gitee").join("extensions"))
43}
44
45/// Directories scanned for `gitee-*` executables (the flat `PATH` portion only;
46/// the managed dir is consulted via [`find_installed`] because its layout is
47/// `<name>/gitee-<name>`, not a flat bin dir).
48fn flat_search_dirs() -> Result<Vec<PathBuf>> {
49    Ok(path_dirs())
50}
51
52fn extension_binary_name(name: &str) -> String {
53    format!("{PREFIX}{name}")
54}
55
56#[cfg(unix)]
57fn is_executable(path: &Path) -> bool {
58    use std::os::unix::fs::PermissionsExt;
59    path.is_file()
60        && path
61            .metadata()
62            .map(|m| m.permissions().mode() & 0o111 != 0)
63            .unwrap_or(false)
64}
65
66#[cfg(not(unix))]
67fn is_executable(path: &Path) -> bool {
68    path.is_file()
69}
70
71/// Resolve `gitee-{name}` on `PATH` (managed dir is consulted first via
72/// [`find_installed`], so an installed extension shadows a same-named binary
73/// elsewhere on `PATH`).
74pub fn find_on_path(name: &str) -> Option<PathBuf> {
75    if name.is_empty() {
76        return None;
77    }
78    if let Ok(Some(p)) = find_installed(name) {
79        return Some(p);
80    }
81    let bin = extension_binary_name(name);
82    for dir in flat_search_dirs().ok()? {
83        let path = dir.join(&bin);
84        if is_executable(&path) {
85            return Some(path);
86        }
87        #[cfg(windows)]
88        for ext in ["exe", "cmd", "bat"] {
89            let path = dir.join(format!("{bin}.{ext}"));
90            if path.is_file() {
91                return Some(path);
92            }
93        }
94    }
95    None
96}
97
98fn file_stem(name: &str) -> &str {
99    Path::new(name)
100        .file_stem()
101        .and_then(|s| s.to_str())
102        .unwrap_or(name)
103}
104
105/// Scan `PATH` and the managed dir for `gitee-*` executables; return extension
106/// names (without prefix), sorted and deduped.
107pub fn list_on_path() -> Vec<String> {
108    let mut names = Vec::new();
109    if let Ok(installed) = list_installed() {
110        names.extend(installed);
111    }
112    for dir in flat_search_dirs().unwrap_or_default() {
113        let Ok(entries) = std::fs::read_dir(&dir) else {
114            continue;
115        };
116        for entry in entries.flatten() {
117            let path = entry.path();
118            let file_name_os = entry.file_name();
119            let Some(file_name) = file_name_os.to_str() else {
120                continue;
121            };
122            let stem = file_stem(file_name);
123            let Some(ext_name) = stem.strip_prefix(PREFIX) else {
124                continue;
125            };
126            if ext_name.is_empty() || !is_executable(&path) {
127                continue;
128            }
129            names.push(ext_name.to_string());
130        }
131    }
132    names.sort();
133    names.dedup();
134    names
135}
136
137/// Names of extensions installed in the managed dir (subdirs containing a
138/// `gitee-<name>` executable at the root).
139pub fn list_installed() -> Result<Vec<String>> {
140    let dir = managed_dir()?;
141    let mut names = Vec::new();
142    let Ok(entries) = std::fs::read_dir(&dir) else {
143        return Ok(names);
144    };
145    for entry in entries.flatten() {
146        let path = entry.path();
147        if !path.is_dir() {
148            continue;
149        }
150        let Some(name) = path.file_name().and_then(|s| s.to_str()) else {
151            continue;
152        };
153        if find_installed(name)?.is_some() {
154            names.push(name.to_string());
155        }
156    }
157    names.sort();
158    Ok(names)
159}
160
161/// Path to `<managed>/<name>/gitee-<name>` if present (and executable on Unix).
162pub fn find_installed(name: &str) -> Result<Option<PathBuf>> {
163    if name.is_empty() {
164        return Ok(None);
165    }
166    let dir = managed_dir()?.join(name);
167    let bin = dir.join(extension_binary_name(name));
168    if is_executable(&bin) {
169        return Ok(Some(bin));
170    }
171    #[cfg(windows)]
172    {
173        for ext in ["exe", "cmd", "bat"] {
174            let p = dir.join(format!("{}-{name}.{ext}", PREFIX));
175            if p.is_file() {
176                return Ok(Some(p));
177            }
178        }
179    }
180    Ok(None)
181}
182
183/// Whether to inject `GITEE_HOST` for an extension child process.
184fn should_set_gitee_host() -> bool {
185    std::env::var_os("GITEE_HOST").is_none()
186}
187
188/// Exec a `gitee-{name}` binary with `args`, forwarding `GITEE_*` env.
189pub fn exec(name: &str, args: &[OsString], host: &str) -> Result<()> {
190    let path = find_on_path(name).ok_or_else(|| {
191        GiteeError::Usage(format!(
192            "extension command '{name}' not found on PATH (expected {PREFIX}{name})"
193        ))
194    })?;
195    let mut cmd = Command::new(&path);
196    cmd.args(args);
197    if should_set_gitee_host() {
198        cmd.env("GITEE_HOST", host);
199    }
200    if std::env::var("GITEE_TOKEN").is_err() {
201        if let Ok(token) = Config::token(host) {
202            cmd.env("GITEE_TOKEN", token);
203        }
204    }
205    let status = cmd.status()?;
206    std::process::exit(status.code().unwrap_or(1));
207}
208
209fn parse_build_kind(s: Option<&str>) -> Result<Option<BuildKind>> {
210    match s {
211        None => Ok(None),
212        Some("cargo") => Ok(Some(BuildKind::Cargo)),
213        Some("npm") => Ok(Some(BuildKind::Npm)),
214        Some(other) => Err(GiteeError::Usage(format!(
215            "unknown --build value '{other}'; expected 'cargo' or 'npm'"
216        ))),
217    }
218}
219
220/// `owner/repo` → extension name (the repo's last path segment, stripped of an
221/// optional `gitee-` prefix). Accepts full URLs too.
222fn repo_to_name(repo: &str) -> Result<String> {
223    let trimmed = repo.trim().trim_end_matches('/');
224    if trimmed.is_empty() {
225        return Err(GiteeError::Usage("install: repo argument is empty".into()));
226    }
227    if !trimmed.contains('/') && !trimmed.contains(':') {
228        return Err(GiteeError::Usage(format!(
229            "install: repo '{repo}' must be owner/name or a full URL"
230        )));
231    }
232    let last = trimmed.rsplit(['/', ':']).next().unwrap_or(trimmed);
233    if last.is_empty() {
234        return Err(GiteeError::Usage(format!(
235            "install: could not derive extension name from '{repo}'"
236        )));
237    }
238    let name = last.strip_prefix(PREFIX).unwrap_or(last);
239    if name.is_empty()
240        || name.contains('/')
241        || name.contains('\\')
242        || name.contains('\0')
243        || name == "."
244        || name == ".."
245    {
246        return Err(GiteeError::Usage(format!(
247            "install: invalid extension name '{name}' derived from '{repo}'"
248        )));
249    }
250    Ok(name.to_string())
251}
252
253fn clone_url(repo: &str, host: &str) -> String {
254    if repo.starts_with("http://")
255        || repo.starts_with("https://")
256        || repo.starts_with("git@")
257        || repo.starts_with("ssh://")
258    {
259        return repo.to_string();
260    }
261    let owner_repo = repo.trim_start_matches('/');
262    format!("https://{host}/{owner_repo}")
263}
264
265fn last_commit_short_sha(dir: &Path) -> Option<String> {
266    let out = Command::new("git")
267        .args(["-C"])
268        .arg(dir)
269        .args(["rev-parse", "--short", "HEAD"])
270        .output()
271        .ok()?;
272    if !out.status.success() {
273        return None;
274    }
275    let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
276    if s.is_empty() {
277        None
278    } else {
279        Some(s)
280    }
281}
282
283fn run_git(dir: &Path, args: &[&str]) -> Result<()> {
284    let status = Command::new("git")
285        .current_dir(dir)
286        .args(args)
287        .status()
288        .map_err(|e| GiteeError::Usage(format!("git: {e}")))?;
289    if !status.success() {
290        return Err(GiteeError::Usage(format!(
291            "git {} failed (exit {:?})",
292            args.join(" "),
293            status.code()
294        )));
295    }
296    Ok(())
297}
298
299fn build_cargo(ext_dir: &Path, name: &str) -> Result<()> {
300    let status = Command::new("cargo")
301        .current_dir(ext_dir)
302        .args(["build", "--release"])
303        .status()
304        .map_err(|e| GiteeError::Usage(format!("cargo: {e}")))?;
305    if !status.success() {
306        return Err(GiteeError::Usage(format!(
307            "cargo build --release failed (exit {:?})",
308            status.code()
309        )));
310    }
311    let target = ext_dir.join("target/release");
312    let bin_name = crate_name(ext_dir).unwrap_or_else(|| extension_binary_name(name));
313    let src = target.join(&bin_name);
314    let dst = ext_dir.join(extension_binary_name(name));
315    if !src.exists() {
316        return Err(GiteeError::Usage(format!(
317            "cargo build produced no binary at {}",
318            src.display()
319        )));
320    }
321    std::fs::copy(&src, &dst).map_err(|e| GiteeError::Usage(format!("copy: {e}")))?;
322    make_executable(&dst);
323    Ok(())
324}
325
326fn crate_name(ext_dir: &Path) -> Option<String> {
327    let toml = std::fs::read_to_string(ext_dir.join("Cargo.toml")).ok()?;
328    for line in toml.lines() {
329        let t = line.trim();
330        if let Some(rest) = t.strip_prefix("name") {
331            let rest = rest.trim_start();
332            if let Some(rest) = rest.strip_prefix('=') {
333                let v = rest.trim().trim_matches('"').trim_matches('\'');
334                if !v.is_empty() {
335                    return Some(v.to_string());
336                }
337            }
338        }
339    }
340    None
341}
342
343fn build_npm(ext_dir: &Path) -> Result<()> {
344    let has_build = std::fs::read_to_string(ext_dir.join("package.json"))
345        .ok()
346        .and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
347        .and_then(|v| {
348            v.get("scripts")
349                .and_then(|s| s.get("build"))
350                .map(|b| b.is_string())
351        })
352        .unwrap_or(false);
353    let install_status = Command::new("npm")
354        .current_dir(ext_dir)
355        .arg("install")
356        .status()
357        .map_err(|e| GiteeError::Usage(format!("npm install: {e}")))?;
358    if !install_status.success() {
359        return Err(GiteeError::Usage(format!(
360            "npm install failed (exit {:?})",
361            install_status.code()
362        )));
363    }
364    if has_build {
365        let build_status = Command::new("npm")
366            .current_dir(ext_dir)
367            .args(["run", "build"])
368            .status()
369            .map_err(|e| GiteeError::Usage(format!("npm run build: {e}")))?;
370        if !build_status.success() {
371            return Err(GiteeError::Usage(format!(
372                "npm run build failed (exit {:?})",
373                build_status.code()
374            )));
375        }
376    }
377    Ok(())
378}
379
380#[cfg(unix)]
381fn make_executable(path: &Path) {
382    use std::os::unix::fs::PermissionsExt;
383    let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755));
384}
385
386#[cfg(not(unix))]
387fn make_executable(_path: &Path) {}
388
389/// Install `owner/repo` into the managed extensions dir. `git` is required.
390pub fn install(repo: &str, build: Option<&str>, yes: bool, host: &str) -> Result<()> {
391    let name = repo_to_name(repo)?;
392    let build = parse_build_kind(build)?;
393    let dir = managed_dir()?;
394    std::fs::create_dir_all(&dir).map_err(|e| GiteeError::Usage(e.to_string()))?;
395    let ext_dir = dir.join(&name);
396    if ext_dir.exists() {
397        return Err(GiteeError::Usage(format!(
398            "extension '{name}' already installed at {}",
399            ext_dir.display()
400        )));
401    }
402    let url = clone_url(repo, host);
403    let action = format!(
404        "install extension '{name}' from {url} — you are about to download and run arbitrary code"
405    );
406    crate::cmd::confirm(&action, yes)?;
407    run_git(&dir, &["clone", "--depth", "1", &url, &name])?;
408    if let Some(sha) = last_commit_short_sha(&ext_dir) {
409        eprintln!("cloned {url} @ {sha}");
410    }
411    match build {
412        Some(BuildKind::Cargo) => build_cargo(&ext_dir, &name)?,
413        Some(BuildKind::Npm) => build_npm(&ext_dir)?,
414        None => {}
415    }
416    if find_installed(&name)?.is_none() {
417        return Err(GiteeError::Usage(format!(
418            "install: repo '{repo}' did not produce a {PREFIX}{name} executable at {}",
419            ext_dir.display()
420        )));
421    }
422    println!("installed extension '{name}'");
423    Ok(())
424}
425
426/// Scaffold a new extension project in `cwd` (not the managed dir).
427pub fn create(name: &str, cargo: bool) -> Result<()> {
428    validate_ext_name(name)?;
429    let cwd = std::env::current_dir().map_err(|e| GiteeError::Usage(e.to_string()))?;
430    let bin = extension_binary_name(name);
431    if cargo {
432        let toml = format!(
433            "[package]\nname = \"{bin}\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\nclap = {{ version = \"4\", features = [\"derive\"] }}\n"
434        );
435        std::fs::write(cwd.join("Cargo.toml"), toml)
436            .map_err(|e| GiteeError::Usage(e.to_string()))?;
437        let src_dir = cwd.join("src");
438        std::fs::create_dir_all(&src_dir).map_err(|e| GiteeError::Usage(e.to_string()))?;
439        let main = format!(
440            "use clap::Parser;\n\n#[derive(Parser)]\n#[command(name = \"{bin}\", about = \"demo extension\")]\nstruct Cli {{\n    args: Vec<String>,\n}}\n\nfn main() {{\n    let _cli = Cli::parse();\n    println!(\"demo extension\");\n}}\n"
441        );
442        std::fs::write(src_dir.join("main.rs"), main)
443            .map_err(|e| GiteeError::Usage(e.to_string()))?;
444    } else {
445        #[cfg(unix)]
446        {
447            let script = "#!/bin/sh\necho \"demo extension\"\n";
448            let path = cwd.join(&bin);
449            std::fs::write(&path, script).map_err(|e| GiteeError::Usage(e.to_string()))?;
450            make_executable(&path);
451        }
452        #[cfg(not(unix))]
453        {
454            let path = cwd.join(format!("{bin}.cmd"));
455            let script = "@echo off\necho demo extension\n";
456            std::fs::write(&path, script).map_err(|e| GiteeError::Usage(e.to_string()))?;
457        }
458    }
459    let readme = format!(
460        "# {bin}\n\nA `gitee` extension. Invoked as `gitee {name} ...`.\n\n## Environment contract\n\nThe `gitee` CLI forwards these to every extension child process:\n\n- `GITEE_TOKEN` — the active personal access token (or your own `$GITEE_TOKEN` if set).\n- `GITEE_HOST` — the active Gitee host (e.g. `gitee.com`), unless you already exported it.\n- All trailing argv, forwarded verbatim.\n\n## Build\n\n{build}\n\n## Install\n\n```\ngitee extension install <owner/{name}>\n```\n",
461        build = if cargo { "```cargo build --release``` produces a `gitee-{name}` binary at `target/release/`." } else { "No build step; the `gitee-{name}` script at the repo root is the entry point." }
462    );
463    std::fs::write(cwd.join("README.md"), readme)
464        .map_err(|e| GiteeError::Usage(e.to_string()))?;
465    println!("scaffolded extension '{name}' in {}", cwd.display());
466    Ok(())
467}
468
469fn validate_ext_name(name: &str) -> Result<()> {
470    if name.is_empty()
471        || name.starts_with('-')
472        || name.contains('/')
473        || name.contains('\\')
474        || name.contains(' ')
475        || name.contains('\0')
476        || name == "."
477        || name == ".."
478    {
479        return Err(GiteeError::Usage(format!(
480            "invalid extension name: '{name}'"
481        )));
482    }
483    Ok(())
484}
485
486/// Remove an installed extension from the managed dir.
487pub fn remove(name: &str, yes: bool) -> Result<()> {
488    validate_ext_name(name)?;
489    let dir = managed_dir()?.join(name);
490    if !dir.exists() {
491        return Err(GiteeError::Usage(format!(
492            "extension '{name}' is not installed (no such dir: {})",
493            dir.display()
494        )));
495    }
496    let action = format!(
497        "remove extension '{name}' at {}",
498        dir.display()
499    );
500    crate::cmd::confirm(&action, yes)?;
501    std::fs::remove_dir_all(&dir).map_err(|e| GiteeError::Usage(e.to_string()))?;
502    println!("removed extension '{name}'");
503    Ok(())
504}
505
506/// Pull (and rebuild, if a build marker is present) one or all installed extensions.
507pub fn upgrade(name: Option<&str>) -> Result<()> {
508    let dir = managed_dir()?;
509    let names: Vec<String> = match name {
510        Some(n) => {
511            validate_ext_name(n)?;
512            vec![n.to_string()]
513        }
514        None => list_installed()?,
515    };
516    if names.is_empty() {
517        println!("no installed extensions to upgrade");
518        return Ok(());
519    }
520    for n in names {
521        let ext_dir = dir.join(&n);
522        if !ext_dir.is_dir() {
523            return Err(GiteeError::Usage(format!(
524                "extension '{n}' is not installed at {}",
525                ext_dir.display()
526            )));
527        }
528        run_git(&ext_dir, &["pull", "--ff-only"])?;
529        let needs_cargo = ext_dir.join("Cargo.toml").exists();
530        let needs_npm = ext_dir.join("package.json").exists();
531        if needs_cargo {
532            build_cargo(&ext_dir, &n)?;
533        } else if needs_npm {
534            build_npm(&ext_dir)?;
535        }
536        println!("upgraded extension '{n}'");
537    }
538    Ok(())
539}
540
541#[cfg(test)]
542mod tests {
543    use super::*;
544    use std::fs;
545
546    #[cfg(unix)]
547    use std::os::unix::fs::PermissionsExt;
548
549    fn temp_bin_dir() -> PathBuf {
550        let path = std::env::temp_dir().join(format!(
551            "gitee-ext-test-{}-{}",
552            std::process::id(),
553            std::time::SystemTime::now()
554                .duration_since(std::time::UNIX_EPOCH)
555                .unwrap()
556                .as_nanos()
557        ));
558        fs::create_dir_all(&path).expect("mkdir");
559        path
560    }
561
562    #[cfg(unix)]
563    fn write_fake_ext(dir: &Path, name: &str) {
564        let path = dir.join(format!("{PREFIX}{name}"));
565        fs::write(&path, b"#!/bin/sh\n").expect("write");
566        let mut perms = fs::metadata(&path).expect("meta").permissions();
567        perms.set_mode(0o755);
568        fs::set_permissions(&path, perms).expect("chmod");
569    }
570
571    #[cfg(not(unix))]
572    fn write_fake_ext(dir: &Path, name: &str) {
573        let path = dir.join(format!("{PREFIX}{name}"));
574        fs::write(&path, b"").expect("write");
575    }
576
577    #[test]
578    fn should_set_gitee_host_respects_existing_env() {
579        let prev = std::env::var_os("GITEE_HOST");
580        std::env::set_var("GITEE_HOST", "self.gitee.test");
581        assert!(!should_set_gitee_host());
582        if let Some(v) = prev {
583            std::env::set_var("GITEE_HOST", v);
584        } else {
585            std::env::remove_var("GITEE_HOST");
586        }
587        assert!(should_set_gitee_host());
588    }
589
590    #[cfg(unix)]
591    #[test]
592    fn find_on_path_discovers_executable() {
593        let _lock = test_managed_env_lock();
594        let managed = unique_managed_dir();
595        set_test_managed_dir(Some(managed.clone()));
596        let dir = temp_bin_dir();
597        write_fake_ext(&dir, "foo");
598        let prev = std::env::var_os("PATH");
599        std::env::set_var("PATH", &dir);
600        let found = find_on_path("foo");
601        if let Some(p) = prev {
602            std::env::set_var("PATH", p);
603        } else {
604            std::env::remove_var("PATH");
605        }
606        set_test_managed_dir(None);
607        let _ = fs::remove_dir_all(&managed);
608        let _ = fs::remove_dir_all(&dir);
609        assert_eq!(found, Some(dir.join("gitee-foo")));
610    }
611
612    #[cfg(unix)]
613    #[test]
614    fn find_on_path_missing_returns_none() {
615        let _lock = test_managed_env_lock();
616        let managed = unique_managed_dir();
617        set_test_managed_dir(Some(managed.clone()));
618        let dir = temp_bin_dir();
619        let prev = std::env::var_os("PATH");
620        std::env::set_var("PATH", &dir);
621        let found = find_on_path("nope");
622        if let Some(p) = prev {
623            std::env::set_var("PATH", p);
624        } else {
625            std::env::remove_var("PATH");
626        }
627        set_test_managed_dir(None);
628        let _ = fs::remove_dir_all(&managed);
629        let _ = fs::remove_dir_all(&dir);
630        assert!(found.is_none());
631    }
632
633    #[cfg(unix)]
634    #[test]
635    fn list_on_path_collects_names() {
636        let _lock = test_managed_env_lock();
637        let managed = unique_managed_dir();
638        set_test_managed_dir(Some(managed.clone()));
639        let dir = temp_bin_dir();
640        write_fake_ext(&dir, "alpha");
641        write_fake_ext(&dir, "beta");
642        let prev = std::env::var_os("PATH");
643        std::env::set_var("PATH", &dir);
644        let names = list_on_path();
645        if let Some(p) = prev {
646            std::env::set_var("PATH", p);
647        } else {
648            std::env::remove_var("PATH");
649        }
650        set_test_managed_dir(None);
651        let _ = fs::remove_dir_all(&managed);
652        let _ = fs::remove_dir_all(&dir);
653        assert_eq!(names, vec!["alpha".to_string(), "beta".to_string()]);
654    }
655
656    fn unique_managed_dir() -> PathBuf {
657        let path = std::env::temp_dir().join(format!(
658            "gitee-ext-managed-{}-{}",
659            std::process::id(),
660            std::time::SystemTime::now()
661                .duration_since(std::time::UNIX_EPOCH)
662                .unwrap()
663                .as_nanos()
664        ));
665        fs::create_dir_all(&path).expect("mkdir");
666        path
667    }
668
669    #[cfg(unix)]
670    fn write_managed_ext(root: &Path, name: &str) {
671        let dir = root.join(name);
672        fs::create_dir_all(&dir).expect("mkdir");
673        write_fake_ext(&dir, name);
674    }
675
676    #[cfg(unix)]
677    #[test]
678    fn list_installed_finds_managed_extensions() {
679        let _lock = test_managed_env_lock();
680        let dir = unique_managed_dir();
681        set_test_managed_dir(Some(dir.clone()));
682        write_managed_ext(&dir, "alpha");
683        write_managed_ext(&dir, "beta");
684        let names = list_installed().expect("list");
685        set_test_managed_dir(None);
686        let _ = fs::remove_dir_all(&dir);
687        assert_eq!(names, vec!["alpha".to_string(), "beta".to_string()]);
688    }
689
690    #[cfg(unix)]
691    #[test]
692    fn find_installed_resolves_managed_binary() {
693        let _lock = test_managed_env_lock();
694        let dir = unique_managed_dir();
695        set_test_managed_dir(Some(dir.clone()));
696        write_managed_ext(&dir, "foo");
697        let found = find_installed("foo").expect("find").expect("present");
698        set_test_managed_dir(None);
699        let _ = fs::remove_dir_all(&dir);
700        assert_eq!(found.file_name().unwrap(), "gitee-foo");
701    }
702
703    #[test]
704    fn find_installed_missing_returns_none() {
705        let _lock = test_managed_env_lock();
706        let dir = unique_managed_dir();
707        set_test_managed_dir(Some(dir.clone()));
708        let found = find_installed("nope").expect("find");
709        set_test_managed_dir(None);
710        let _ = fs::remove_dir_all(&dir);
711        assert!(found.is_none());
712    }
713
714    #[cfg(unix)]
715    #[test]
716    fn managed_dir_shadows_path_in_find_on_path() {
717        let _lock = test_managed_env_lock();
718        let managed = unique_managed_dir();
719        set_test_managed_dir(Some(managed.clone()));
720        write_managed_ext(&managed, "dup");
721
722        let path_dir = temp_bin_dir();
723        write_fake_ext(&path_dir, "dup");
724        let prev = std::env::var_os("PATH");
725        std::env::set_var("PATH", &path_dir);
726
727        let found = find_on_path("dup").expect("found");
728
729        if let Some(p) = prev {
730            std::env::set_var("PATH", p);
731        } else {
732            std::env::remove_var("PATH");
733        }
734        set_test_managed_dir(None);
735        let _ = fs::remove_dir_all(&managed);
736        let _ = fs::remove_dir_all(&path_dir);
737
738        assert!(found.starts_with(&managed), "managed dir should win: {found:?}");
739    }
740
741    #[test]
742    fn repo_to_name_strips_gitee_prefix() {
743        assert_eq!(repo_to_name("owner/my-ext").unwrap(), "my-ext");
744        assert_eq!(repo_to_name("owner/gitee-my-ext").unwrap(), "my-ext");
745        assert_eq!(
746            repo_to_name("https://gitee.com/owner/gitee-foo").unwrap(),
747            "foo"
748        );
749        assert!(repo_to_name("").is_err());
750        assert!(repo_to_name("owner/").is_err());
751        assert!(repo_to_name("just-a-name").is_err());
752        assert!(repo_to_name("owner/../").is_err());
753    }
754
755    #[test]
756    fn parse_build_kind_accepts_known_values() {
757        assert!(parse_build_kind(None).unwrap().is_none());
758        assert!(matches!(parse_build_kind(Some("cargo")).unwrap(), Some(BuildKind::Cargo)));
759        assert!(matches!(parse_build_kind(Some("npm")).unwrap(), Some(BuildKind::Npm)));
760        assert!(parse_build_kind(Some("go")).is_err());
761    }
762
763    #[cfg(unix)]
764    #[test]
765    fn create_scaffolds_shell_script() {
766        let dir = unique_managed_dir();
767        let prev = std::env::current_dir().unwrap();
768        std::env::set_current_dir(&dir).unwrap();
769        create("demo", false).expect("create");
770        std::env::set_current_dir(&prev).unwrap();
771        let script = dir.join("gitee-demo");
772        assert!(script.is_file(), "script should exist at {}", script.display());
773        let body = fs::read_to_string(&script).unwrap();
774        assert!(body.contains("demo extension"));
775        let readme = fs::read_to_string(dir.join("README.md")).unwrap();
776        assert!(readme.contains("GITEE_TOKEN"));
777        assert!(readme.contains("GITEE_HOST"));
778        let _ = fs::remove_dir_all(&dir);
779    }
780
781    #[cfg(unix)]
782    #[test]
783    fn create_scaffolds_cargo_project() {
784        let dir = unique_managed_dir();
785        let prev = std::env::current_dir().unwrap();
786        std::env::set_current_dir(&dir).unwrap();
787        create("demo", true).expect("create");
788        std::env::set_current_dir(&prev).unwrap();
789        assert!(dir.join("Cargo.toml").is_file());
790        assert!(dir.join("src/main.rs").is_file());
791        let toml = fs::read_to_string(dir.join("Cargo.toml")).unwrap();
792        assert!(toml.contains("gitee-demo"));
793        let _ = fs::remove_dir_all(&dir);
794    }
795
796    #[cfg(unix)]
797    #[test]
798    fn remove_errors_when_not_installed() {
799        let _lock = test_managed_env_lock();
800        let dir = unique_managed_dir();
801        set_test_managed_dir(Some(dir.clone()));
802        let err = remove("nope", true).unwrap_err();
803        set_test_managed_dir(None);
804        let _ = fs::remove_dir_all(&dir);
805        assert!(err.to_string().contains("not installed"));
806    }
807
808    #[cfg(unix)]
809    #[test]
810    fn install_errors_on_already_installed() {
811        let _lock = test_managed_env_lock();
812        let dir = unique_managed_dir();
813        set_test_managed_dir(Some(dir.clone()));
814        write_managed_ext(&dir, "foo");
815        let err = install("owner/foo", None, true, "gitee.com").unwrap_err();
816        set_test_managed_dir(None);
817        let _ = fs::remove_dir_all(&dir);
818        assert!(err.to_string().contains("already installed"));
819    }
820}