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