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
25pub 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
32pub 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
45fn 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
71pub 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
105pub 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
137pub 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
161pub 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
183fn should_set_gitee_host() -> bool {
185 std::env::var_os("GITEE_HOST").is_none()
186}
187
188pub 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
220fn 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
389pub 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
426pub 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
486pub 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
506pub 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}