vx_installer/formats/
binary.rs

1//! Binary file handler for direct executable installation
2
3use super::FormatHandler;
4use crate::{progress::ProgressContext, Result};
5use std::path::{Path, PathBuf};
6
7/// Handler for binary files (direct executables)
8pub struct BinaryHandler;
9
10impl BinaryHandler {
11    /// Create a new binary handler
12    pub fn new() -> Self {
13        Self
14    }
15
16    /// Check if a file appears to be a binary executable
17    fn is_likely_binary(&self, file_path: &Path) -> bool {
18        // Check file extension
19        if let Some(ext) = file_path.extension().and_then(|e| e.to_str()) {
20            let ext_lower = ext.to_lowercase();
21
22            // Windows executables
23            if cfg!(windows) && matches!(ext_lower.as_str(), "exe" | "msi" | "bat" | "cmd") {
24                return true;
25            }
26
27            // Known binary extensions
28            if matches!(ext_lower.as_str(), "bin" | "run" | "app") {
29                return true;
30            }
31        }
32
33        // Check if filename suggests it's a binary
34        if let Some(filename) = file_path.file_name().and_then(|n| n.to_str()) {
35            // No extension might indicate a Unix binary
36            if !filename.contains('.') && !cfg!(windows) {
37                return true;
38            }
39        }
40
41        false
42    }
43
44    /// Determine the target executable name for a tool
45    fn get_target_name(&self, tool_name: &str, source_path: &Path) -> String {
46        // If the source already has the correct name, use it
47        if let Some(filename) = source_path.file_name().and_then(|n| n.to_str()) {
48            // Remove extension for comparison
49            let name_without_ext =
50                if let Some(stem) = source_path.file_stem().and_then(|s| s.to_str()) {
51                    stem
52                } else {
53                    filename
54                };
55
56            if name_without_ext.starts_with(tool_name) {
57                // For Windows, ensure .exe extension
58                if cfg!(windows) && !filename.ends_with(".exe") {
59                    return format!("{}.exe", filename);
60                }
61                return filename.to_string();
62            }
63        }
64
65        // Otherwise, use the standard executable name
66        self.get_executable_name(tool_name)
67    }
68}
69
70#[async_trait::async_trait]
71impl FormatHandler for BinaryHandler {
72    fn name(&self) -> &str {
73        "binary"
74    }
75
76    fn can_handle(&self, file_path: &Path) -> bool {
77        // This handler is a fallback for files that don't match other formats
78        // It should be checked last in the handler chain
79        self.is_likely_binary(file_path)
80    }
81
82    async fn extract(
83        &self,
84        source_path: &Path,
85        target_dir: &Path,
86        progress: &ProgressContext,
87    ) -> Result<Vec<PathBuf>> {
88        // For binary files, "extraction" means copying to the bin directory
89        let bin_dir = target_dir.join("bin");
90        std::fs::create_dir_all(&bin_dir)?;
91
92        progress.start("Installing binary", Some(1)).await?;
93
94        // Determine the target filename
95        let tool_name = target_dir
96            .parent()
97            .and_then(|p| p.file_name())
98            .and_then(|n| n.to_str())
99            .unwrap_or("tool");
100
101        let target_name = self.get_target_name(tool_name, source_path);
102        let target_path = bin_dir.join(target_name);
103
104        // Copy the binary
105        std::fs::copy(source_path, &target_path)?;
106
107        // Make it executable
108        self.make_executable(&target_path)?;
109
110        progress.increment(1).await?;
111        progress.finish("Binary installation completed").await?;
112
113        Ok(vec![target_path])
114    }
115}
116
117impl Default for BinaryHandler {
118    fn default() -> Self {
119        Self::new()
120    }
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126    use crate::progress::ProgressContext;
127    use std::io::Write;
128    use tempfile::TempDir;
129
130    #[tokio::test]
131    async fn test_binary_handler_name() {
132        let handler = BinaryHandler::new();
133        assert_eq!(handler.name(), "binary");
134    }
135
136    #[test]
137    fn test_is_likely_binary() {
138        let handler = BinaryHandler::new();
139
140        // Windows executables
141        if cfg!(windows) {
142            assert!(handler.is_likely_binary(Path::new("tool.exe")));
143            assert!(handler.is_likely_binary(Path::new("installer.msi")));
144            assert!(handler.is_likely_binary(Path::new("script.bat")));
145        }
146
147        // Binary extensions
148        assert!(handler.is_likely_binary(Path::new("tool.bin")));
149        assert!(handler.is_likely_binary(Path::new("app.run")));
150
151        // Unix-style binaries (no extension)
152        if !cfg!(windows) {
153            assert!(handler.is_likely_binary(Path::new("node")));
154            assert!(handler.is_likely_binary(Path::new("go")));
155        }
156
157        // Not binaries
158        assert!(!handler.is_likely_binary(Path::new("archive.zip")));
159        assert!(!handler.is_likely_binary(Path::new("source.tar.gz")));
160        assert!(!handler.is_likely_binary(Path::new("readme.txt")));
161    }
162
163    #[test]
164    fn test_get_target_name() {
165        let handler = BinaryHandler::new();
166
167        // Source already has correct name
168        let expected = if cfg!(windows) {
169            "node-v18.17.0.exe"
170        } else {
171            "node-v18.17.0"
172        };
173        assert_eq!(
174            handler.get_target_name("node", Path::new("node-v18.17.0")),
175            expected
176        );
177
178        // Source starts with tool name, should keep original name
179        if cfg!(windows) {
180            assert_eq!(
181                handler.get_target_name("go", Path::new("golang.exe")),
182                "golang.exe" // Should keep original name since "golang" starts with "go"
183            );
184        } else {
185            assert_eq!(handler.get_target_name("go", Path::new("golang")), "golang");
186        }
187
188        // Source doesn't match, use standard name
189        if cfg!(windows) {
190            assert_eq!(
191                handler.get_target_name("go", Path::new("python.exe")),
192                "go.exe" // Should use standard name since "python" doesn't start with "go"
193            );
194        } else {
195            assert_eq!(handler.get_target_name("go", Path::new("python")), "go");
196        }
197    }
198
199    #[tokio::test]
200    async fn test_binary_extraction() {
201        let handler = BinaryHandler::new();
202        let temp_dir = TempDir::new().unwrap();
203        let source_dir = temp_dir.path().join("source");
204        let target_dir = temp_dir.path().join("target").join("tool").join("1.0.0");
205
206        std::fs::create_dir_all(&source_dir).unwrap();
207
208        // Create a mock binary file
209        let source_file = source_dir.join("tool");
210        let mut file = std::fs::File::create(&source_file).unwrap();
211        file.write_all(b"#!/bin/bash\necho 'Hello World'").unwrap();
212
213        let progress = ProgressContext::disabled();
214
215        let result = handler.extract(&source_file, &target_dir, &progress).await;
216        assert!(result.is_ok());
217
218        let extracted_files = result.unwrap();
219        assert_eq!(extracted_files.len(), 1);
220
221        let expected_path =
222            target_dir
223                .join("bin")
224                .join(if cfg!(windows) { "tool.exe" } else { "tool" });
225        assert_eq!(extracted_files[0], expected_path);
226        assert!(expected_path.exists());
227    }
228}