Skip to main content

rusty_stack/installers/components/
vllm_studio.rs

1//! vLLM Studio installer — ports `scripts/install_vllm_studio.sh`.
2//!
3//! Clones the vLLM Studio repository to the correct target directory,
4//! installs controller and frontend dependencies via bun/npm.
5//!
6//! # Validation Assertions
7//!
8//! - **VAL-INSTALL-022**: vLLM Studio correct git clone
9//! - **VAL-INSTALL-025**: App installers correct target directory
10
11use crate::installers::common::command_exists;
12
13// ===========================================================================
14// Types
15// ===========================================================================
16
17/// A constructed shell command.
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct ShellCommand {
20    /// The program to run.
21    pub program: String,
22    /// Arguments to pass.
23    pub args: Vec<String>,
24    /// Environment variables to set.
25    pub env: Vec<(String, String)>,
26}
27
28impl ShellCommand {
29    /// Format as a shell command string.
30    pub fn to_command_string(&self) -> String {
31        let env_prefix = self
32            .env
33            .iter()
34            .map(|(k, v)| format!("{k}={v}"))
35            .collect::<Vec<_>>()
36            .join(" ");
37        let cmd = if self.args.is_empty() {
38            self.program.clone()
39        } else {
40            format!("{} {}", self.program, self.args.join(" "))
41        };
42        if env_prefix.is_empty() {
43            cmd
44        } else {
45            format!("{env_prefix} {cmd}")
46        }
47    }
48}
49
50/// Configuration for the vLLM Studio installer.
51#[derive(Debug, Clone)]
52pub struct VllmStudioConfig {
53    /// Target installation directory.
54    pub install_dir: String,
55    /// Python binary to use.
56    pub python_bin: String,
57    /// Whether to run in dry-run mode.
58    pub dry_run: bool,
59}
60
61impl Default for VllmStudioConfig {
62    fn default() -> Self {
63        Self {
64            install_dir: default_install_dir(),
65            python_bin: "python3".to_string(),
66            dry_run: false,
67        }
68    }
69}
70
71/// vLLM Studio Git repository URL.
72pub const REPO_URL: &str = "https://github.com/0xSero/vllm-studio.git";
73
74/// Default branch for vLLM Studio (uses default from remote).
75pub const DEFAULT_BRANCH: &str = "main";
76
77/// Default vLLM Studio installation directory.
78pub fn default_install_dir() -> String {
79    format!("{}/vllm-studio", std::env::var("HOME").unwrap_or_default())
80}
81
82/// The vLLM Studio installer.
83pub struct VllmStudioInstaller {
84    config: VllmStudioConfig,
85}
86
87impl VllmStudioInstaller {
88    /// Create a new vLLM Studio installer with the given config.
89    pub fn new(config: VllmStudioConfig) -> Self {
90        Self { config }
91    }
92
93    /// Create with default config.
94    pub fn with_defaults() -> Self {
95        Self::new(VllmStudioConfig::default())
96    }
97
98    // -----------------------------------------------------------------------
99    // Command construction (VAL-INSTALL-022, VAL-INSTALL-025)
100    // -----------------------------------------------------------------------
101
102    /// Construct the git clone command for vLLM Studio.
103    ///
104    /// The original script runs:
105    /// `git clone "https://github.com/0xSero/vllm-studio.git" "$VLLM_STUDIO_DIR"`
106    pub fn build_git_clone_command(&self) -> ShellCommand {
107        ShellCommand {
108            program: "git".to_string(),
109            args: vec![
110                "clone".to_string(),
111                REPO_URL.to_string(),
112                self.config.install_dir.clone(),
113            ],
114            env: vec![],
115        }
116    }
117
118    /// Construct the git update commands for an existing clone.
119    ///
120    /// The original script runs:
121    /// 1. `git -C "$DIR" remote set-head origin -a`
122    /// 2. `git -C "$DIR" checkout "$DEFAULT_BRANCH"`
123    /// 3. `git -C "$DIR" fetch --all`
124    /// 4. `git -C "$DIR" reset --hard "origin/$DEFAULT_BRANCH"`
125    pub fn build_git_update_commands(&self) -> Vec<ShellCommand> {
126        let dir = &self.config.install_dir;
127        vec![
128            ShellCommand {
129                program: "git".to_string(),
130                args: vec![
131                    "-C".to_string(),
132                    dir.clone(),
133                    "remote".to_string(),
134                    "set-head".to_string(),
135                    "origin".to_string(),
136                    "-a".to_string(),
137                ],
138                env: vec![],
139            },
140            ShellCommand {
141                program: "git".to_string(),
142                args: vec![
143                    "-C".to_string(),
144                    dir.clone(),
145                    "checkout".to_string(),
146                    DEFAULT_BRANCH.to_string(),
147                ],
148                env: vec![],
149            },
150            ShellCommand {
151                program: "git".to_string(),
152                args: vec![
153                    "-C".to_string(),
154                    dir.clone(),
155                    "fetch".to_string(),
156                    "--all".to_string(),
157                ],
158                env: vec![],
159            },
160            ShellCommand {
161                program: "git".to_string(),
162                args: vec![
163                    "-C".to_string(),
164                    dir.clone(),
165                    "reset".to_string(),
166                    "--hard".to_string(),
167                    format!("origin/{DEFAULT_BRANCH}"),
168                ],
169                env: vec![],
170            },
171        ]
172    }
173
174    /// Detect which package manager to use (bun preferred, npm fallback).
175    pub fn detect_pkg_manager(&self) -> Option<String> {
176        if command_exists("bun") {
177            Some("bun".to_string())
178        } else if command_exists("npm") {
179            Some("npm".to_string())
180        } else {
181            None
182        }
183    }
184
185    /// Construct the package install command for controller/frontend.
186    pub fn build_pkg_install_command(&self, pkg_mgr: &str) -> ShellCommand {
187        ShellCommand {
188            program: pkg_mgr.to_string(),
189            args: vec!["install".to_string()],
190            env: vec![],
191        }
192    }
193
194    /// Construct the frontend build command.
195    pub fn build_frontend_build_command(&self, pkg_mgr: &str) -> ShellCommand {
196        ShellCommand {
197            program: pkg_mgr.to_string(),
198            args: vec!["run".to_string(), "build".to_string()],
199            env: vec![],
200        }
201    }
202
203    /// Construct the shim creation command.
204    ///
205    /// Creates a shell script at `/usr/local/bin/vllm-studio` that
206    /// runs `cd "$DIR/controller" && $PKG_MGR run start`.
207    pub fn build_shim_content(&self, pkg_mgr: &str) -> String {
208        format!(
209            "#!/bin/bash\ncd \"{}/controller\" && {} run start\n",
210            self.config.install_dir, pkg_mgr,
211        )
212    }
213
214    /// Get the install directory.
215    pub fn install_dir(&self) -> &str {
216        &self.config.install_dir
217    }
218
219    /// Get the repository URL.
220    pub fn repo_url(&self) -> &str {
221        REPO_URL
222    }
223
224    /// Get the default branch.
225    pub fn default_branch(&self) -> &str {
226        DEFAULT_BRANCH
227    }
228}
229
230// ===========================================================================
231// Tests
232// ===========================================================================
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237
238    fn make_installer(dir: &str) -> VllmStudioInstaller {
239        VllmStudioInstaller::new(VllmStudioConfig {
240            install_dir: dir.to_string(),
241            python_bin: "python3".to_string(),
242            dry_run: false,
243        })
244    }
245
246    // -----------------------------------------------------------------------
247    // VAL-INSTALL-022: vLLM Studio correct git clone
248    // -----------------------------------------------------------------------
249
250    #[test]
251    fn test_git_clone_url_matches_original_script() {
252        let installer = make_installer("/home/user/vllm-studio");
253        let cmd = installer.build_git_clone_command();
254        assert_eq!(cmd.program, "git");
255        assert!(cmd.args.contains(&"clone".to_string()));
256        assert!(cmd.args.contains(&REPO_URL.to_string()));
257        assert!(cmd.args.contains(&"/home/user/vllm-studio".to_string()));
258    }
259
260    #[test]
261    fn test_git_clone_uses_correct_repo_url() {
262        assert_eq!(
263            REPO_URL, "https://github.com/0xSero/vllm-studio.git",
264            "URL must match install_vllm_studio.sh REPO_URL"
265        );
266    }
267
268    #[test]
269    fn test_git_clone_command_string() {
270        let installer = make_installer("/home/user/vllm-studio");
271        let cmd = installer.build_git_clone_command();
272        let s = cmd.to_command_string();
273        assert!(s.contains("git clone"));
274        assert!(s.contains(REPO_URL));
275        assert!(s.contains("/home/user/vllm-studio"));
276    }
277
278    // -----------------------------------------------------------------------
279    // VAL-INSTALL-025: App installers correct target directory
280    // -----------------------------------------------------------------------
281
282    #[test]
283    fn test_default_install_dir_is_home_vllm_studio() {
284        let config = VllmStudioConfig::default();
285        assert!(
286            config.install_dir.ends_with("/vllm-studio"),
287            "Default dir must end with /vllm-studio, got: {}",
288            config.install_dir,
289        );
290    }
291
292    #[test]
293    fn test_custom_install_dir() {
294        let installer = make_installer("/opt/apps/vllm-studio");
295        assert_eq!(installer.install_dir(), "/opt/apps/vllm-studio");
296    }
297
298    #[test]
299    fn test_git_update_commands_reference_correct_dir() {
300        let installer = make_installer("/home/test/vllm-studio");
301        let cmds = installer.build_git_update_commands();
302        assert_eq!(cmds.len(), 4);
303        // All commands should reference the install dir
304        for cmd in &cmds {
305            assert!(
306                cmd.args.contains(&"/home/test/vllm-studio".to_string()),
307                "Command {} should reference install dir",
308                cmd.to_command_string(),
309            );
310        }
311    }
312
313    #[test]
314    fn test_pkg_install_command_bun() {
315        let installer = make_installer("/home/user/vllm-studio");
316        let cmd = installer.build_pkg_install_command("bun");
317        assert_eq!(cmd.program, "bun");
318        assert_eq!(cmd.args, vec!["install"]);
319    }
320
321    #[test]
322    fn test_pkg_install_command_npm() {
323        let installer = make_installer("/home/user/vllm-studio");
324        let cmd = installer.build_pkg_install_command("npm");
325        assert_eq!(cmd.program, "npm");
326        assert_eq!(cmd.args, vec!["install"]);
327    }
328
329    #[test]
330    fn test_frontend_build_command() {
331        let installer = make_installer("/home/user/vllm-studio");
332        let cmd = installer.build_frontend_build_command("npm");
333        assert_eq!(cmd.program, "npm");
334        assert!(cmd.args.contains(&"run".to_string()));
335        assert!(cmd.args.contains(&"build".to_string()));
336    }
337
338    #[test]
339    fn test_shim_content_references_controller_dir() {
340        let installer = make_installer("/home/user/vllm-studio");
341        let shim = installer.build_shim_content("npm");
342        assert!(shim.contains("/home/user/vllm-studio/controller"));
343        assert!(shim.contains("npm run start"));
344        assert!(shim.starts_with("#!/bin/bash"));
345    }
346
347    #[test]
348    fn test_default_branch_is_main() {
349        assert_eq!(DEFAULT_BRANCH, "main");
350    }
351}