vx_plugin/
utils.rs

1//! Utility functions and helpers for plugin development
2//!
3//! This module provides common utilities that plugin developers can use
4//! to simplify their implementations.
5
6use crate::{Result, VersionInfo};
7use std::path::Path;
8
9/// Check if a command is available in the system PATH
10///
11/// This is useful for checking if a tool or package manager is installed
12/// on the system before attempting to use it.
13pub fn is_command_available(command: &str) -> bool {
14    which::which(command).is_ok()
15}
16
17/// Get the platform-specific executable extension
18///
19/// Returns ".exe" on Windows, empty string on other platforms.
20pub fn get_exe_extension() -> &'static str {
21    if cfg!(target_os = "windows") {
22        ".exe"
23    } else {
24        ""
25    }
26}
27
28/// Get the platform-specific executable name
29///
30/// Adds the appropriate extension for the current platform.
31pub fn get_exe_name(base_name: &str) -> String {
32    format!("{}{}", base_name, get_exe_extension())
33}
34
35/// Check if a path exists and is executable
36///
37/// This function checks if a file exists and has execute permissions
38/// (on Unix-like systems).
39pub fn is_executable(path: &Path) -> bool {
40    if !path.exists() {
41        return false;
42    }
43
44    #[cfg(unix)]
45    {
46        use std::os::unix::fs::PermissionsExt;
47        if let Ok(metadata) = path.metadata() {
48            let permissions = metadata.permissions();
49            return permissions.mode() & 0o111 != 0;
50        }
51        false
52    }
53
54    #[cfg(not(unix))]
55    {
56        // On Windows, if the file exists, assume it's executable
57        // A more sophisticated check could look at file extensions
58        true
59    }
60}
61
62/// Find an executable in a directory
63///
64/// Searches for an executable with the given name in the specified directory,
65/// trying common subdirectories like "bin", "Scripts", etc.
66pub fn find_executable_in_dir(dir: &Path, exe_name: &str) -> Option<std::path::PathBuf> {
67    let exe_name_with_ext = get_exe_name(exe_name);
68
69    // Try common locations
70    let candidates = vec![
71        dir.join(&exe_name_with_ext),
72        dir.join("bin").join(&exe_name_with_ext),
73        dir.join("Scripts").join(&exe_name_with_ext), // Windows Python-style
74        dir.join("sbin").join(&exe_name_with_ext),    // System binaries
75    ];
76
77    candidates
78        .into_iter()
79        .find(|candidate| is_executable(candidate))
80}
81/// Parse version string into components
82///
83/// Attempts to parse a semantic version string into major, minor, and patch components.
84/// Returns None if the version string is not in a recognizable format.
85pub fn parse_version(version: &str) -> Option<(u32, u32, u32)> {
86    let parts: Vec<&str> = version.trim_start_matches('v').split('.').collect();
87
88    if parts.len() >= 3 {
89        let major = parts[0].parse().ok()?;
90        let minor = parts[1].parse().ok()?;
91        // Handle patch versions that might have additional suffixes (e.g., "1-beta")
92        let patch_str = parts[2].split('-').next().unwrap_or(parts[2]);
93        let patch = patch_str.parse().ok()?;
94
95        Some((major, minor, patch))
96    } else {
97        None
98    }
99}
100
101/// Compare two version strings
102///
103/// Returns:
104/// - `std::cmp::Ordering::Less` if `a < b`
105/// - `std::cmp::Ordering::Equal` if `a == b`
106/// - `std::cmp::Ordering::Greater` if `a > b`
107/// - `None` if versions cannot be compared
108pub fn compare_versions(a: &str, b: &str) -> Option<std::cmp::Ordering> {
109    let (a_major, a_minor, a_patch) = parse_version(a)?;
110    let (b_major, b_minor, b_patch) = parse_version(b)?;
111
112    Some((a_major, a_minor, a_patch).cmp(&(b_major, b_minor, b_patch)))
113}
114
115/// Sort versions in descending order (newest first)
116///
117/// This function sorts a vector of version strings, placing the newest
118/// versions first. Versions that cannot be parsed are placed at the end.
119pub fn sort_versions_desc(versions: &mut [String]) {
120    versions.sort_by(|a, b| {
121        match compare_versions(a, b) {
122            Some(ordering) => ordering.reverse(), // Reverse for descending order
123            None => std::cmp::Ordering::Equal,
124        }
125    });
126}
127
128/// Check if a version is a prerelease
129///
130/// Returns true if the version string contains prerelease indicators
131/// like "alpha", "beta", "rc", etc.
132pub fn is_prerelease(version: &str) -> bool {
133    let version_lower = version.to_lowercase();
134    version_lower.contains("alpha")
135        || version_lower.contains("beta")
136        || version_lower.contains("rc")
137        || version_lower.contains("pre")
138        || version_lower.contains("dev")
139        || version_lower.contains("snapshot")
140}
141
142/// Create a VersionInfo from a simple version string
143///
144/// This is a convenience function for creating VersionInfo objects
145/// with automatic prerelease detection.
146pub fn create_version_info(version: &str, download_url: Option<String>) -> VersionInfo {
147    VersionInfo {
148        version: version.to_string(),
149        prerelease: is_prerelease(version),
150        release_date: None,
151        release_notes: None,
152        download_url,
153        checksum: None,
154        file_size: None,
155        metadata: std::collections::HashMap::new(),
156    }
157}
158/// Validate a tool name
159///
160/// Checks if a tool name follows the expected conventions:
161/// - Contains only alphanumeric characters, hyphens, and underscores
162/// - Starts with a letter
163/// - Is not empty and not too long
164pub fn validate_tool_name(name: &str) -> Result<()> {
165    if name.is_empty() {
166        return Err(anyhow::anyhow!("Tool name cannot be empty"));
167    }
168
169    if name.len() > 64 {
170        return Err(anyhow::anyhow!(
171            "Tool name cannot be longer than 64 characters"
172        ));
173    }
174
175    if !name.chars().next().unwrap().is_ascii_alphabetic() {
176        return Err(anyhow::anyhow!("Tool name must start with a letter"));
177    }
178
179    for ch in name.chars() {
180        if !ch.is_ascii_alphanumeric() && ch != '-' && ch != '_' {
181            return Err(anyhow::anyhow!(
182                "Tool name can only contain letters, numbers, hyphens, and underscores"
183            ));
184        }
185    }
186
187    Ok(())
188}
189
190/// Validate a version string
191///
192/// Checks if a version string is in a valid format.
193/// Accepts semantic versioning and other common version formats.
194pub fn validate_version(version: &str) -> Result<()> {
195    if version.is_empty() {
196        return Err(anyhow::anyhow!("Version cannot be empty"));
197    }
198
199    // Allow 'v' prefix
200    let version = version.strip_prefix('v').unwrap_or(version);
201
202    // Check for basic version pattern (at least one number)
203    if !version.chars().any(|c| c.is_ascii_digit()) {
204        return Err(anyhow::anyhow!("Version must contain at least one number"));
205    }
206
207    // More sophisticated validation could be added here
208    Ok(())
209}
210
211/// Get the default vx directory
212///
213/// Returns the default directory where vx stores its data.
214/// This is typically `~/.vx` on Unix-like systems and `%USERPROFILE%\.vx` on Windows.
215pub fn get_vx_dir() -> std::path::PathBuf {
216    dirs::home_dir()
217        .unwrap_or_else(|| std::path::PathBuf::from("."))
218        .join(".vx")
219}
220
221/// Get the tools directory
222///
223/// Returns the directory where vx stores installed tools.
224pub fn get_tools_dir() -> std::path::PathBuf {
225    get_vx_dir().join("tools")
226}
227
228/// Get the plugins directory
229///
230/// Returns the directory where vx looks for plugins.
231pub fn get_plugins_dir() -> std::path::PathBuf {
232    get_vx_dir().join("plugins")
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238
239    #[test]
240    fn test_parse_version() {
241        assert_eq!(parse_version("1.2.3"), Some((1, 2, 3)));
242        assert_eq!(parse_version("v1.2.3"), Some((1, 2, 3)));
243        assert_eq!(parse_version("1.2.3-beta"), Some((1, 2, 3)));
244        assert_eq!(parse_version("invalid"), None);
245    }
246
247    #[test]
248    fn test_is_prerelease() {
249        assert!(is_prerelease("1.0.0-alpha"));
250        assert!(is_prerelease("1.0.0-beta.1"));
251        assert!(is_prerelease("1.0.0-rc.1"));
252        assert!(!is_prerelease("1.0.0"));
253    }
254
255    #[test]
256    fn test_validate_tool_name() {
257        assert!(validate_tool_name("node").is_ok());
258        assert!(validate_tool_name("my-tool").is_ok());
259        assert!(validate_tool_name("tool_name").is_ok());
260        assert!(validate_tool_name("").is_err());
261        assert!(validate_tool_name("123tool").is_err());
262        assert!(validate_tool_name("tool@name").is_err());
263    }
264}