1use std::collections::HashSet;
2use std::env;
3use std::ffi::OsStr;
4use std::path::{Path, PathBuf};
5
6use serde::Serialize;
7use tracing::warn;
8
9use crate::Error;
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
13#[serde(rename_all = "snake_case")]
14pub enum BinarySource {
15 Bundled,
17 EnvOverride,
19 Local,
21}
22
23pub fn copilot_binary() -> Result<PathBuf, Error> {
29 copilot_binary_with_source().map(|(path, _)| path)
30}
31
32pub fn copilot_binary_with_source() -> Result<(PathBuf, BinarySource), Error> {
34 if let Ok(value) = env::var("COPILOT_CLI_PATH") {
35 let candidate = PathBuf::from(value);
36 if candidate.is_file() {
37 return Ok((candidate, BinarySource::EnvOverride));
38 }
39 if candidate.is_dir()
40 && let Some(found) = find_copilot_in_dir(&candidate)
41 {
42 return Ok((found, BinarySource::EnvOverride));
43 }
44 warn!(path = %candidate.display(), "COPILOT_CLI_PATH set but not usable");
45 }
46
47 if let Some(path) = crate::embeddedcli::path() {
48 return Ok((path, BinarySource::Bundled));
49 }
50
51 for dir in standard_search_paths() {
52 if let Some(found) = find_copilot_in_dir(&dir) {
53 return Ok((found, BinarySource::Local));
54 }
55 }
56
57 Err(Error::BinaryNotFound {
58 name: "copilot",
59 hint: "ensure the GitHub Copilot CLI is installed and on PATH, or set COPILOT_CLI_PATH. use COPILOT_CLI_NAME to override the binary name (default: copilot)",
60 })
61}
62
63pub fn copilot_binary_on_path() -> Result<PathBuf, Error> {
68 if let Some(found) = find_executable_in_path(
69 env::var_os("PATH").as_deref(),
70 &literal_copilot_executable_names(),
71 ) {
72 return Ok(found);
73 }
74
75 Err(Error::BinaryNotFound {
76 name: "copilot",
77 hint: "ensure the `copilot` command is installed and available on PATH",
78 })
79}
80
81pub fn extended_path(extra: &[PathBuf]) -> Option<std::ffi::OsString> {
84 let mut paths = SearchPaths::new();
85 for p in extra {
86 paths.push(p.clone());
87 }
88 paths.append_standard();
89 if paths.is_empty() {
90 return None;
91 }
92 env::join_paths(paths).ok()
93}
94
95fn copilot_executable_names() -> Vec<String> {
96 let base = env::var("COPILOT_CLI_NAME").unwrap_or_else(|_| "copilot".to_string());
97 executable_names_for_base(&base)
98}
99
100fn literal_copilot_executable_names() -> Vec<String> {
101 executable_names_for_base("copilot")
102}
103
104fn executable_names_for_base(base: &str) -> Vec<String> {
105 #[cfg(target_os = "windows")]
106 {
107 vec![
108 format!("{}.exe", base),
109 format!("{}.cmd", base),
110 format!("{}.bat", base),
111 ]
112 }
113 #[cfg(not(target_os = "windows"))]
114 {
115 vec![base.to_string()]
116 }
117}
118
119fn find_executable(dir: &Path, names: &[impl AsRef<std::ffi::OsStr>]) -> Option<PathBuf> {
120 if dir.as_os_str().is_empty() {
121 return None;
122 }
123 names
124 .iter()
125 .map(|n| dir.join(n.as_ref()))
126 .find(|c| c.is_file())
127}
128
129fn find_copilot_in_dir(dir: &Path) -> Option<PathBuf> {
130 find_executable(dir, &copilot_executable_names())
131}
132
133fn find_executable_in_path(
134 path_env: Option<&OsStr>,
135 names: &[impl AsRef<std::ffi::OsStr>],
136) -> Option<PathBuf> {
137 let path_env = path_env?;
138 for dir in env::split_paths(path_env) {
139 if let Some(found) = find_executable(&dir, names) {
140 return Some(found);
141 }
142 }
143 None
144}
145
146struct SearchPaths {
152 seen: HashSet<PathBuf>,
153 paths: Vec<PathBuf>,
154}
155
156impl SearchPaths {
157 fn new() -> Self {
158 Self {
159 seen: HashSet::new(),
160 paths: Vec::new(),
161 }
162 }
163
164 fn push(&mut self, path: PathBuf) {
166 if !path.as_os_str().is_empty() && self.seen.insert(path.clone()) {
167 self.paths.push(path);
168 }
169 }
170
171 fn is_empty(&self) -> bool {
172 self.paths.is_empty()
173 }
174
175 fn append_standard(&mut self) {
178 if let Some(existing) = env::var_os("PATH") {
179 for p in env::split_paths(&existing) {
180 self.push(p);
181 }
182 }
183
184 if let Some(home) = dirs::home_dir() {
185 self.push(home.join(".local/bin"));
186 self.push(home.join(".cargo/bin"));
187 self.push(home.join(".bun/bin"));
188 self.push(home.join(".npm-global/bin"));
189 self.push(home.join(".yarn/bin"));
190 self.push(home.join(".volta/bin"));
191 self.push(home.join(".asdf/shims"));
192 self.push(home.join("bin"));
193 }
194
195 #[cfg(target_os = "macos")]
200 {
201 self.push(PathBuf::from("/opt/homebrew/bin"));
202 self.push(PathBuf::from("/usr/local/bin"));
203 self.push(PathBuf::from("/usr/bin"));
204 self.push(PathBuf::from("/bin"));
205 self.push(PathBuf::from("/usr/sbin"));
206 self.push(PathBuf::from("/sbin"));
207 }
208
209 #[cfg(target_os = "linux")]
210 {
211 self.push(PathBuf::from("/usr/local/bin"));
212 self.push(PathBuf::from("/usr/bin"));
213 self.push(PathBuf::from("/bin"));
214 self.push(PathBuf::from("/snap/bin"));
215 }
216
217 #[cfg(target_os = "windows")]
218 {
219 if let Some(appdata) = env::var_os("APPDATA") {
220 self.push(PathBuf::from(appdata).join("npm"));
221 }
222 if let Some(local) = env::var_os("LOCALAPPDATA") {
223 let local = PathBuf::from(local);
224 self.push(local.join("Programs"));
225 self.push(local.join("Programs").join("Git").join("cmd"));
227 self.push(local.join("Programs").join("Git").join("bin"));
228 }
229 for env_var in ["ProgramFiles", "ProgramW6432", "ProgramFiles(x86)"] {
231 if let Some(program_files) = env::var_os(env_var) {
232 let program_files = PathBuf::from(program_files);
233 self.push(program_files.join("Git").join("cmd"));
234 self.push(program_files.join("Git").join("bin"));
235 }
236 }
237 }
238
239 for p in collect_nvm_paths() {
243 self.push(p);
244 }
245 for p in collect_nodenv_paths() {
246 self.push(p);
247 }
248 for p in collect_fnm_paths() {
249 self.push(p);
250 }
251 }
252}
253
254impl IntoIterator for SearchPaths {
255 type IntoIter = std::vec::IntoIter<PathBuf>;
256 type Item = PathBuf;
257
258 fn into_iter(self) -> Self::IntoIter {
259 self.paths.into_iter()
260 }
261}
262
263fn standard_search_paths() -> SearchPaths {
265 let mut paths = SearchPaths::new();
266 paths.append_standard();
267 paths
268}
269
270fn collect_nvm_paths() -> Vec<PathBuf> {
271 let mut paths = Vec::new();
272 let nvm_dir = env::var_os("NVM_DIR")
273 .map(PathBuf::from)
274 .or_else(|| dirs::home_dir().map(|home| home.join(".nvm")));
275 let Some(nvm_dir) = nvm_dir else {
276 return paths;
277 };
278 let versions_dir = nvm_dir.join("versions").join("node");
279 let entries = match std::fs::read_dir(&versions_dir) {
280 Ok(entries) => entries,
281 Err(_) => return paths,
282 };
283 for entry in entries.flatten() {
284 let path = entry.path();
285 if path.is_dir() {
286 paths.push(path.join("bin"));
287 }
288 }
289 paths
290}
291
292fn collect_nodenv_paths() -> Vec<PathBuf> {
293 let mut paths = Vec::new();
294 let root = env::var_os("NODENV_ROOT")
295 .map(PathBuf::from)
296 .or_else(|| dirs::home_dir().map(|home| home.join(".nodenv")));
297 let Some(root) = root else {
298 return paths;
299 };
300 let versions_dir = root.join("versions");
301 let entries = match std::fs::read_dir(&versions_dir) {
302 Ok(entries) => entries,
303 Err(_) => return paths,
304 };
305 for entry in entries.flatten() {
306 let path = entry.path();
307 if path.is_dir() {
308 paths.push(path.join("bin"));
309 }
310 }
311 paths
312}
313
314fn fnm_root_candidates_from(
315 fnm_dir: Option<PathBuf>,
316 xdg_data_home: Option<PathBuf>,
317 home: Option<PathBuf>,
318) -> Vec<PathBuf> {
319 let mut roots = SearchPaths::new();
320
321 if let Some(fnm_dir) = fnm_dir.filter(|path| !path.as_os_str().is_empty()) {
322 roots.push(fnm_dir);
323 }
324
325 if let Some(xdg_data_home) = xdg_data_home.filter(|path| !path.as_os_str().is_empty()) {
326 roots.push(xdg_data_home.join("fnm"));
327 }
328
329 if let Some(home) = home {
330 roots.push(home.join(".local").join("share").join("fnm"));
331 roots.push(home.join(".fnm"));
332 }
333
334 roots.paths
335}
336
337fn collect_fnm_paths() -> Vec<PathBuf> {
338 let roots = fnm_root_candidates_from(
339 env::var_os("FNM_DIR").map(PathBuf::from),
340 env::var_os("XDG_DATA_HOME").map(PathBuf::from),
341 dirs::home_dir(),
342 );
343
344 let mut paths = SearchPaths::new();
345 for root in &roots {
346 paths.push(root.join("aliases").join("default").join("bin"));
347
348 let versions_dir = root.join("node-versions");
349 let entries = match std::fs::read_dir(&versions_dir) {
350 Ok(entries) => entries,
351 Err(_) => continue,
352 };
353 for entry in entries.flatten() {
354 let path = entry.path();
355 if path.is_dir() {
356 paths.push(path.join("installation").join("bin"));
357 }
358 }
359 }
360
361 paths.paths
362}
363
364#[cfg(test)]
365mod tests {
366 use std::path::{Path, PathBuf};
367 use std::{env, fs};
368
369 use serial_test::serial;
370 use tempfile::tempdir;
371
372 use super::{
373 copilot_binary_on_path, find_executable_in_path, fnm_root_candidates_from,
374 literal_copilot_executable_names,
375 };
376
377 #[test]
378 fn fnm_root_candidates_include_xdg_and_legacy_locations() {
379 let home = PathBuf::from("/tmp/copilot-home");
380
381 let roots = fnm_root_candidates_from(None, None, Some(home.clone()));
382
383 assert_eq!(
384 roots,
385 vec![
386 home.join(".local").join("share").join("fnm"),
387 home.join(".fnm"),
388 ]
389 );
390 }
391
392 #[test]
393 fn fnm_root_candidates_prefer_explicit_locations_first() {
394 let home = PathBuf::from("/tmp/copilot-home");
395 let explicit_fnm_dir = PathBuf::from("/tmp/custom-fnm");
396 let xdg_data_home = PathBuf::from("/tmp/xdg-data");
397
398 let roots = fnm_root_candidates_from(
399 Some(explicit_fnm_dir.clone()),
400 Some(xdg_data_home.clone()),
401 Some(home.clone()),
402 );
403
404 assert_eq!(
405 roots,
406 vec![
407 explicit_fnm_dir,
408 xdg_data_home.join("fnm"),
409 home.join(".local").join("share").join("fnm"),
410 home.join(".fnm"),
411 ]
412 );
413 }
414
415 #[test]
416 fn fnm_root_candidates_ignore_empty_xdg_data_home() {
417 let home = PathBuf::from("/tmp/copilot-home");
418
419 let roots = fnm_root_candidates_from(None, Some(PathBuf::new()), Some(home.clone()));
420
421 assert_eq!(
422 roots,
423 vec![
424 home.join(".local").join("share").join("fnm"),
425 home.join(".fnm"),
426 ]
427 );
428 assert!(!roots.iter().any(|path| path == &PathBuf::from("fnm")));
429 }
430
431 #[test]
432 fn fnm_root_produces_expected_bin_paths() {
433 let temp_dir = tempdir().expect("should create temp dir");
434 let root = temp_dir.path().join("fnm-root");
435 let alias_bin = root.join("aliases").join("default").join("bin");
436 let version_bin = root
437 .join("node-versions")
438 .join("v22.18.0")
439 .join("installation")
440 .join("bin");
441
442 fs::create_dir_all(&alias_bin).expect("should create fnm alias bin");
443 fs::create_dir_all(&version_bin).expect("should create fnm version bin");
444
445 let roots = fnm_root_candidates_from(Some(root.clone()), None, None);
446 assert_eq!(roots, vec![root.clone()]);
447
448 assert!(alias_bin.is_dir());
450 assert!(version_bin.is_dir());
451 }
452
453 #[test]
454 fn find_copilot_in_path_finds_binary_in_path_entries() {
455 let temp_dir = tempdir().expect("should create temp dir");
456 let bin_dir = temp_dir.path().join("bin");
457 fs::create_dir_all(&bin_dir).expect("should create bin dir");
458
459 let executable_name = literal_copilot_executable_names()
460 .into_iter()
461 .next()
462 .expect("should provide a copilot executable name");
463 let executable_path = bin_dir.join(&executable_name);
464 fs::write(&executable_path, "#!/bin/sh\n").expect("should create fake binary");
465
466 let path_env =
467 env::join_paths([Path::new("/missing"), bin_dir.as_path()]).expect("should build PATH");
468
469 assert_eq!(
470 find_executable_in_path(
471 Some(path_env.as_os_str()),
472 &literal_copilot_executable_names()
473 ),
474 Some(executable_path)
475 );
476 }
477
478 #[test]
479 fn find_copilot_in_path_ignores_missing_entries() {
480 let path_env = env::join_paths([Path::new("/missing-one"), Path::new("/missing-two")])
481 .expect("should build PATH");
482
483 assert_eq!(
484 find_executable_in_path(
485 Some(path_env.as_os_str()),
486 &literal_copilot_executable_names()
487 ),
488 None
489 );
490 }
491
492 #[test]
493 #[serial]
494 #[cfg(target_os = "macos")]
495 fn platform_dirs_precede_version_manager_dirs() {
496 let temp = tempdir().expect("should create temp dir");
497 let fake_home = temp.path().join("home");
498
499 let nvm_dir = fake_home.join(".nvm");
501 let nvm_version_bin = nvm_dir
502 .join("versions")
503 .join("node")
504 .join("v18.0.0")
505 .join("bin");
506 fs::create_dir_all(&nvm_version_bin).expect("should create nvm version bin");
507
508 let nodenv_root = fake_home.join(".nodenv");
510 let nodenv_version_bin = nodenv_root.join("versions").join("20.0.0").join("bin");
511 fs::create_dir_all(&nodenv_version_bin).expect("should create nodenv version bin");
512
513 let fnm_root = fake_home.join(".local").join("share").join("fnm");
515 let fnm_version_bin = fnm_root
516 .join("node-versions")
517 .join("v22.0.0")
518 .join("installation")
519 .join("bin");
520 fs::create_dir_all(&fnm_version_bin).expect("should create fnm version bin");
521
522 let prev_path = env::var_os("PATH");
524 let prev_home = env::var_os("HOME");
525 let prev_nvm_dir = env::var_os("NVM_DIR");
526 let prev_nodenv_root = env::var_os("NODENV_ROOT");
527 let prev_fnm_dir = env::var_os("FNM_DIR");
528 let prev_xdg_data_home = env::var_os("XDG_DATA_HOME");
529
530 unsafe {
534 env::set_var("PATH", "");
535 env::set_var("HOME", &fake_home);
536 env::set_var("NVM_DIR", &nvm_dir);
537 env::set_var("NODENV_ROOT", &nodenv_root);
538 env::remove_var("FNM_DIR");
539 env::remove_var("XDG_DATA_HOME");
540 }
541
542 let paths: Vec<PathBuf> = super::standard_search_paths().into_iter().collect();
543
544 unsafe {
547 match prev_path {
548 Some(v) => env::set_var("PATH", v),
549 None => env::remove_var("PATH"),
550 }
551 match prev_home {
552 Some(v) => env::set_var("HOME", v),
553 None => env::remove_var("HOME"),
554 }
555 match prev_nvm_dir {
556 Some(v) => env::set_var("NVM_DIR", v),
557 None => env::remove_var("NVM_DIR"),
558 }
559 match prev_nodenv_root {
560 Some(v) => env::set_var("NODENV_ROOT", v),
561 None => env::remove_var("NODENV_ROOT"),
562 }
563 match prev_fnm_dir {
564 Some(v) => env::set_var("FNM_DIR", v),
565 None => env::remove_var("FNM_DIR"),
566 }
567 match prev_xdg_data_home {
568 Some(v) => env::set_var("XDG_DATA_HOME", v),
569 None => env::remove_var("XDG_DATA_HOME"),
570 }
571 }
572
573 let platform_dirs: Vec<PathBuf> = vec![
574 PathBuf::from("/opt/homebrew/bin"),
575 PathBuf::from("/usr/local/bin"),
576 PathBuf::from("/usr/bin"),
577 PathBuf::from("/bin"),
578 PathBuf::from("/usr/sbin"),
579 PathBuf::from("/sbin"),
580 ];
581
582 let last_platform_idx = platform_dirs
584 .iter()
585 .filter_map(|d| paths.iter().position(|p| p == d))
586 .max()
587 .expect("at least one platform dir should be present");
588
589 let version_manager_prefixes = [
590 nvm_version_bin.parent().unwrap().parent().unwrap(), nodenv_version_bin.parent().unwrap().parent().unwrap(), fnm_version_bin
593 .parent()
594 .unwrap()
595 .parent()
596 .unwrap()
597 .parent()
598 .unwrap()
599 .parent()
600 .unwrap(), ];
602
603 let first_version_mgr_idx = paths
604 .iter()
605 .position(|p| {
606 version_manager_prefixes
607 .iter()
608 .any(|prefix| p.starts_with(prefix))
609 })
610 .expect("at least one version-manager dir should be present");
611
612 assert!(
613 last_platform_idx < first_version_mgr_idx,
614 "Platform dirs (last at index {last_platform_idx}) must precede \
615 version-manager dirs (first at index {first_version_mgr_idx}).\n\
616 Full path list: {paths:#?}"
617 );
618 }
619
620 #[test]
621 #[serial]
622 fn find_executable_in_path_can_ignore_copilot_name_override() {
623 let temp_dir = tempdir().expect("should create temp dir");
624 let bin_dir = temp_dir.path().join("bin");
625 fs::create_dir_all(&bin_dir).expect("should create bin dir");
626
627 let path_executable_name = literal_copilot_executable_names()
628 .into_iter()
629 .next()
630 .expect("should provide a literal copilot executable name");
631 #[cfg(target_os = "windows")]
632 let overridden_executable_name = "my-copilot.exe";
633
634 #[cfg(not(target_os = "windows"))]
635 let overridden_executable_name = "my-copilot";
636
637 let path_executable_path = bin_dir.join(&path_executable_name);
638 let overridden_executable_path = bin_dir.join(overridden_executable_name);
639
640 fs::write(&path_executable_path, "#!/bin/sh\n").expect("should create literal fake binary");
641 fs::write(&overridden_executable_path, "#!/bin/sh\n")
642 .expect("should create overridden fake binary");
643
644 let path_env =
645 env::join_paths([Path::new("/missing"), bin_dir.as_path()]).expect("should build PATH");
646
647 let previous_path = env::var_os("PATH");
648 let previous_copilot_cli_name = env::var_os("COPILOT_CLI_NAME");
649 unsafe {
651 env::set_var("PATH", &path_env);
652 env::set_var("COPILOT_CLI_NAME", "my-copilot");
653 }
654
655 let resolved_path = copilot_binary_on_path();
656
657 unsafe {
659 if let Some(previous_path) = previous_path {
660 env::set_var("PATH", previous_path);
661 } else {
662 env::remove_var("PATH");
663 }
664
665 if let Some(previous_copilot_cli_name) = previous_copilot_cli_name {
666 env::set_var("COPILOT_CLI_NAME", previous_copilot_cli_name);
667 } else {
668 env::remove_var("COPILOT_CLI_NAME");
669 }
670 }
671
672 assert_eq!(
673 resolved_path.expect("should find the literal copilot binary on PATH"),
674 path_executable_path
675 );
676 }
677}