Skip to main content

cotis_cli/
routines.rs

1//! Cache path helpers and installed-routine discovery.
2//!
3//! Routines are stored under `ProjectDirs::cache_dir()`:
4//!
5//! ```text
6//! cache/routines/<name>/<version>/<target_triple>/plugin/<library>
7//! cache/routines/<name>/<version>/<target_triple>/descriptor.json
8//! ```
9//!
10//! ## Version resolution
11//!
12//! [`find_latest_installed_version`] and default `run` behavior use **lexicographic** sorting on
13//! version directory names. The last sorted entry is treated as "latest". This is **not**
14//! semver-aware (e.g. `1.9.0` sorts after `1.10.0`). Pin versions with `run <name>@<version>`.
15
16use std::path::PathBuf;
17use std::process::{Command, Stdio};
18
19use directories::ProjectDirs;
20
21/// Root cache directory (`proj_dirs.cache_dir()`).
22pub fn cache_root(proj_dirs: &ProjectDirs) -> PathBuf {
23    proj_dirs.cache_dir().to_path_buf()
24}
25
26/// `cache/routines` — parent of all installed routine names.
27pub fn routines_root(proj_dirs: &ProjectDirs) -> PathBuf {
28    cache_root(proj_dirs).join("routines")
29}
30
31/// `cache/routines/<name>`.
32pub fn routine_root(proj_dirs: &ProjectDirs, name: &str) -> PathBuf {
33    routines_root(proj_dirs).join(name)
34}
35
36/// `cache/routines/<name>/<version>`.
37pub fn routine_version_root(proj_dirs: &ProjectDirs, name: &str, version: &str) -> PathBuf {
38    routine_root(proj_dirs, name).join(version)
39}
40
41/// `cache/routines/<name>/<version>/<target_triple>`.
42pub fn routine_target_root(
43    proj_dirs: &ProjectDirs,
44    name: &str,
45    version: &str,
46    target_triple: &str,
47) -> PathBuf {
48    routine_version_root(proj_dirs, name, version).join(target_triple)
49}
50
51/// `cache/routines/<name>/<version>/<target_triple>/plugin`.
52pub fn plugin_dir(
53    proj_dirs: &ProjectDirs,
54    name: &str,
55    version: &str,
56    target_triple: &str,
57) -> PathBuf {
58    routine_target_root(proj_dirs, name, version, target_triple).join("plugin")
59}
60
61/// Path to `descriptor.json` for an installed routine version and target.
62pub fn descriptor_path(
63    proj_dirs: &ProjectDirs,
64    name: &str,
65    version: &str,
66    target_triple: &str,
67) -> PathBuf {
68    routine_target_root(proj_dirs, name, version, target_triple).join("descriptor.json")
69}
70
71/// Platform-specific dynamic library filename for a routine name stem.
72///
73/// Same convention as [`crate::cdylib_filename`].
74pub fn platform_library_filename(crate_name: &str) -> String {
75    if cfg!(windows) {
76        format!("{crate_name}.dll")
77    } else if cfg!(target_os = "macos") {
78        format!("lib{crate_name}.dylib")
79    } else {
80        format!("lib{crate_name}.so")
81    }
82}
83
84/// Host target triple from `rustc -vV` (`host:` line).
85///
86/// # Errors
87///
88/// Returns an error if `rustc` is missing, exits non-zero, or output cannot be parsed.
89///
90/// # Examples
91///
92/// ```ignore
93/// let triple = host_target_triple().unwrap();
94/// assert!(!triple.is_empty());
95/// ```
96pub fn host_target_triple() -> Result<String, String> {
97    let out = Command::new("rustc")
98        .args(["-vV"])
99        .stderr(Stdio::inherit())
100        .output()
101        .map_err(|e| format!("Failed to run rustc: {e}"))?;
102    if !out.status.success() {
103        return Err("rustc -vV failed".to_string());
104    }
105    let stdout = String::from_utf8_lossy(&out.stdout);
106    for line in stdout.lines() {
107        if let Some(rest) = line.strip_prefix("host: ") {
108            return Ok(rest.trim().to_string());
109        }
110    }
111    Err("Failed to parse rustc host triple".to_string())
112}
113
114/// Sorted routine names that have at least one installed version directory.
115pub fn installed_routine_names(proj_dirs: &ProjectDirs) -> Vec<String> {
116    let root = routines_root(proj_dirs);
117    if !root.exists() {
118        return Vec::new();
119    }
120    let mut names: Vec<String> = std::fs::read_dir(&root)
121        .into_iter()
122        .flatten()
123        .filter_map(|e| e.ok())
124        .filter(|e| e.path().is_dir())
125        .filter_map(|e| e.file_name().to_str().map(|s| s.to_string()))
126        .collect();
127    names.sort();
128    names
129}
130
131/// Version directory names for a routine, sorted lexicographically.
132///
133/// "Latest" is often the last entry after sort; see [module-level docs](self#version-resolution).
134pub fn installed_versions_for_routine(proj_dirs: &ProjectDirs, name: &str) -> Vec<String> {
135    let root = routine_root(proj_dirs, name);
136    if !root.exists() {
137        return Vec::new();
138    }
139    let mut versions: Vec<String> = std::fs::read_dir(&root)
140        .into_iter()
141        .flatten()
142        .filter_map(|e| e.ok())
143        .filter(|e| e.path().is_dir())
144        .filter_map(|e| e.file_name().to_str().map(|s| s.to_string()))
145        .collect();
146    versions.sort();
147    versions
148}
149
150/// Lexicographically greatest installed version directory name, if any.
151///
152/// Used when `run <name>` omits `@version`. Not semver-aware.
153pub fn find_latest_installed_version(proj_dirs: &ProjectDirs, name: &str) -> Option<String> {
154    let root = routine_root(proj_dirs, name);
155    let mut versions: Vec<String> = std::fs::read_dir(&root)
156        .ok()?
157        .filter_map(|e| e.ok())
158        .filter(|e| e.path().is_dir())
159        .filter_map(|e| e.file_name().to_str().map(|s| s.to_string()))
160        .collect();
161    versions.sort();
162    versions.pop()
163}
164
165/// Full path to the installed plugin dynamic library file.
166pub fn plugin_library_path(
167    proj_dirs: &ProjectDirs,
168    name: &str,
169    version: &str,
170    target_triple: &str,
171) -> PathBuf {
172    plugin_dir(proj_dirs, name, version, target_triple).join(platform_library_filename(name))
173}
174
175/// Whether the plugin library file exists for the given routine, version, and target.
176pub fn is_installed_for_target(
177    proj_dirs: &ProjectDirs,
178    name: &str,
179    version: &str,
180    target_triple: &str,
181) -> bool {
182    plugin_library_path(proj_dirs, name, version, target_triple).exists()
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188    use directories::ProjectDirs;
189
190    fn test_proj_dirs() -> ProjectDirs {
191        ProjectDirs::from("com", "cotis", "cotis-cli-test").expect("valid qualifier/org/app")
192    }
193
194    #[test]
195    fn platform_library_filename_matches_host() {
196        if cfg!(windows) {
197            assert_eq!(platform_library_filename("my_routine"), "my_routine.dll");
198        } else if cfg!(target_os = "macos") {
199            assert_eq!(
200                platform_library_filename("my_routine"),
201                "libmy_routine.dylib"
202            );
203        } else {
204            assert_eq!(platform_library_filename("my_routine"), "libmy_routine.so");
205        }
206    }
207
208    #[test]
209    fn descriptor_path_follows_cache_layout() {
210        let proj_dirs = test_proj_dirs();
211        let path = descriptor_path(&proj_dirs, "web_builder", "0.1.0", "x86_64-pc-windows-msvc");
212        assert!(
213            path.ends_with(
214                PathBuf::from("routines")
215                    .join("web_builder")
216                    .join("0.1.0")
217                    .join("x86_64-pc-windows-msvc")
218                    .join("descriptor.json")
219            )
220        );
221    }
222
223    #[test]
224    fn plugin_dir_is_under_target_root() {
225        let proj_dirs = test_proj_dirs();
226        let path = plugin_dir(
227            &proj_dirs,
228            "web_builder",
229            "dev",
230            "aarch64-unknown-linux-gnu",
231        );
232        assert!(
233            path.ends_with(
234                PathBuf::from("routines")
235                    .join("web_builder")
236                    .join("dev")
237                    .join("aarch64-unknown-linux-gnu")
238                    .join("plugin")
239            )
240        );
241    }
242}