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::{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        ];
145
146        for case in cases {
147            assert_eq!(normalize_url(case.0), case.1, "input: {:?}", case.0);
148        }
149
150        #[cfg(unix)]
151        {
152            unsafe { std::env::set_var("HOME", "/home/user") };
153
154            let cases = [
155                ("~/path/to/file.txt", "file:///home/user/path/to/file.txt"),
156                ("/file with spaces.txt", "file:///file%20with%20spaces.txt"),
157                ("/file+with+pluses.txt", "file:///file+with+pluses.txt"),
158            ];
159
160            for case in cases {
161                assert_eq!(normalize_url(case.0), case.1, "input: {:?}", case.0);
162            }
163
164            let cur_dir = std::env::current_dir().unwrap().display().to_string();
165
166            let input = "path/to/file.txt";
167            let want = "file://".to_string() + &cur_dir + "/path/to/file.txt";
168            assert_eq!(
169                normalize_url(input),
170                want,
171                "relative path should be get added after current directory, input: {:?}",
172                input
173            );
174
175            let input = "../path/./file.txt";
176            let want = "file://".to_string() + &cur_dir + "/../path/file.txt";
177            assert_eq!(
178                normalize_url(input),
179                want,
180                "relative path should be get added after current directory, input: {:?}",
181                input
182            );
183
184            let input = "another-type-of-a-string";
185            let want = "file://".to_string() + &cur_dir + "/another-type-of-a-string";
186            assert_eq!(
187                normalize_url(input),
188                want,
189                "non-path-looking input should be treated as a file in current directory, input: {:?}",
190                input
191            );
192
193            let input = "hello\\ world!";
194            let want = "file://".to_string() + &cur_dir + "/hello%5C%20world!";
195            assert_eq!(
196                normalize_url(input),
197                want,
198                "output should be url encoded, input: {:?}",
199                input
200            );
201        }
202
203        #[cfg(windows)]
204        {
205            let cases = [
206                (
207                    "/file with spaces.txt",
208                    "file:///C:/file%20with%20spaces.txt",
209                ),
210                ("/file+with+pluses.txt", "file:///C:/file+with+pluses.txt"),
211            ];
212
213            for case in cases {
214                assert_eq!(normalize_url(case.0), case.1, "input: {:?}", case.0);
215            }
216        }
217    }
218}