Skip to main content

github_copilot_sdk/
resolve.rs

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/// How the copilot binary was resolved.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
13#[serde(rename_all = "snake_case")]
14pub enum BinarySource {
15    /// Extracted from the build-time embedded binary.
16    Bundled,
17    /// Set via `COPILOT_CLI_PATH` environment variable.
18    EnvOverride,
19    /// Found on PATH or standard search locations.
20    Local,
21}
22
23/// Find the `copilot` CLI binary on the system.
24///
25/// Checks `COPILOT_CLI_PATH` env var first, then searches PATH and common
26/// install locations (homebrew, nvm, nodenv, fnm, volta, cargo, etc.).
27/// Use `COPILOT_CLI_NAME` to override the binary name (default: `copilot`).
28pub fn copilot_binary() -> Result<PathBuf, Error> {
29    copilot_binary_with_source().map(|(path, _)| path)
30}
31
32/// Like [`copilot_binary`] but also reports how the binary was resolved.
33pub 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
63/// Find the `copilot` CLI binary using only the current PATH entries.
64///
65/// This is intentionally narrower than [`copilot_binary`]: it does not honor
66/// override env vars and does not search inferred install locations.
67pub 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
81/// Build an extended `PATH` by prepending `extra` dirs to the standard
82/// search paths (current PATH + common install locations).
83pub 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
146/// Ordered, deduplicated collection of directory paths to search for binaries.
147///
148/// Paths are stored in insertion order. Duplicates and empty paths are
149/// silently dropped on `push`. Implements `Iterator` so it can be passed
150/// directly to `env::join_paths` or used in a `for` loop.
151struct 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    /// Add a path if it hasn't been seen before. Empty paths are ignored.
165    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    /// Append the standard search paths: current PATH, home-relative dirs,
176    /// version manager paths (nvm, nodenv, fnm), and platform-specific dirs.
177    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        // Platform-specific standard dirs come before version-manager paths
196        // so that the system-installed node (e.g. /opt/homebrew/bin/node)
197        // takes precedence over arbitrary old versions found under
198        // ~/.nvm/versions, ~/.nodenv/versions, etc.
199        #[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                // User-scope winget install of Git for Windows.
226                self.push(local.join("Programs").join("Git").join("cmd"));
227                self.push(local.join("Programs").join("Git").join("bin"));
228            }
229            // Git for Windows standard machine-scope install locations.
230            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        // Version manager paths are a fallback for binary discovery —
240        // they enumerate every installed version, so an arbitrary old
241        // node/copilot can appear first if filesystem ordering is unlucky.
242        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
263/// Collect standard search paths for binary resolution.
264fn 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        // Verify the expected bin paths exist under the root structure
449        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        // Create fake nvm version dirs so collect_nvm_paths() returns entries.
500        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        // Create fake nodenv version dirs.
509        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        // Create fake fnm version dirs.
514        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        // Save env vars.
523        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        // Set env: empty PATH so only append_standard() dirs appear,
531        // HOME to our fake home, and explicit version-manager roots.
532        // Safety: test-only, single-threaded via #[serial].
533        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        // Restore env vars.
545        // Safety: test-only, single-threaded via #[serial].
546        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        // Find the last platform dir index and the first version-manager dir index.
583        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(), // .nvm/versions/node
591            nodenv_version_bin.parent().unwrap().parent().unwrap(), // .nodenv/versions
592            fnm_version_bin
593                .parent()
594                .unwrap()
595                .parent()
596                .unwrap()
597                .parent()
598                .unwrap()
599                .parent()
600                .unwrap(), // .local/share/fnm
601        ];
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        // Safety: test-only, single-threaded via #[serial].
650        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        // Safety: test-only, single-threaded via #[serial].
658        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}