1mod activate;
2mod env;
3mod package;
4mod store;
5mod term;
6
7use slox_cli::{Commands, EnvCommand, PkgCommand};
8use store::StorePaths;
9use term::{Progress, Report};
10
11pub fn report_error(error: &str) {
12 term::report_error(error);
13}
14
15pub fn run(command: Commands) -> Result<(), String> {
16 run_with_store(command, &StorePaths::default())
17}
18
19fn run_with_store(command: Commands, store: &StorePaths) -> Result<(), String> {
20 match command {
21 Commands::Env { command } => {
22 let progress = Progress::start("updating environment");
23 match handle_env(store, command) {
24 Ok(report) => {
25 progress.finish(report);
26 Ok(())
27 }
28 Err(error) => {
29 progress.fail();
30 Err(error)
31 }
32 }
33 }
34 Commands::Activate { command } => {
35 println!("{}", activate::activation_script(store, command)?);
36 Ok(())
37 }
38 Commands::Pkg { command } => {
39 let progress = Progress::start("processing package");
40 match handle_pkg(store, command) {
41 Ok(report) => {
42 progress.finish(report);
43 Ok(())
44 }
45 Err(error) => {
46 progress.fail();
47 Err(error)
48 }
49 }
50 }
51 }
52}
53
54fn handle_env(store: &StorePaths, cmd: EnvCommand) -> Result<Report, String> {
55 match cmd {
56 EnvCommand::Add { path } => {
57 let env_dir = env::add_env(store, &path)?;
58 Ok(Report::new(format!("added env `{path}`"))
59 .detail(format!("path: {}", env_dir.display()))
60 .detail(format!("bin: {}", store.env_bin_dir(&path).display()))
61 .detail(format!("shims: {}", store.shim_bin_dir().display())))
62 }
63 EnvCommand::Remove { path } => {
64 let result = env::remove_env(store, &path)?;
65 let summary = if result.removed {
66 format!("removed env `{path}`")
67 } else {
68 format!("env `{path}` was already absent")
69 };
70 let mut report =
71 Report::new(summary).detail(format!("path: {}", result.env_dir.display()));
72 if result.cleared_active {
73 report = report.detail("active env cleared; shims now point to root/bin");
74 }
75 Ok(report)
76 }
77 EnvCommand::Set { path } => {
78 let active_bin = env::set_env(store, &path)?;
79 Ok(Report::new(format!("activated env `{path}`"))
80 .detail(format!("bin: {}", active_bin.display()))
81 .detail(format!("shims: {}", store.shim_bin_dir().display())))
82 }
83 }
84}
85
86fn handle_pkg(store: &StorePaths, cmd: PkgCommand) -> Result<Report, String> {
87 match cmd {
88 PkgCommand::Add { path } => {
89 let install = package::download(store, &package::parse_pkg(&path)?)?;
90 Ok(
91 Report::new(format!("added package `{}`", install.package_name))
92 .detail(format!("binary: {}", install.installed_binary.display()))
93 .detail(format!("shim: {}", install.shim_binary.display())),
94 )
95 }
96 PkgCommand::Remove { path } => {
97 let removed = package::remove(store, &path)?;
98 let summary = if removed.removed {
99 format!("removed package `{}`", removed.package_name)
100 } else {
101 format!("package `{}` was not installed", removed.package_name)
102 };
103 Ok(Report::new(summary)
104 .detail(format!("binary: {}", removed.removed_binary.display()))
105 .detail(format!("shim: {}", removed.shim_binary.display())))
106 }
107 }
108}
109
110#[cfg(test)]
111mod tests {
112 use super::{
113 activate::activation_script,
114 env::{active_bin_dir, active_env_name, add_env, remove_env, set_env},
115 package::{
116 BuildConfig, Pkg, install_built_binary, parse_pkg, remove as remove_pkg,
117 run_build_script,
118 },
119 store::StorePaths,
120 };
121 use crate::activate;
122 use slox_cli::ActivateCommand;
123 use std::fs;
124 use std::path::PathBuf;
125 use std::time::{SystemTime, UNIX_EPOCH};
126
127 fn make_temp_dir(name: &str) -> PathBuf {
128 let unique = SystemTime::now()
129 .duration_since(UNIX_EPOCH)
130 .expect("time went backwards")
131 .as_nanos();
132 let dir = std::env::temp_dir().join(format!("slox-{name}-{unique}"));
133 fs::create_dir_all(&dir).expect("failed to create temp dir");
134 dir
135 }
136
137 fn make_store() -> StorePaths {
138 StorePaths {
139 base: make_temp_dir("store"),
140 }
141 }
142
143 #[test]
144 fn parse_github_package() {
145 let pkg = parse_pkg("devwon/slox@github").expect("github package should parse");
146 match pkg {
147 Pkg::GitHub { user, repo } => {
148 assert_eq!(user, "devwon");
149 assert_eq!(repo, "slox");
150 }
151 Pkg::Regit { .. } => panic!("expected github package"),
152 }
153 }
154
155 #[test]
156 fn parse_regit_package() {
157 let pkg = parse_pkg("plur@sloxpkgs").expect("sloxpkgs package should parse");
158 match pkg {
159 Pkg::Regit { pkg } => assert_eq!(pkg, "plur"),
160 Pkg::GitHub { .. } => panic!("expected sloxpkgs package"),
161 }
162 }
163
164 #[test]
165 fn reject_invalid_package_format() {
166 let error = parse_pkg("invalid-package").expect_err("package parse should fail");
167 assert!(error.contains("user/repo@github"));
168 }
169
170 #[test]
171 fn activation_script_uses_root_bin_path() {
172 let store = StorePaths {
173 base: PathBuf::from("/tmp/slox-store"),
174 };
175
176 let script = activation_script(&store, ActivateCommand::Zsh)
177 .expect("root activation script should render");
178 assert_eq!(
179 script,
180 concat!(
181 "_slox_store='/tmp/slox-store'\n",
182 "_slox_bin='/tmp/slox-store/bin'\n",
183 "_slox_filtered_path=\n",
184 "_slox_old_ifs=$IFS\n",
185 "IFS=:\n",
186 "for _slox_entry in $PATH; do\n",
187 " case \"$_slox_entry\" in\n",
188 " \"$_slox_store\"/bin|\"$_slox_store\"/bin/)\n",
189 " ;;\n",
190 " '')\n",
191 " ;;\n",
192 " *)\n",
193 " if [ -n \"$_slox_filtered_path\" ]; then\n",
194 " _slox_filtered_path=\"$_slox_filtered_path:$_slox_entry\"\n",
195 " else\n",
196 " _slox_filtered_path=\"$_slox_entry\"\n",
197 " fi\n",
198 " ;;\n",
199 " esac\n",
200 "done\n",
201 "IFS=$_slox_old_ifs\n",
202 "if [ -n \"$_slox_filtered_path\" ]; then\n",
203 " export PATH=\"$_slox_bin:$_slox_filtered_path\"\n",
204 "else\n",
205 " export PATH=\"$_slox_bin\"\n",
206 "fi\n",
207 "unset _slox_store _slox_bin _slox_filtered_path _slox_old_ifs _slox_entry"
208 )
209 );
210 }
211
212 #[test]
213 fn add_and_remove_env_under_store() {
214 let store = make_store();
215 let env_dir = store.env_dir("demo");
216
217 add_env(&store, "demo").expect("env should be created");
218 assert!(env_dir.is_dir());
219 assert!(store.env_bin_dir("demo").is_dir());
220
221 remove_env(&store, "demo").expect("env should be removed");
222 assert!(!env_dir.exists());
223
224 fs::remove_dir_all(store.base).expect("failed to clean store dir");
225 }
226
227 #[test]
228 fn set_env_changes_active_bin_dir_and_activation_script() {
229 let store = make_store();
230 add_env(&store, "demo").expect("env should be created");
231
232 set_env(&store, "demo").expect("env should be set");
233 assert_eq!(
234 active_env_name(&store).expect("active env should be readable"),
235 Some("demo".to_string())
236 );
237 assert_eq!(
238 active_bin_dir(&store).expect("active bin dir should resolve"),
239 store.env_bin_dir("demo")
240 );
241
242 let script = activation_script(&store, ActivateCommand::Bash)
243 .expect("env activation script should render");
244 assert_eq!(
245 script,
246 format!(
247 concat!(
248 "_slox_store={store}\n",
249 "_slox_bin={bin}\n",
250 "_slox_filtered_path=\n",
251 "_slox_old_ifs=$IFS\n",
252 "IFS=:\n",
253 "for _slox_entry in $PATH; do\n",
254 " case \"$_slox_entry\" in\n",
255 " \"$_slox_store\"/bin|\"$_slox_store\"/bin/)\n",
256 " ;;\n",
257 " '')\n",
258 " ;;\n",
259 " *)\n",
260 " if [ -n \"$_slox_filtered_path\" ]; then\n",
261 " _slox_filtered_path=\"$_slox_filtered_path:$_slox_entry\"\n",
262 " else\n",
263 " _slox_filtered_path=\"$_slox_entry\"\n",
264 " fi\n",
265 " ;;\n",
266 " esac\n",
267 "done\n",
268 "IFS=$_slox_old_ifs\n",
269 "if [ -n \"$_slox_filtered_path\" ]; then\n",
270 " export PATH=\"$_slox_bin:$_slox_filtered_path\"\n",
271 "else\n",
272 " export PATH=\"$_slox_bin\"\n",
273 "fi\n",
274 "unset _slox_store _slox_bin _slox_filtered_path _slox_old_ifs _slox_entry"
275 ),
276 store = activate::shell_single_quote(&store.base.display().to_string()),
277 bin = activate::shell_single_quote(&store.shim_bin_dir().display().to_string())
278 )
279 );
280
281 fs::remove_dir_all(store.base).expect("failed to clean store dir");
282 }
283
284 #[test]
285 fn removing_active_env_clears_active_env_file() {
286 let store = make_store();
287 add_env(&store, "demo").expect("env should be created");
288 set_env(&store, "demo").expect("env should be set");
289
290 remove_env(&store, "demo").expect("env should be removed");
291 assert_eq!(
292 active_env_name(&store).expect("active env should be readable"),
293 None
294 );
295 assert_eq!(
296 active_bin_dir(&store).expect("root bin should be used"),
297 store.root_bin_dir()
298 );
299
300 fs::remove_dir_all(store.base).expect("failed to clean store dir");
301 }
302
303 #[test]
304 fn build_and_install_github_package_layout() {
305 let repo_name = "demo";
306 let repo_dir = make_temp_dir("repo");
307 let root_bin_dir = make_temp_dir("root-bin");
308
309 fs::write(
310 repo_dir.join("build"),
311 format!(
312 "#!/bin/sh\nmkdir -p bin\nprintf '#!/bin/sh\\necho installed\\n' > bin/{repo_name}\nchmod +x bin/{repo_name}\n"
313 ),
314 )
315 .expect("failed to write build script");
316
317 let build_config = BuildConfig {
318 name: repo_name.to_string(),
319 script: PathBuf::from("build"),
320 binary: PathBuf::from("bin").join(repo_name),
321 };
322 run_build_script(&repo_dir, &build_config).expect("build script should succeed");
323 let installed_binary =
324 install_built_binary(&repo_dir, &build_config.binary, repo_name, &root_bin_dir)
325 .expect("install should succeed");
326
327 assert!(installed_binary.is_file());
328 let contents =
329 fs::read_to_string(installed_binary).expect("failed to read installed binary");
330 assert!(contents.contains("installed"));
331
332 fs::remove_dir_all(repo_dir).expect("failed to clean repo dir");
333 fs::remove_dir_all(root_bin_dir).expect("failed to clean root bin dir");
334 }
335
336 #[test]
337 fn install_target_follows_active_env_bin() {
338 let store = make_store();
339 let repo_name = "demo";
340 let repo_dir = make_temp_dir("repo");
341
342 add_env(&store, "demo-env").expect("env should be created");
343 set_env(&store, "demo-env").expect("env should be set");
344
345 fs::write(
346 repo_dir.join("build"),
347 format!(
348 "#!/bin/sh\nmkdir -p bin\nprintf '#!/bin/sh\\necho active-env\\n' > bin/{repo_name}\nchmod +x bin/{repo_name}\n"
349 ),
350 )
351 .expect("failed to write build script");
352
353 let build_config = BuildConfig {
354 name: repo_name.to_string(),
355 script: PathBuf::from("build"),
356 binary: PathBuf::from("bin").join(repo_name),
357 };
358 run_build_script(&repo_dir, &build_config).expect("build script should succeed");
359 let install_dir = active_bin_dir(&store).expect("active bin dir should resolve");
360 let installed_binary =
361 install_built_binary(&repo_dir, &build_config.binary, repo_name, &install_dir)
362 .expect("install should succeed");
363
364 assert_eq!(
365 installed_binary,
366 store.env_bin_dir("demo-env").join(repo_name)
367 );
368 assert!(installed_binary.is_file());
369
370 fs::remove_dir_all(repo_dir).expect("failed to clean repo dir");
371 fs::remove_dir_all(store.base).expect("failed to clean store dir");
372 }
373
374 #[test]
375 fn sync_active_shims_points_to_active_env_bins() {
376 let store = make_store();
377 add_env(&store, "demo").expect("env should be created");
378
379 fs::create_dir_all(store.env_bin_dir("demo")).expect("env bin dir should exist");
380 fs::write(
381 store.env_bin_dir("demo").join("demo"),
382 "#!/bin/sh\necho env\n",
383 )
384 .expect("binary should be written");
385
386 set_env(&store, "demo").expect("env should be set");
387
388 let shim_path = store.shim_bin_dir().join("demo");
389 assert!(shim_path.is_file());
390 let shim_contents = fs::read_to_string(&shim_path).expect("shim should be readable");
391 assert!(
392 shim_contents.contains(&store.env_bin_dir("demo").join("demo").display().to_string())
393 );
394
395 fs::remove_dir_all(store.base).expect("failed to clean store dir");
396 }
397
398 #[test]
399 fn clearing_active_env_refreshes_shims_to_root_bin() {
400 let store = make_store();
401 add_env(&store, "demo").expect("env should be created");
402 fs::create_dir_all(store.root_bin_dir()).expect("root bin dir should exist");
403 fs::write(
404 store.root_bin_dir().join("root-tool"),
405 "#!/bin/sh\necho root\n",
406 )
407 .expect("root binary should be written");
408 fs::write(
409 store.env_bin_dir("demo").join("env-tool"),
410 "#!/bin/sh\necho env\n",
411 )
412 .expect("env binary should be written");
413
414 set_env(&store, "demo").expect("env should be set");
415 remove_env(&store, "demo").expect("env should be removed");
416
417 let shim_dir = store.shim_bin_dir();
418 assert!(shim_dir.join("root-tool").is_file());
419 assert!(!shim_dir.join("env-tool").exists());
420
421 fs::remove_dir_all(store.base).expect("failed to clean store dir");
422 }
423
424 #[test]
425 fn remove_package_deletes_binary_and_refreshes_shim() {
426 let store = make_store();
427 fs::create_dir_all(store.root_bin_dir()).expect("root bin dir should exist");
428 fs::write(store.root_bin_dir().join("demo"), "#!/bin/sh\necho root\n")
429 .expect("package binary should be written");
430 super::env::sync_active_shims(&store).expect("shims should be created");
431
432 let removed = remove_pkg(&store, "demo").expect("package should be removable");
433
434 assert!(removed.removed);
435 assert!(!store.root_bin_dir().join("demo").exists());
436 assert!(!store.shim_bin_dir().join("demo").exists());
437
438 fs::remove_dir_all(store.base).expect("failed to clean store dir");
439 }
440
441 #[test]
442 fn build_toml_can_override_script_and_binary_location() {
443 let repo_dir = make_temp_dir("repo");
444 let root_bin_dir = make_temp_dir("root-bin");
445
446 fs::create_dir_all(repo_dir.join("scripts")).expect("scripts dir should exist");
447 fs::write(
448 repo_dir.join("scripts").join("custom-build.sh"),
449 "#!/bin/sh\nmkdir -p dist\nprintf '#!/bin/sh\\necho custom\\n' > dist/custom-bin\nchmod +x dist/custom-bin\n",
450 )
451 .expect("build script should be written");
452 fs::write(
453 repo_dir.join("build.toml"),
454 "script = \"scripts/custom-build.sh\"\nbinary = \"dist/custom-bin\"\n",
455 )
456 .expect("build config should be written");
457
458 let build_config =
459 super::package::load_build_config(&repo_dir, "demo").expect("config should load");
460 run_build_script(&repo_dir, &build_config).expect("custom build should succeed");
461 let installed_binary =
462 install_built_binary(&repo_dir, &build_config.binary, "demo", &root_bin_dir)
463 .expect("custom binary should install");
464
465 assert_eq!(installed_binary, root_bin_dir.join("demo"));
466 let contents =
467 fs::read_to_string(installed_binary).expect("failed to read installed binary");
468 assert!(contents.contains("custom"));
469
470 fs::remove_dir_all(repo_dir).expect("failed to clean repo dir");
471 fs::remove_dir_all(root_bin_dir).expect("failed to clean root bin dir");
472 }
473
474 #[test]
475 fn build_toml_can_override_installed_name() {
476 let repo_dir = make_temp_dir("repo");
477 let root_bin_dir = make_temp_dir("root-bin");
478
479 fs::write(
480 repo_dir.join("build"),
481 "#!/bin/sh\nmkdir -p bin\nprintf '#!/bin/sh\\necho renamed\\n' > bin/original\nchmod +x bin/original\n",
482 )
483 .expect("build script should be written");
484 fs::write(
485 repo_dir.join("build.toml"),
486 "name = \"renamed\"\nbinary = \"bin/original\"\n",
487 )
488 .expect("build config should be written");
489
490 let build_config =
491 super::package::load_build_config(&repo_dir, "demo").expect("config should load");
492 run_build_script(&repo_dir, &build_config).expect("custom build should succeed");
493 let installed_binary = install_built_binary(
494 &repo_dir,
495 &build_config.binary,
496 &build_config.name,
497 &root_bin_dir,
498 )
499 .expect("custom binary should install");
500
501 assert_eq!(build_config.name, "renamed");
502 assert_eq!(installed_binary, root_bin_dir.join("renamed"));
503
504 fs::remove_dir_all(repo_dir).expect("failed to clean repo dir");
505 fs::remove_dir_all(root_bin_dir).expect("failed to clean root bin dir");
506 }
507}