rusty_stack/installers/common/
ui_helper.rs1use crate::installers::common::utils;
13use std::path::Path;
14
15#[derive(Debug, Clone, Default)]
29pub struct UiArgs {
30 pub dry_run: bool,
32 pub install_dir: Option<String>,
34 pub force: bool,
36}
37
38impl UiArgs {
39 pub fn parse(args: &[&str]) -> Result<Self, UiArgError> {
52 let mut dry_run = false;
53 let mut install_dir = None;
54 let mut force = false;
55
56 let mut i = 0;
57 while i < args.len() {
58 match args[i] {
59 "--dry-run" => {
60 dry_run = true;
61 }
62 "--force" => {
63 force = true;
64 }
65 "--dir" => {
66 if i + 1 >= args.len() {
67 return Err(UiArgError::MissingValue {
68 arg: "--dir".to_string(),
69 });
70 }
71 i += 1;
72 let dir = args[i];
73
74 if !dir.starts_with('/') {
76 return Err(UiArgError::NotAbsolute {
77 path: dir.to_string(),
78 });
79 }
80
81 let resolved = if Path::new(dir).exists() {
83 std::fs::canonicalize(dir)
84 .map(|p| p.to_string_lossy().to_string())
85 .unwrap_or_else(|_| dir.to_string())
86 } else {
87 dir.to_string()
88 };
89
90 if is_system_path(&resolved) {
92 return Err(UiArgError::SystemPath { path: resolved });
93 }
94
95 install_dir = Some(resolved);
96 }
97 "--help" | "-h" => {
98 return Err(UiArgError::HelpRequested);
99 }
100 _ => {
101 }
103 }
104 i += 1;
105 }
106
107 Ok(Self {
108 dry_run,
109 install_dir,
110 force,
111 })
112 }
113
114 pub fn install_dir_or<'a>(&'a self, default: &'a str) -> &'a str {
116 match &self.install_dir {
117 Some(dir) => dir.as_str(),
118 None => default,
119 }
120 }
121}
122
123#[derive(Debug)]
125pub enum UiArgError {
126 MissingValue {
128 arg: String,
130 },
131 NotAbsolute {
133 path: String,
135 },
136 SystemPath {
138 path: String,
140 },
141 HelpRequested,
143}
144
145impl std::fmt::Display for UiArgError {
146 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
147 match self {
148 Self::MissingValue { arg } => {
149 write!(f, "Error: {arg} requires a path argument")
150 }
151 Self::NotAbsolute { path } => {
152 write!(f, "Error: --dir requires an absolute path (got: {path})")
153 }
154 Self::SystemPath { path } => {
155 write!(f, "Error: --dir targets a system directory: {path}")
156 }
157 Self::HelpRequested => {
158 write!(f, "Usage: [--dry-run] [--dir <path>] [--help]")
159 }
160 }
161 }
162}
163
164impl std::error::Error for UiArgError {}
165
166const BLOCKED_SYSTEM_PATHS: &[&str] = &[
172 "/",
173 "/usr",
174 "/bin",
175 "/sbin",
176 "/etc",
177 "/var",
178 "/boot",
179 "/dev",
180 "/proc",
181 "/sys",
182 "/opt/rocm",
183];
184
185pub fn is_system_path(path: &str) -> bool {
189 let resolved = Path::new(path);
190
191 for blocked in BLOCKED_SYSTEM_PATHS {
192 let blocked_path = Path::new(blocked);
193 if resolved == blocked_path {
194 return true;
195 }
196 }
197
198 false
199}
200
201pub fn git_clone_or_update(install_dir: &str, repo_url: &str, dry_run: bool) -> Result<(), String> {
212 let git_dir = Path::new(install_dir).join(".git");
213
214 if git_dir.exists() {
215 let fetch_cmd = format!("git -C \"{install_dir}\" fetch --all");
217 utils::execute_command(&fetch_cmd, "Fetching latest changes", dry_run)?;
218
219 let target_branch = get_current_branch(install_dir, dry_run);
221
222 let reset_cmd = format!("git -C \"{install_dir}\" reset --hard \"origin/{target_branch}\"");
223 utils::execute_command(&reset_cmd, "Resetting to latest", dry_run)?;
224 } else {
225 let clone_cmd = format!("git clone \"{repo_url}\" \"{install_dir}\"");
227 utils::execute_command(&clone_cmd, "Cloning repository", dry_run)?;
228 }
229
230 Ok(())
231}
232
233fn get_current_branch(repo_dir: &str, dry_run: bool) -> String {
235 if dry_run {
236 return "main".to_string();
237 }
238
239 let output = std::process::Command::new("git")
241 .args(["-C", repo_dir, "symbolic-ref", "refs/remotes/origin/HEAD"])
242 .output();
243
244 if let Ok(out) = output {
245 if out.status.success() {
246 let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
247 if let Some(branch) = stdout.strip_prefix("refs/remotes/origin/") {
248 return branch.to_string();
249 }
250 }
251 }
252
253 let output = std::process::Command::new("git")
255 .args(["-C", repo_dir, "rev-parse", "--abbrev-ref", "HEAD"])
256 .output();
257
258 if let Ok(out) = output {
259 if out.status.success() {
260 let branch = String::from_utf8_lossy(&out.stdout).trim().to_string();
261 if !branch.is_empty() {
262 return branch;
263 }
264 }
265 }
266
267 "main".to_string()
268}
269
270#[cfg(all(unix, feature = "unix-deps"))]
275pub fn fix_ownership(install_dir: &str) -> Result<(), String> {
276 let euid = unsafe { libc::geteuid() };
277 if euid != 0 {
278 return Ok(());
279 }
280
281 let sudo_user = match std::env::var("SUDO_USER") {
282 Ok(v) if !v.is_empty() => v,
283 _ => return Ok(()),
284 };
285
286 let cmd = format!("chown -R {sudo_user}:{sudo_user} \"{install_dir}\"");
287 let result = std::process::Command::new("bash")
288 .arg("-c")
289 .arg(&cmd)
290 .output();
291
292 match result {
293 Ok(output) if output.status.success() => Ok(()),
294 Ok(output) => {
295 let stderr = String::from_utf8_lossy(&output.stderr);
296 Err(format!("Failed to fix ownership: {stderr}"))
297 }
298 Err(e) => Err(format!("Failed to execute chown: {e}")),
299 }
300}
301
302#[cfg(not(all(unix, feature = "unix-deps")))]
303pub fn fix_ownership(_install_dir: &str) -> Result<(), String> {
304 Ok(())
305}
306
307#[cfg(test)]
312mod tests {
313 use super::*;
314
315 #[test]
318 fn test_parse_empty_args() {
319 let args = UiArgs::parse(&[]).unwrap();
320 assert!(!args.dry_run);
321 assert!(args.install_dir.is_none());
322 assert!(!args.force);
323 }
324
325 #[test]
326 fn test_parse_dry_run() {
327 let args = UiArgs::parse(&["--dry-run"]).unwrap();
328 assert!(args.dry_run);
329 }
330
331 #[test]
332 fn test_parse_force() {
333 let args = UiArgs::parse(&["--force"]).unwrap();
334 assert!(args.force);
335 }
336
337 #[test]
338 fn test_parse_dir_valid() {
339 let temp_dir = tempfile::tempdir().unwrap();
340 let temp_path = temp_dir.path().to_string_lossy().to_string();
341 let args = UiArgs::parse(&["--dir", &temp_path]).unwrap();
342 assert!(args.install_dir.is_some());
343 }
344
345 #[test]
346 fn test_parse_dir_nonexistent_absolute() {
347 let args = UiArgs::parse(&["--dir", "/tmp/nonexistent_test_dir_xyz"]).unwrap();
349 assert_eq!(
350 args.install_dir,
351 Some("/tmp/nonexistent_test_dir_xyz".to_string())
352 );
353 }
354
355 #[test]
356 fn test_parse_dir_missing_value() {
357 let result = UiArgs::parse(&["--dir"]);
358 assert!(result.is_err());
359 let err = result.unwrap_err();
360 assert!(matches!(err, UiArgError::MissingValue { .. }));
361 assert!(format!("{err}").contains("--dir requires a path argument"));
362 }
363
364 #[test]
365 fn test_parse_dir_not_absolute() {
366 let result = UiArgs::parse(&["--dir", "relative/path"]);
367 assert!(result.is_err());
368 let err = result.unwrap_err();
369 assert!(matches!(err, UiArgError::NotAbsolute { .. }));
370 }
371
372 #[test]
373 fn test_parse_dir_system_path_root() {
374 let result = UiArgs::parse(&["--dir", "/"]);
375 assert!(result.is_err());
376 let err = result.unwrap_err();
377 assert!(matches!(err, UiArgError::SystemPath { .. }));
378 }
379
380 #[test]
381 fn test_parse_dir_system_path_usr() {
382 let result = UiArgs::parse(&["--dir", "/usr"]);
383 assert!(result.is_err());
384 }
385
386 #[test]
387 fn test_parse_dir_system_path_bin() {
388 let result = is_system_path("/bin");
391 assert!(result);
392 }
393
394 #[test]
395 fn test_parse_dir_system_path_sbin() {
396 let result = is_system_path("/sbin");
398 assert!(result);
399 }
400
401 #[test]
402 fn test_parse_dir_system_path_etc() {
403 let result = UiArgs::parse(&["--dir", "/etc"]);
404 assert!(result.is_err());
405 }
406
407 #[test]
408 fn test_parse_dir_system_path_var() {
409 let result = UiArgs::parse(&["--dir", "/var"]);
410 assert!(result.is_err());
411 }
412
413 #[test]
414 fn test_parse_dir_system_path_rocm() {
415 let result = UiArgs::parse(&["--dir", "/opt/rocm"]);
416 assert!(result.is_err());
417 }
418
419 #[test]
420 fn test_parse_help_long() {
421 let result = UiArgs::parse(&["--help"]);
422 assert!(result.is_err());
423 assert!(matches!(result.unwrap_err(), UiArgError::HelpRequested));
424 }
425
426 #[test]
427 fn test_parse_help_short() {
428 let result = UiArgs::parse(&["-h"]);
429 assert!(result.is_err());
430 assert!(matches!(result.unwrap_err(), UiArgError::HelpRequested));
431 }
432
433 #[test]
434 fn test_parse_combined_args() {
435 let temp_dir = tempfile::tempdir().unwrap();
436 let temp_path = temp_dir.path().to_string_lossy().to_string();
437 let args = UiArgs::parse(&["--dry-run", "--force", "--dir", &temp_path]).unwrap();
438 assert!(args.dry_run);
439 assert!(args.force);
440 assert!(args.install_dir.is_some());
441 }
442
443 #[test]
444 fn test_parse_unknown_args_ignored() {
445 let args = UiArgs::parse(&["--unknown", "value"]).unwrap();
446 assert!(!args.dry_run);
447 }
448
449 #[test]
450 fn test_install_dir_or_default() {
451 let args = UiArgs::parse(&[]).unwrap();
452 assert_eq!(args.install_dir_or("/default"), "/default");
453
454 let temp_dir = tempfile::tempdir().unwrap();
455 let temp_path = temp_dir.path().to_string_lossy().to_string();
456 let args = UiArgs::parse(&["--dir", &temp_path]).unwrap();
457 assert_ne!(args.install_dir_or("/default"), "/default");
458 }
459
460 #[test]
461 fn test_ui_args_default() {
462 let args = UiArgs::default();
463 assert!(!args.dry_run);
464 assert!(args.install_dir.is_none());
465 assert!(!args.force);
466 }
467
468 #[test]
471 fn test_is_system_path_blocked() {
472 assert!(is_system_path("/"));
473 assert!(is_system_path("/usr"));
474 assert!(is_system_path("/bin"));
475 assert!(is_system_path("/sbin"));
476 assert!(is_system_path("/etc"));
477 assert!(is_system_path("/var"));
478 assert!(is_system_path("/boot"));
479 assert!(is_system_path("/dev"));
480 assert!(is_system_path("/proc"));
481 assert!(is_system_path("/sys"));
482 assert!(is_system_path("/opt/rocm"));
483 }
484
485 #[test]
486 fn test_is_system_path_safe() {
487 assert!(!is_system_path("/home/user/apps"));
488 assert!(!is_system_path("/opt/mlstack"));
489 assert!(!is_system_path("/tmp/test"));
490 assert!(!is_system_path("/usr/local/apps/myapp"));
491 }
492
493 #[test]
496 fn test_ui_arg_error_display() {
497 let err = UiArgError::MissingValue {
498 arg: "--dir".to_string(),
499 };
500 assert!(format!("{err}").contains("--dir requires a path argument"));
501
502 let err = UiArgError::NotAbsolute {
503 path: "relative".to_string(),
504 };
505 assert!(format!("{err}").contains("absolute path"));
506
507 let err = UiArgError::SystemPath {
508 path: "/usr".to_string(),
509 };
510 assert!(format!("{err}").contains("system directory"));
511
512 let err = UiArgError::HelpRequested;
513 assert!(format!("{err}").contains("Usage"));
514 }
515
516 #[test]
517 fn test_ui_arg_error_is_std_error() {
518 fn assert_error<E: std::error::Error>(_: &E) {}
519 let err = UiArgError::MissingValue {
520 arg: "test".to_string(),
521 };
522 assert_error(&err);
523 }
524
525 #[test]
528 fn test_git_clone_dry_run() {
529 let result = git_clone_or_update(
531 "/tmp/test_clone_dir",
532 "https://github.com/example/repo.git",
533 true,
534 );
535 assert!(result.is_ok());
536 }
537
538 #[test]
541 fn test_fix_ownership_no_sudo() {
542 let result = fix_ownership("/tmp/test");
544 let _ = result;
546 }
547}