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};
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}