asimov_cli/
shared.rs

1// This is free and unencumbered software released into the public domain.
2
3use crate::Result;
4use asimov_env::paths::asimov_root;
5use asimov_module::{models::ModuleManifest, resolve::Resolver};
6use clientele::{Subcommand, SubcommandsProvider, SysexitsError::*};
7use miette::{IntoDiagnostic, miette};
8
9pub(crate) fn build_resolver(pattern: &str) -> miette::Result<Resolver> {
10    let mut resolver = Resolver::new();
11
12    let module_dir_path = asimov_root().join("modules");
13    let module_dir = std::fs::read_dir(&module_dir_path)
14        .map_err(|e| miette!("Failed to read module manifest directory: {e}"))?
15        .filter_map(Result::ok);
16
17    for entry in module_dir {
18        let filename = entry.file_name();
19        let filename = filename.to_string_lossy();
20        if !filename.ends_with(".yml") && !filename.ends_with(".yaml") {
21            continue;
22        }
23        let file = std::fs::File::open(entry.path()).into_diagnostic()?;
24
25        let manifest: ModuleManifest = serde_yml::from_reader(file).map_err(|e| {
26            miette!(
27                "Invalid module manifest at `{}`: {}",
28                entry.path().display(),
29                e
30            )
31        })?;
32
33        if !manifest
34            .provides
35            .programs
36            .iter()
37            .any(|program| program.split('-').next_back().is_some_and(|p| p == pattern))
38        {
39            continue;
40        }
41
42        resolver
43            .insert_manifest(&manifest)
44            .map_err(|e| miette!("{e}"))?;
45    }
46
47    Ok(resolver)
48}
49
50/// Locates the given subcommand or prints an error.
51pub fn locate_subcommand(name: &str) -> Result<Subcommand> {
52    let libexec = asimov_root().join("libexec");
53    if libexec.exists() {
54        let file = std::fs::read_dir(libexec)?
55            .filter_map(Result::ok)
56            .find(|entry| {
57                entry.file_name().to_str().is_some_and(|filename| {
58                    filename.starts_with("asimov-") && filename.ends_with(name)
59                })
60            });
61        if let Some(entry) = file {
62            return Ok(Subcommand {
63                name: format!("asimov-{}", name),
64                path: entry.path(),
65            });
66        }
67    }
68
69    match SubcommandsProvider::find("asimov-", name) {
70        Some(cmd) => Ok(cmd),
71        None => {
72            eprintln!("{}: command not found: {}{}", "asimov", "asimov-", name);
73            Err(EX_UNAVAILABLE)
74        }
75    }
76}
77
78pub fn normalize_url(url: &str) -> String {
79    // test whether it's a normal, valid, URL
80    if let Ok(url) = <url::Url>::parse(url) {
81        return url.to_string();
82    };
83
84    // all the below cases treat the url as a file path.
85
86    // replace a `~/` prefix with the path to the user's home dir.
87    let url = url
88        .strip_prefix("~/")
89        .map(|path| {
90            std::env::home_dir()
91                .expect("unable to determine home directory")
92                .join(path)
93        })
94        .unwrap_or_else(|| std::path::PathBuf::from(url));
95
96    // `std::path::Path::canonicalize`:
97    // > Returns the canonical, absolute form of the path with all
98    // > intermediate components normalized and symbolic links resolved.
99    //
100    // This will only work if the file actually exists.
101    if let Ok(path) = std::path::Path::new(&url)
102        .canonicalize()
103        .map_err(|_| ())
104        .and_then(url::Url::from_file_path)
105    {
106        return path.to_string();
107    };
108
109    // `std::path::absolute`:
110    // > Makes the path absolute without accessing the filesystem.
111    if let Ok(path) = std::path::absolute(&url)
112        .map_err(|_| ())
113        .and_then(url::Url::from_file_path)
114    {
115        return path.to_string();
116    }
117
118    // TODO: add `std::path::Path::normalize_lexically` once it stabilizes.
119    // https://github.com/rust-lang/rust/issues/134694
120    //
121    // if let Ok(path) = std::path::Path::new(url).normalize_lexically() {
122    //     return url::Url::from_file_path(path).unwrap().to_string();
123    // }
124
125    // one last try, test whether the `url` crate accepts it as path without changes.
126    if let Ok(path) = url::Url::from_file_path(std::path::Path::new(&url)) {
127        return path.to_string();
128    }
129
130    // otherwise just convert to a file URL without changes and hope for the best :)
131    // (we should not really get here but just in case.)
132    format!("file://{}", url.display())
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138
139    #[test]
140    fn url_normalization() {
141        let cases = [
142            ("https://example.org/", "https://example.org/"),
143            ("near://testnet/123456789", "near://testnet/123456789"),
144            ("/file with spaces.txt", "file:///file%20with%20spaces.txt"),
145            ("/file+with+pluses.txt", "file:///file+with+pluses.txt"),
146        ];
147
148        for case in cases {
149            assert_eq!(normalize_url(case.0), case.1, "input: {:?}", case.0);
150        }
151
152        #[cfg(unix)]
153        {
154            unsafe { std::env::set_var("HOME", "/home/user") };
155
156            let input = "~/path/to/file.txt";
157            let want = "file:///home/user/path/to/file.txt";
158
159            assert_eq!(
160                normalize_url(input),
161                want,
162                "home directory should be expanded, input: {:?}",
163                input
164            );
165        }
166
167        let cur_dir = std::env::current_dir().unwrap().display().to_string();
168
169        let input = "path/to/file.txt";
170        let want = "file://".to_string() + &cur_dir + "/path/to/file.txt";
171        assert_eq!(
172            normalize_url(input),
173            want,
174            "relative path should be get added after current directory, input: {:?}",
175            input
176        );
177
178        let input = "../path/./file.txt";
179        let want = "file://".to_string() + &cur_dir + "/../path/file.txt";
180        assert_eq!(
181            normalize_url(input),
182            want,
183            "relative path should be get added after current directory, input: {:?}",
184            input
185        );
186
187        let input = "another-type-of-a-string";
188        let want = "file://".to_string() + &cur_dir + "/another-type-of-a-string";
189        assert_eq!(
190            normalize_url(input),
191            want,
192            "non-path-looking input should be treated as a file in current directory, input: {:?}",
193            input
194        );
195
196        let input = "hello\\ world!";
197        let want = "file://".to_string() + &cur_dir + "/hello%5C%20world!";
198        assert_eq!(
199            normalize_url(input),
200            want,
201            "output should be url encoded, input: {:?}",
202            input
203        );
204    }
205}