Skip to main content

cargo_dev_install/
lib.rs

1pub mod cli;
2pub mod install;
3pub mod project;
4pub mod tui_select;
5
6use std::io::IsTerminal;
7use std::path::{Path, PathBuf};
8
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct InstallPlan {
11    pub crate_root: PathBuf,
12    pub manifest_path: PathBuf,
13    pub bin_name: String,
14    pub install_dir: PathBuf,
15    pub wrapper_path: PathBuf,
16    pub wrapper_contents: String,
17    pub warn_path_missing: bool,
18}
19
20#[derive(Debug, Clone)]
21pub struct EnvSnapshot {
22    pub home: Option<PathBuf>,
23    pub xdg_bin_home: Option<PathBuf>,
24    pub path: Option<String>,
25}
26
27impl EnvSnapshot {
28    pub fn capture() -> Self {
29        Self {
30            home: std::env::var_os("HOME").map(PathBuf::from),
31            xdg_bin_home: std::env::var_os("XDG_BIN_HOME").map(PathBuf::from),
32            path: std::env::var("PATH").ok(),
33        }
34    }
35}
36
37pub fn run() -> Result<(), String> {
38    let args = cli::parse_args(std::env::args()).map_err(|err| err.to_string())?;
39    let env = EnvSnapshot::capture();
40    let cwd = std::env::current_dir().map_err(|err| format!("failed to read cwd: {err}"))?;
41    let plan = make_plan(&args, &env, &cwd)?;
42    apply_plan(&plan, args.force)
43}
44
45pub fn make_plan(
46    args: &cli::CliArgs,
47    env: &EnvSnapshot,
48    cwd: &Path,
49) -> Result<InstallPlan, String> {
50    let crate_root = project::find_crate_root(cwd)?;
51    let manifest_path = crate_root.join("Cargo.toml");
52
53    let bin_names = project::list_bins(&manifest_path)?;
54    let bin_name = select_bin(args, &bin_names)?;
55
56    let install_dir = install::install_dir(env)
57        .ok_or_else(|| "HOME is not set; cannot determine install directory".to_string())?;
58
59    let wrapper_path = install_dir.join(&bin_name);
60    let wrapper_contents = install::render_wrapper(&crate_root);
61    let warn_path_missing = !install::is_on_path(&install_dir, env.path.as_deref());
62
63    Ok(InstallPlan {
64        crate_root,
65        manifest_path,
66        bin_name,
67        install_dir,
68        wrapper_path,
69        wrapper_contents,
70        warn_path_missing,
71    })
72}
73
74pub fn apply_plan(plan: &InstallPlan, force: bool) -> Result<(), String> {
75    install::write_wrapper(&plan.wrapper_path, &plan.wrapper_contents, force)
76        .map_err(|err| format!("failed to write wrapper: {err}"))?;
77
78    if plan.warn_path_missing {
79        eprintln!("Warning: install directory is not on PATH");
80        eprintln!("Add it to your shell profile, e.g.:");
81        eprintln!("export PATH=\"{}:$PATH\"", plan.install_dir.display());
82    }
83
84    Ok(())
85}
86
87fn select_bin(args: &cli::CliArgs, bin_names: &[String]) -> Result<String, String> {
88    if bin_names.is_empty() {
89        return Err("no binary targets found in Cargo.toml".to_string());
90    }
91
92    if bin_names.len() == 1 {
93        return Ok(bin_names[0].clone());
94    }
95
96    if let Some(bin) = &args.bin {
97        if bin_names.iter().any(|name| name == bin) {
98            return Ok(bin.clone());
99        }
100        return Err(format!("binary '{bin}' not found in crate"));
101    }
102
103    if std::io::stdin().is_terminal() {
104        let mut stdin = std::io::stdin().lock();
105        let mut stdout = std::io::stdout().lock();
106        return tui_select::select_bin(bin_names, &mut stdin, &mut stdout)
107            .map_err(|err| format!("failed to select binary: {err}"));
108    }
109
110    Err("multiple binaries found; pass --bin <name>".to_string())
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116    use std::fs;
117
118    fn write_file(path: &Path, contents: &str) {
119        if let Some(parent) = path.parent() {
120            fs::create_dir_all(parent).expect("create parent");
121        }
122        fs::write(path, contents).expect("write file");
123    }
124
125    fn default_env(home: &Path, path_var: &str) -> EnvSnapshot {
126        EnvSnapshot {
127            home: Some(home.to_path_buf()),
128            xdg_bin_home: None,
129            path: Some(path_var.to_string()),
130        }
131    }
132
133    #[test]
134    fn make_plan_single_bin() {
135        let dir = tempfile::tempdir().expect("tempdir");
136        write_file(
137            &dir.path().join("Cargo.toml"),
138            "[package]\nname = \"demo\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[[bin]]\nname = \"demo\"\npath = \"src/main.rs\"\n",
139        );
140        write_file(&dir.path().join("src/main.rs"), "fn main() {}\n");
141
142        let args = cli::CliArgs {
143            bin: None,
144            force: false,
145        };
146        let env = default_env(dir.path(), "/usr/bin");
147
148        let plan = make_plan(&args, &env, dir.path()).expect("plan");
149        assert_eq!(plan.bin_name, "demo");
150        assert_eq!(plan.manifest_path, dir.path().join("Cargo.toml"));
151        assert_eq!(plan.wrapper_path, dir.path().join(".local/bin/demo"));
152        assert!(plan.wrapper_contents.contains("REPO=\""));
153    }
154
155    #[test]
156    fn make_plan_respects_bin_flag() {
157        let dir = tempfile::tempdir().expect("tempdir");
158        write_file(
159            &dir.path().join("Cargo.toml"),
160            "[package]\nname = \"demo\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[[bin]]\nname = \"alpha\"\npath = \"src/main.rs\"\n\n[[bin]]\nname = \"beta\"\npath = \"src/bin/beta.rs\"\n",
161        );
162        write_file(&dir.path().join("src/main.rs"), "fn main() {}\n");
163        write_file(&dir.path().join("src/bin/beta.rs"), "fn main() {}\n");
164
165        let args = cli::CliArgs {
166            bin: Some("beta".to_string()),
167            force: false,
168        };
169        let env = default_env(dir.path(), "/usr/bin");
170
171        let plan = make_plan(&args, &env, dir.path()).expect("plan");
172        assert_eq!(plan.bin_name, "beta");
173    }
174
175    #[test]
176    fn make_plan_warns_when_path_missing() {
177        let dir = tempfile::tempdir().expect("tempdir");
178        write_file(
179            &dir.path().join("Cargo.toml"),
180            "[package]\nname = \"demo\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[[bin]]\nname = \"demo\"\npath = \"src/main.rs\"\n",
181        );
182        write_file(&dir.path().join("src/main.rs"), "fn main() {}\n");
183
184        let args = cli::CliArgs {
185            bin: None,
186            force: false,
187        };
188        let env = default_env(dir.path(), "/usr/bin");
189
190        let plan = make_plan(&args, &env, dir.path()).expect("plan");
191        assert!(plan.warn_path_missing);
192    }
193}