Skip to main content

slox_core/
lib.rs

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            script: PathBuf::from("build"),
319            binary: PathBuf::from("bin").join(repo_name),
320        };
321        run_build_script(&repo_dir, &build_config).expect("build script should succeed");
322        let installed_binary =
323            install_built_binary(&repo_dir, &build_config.binary, repo_name, &root_bin_dir)
324                .expect("install should succeed");
325
326        assert!(installed_binary.is_file());
327        let contents =
328            fs::read_to_string(installed_binary).expect("failed to read installed binary");
329        assert!(contents.contains("installed"));
330
331        fs::remove_dir_all(repo_dir).expect("failed to clean repo dir");
332        fs::remove_dir_all(root_bin_dir).expect("failed to clean root bin dir");
333    }
334
335    #[test]
336    fn install_target_follows_active_env_bin() {
337        let store = make_store();
338        let repo_name = "demo";
339        let repo_dir = make_temp_dir("repo");
340
341        add_env(&store, "demo-env").expect("env should be created");
342        set_env(&store, "demo-env").expect("env should be set");
343
344        fs::write(
345            repo_dir.join("build"),
346            format!(
347                "#!/bin/sh\nmkdir -p bin\nprintf '#!/bin/sh\\necho active-env\\n' > bin/{repo_name}\nchmod +x bin/{repo_name}\n"
348            ),
349        )
350        .expect("failed to write build script");
351
352        let build_config = BuildConfig {
353            script: PathBuf::from("build"),
354            binary: PathBuf::from("bin").join(repo_name),
355        };
356        run_build_script(&repo_dir, &build_config).expect("build script should succeed");
357        let install_dir = active_bin_dir(&store).expect("active bin dir should resolve");
358        let installed_binary =
359            install_built_binary(&repo_dir, &build_config.binary, repo_name, &install_dir)
360                .expect("install should succeed");
361
362        assert_eq!(
363            installed_binary,
364            store.env_bin_dir("demo-env").join(repo_name)
365        );
366        assert!(installed_binary.is_file());
367
368        fs::remove_dir_all(repo_dir).expect("failed to clean repo dir");
369        fs::remove_dir_all(store.base).expect("failed to clean store dir");
370    }
371
372    #[test]
373    fn sync_active_shims_points_to_active_env_bins() {
374        let store = make_store();
375        add_env(&store, "demo").expect("env should be created");
376
377        fs::create_dir_all(store.env_bin_dir("demo")).expect("env bin dir should exist");
378        fs::write(
379            store.env_bin_dir("demo").join("demo"),
380            "#!/bin/sh\necho env\n",
381        )
382        .expect("binary should be written");
383
384        set_env(&store, "demo").expect("env should be set");
385
386        let shim_path = store.shim_bin_dir().join("demo");
387        assert!(shim_path.is_file());
388        let shim_contents = fs::read_to_string(&shim_path).expect("shim should be readable");
389        assert!(
390            shim_contents.contains(&store.env_bin_dir("demo").join("demo").display().to_string())
391        );
392
393        fs::remove_dir_all(store.base).expect("failed to clean store dir");
394    }
395
396    #[test]
397    fn clearing_active_env_refreshes_shims_to_root_bin() {
398        let store = make_store();
399        add_env(&store, "demo").expect("env should be created");
400        fs::create_dir_all(store.root_bin_dir()).expect("root bin dir should exist");
401        fs::write(
402            store.root_bin_dir().join("root-tool"),
403            "#!/bin/sh\necho root\n",
404        )
405        .expect("root binary should be written");
406        fs::write(
407            store.env_bin_dir("demo").join("env-tool"),
408            "#!/bin/sh\necho env\n",
409        )
410        .expect("env binary should be written");
411
412        set_env(&store, "demo").expect("env should be set");
413        remove_env(&store, "demo").expect("env should be removed");
414
415        let shim_dir = store.shim_bin_dir();
416        assert!(shim_dir.join("root-tool").is_file());
417        assert!(!shim_dir.join("env-tool").exists());
418
419        fs::remove_dir_all(store.base).expect("failed to clean store dir");
420    }
421
422    #[test]
423    fn remove_package_deletes_binary_and_refreshes_shim() {
424        let store = make_store();
425        fs::create_dir_all(store.root_bin_dir()).expect("root bin dir should exist");
426        fs::write(store.root_bin_dir().join("demo"), "#!/bin/sh\necho root\n")
427            .expect("package binary should be written");
428        super::env::sync_active_shims(&store).expect("shims should be created");
429
430        let removed = remove_pkg(&store, "demo").expect("package should be removable");
431
432        assert!(removed.removed);
433        assert!(!store.root_bin_dir().join("demo").exists());
434        assert!(!store.shim_bin_dir().join("demo").exists());
435
436        fs::remove_dir_all(store.base).expect("failed to clean store dir");
437    }
438
439    #[test]
440    fn build_toml_can_override_script_and_binary_location() {
441        let repo_dir = make_temp_dir("repo");
442        let root_bin_dir = make_temp_dir("root-bin");
443
444        fs::create_dir_all(repo_dir.join("scripts")).expect("scripts dir should exist");
445        fs::write(
446            repo_dir.join("scripts").join("custom-build.sh"),
447            "#!/bin/sh\nmkdir -p dist\nprintf '#!/bin/sh\\necho custom\\n' > dist/custom-bin\nchmod +x dist/custom-bin\n",
448        )
449        .expect("build script should be written");
450        fs::write(
451            repo_dir.join("build.toml"),
452            "script = \"scripts/custom-build.sh\"\nbinary = \"dist/custom-bin\"\n",
453        )
454        .expect("build config should be written");
455
456        let build_config =
457            super::package::load_build_config(&repo_dir, "demo").expect("config should load");
458        run_build_script(&repo_dir, &build_config).expect("custom build should succeed");
459        let installed_binary =
460            install_built_binary(&repo_dir, &build_config.binary, "demo", &root_bin_dir)
461                .expect("custom binary should install");
462
463        assert_eq!(installed_binary, root_bin_dir.join("demo"));
464        let contents =
465            fs::read_to_string(installed_binary).expect("failed to read installed binary");
466        assert!(contents.contains("custom"));
467
468        fs::remove_dir_all(repo_dir).expect("failed to clean repo dir");
469        fs::remove_dir_all(root_bin_dir).expect("failed to clean root bin dir");
470    }
471}