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}