From 05ad806005dcfd61b30853579b6100a27d902f38 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Fri, 3 Jul 2026 14:36:03 -0400 Subject: [PATCH 01/11] chore: move clone_mono_repos to repository module as clone_repos, rename clone_repository to clone_repo --- src/commands/mono/clone.rs | 37 --------------------- src/commands/mono/mod.rs | 2 -- src/commands/mono/mode.rs | 5 ++- src/commands/single.rs | 4 +-- src/repository.rs | 33 ++++++++++++++++++- tests/commands/mono.rs | 2 -- tests/commands/mono/clone.rs | 46 -------------------------- tests/repository.rs | 63 ++++++++++++++++++++++++++++++++---- 8 files changed, 93 insertions(+), 99 deletions(-) delete mode 100644 src/commands/mono/clone.rs delete mode 100644 tests/commands/mono/clone.rs diff --git a/src/commands/mono/clone.rs b/src/commands/mono/clone.rs deleted file mode 100644 index 1a595fd..0000000 --- a/src/commands/mono/clone.rs +++ /dev/null @@ -1,37 +0,0 @@ -use crate::{ctx::RunCtx, repository::clone_repository}; - -/// Clones all repositories into the mono-repo directory. -/// # Errors -/// Returns an error if any repository fails to clone. -pub fn clone_mono_repos( - repos: &[String], - repos_path: &std::path::Path, - ssh: bool, - ctx: &mut RunCtx<'_, '_>, -) -> Result<(), String> { - writeln!(ctx.io.output, "Cloning repositories").ok(); - crate::time!(ctx.flags.timing, ctx.io.output, "Clone", { - for repo in repos { - if ctx.flags.verbose { - writeln!( - ctx.io.output, - " Cloning {}", - crate::repository::repo_dir_name(repo) - ) - .ok(); - } - clone_repository(repo, repos_path, ssh, true, false, ctx)?; - } - if ctx.flags.verbose { - writeln!( - ctx.io.output, - " Finished cloning ({} repositories)", - repos.len() - ) - .ok(); - } - Ok::<(), String>(()) - })?; - writeln!(ctx.io.output).ok(); - Ok(()) -} diff --git a/src/commands/mono/mod.rs b/src/commands/mono/mod.rs index 1cd8f72..d271f15 100644 --- a/src/commands/mono/mod.rs +++ b/src/commands/mono/mod.rs @@ -1,5 +1,3 @@ -pub mod clone; -pub use clone::clone_mono_repos; pub mod config; pub use config::{ create_mono_repo_cmakelists, create_mono_repo_mesonbuild, create_mono_repo_package_json, diff --git a/src/commands/mono/mode.rs b/src/commands/mono/mode.rs index b7265a7..169974b 100644 --- a/src/commands/mono/mode.rs +++ b/src/commands/mono/mode.rs @@ -3,7 +3,6 @@ use crate::{ commands::{ build_project, build_repo_list, extract_repo_input, mono::{ - clone_mono_repos, display::{resolve_setup_paths, SetupPaths}, generate_mono_config, generate_watch_scripts, open_watch_scripts, print_setup_complete, }, @@ -11,7 +10,7 @@ use crate::{ }, config::SetupConfig, ctx::RunCtx, - repository::repo_dir_name, + repository::{clone_repos, repo_dir_name}, utils::{dry_run::detect_or_dry_run, dry_run_or_do}, }; use std::{ @@ -70,7 +69,7 @@ pub fn mono_repo_mode( writeln!(ctx.io.output).ok(); } - clone_mono_repos(&repos, &repos_path, args.connection.ssh, ctx)?; + clone_repos(&repos, &repos_path, args.connection.ssh, ctx)?; let repo_dirs: Vec = repos .iter() diff --git a/src/commands/single.rs b/src/commands/single.rs index 46f3a0e..6cef2d0 100644 --- a/src/commands/single.rs +++ b/src/commands/single.rs @@ -2,7 +2,7 @@ use crate::{ cli::{detect_build_system, BuildSystem, ResolvedArgs}, commands::{build_project, extract_repo_input, prepare_build_dir, print_mode_header, ModeHeader}, ctx::RunCtx, - repository::{clone_repository, repo_dir_name}, + repository::{clone_repo, repo_dir_name}, utils::dry_run::detect_or_dry_run, }; use std::path::Path; @@ -35,7 +35,7 @@ pub fn single_repo_mode( ); writeln!(ctx.io.output, "Cloning repository").ok(); - clone_repository(repo, base_dir, args.connection.ssh, false, args.yes, ctx)?; + clone_repo(repo, base_dir, args.connection.ssh, false, args.yes, ctx)?; writeln!(ctx.io.output).ok(); let build_path = repo_path.join(&args.build.build_dir); diff --git a/src/repository.rs b/src/repository.rs index e50b8d8..e8577b0 100644 --- a/src/repository.rs +++ b/src/repository.rs @@ -37,7 +37,7 @@ pub fn resolve_repo_url(repo_input: &str, use_ssh: bool) -> String { /// Skips if the repository already exists. /// # Errors /// Returns an error if the git clone command fails -pub fn clone_repository( +pub fn clone_repo( repo_path: &str, target_dir: &Path, use_ssh: bool, @@ -75,6 +75,37 @@ pub fn clone_repository( Ok(()) } +/// Clones all repositories into the given directory. +/// # Errors +/// Returns an error if any repository fails to clone. +pub fn clone_repos( + repos: &[String], + target_dir: &Path, + ssh: bool, + ctx: &mut RunCtx<'_, '_>, +) -> Result<(), String> { + writeln!(ctx.io.output, "Cloning repositories").ok(); + crate::time!(ctx.flags.timing, ctx.io.output, "Clone", { + for repo in repos { + if ctx.flags.verbose { + writeln!(ctx.io.output, " Cloning {}", repo_dir_name(repo)).ok(); + } + clone_repo(repo, target_dir, ssh, true, false, ctx)?; + } + if ctx.flags.verbose { + writeln!( + ctx.io.output, + " Finished cloning ({} repositories)", + repos.len() + ) + .ok(); + } + Ok::<(), String>(()) + })?; + writeln!(ctx.io.output).ok(); + Ok(()) +} + /// Pulls the latest changes for an existing repository. /// # Errors /// Returns an error if the `git pull` command fails. diff --git a/tests/commands/mono.rs b/tests/commands/mono.rs index d4c14e2..d44f962 100644 --- a/tests/commands/mono.rs +++ b/tests/commands/mono.rs @@ -1,5 +1,3 @@ -#[path = "mono/clone.rs"] -mod clone; #[path = "mono/config.rs"] mod config; #[path = "mono/mode.rs"] diff --git a/tests/commands/mono/clone.rs b/tests/commands/mono/clone.rs deleted file mode 100644 index a7d7447..0000000 --- a/tests/commands/mono/clone.rs +++ /dev/null @@ -1,46 +0,0 @@ -use super::super::common::{with_ctx, with_ctx_runner, MockRunner}; -use star_setup::commands::mono::clone::clone_mono_repos; - -#[test] -fn test_clone_mono_repos_calls_clone_for_each_repo() { - let runner = with_ctx_runner(MockRunner::new(), |tmp_path, ctx| { - let repos = vec!["user/repo1".to_string(), "user/repo2".to_string()]; - clone_mono_repos(&repos, tmp_path, false, ctx).unwrap(); - }); - - assert_eq!(runner.calls.len(), 2); - assert!(runner - .calls - .iter() - .all(|(cmd, _)| cmd[0] == "git" && cmd[1] == "clone")); -} - -#[test] -fn test_clone_mono_repos_empty() { - let runner = with_ctx_runner(MockRunner::new(), |tmp_path, ctx| { - clone_mono_repos(&[], tmp_path, false, ctx).unwrap(); - }); - - assert!(runner.calls.is_empty()); -} - -#[test] -fn test_clone_mono_repos_per_repo_lines_require_verbose() { - let repos = vec!["user/repo1".to_string(), "user/repo2".to_string()]; - - let (_, output) = with_ctx(MockRunner::new(), |tmp_path, ctx| { - clone_mono_repos(&repos, tmp_path, false, ctx).unwrap(); - }); - let out = String::from_utf8(output).unwrap(); - assert!(out.contains("Cloning repositories")); - assert!(!out.contains("Cloning user-repo1")); - assert!(!out.contains("Finished cloning (2 repositories)")); - - let (_, output) = with_ctx(MockRunner::new(), |tmp_path, ctx| { - ctx.flags.verbose = true; - clone_mono_repos(&repos, tmp_path, false, ctx).unwrap(); - }); - let out = String::from_utf8(output).unwrap(); - assert!(out.contains("Cloning user-repo1")); - assert!(out.contains("Finished cloning (2 repositories)")); -} diff --git a/tests/repository.rs b/tests/repository.rs index 7775c77..ca1d446 100644 --- a/tests/repository.rs +++ b/tests/repository.rs @@ -2,9 +2,14 @@ mod common; use common::{with_ctx_runner, MockRunner}; use star_setup::{ ctx::ProcessRunner, - repository::{clone_repository, repo_dir_name, resolve_repo_url}, + repository::{clone_repo, clone_repos, repo_dir_name, resolve_repo_url}, }; +use std::fs; +use tempfile::TempDir; +use crate::common::with_ctx; + +/* =========== REPO_DIR_NAME ========== */ #[test] fn test_repo_dir_name() { let cases = [ @@ -30,6 +35,7 @@ fn test_repo_dir_name_no_owner() { assert_eq!(repo_dir_name("repo"), "repo"); } +/* =========== RESOLVE_REPO_URL ========== */ #[test] fn test_resolve_repo_url() { let cases = vec![ @@ -64,24 +70,25 @@ fn test_resolve_repo_url() { } } +/* =========== CLONE_REPOSITORY ========== */ #[test] fn test_clone_skips_existing_directory() { with_ctx_runner(ProcessRunner, |tmp_path, ctx| { let repo_dir = tmp_path.join("owner-repo"); - std::fs::create_dir_all(&repo_dir).unwrap(); + fs::create_dir_all(&repo_dir).unwrap(); - let result = clone_repository("owner/repo", tmp_path, false, true, false, ctx); + let result = clone_repo("owner/repo", tmp_path, false, true, false, ctx); assert!(result.is_ok()); assert!(repo_dir.exists()); }); } #[test] -fn test_clone_repository_calls_git_clone() { - let tmp = tempfile::TempDir::new().unwrap(); +fn test_clone_repo_calls_git_clone() { + let tmp = TempDir::new().unwrap(); let runner = with_ctx_runner(MockRunner::new(), |_, ctx| { - clone_repository("user/repo", tmp.path(), false, true, false, ctx).unwrap(); + clone_repo("user/repo", tmp.path(), false, true, false, ctx).unwrap(); }); assert_eq!(runner.calls.len(), 1); @@ -91,3 +98,47 @@ fn test_clone_repository_calls_git_clone() { assert!(cmd[2].contains("user/repo")); assert_eq!(cwd.as_deref(), Some(tmp.path())); } + +#[test] +fn test_clone_repos_calls_clone_for_each_repo() { + let runner = with_ctx_runner(MockRunner::new(), |tmp_path, ctx| { + let repos = vec!["user/repo1".to_string(), "user/repo2".to_string()]; + clone_repos(&repos, tmp_path, false, ctx).unwrap(); + }); + + assert_eq!(runner.calls.len(), 2); + assert!(runner + .calls + .iter() + .all(|(cmd, _)| cmd[0] == "git" && cmd[1] == "clone")); +} + +#[test] +fn test_clone_repos_empty() { + let runner = with_ctx_runner(MockRunner::new(), |tmp_path, ctx| { + clone_repos(&[], tmp_path, false, ctx).unwrap(); + }); + + assert!(runner.calls.is_empty()); +} + +#[test] +fn test_clone_repos_per_repo_lines_require_verbose() { + let repos = vec!["user/repo1".to_string(), "user/repo2".to_string()]; + + let (_, output) = with_ctx(MockRunner::new(), |tmp_path, ctx| { + clone_repos(&repos, tmp_path, false, ctx).unwrap(); + }); + let out = String::from_utf8(output).unwrap(); + assert!(out.contains("Cloning repositories")); + assert!(!out.contains("Cloning user-repo1")); + assert!(!out.contains("Finished cloning (2 repositories)")); + + let (_, output) = with_ctx(MockRunner::new(), |tmp_path, ctx| { + ctx.flags.verbose = true; + clone_repos(&repos, tmp_path, false, ctx).unwrap(); + }); + let out = String::from_utf8(output).unwrap(); + assert!(out.contains("Cloning user-repo1")); + assert!(out.contains("Finished cloning (2 repositories)")); +} From e6dbab47113217981b1ef4ca845f80d3d8d02cb1 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Fri, 3 Jul 2026 15:16:18 -0400 Subject: [PATCH 02/11] chore: split prompts into ask, confirm, helper modules --- src/{prompts.rs => prompts/ask.rs} | 38 +-------------------- src/prompts/confirm.rs | 23 +++++++++++++ src/prompts/helper.rs | 15 ++++++++ src/prompts/mod.rs | 6 ++++ tests/prompts.rs | 55 +++--------------------------- tests/prompts/ask.rs | 23 +++++++++++++ tests/prompts/confirm.rs | 30 ++++++++++++++++ 7 files changed, 103 insertions(+), 87 deletions(-) rename src/{prompts.rs => prompts/ask.rs} (67%) create mode 100644 src/prompts/confirm.rs create mode 100644 src/prompts/helper.rs create mode 100644 src/prompts/mod.rs create mode 100644 tests/prompts/ask.rs create mode 100644 tests/prompts/confirm.rs diff --git a/src/prompts.rs b/src/prompts/ask.rs similarity index 67% rename from src/prompts.rs rename to src/prompts/ask.rs index dd2f7f1..13d6a8b 100644 --- a/src/prompts.rs +++ b/src/prompts/ask.rs @@ -1,18 +1,4 @@ -//! Interactive prompt helpers. - -use crate::ctx::IoCtx; - -/// Internal helper to print a prompt, flush, and read a trimmed line of input. -fn read_input_line(prompt: &str, io: &mut IoCtx<'_>) -> Result { - write!(io.output, "{prompt}").ok(); - io.output.flush().ok(); - - let mut line = String::new(); - if io.input.read_line(&mut line).unwrap_or(0) == 0 { - return Err("unexpected end of input".to_string()); - } - Ok(line.trim().to_string()) -} +use crate::{ctx::IoCtx, prompts::read_input_line}; /// Prompts the user for a required string value. /// # Errors @@ -87,25 +73,3 @@ pub fn ask_required(prompt: &str, io: &mut IoCtx<'_>) -> Result } } } - -/// Returns `true` if `yes` is set or the user enters `y`/`Y`. -/// # Errors -/// Returns an error if stdin reaches EOF unexpectedly. -pub fn confirm(prompt: &str, yes: bool, io: &mut IoCtx<'_>) -> Result { - if yes { - return Ok(true); - } - let input = read_input_line(&format!("{prompt} (y/n): "), io)?; - Ok(input.eq_ignore_ascii_case("y")) -} - -/// Prompts the user to confirm or abort an option. -/// # Errors -/// Returns an error if stdin reaches EOF unexpecedly. -pub fn confirm_abort(warning_msg: &str, yes: bool, io: &mut IoCtx<'_>) -> Result { - if !confirm(warning_msg, yes, io)? { - writeln!(io.output, " Aborted.").ok(); - return Ok(false); - } - Ok(true) -} diff --git a/src/prompts/confirm.rs b/src/prompts/confirm.rs new file mode 100644 index 0000000..ea63886 --- /dev/null +++ b/src/prompts/confirm.rs @@ -0,0 +1,23 @@ +use crate::{ctx::IoCtx, prompts::read_input_line}; + +/// Returns `true` if `yes` is set or the user enters `y`/`Y`. +/// # Errors +/// Returns an error if stdin reaches EOF unexpectedly. +pub fn confirm(prompt: &str, yes: bool, io: &mut IoCtx<'_>) -> Result { + if yes { + return Ok(true); + } + let input = read_input_line(&format!("{prompt} (y/n): "), io)?; + Ok(input.eq_ignore_ascii_case("y")) +} + +/// Prompts the user to confirm or abort an option. +/// # Errors +/// Returns an error if stdin reaches EOF unexpecedly. +pub fn confirm_abort(warning_msg: &str, yes: bool, io: &mut IoCtx<'_>) -> Result { + if !confirm(warning_msg, yes, io)? { + writeln!(io.output, " Aborted.").ok(); + return Ok(false); + } + Ok(true) +} diff --git a/src/prompts/helper.rs b/src/prompts/helper.rs new file mode 100644 index 0000000..3858703 --- /dev/null +++ b/src/prompts/helper.rs @@ -0,0 +1,15 @@ +use crate::ctx::IoCtx; + +/// Internal helper to print a prompt, flush, and read a trimmed line of input. +/// # Errors +/// Returns an error if an unexpected EOF is encountered. +pub fn read_input_line(prompt: &str, io: &mut IoCtx<'_>) -> Result { + write!(io.output, "{prompt}").ok(); + io.output.flush().ok(); + + let mut line = String::new(); + if io.input.read_line(&mut line).unwrap_or(0) == 0 { + return Err("unexpected end of input".to_string()); + } + Ok(line.trim().to_string()) +} diff --git a/src/prompts/mod.rs b/src/prompts/mod.rs new file mode 100644 index 0000000..02bcbe7 --- /dev/null +++ b/src/prompts/mod.rs @@ -0,0 +1,6 @@ +pub mod ask; +pub use ask::{ask, ask_bool_if, ask_choice, ask_default, ask_required, ask_yesno}; +pub mod confirm; +pub use confirm::{confirm, confirm_abort}; +pub mod helper; +pub use helper::read_input_line; diff --git a/tests/prompts.rs b/tests/prompts.rs index 1255811..b27e073 100644 --- a/tests/prompts.rs +++ b/tests/prompts.rs @@ -1,52 +1,7 @@ -use star_setup::prompts::{ask, ask_default, ask_yesno, confirm}; +#[path = "common/mod.rs"] mod common; -use common::with_io_input; -#[test] -fn test_ask_errors_on_eof() { - assert!(with_io_input(b"", |io| ask("prompt", io)).is_err()); -} - -#[test] -fn test_ask_default_errors_on_eof() { - assert!(with_io_input(b"", |io| ask_default("prompt", "default", io)).is_err()); -} - -#[test] -fn test_ask_yesno_errors_on_eof() { - assert!(with_io_input(b"", |io| ask_yesno("prompt", true, io)).is_err()); -} - -#[test] -fn test_ask_default_returns_input_when_not_empty() { - let result = with_io_input(b"custom\n", |io| ask_default("prompt", "default", io)); - assert_eq!(result.unwrap(), "custom"); -} - -#[test] -fn test_confirm_input_cases() { - let cases = [ - (b"y\n" as &[u8], true, "y accepts"), - (b"Y\n", true, "Y accepts"), - (b" y \n", true, "padded y accepts"), - (b"n\n", false, "n rejects"), - (b"yes\n", false, "yes rejects"), - ]; - - for (input, expected, name) in cases { - let result = with_io_input(input, |io| confirm("prompt", false, io)); - assert_eq!(result.unwrap(), expected, "Failed: {name}"); - } -} - -#[test] -fn test_confirm_yes_flag_returns_true() { - assert!(with_io_input(b"", |io| confirm("prompt", true, io)).unwrap()); -} - -#[test] -fn test_confirm_errors_on_eof() { - let result = with_io_input(b"", |io| confirm("prompt", false, io)); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("unexpected end of input")); -} +#[path = "prompts/ask.rs"] +mod ask; +#[path = "prompts/confirm.rs"] +mod confirm; diff --git a/tests/prompts/ask.rs b/tests/prompts/ask.rs new file mode 100644 index 0000000..9163d99 --- /dev/null +++ b/tests/prompts/ask.rs @@ -0,0 +1,23 @@ +use star_setup::prompts::{ask, ask_default, ask_yesno}; +use crate::common::with_io_input; + +#[test] +fn test_ask_errors_on_eof() { + assert!(with_io_input(b"", |io| ask("prompt", io)).is_err()); +} + +#[test] +fn test_ask_default_errors_on_eof() { + assert!(with_io_input(b"", |io| ask_default("prompt", "default", io)).is_err()); +} + +#[test] +fn test_ask_yesno_errors_on_eof() { + assert!(with_io_input(b"", |io| ask_yesno("prompt", true, io)).is_err()); +} + +#[test] +fn test_ask_default_returns_input_when_not_empty() { + let result = with_io_input(b"custom\n", |io| ask_default("prompt", "default", io)); + assert_eq!(result.unwrap(), "custom"); +} diff --git a/tests/prompts/confirm.rs b/tests/prompts/confirm.rs new file mode 100644 index 0000000..e2c639f --- /dev/null +++ b/tests/prompts/confirm.rs @@ -0,0 +1,30 @@ +use star_setup::prompts::confirm; +use crate::common::with_io_input; + +#[test] +fn test_confirm_input_cases() { + let cases = [ + (b"y\n" as &[u8], true, "y accepts"), + (b"Y\n", true, "Y accepts"), + (b" y \n", true, "padded y accepts"), + (b"n\n", false, "n rejects"), + (b"yes\n", false, "yes rejects"), + ]; + + for (input, expected, name) in cases { + let result = with_io_input(input, |io| confirm("prompt", false, io)); + assert_eq!(result.unwrap(), expected, "Failed: {name}"); + } +} + +#[test] +fn test_confirm_yes_flag_returns_true() { + assert!(with_io_input(b"", |io| confirm("prompt", true, io)).unwrap()); +} + +#[test] +fn test_confirm_errors_on_eof() { + let result = with_io_input(b"", |io| confirm("prompt", false, io)); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("unexpected end of input")); +} From f268f8fd7732c53ef80ddc0e171e5df299eaf288 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Fri, 3 Jul 2026 15:26:04 -0400 Subject: [PATCH 03/11] feat: add confirm_batch prompt primitive --- src/prompts/confirm.rs | 28 ++++++++++++++++++++++++++++ src/prompts/helper.rs | 2 +- tests/prompts/ask.rs | 2 +- tests/prompts/confirm.rs | 2 +- 4 files changed, 31 insertions(+), 3 deletions(-) diff --git a/src/prompts/confirm.rs b/src/prompts/confirm.rs index ea63886..d7206ca 100644 --- a/src/prompts/confirm.rs +++ b/src/prompts/confirm.rs @@ -21,3 +21,31 @@ pub fn confirm_abort(warning_msg: &str, yes: bool, io: &mut IoCtx<'_>) -> Result } Ok(true) } + +pub enum BatchConfirm { + Yes, + No, + YesAll, + NoAll, +} + +/// Prompts y/n, plus (when `allow_all`) shortcuts to apply the answer to all remaining items. +/// # Errors +/// Returns an error if stdin reaches EOF unexpectedly. +pub fn confirm_batch( + prompt: &str, + allow_all: bool, + io: &mut IoCtx<'_>, +) -> Result { + let suffix = if allow_all { " (y/n/a/s)" } else { " (y/n)" }; + loop { + let input = read_input_line(&format!("{prompt}{suffix}: "), io)?; + match input.to_lowercase().as_str() { + "y" => return Ok(BatchConfirm::Yes), + "n" | "" => return Ok(BatchConfirm::No), + "a" if allow_all => return Ok(BatchConfirm::YesAll), + "s" if allow_all => return Ok(BatchConfirm::NoAll), + _ => {} + } + } +} diff --git a/src/prompts/helper.rs b/src/prompts/helper.rs index 3858703..1552fd3 100644 --- a/src/prompts/helper.rs +++ b/src/prompts/helper.rs @@ -2,7 +2,7 @@ use crate::ctx::IoCtx; /// Internal helper to print a prompt, flush, and read a trimmed line of input. /// # Errors -/// Returns an error if an unexpected EOF is encountered. +/// Returns an error if stdin reaches EOF unexpectedly. pub fn read_input_line(prompt: &str, io: &mut IoCtx<'_>) -> Result { write!(io.output, "{prompt}").ok(); io.output.flush().ok(); diff --git a/tests/prompts/ask.rs b/tests/prompts/ask.rs index 9163d99..f71e3f5 100644 --- a/tests/prompts/ask.rs +++ b/tests/prompts/ask.rs @@ -1,5 +1,5 @@ -use star_setup::prompts::{ask, ask_default, ask_yesno}; use crate::common::with_io_input; +use star_setup::prompts::{ask, ask_default, ask_yesno}; #[test] fn test_ask_errors_on_eof() { diff --git a/tests/prompts/confirm.rs b/tests/prompts/confirm.rs index e2c639f..5abad60 100644 --- a/tests/prompts/confirm.rs +++ b/tests/prompts/confirm.rs @@ -1,5 +1,5 @@ -use star_setup::prompts::confirm; use crate::common::with_io_input; +use star_setup::prompts::confirm; #[test] fn test_confirm_input_cases() { From 6b3232afdbaeacaf6a3df514c21882498da2fdf2 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Fri, 3 Jul 2026 16:43:42 -0400 Subject: [PATCH 04/11] feat: offer to remove and re-clone invalid repository directories --- src/repository.rs | 76 ++++++++++++++++++++++++++++--------- tests/commands/mono/mode.rs | 18 +++++---- tests/commands/single.rs | 15 ++++---- tests/repository.rs | 4 +- 4 files changed, 80 insertions(+), 33 deletions(-) diff --git a/src/repository.rs b/src/repository.rs index e8577b0..c6a504d 100644 --- a/src/repository.rs +++ b/src/repository.rs @@ -1,7 +1,13 @@ //! Repository functions including cloning and URL resolution. use crate::{ctx::RunCtx, prompts::confirm}; -use std::path::Path; +use std::{fs, path::Path}; + +/// Returns `true` if the directory contains a `.git` entry. +#[must_use] +pub fn is_git_repo(dir: &Path) -> bool { + dir.join(".git").exists() +} /// Converts a repository path or URL to a local directory name (`owner-repo`). #[must_use] @@ -33,10 +39,14 @@ pub fn resolve_repo_url(repo_input: &str, use_ssh: bool) -> String { } } +fn is_dir_empty(dir: &Path) -> bool { + fs::read_dir(dir).is_ok_and(|mut entries| entries.next().is_none()) +} + /// Clones a single repository into the target directory. /// Skips if the repository already exists. /// # Errors -/// Returns an error if the git clone command fails +/// Returns an error if the git clone command fails or an existing invalid directory cannot be removed. pub fn clone_repo( repo_path: &str, target_dir: &Path, @@ -48,30 +58,62 @@ pub fn clone_repo( let repo_name = repo_dir_name(repo_path); let repo_dir = target_dir.join(&repo_name); - if repo_dir.exists() { + if repo_dir.exists() && is_git_repo(&repo_dir) { writeln!(ctx.io.output, " Repository already exists").ok(); if !on_exists_skip && confirm(" Update existing repository?", yes, &mut ctx.io)? { crate::time!(ctx.flags.timing, ctx.io.output, "Update", { pull_repository(&repo_dir, ctx)?; }); } - } else { - let repo_url = resolve_repo_url(repo_path, use_ssh); - crate::time!(ctx.flags.timing, ctx.io.output, "Clone", { - ctx.runner.run( - &["git", "clone", &repo_url, &repo_name], - Some(target_dir), - ctx.flags, + return Ok(()); + } + + if repo_dir.exists() && !is_dir_empty(&repo_dir) { + writeln!( + ctx.io.output, + " Directory exists but is not a git repository" + ) + .ok(); + if !confirm( + &format!( + " Remove it and re-clone? WARNING: This will delete all files in {}", + repo_dir.display() + ), + false, + &mut ctx.io, + )? { + writeln!(ctx.io.output, " Skipping {repo_name}").ok(); + return Ok(()); + } + + if ctx.flags.dry_run { + writeln!( ctx.io.output, - )?; - if ctx.flags.verbose { - writeln!(ctx.io.output, " Finished cloning {repo_name}").ok(); - } - Ok::<(), String>(()) - }) - .map_err(|e| format!("Failed to clone {repo_path}: {e}"))?; + " Would remove directory: {}", + repo_dir.display() + ) + .ok(); + } else { + fs::remove_dir_all(&repo_dir) + .map_err(|e| format!("Failed to remove {}: {e}", repo_dir.display()))?; + } } + let repo_url = resolve_repo_url(repo_path, use_ssh); + crate::time!(ctx.flags.timing, ctx.io.output, "Clone", { + ctx.runner.run( + &["git", "clone", &repo_url, &repo_name], + Some(target_dir), + ctx.flags, + ctx.io.output, + )?; + if ctx.flags.verbose { + writeln!(ctx.io.output, " Finished cloning {repo_name}").ok(); + } + Ok::<(), String>(()) + }) + .map_err(|e| format!("Failed to clone {repo_path}: {e}"))?; + Ok(()) } diff --git a/tests/commands/mono/mode.rs b/tests/commands/mono/mode.rs index 23a84cc..4328799 100644 --- a/tests/commands/mono/mode.rs +++ b/tests/commands/mono/mode.rs @@ -1,10 +1,14 @@ use crate::common::{default_resolved_mono, with_ctx, with_ctx_runner, MockRunner}; use star_setup::{commands::mono_repo_mode, config::SetupConfig, ctx::DryRunRunner}; +use std::{ + fs::{create_dir_all, read_dir, write}, + path::Path, +}; -fn make_cmake_repo(repos_path: &std::path::Path, name: &str) { +fn make_cmake_repo(repos_path: &Path, name: &str) { let dir = repos_path.join(name); - std::fs::create_dir_all(&dir).unwrap(); - std::fs::write(dir.join("CMakeLists.txt"), "").unwrap(); + create_dir_all(dir.join(".git")).unwrap(); + write(dir.join("CMakeLists.txt"), "").unwrap(); } #[test] @@ -13,7 +17,7 @@ fn test_mono_repo_mode_clones_and_configures() { let (_, output) = with_ctx(MockRunner::new(), |tmp_path, ctx| { let repos_path = tmp_path.join(&args.mono.mono_dir).join("repos"); - std::fs::create_dir_all(&repos_path).unwrap(); + create_dir_all(&repos_path).unwrap(); make_cmake_repo(&repos_path, "user-lib1"); make_cmake_repo(&repos_path, "user-test-repo"); @@ -35,7 +39,7 @@ fn test_mono_repo_mode_dry_run_makes_no_fs_changes() { mono_repo_mode(&args, &SetupConfig::new(), tmp_path, ctx).unwrap(); - assert!(std::fs::read_dir(tmp_path).unwrap().next().is_none()); + assert!(read_dir(tmp_path).unwrap().next().is_none()); }); } @@ -57,7 +61,7 @@ fn test_mono_repo_mode_dry_run_with_build_system_makes_no_fs_changes() { mono_repo_mode(&args, &SetupConfig::new(), tmp_path, ctx).unwrap(); assert!( - std::fs::read_dir(tmp_path).unwrap().next().is_none(), + read_dir(tmp_path).unwrap().next().is_none(), "{bs:?} dry-run wrote to disk" ); }); @@ -71,7 +75,7 @@ fn test_mono_repo_mode_with_build_system_flag() { let runner = with_ctx_runner(MockRunner::new(), |tmp_path, ctx| { let repos_path = tmp_path.join(&args.mono.mono_dir).join("repos"); - std::fs::create_dir_all(&repos_path).unwrap(); + create_dir_all(&repos_path).unwrap(); make_cmake_repo(&repos_path, "user-lib1"); make_cmake_repo(&repos_path, "user-test-repo"); diff --git a/tests/commands/single.rs b/tests/commands/single.rs index 14d6770..3cd55b9 100644 --- a/tests/commands/single.rs +++ b/tests/commands/single.rs @@ -1,10 +1,11 @@ use crate::common::{default_resolved, with_ctx_input, MockRunner}; use star_setup::{cli::BuildSystem, commands::single_repo_mode, ctx::DryRunRunner}; +use std::fs::{create_dir_all, read_dir, write}; fn make_repo_fixture(base: &std::path::Path) { let repo_dir = base.join("user-repo"); - std::fs::create_dir_all(&repo_dir).unwrap(); - std::fs::write(repo_dir.join("CMakeLists.txt"), "").unwrap(); + create_dir_all(repo_dir.join(".git")).unwrap(); + write(repo_dir.join("CMakeLists.txt"), "").unwrap(); } #[test] @@ -39,8 +40,8 @@ fn test_single_repo_mode_cleans_build_dir() { with_ctx_input(b"n\n", MockRunner::new(), |tmp_path, ctx| { make_repo_fixture(tmp_path); let build_dir = tmp_path.join("user-repo").join(&args.build.build_dir); - std::fs::create_dir_all(&build_dir).unwrap(); - std::fs::write(build_dir.join("dummy.txt"), "old content").unwrap(); + create_dir_all(&build_dir).unwrap(); + write(build_dir.join("dummy.txt"), "old content").unwrap(); single_repo_mode(&args, tmp_path, ctx).unwrap(); assert!(!build_dir.join("dummy.txt").exists()); assert!(build_dir.exists()); @@ -67,7 +68,7 @@ fn test_single_repo_mode_dry_run_makes_no_fs_changes() { with_ctx_input(b"", DryRunRunner, |tmp_path, ctx| { ctx.flags.dry_run = true; single_repo_mode(&args, tmp_path, ctx).unwrap(); - assert!(std::fs::read_dir(tmp_path).unwrap().next().is_none()); + assert!(read_dir(tmp_path).unwrap().next().is_none()); }); } @@ -81,7 +82,7 @@ fn test_single_repo_mode_dry_run_clean_prints_would_remove() { ctx.flags.dry_run = true; ctx.flags.verbose = true; single_repo_mode(&args, tmp_path, ctx).unwrap(); - assert!(std::fs::read_dir(tmp_path).unwrap().next().is_none()); + assert!(read_dir(tmp_path).unwrap().next().is_none()); }); assert!(String::from_utf8(output) .unwrap() @@ -98,7 +99,7 @@ fn test_single_repo_mode_dry_run_with_build_system_says_would_finish() { let (_, output) = with_ctx_input(b"", DryRunRunner, |tmp_path, ctx| { ctx.flags.dry_run = true; single_repo_mode(&args, tmp_path, ctx).unwrap(); - assert!(std::fs::read_dir(tmp_path).unwrap().next().is_none()); + assert!(read_dir(tmp_path).unwrap().next().is_none()); }); let out = String::from_utf8(output).unwrap(); diff --git a/tests/repository.rs b/tests/repository.rs index ca1d446..e27e9e8 100644 --- a/tests/repository.rs +++ b/tests/repository.rs @@ -4,7 +4,7 @@ use star_setup::{ ctx::ProcessRunner, repository::{clone_repo, clone_repos, repo_dir_name, resolve_repo_url}, }; -use std::fs; +use std::fs::create_dir_all; use tempfile::TempDir; use crate::common::with_ctx; @@ -75,7 +75,7 @@ fn test_resolve_repo_url() { fn test_clone_skips_existing_directory() { with_ctx_runner(ProcessRunner, |tmp_path, ctx| { let repo_dir = tmp_path.join("owner-repo"); - fs::create_dir_all(&repo_dir).unwrap(); + create_dir_all(repo_dir.join(".git")).unwrap(); let result = clone_repo("owner/repo", tmp_path, false, true, false, ctx); assert!(result.is_ok()); From a60918282d88d17a68450aece5afefb078e3376e Mon Sep 17 00:00:00 2001 From: Masonlet Date: Fri, 3 Jul 2026 16:54:33 -0400 Subject: [PATCH 05/11] chore: split repository into resolve, clone, pull modules --- src/{repository.rs => repository/clone.rs} | 65 +++------ src/repository/mod.rs | 6 + src/repository/pull.rs | 11 ++ src/repository/resolve.rs | 29 ++++ src/workspace/update.rs | 4 +- tests/repository.rs | 147 +-------------------- tests/repository/clone.rs | 81 ++++++++++++ tests/repository/resolve.rs | 60 +++++++++ 8 files changed, 209 insertions(+), 194 deletions(-) rename src/{repository.rs => repository/clone.rs} (64%) create mode 100644 src/repository/mod.rs create mode 100644 src/repository/pull.rs create mode 100644 src/repository/resolve.rs create mode 100644 tests/repository/clone.rs create mode 100644 tests/repository/resolve.rs diff --git a/src/repository.rs b/src/repository/clone.rs similarity index 64% rename from src/repository.rs rename to src/repository/clone.rs index c6a504d..5c65b62 100644 --- a/src/repository.rs +++ b/src/repository/clone.rs @@ -1,48 +1,22 @@ -//! Repository functions including cloning and URL resolution. +use crate::{ + ctx::RunCtx, + prompts::confirm, + repository::{pull_repo, repo_dir_name, resolve_repo_url}, +}; +use std::{ + fs::{read_dir, remove_dir_all}, + path::Path, +}; -use crate::{ctx::RunCtx, prompts::confirm}; -use std::{fs, path::Path}; +fn is_dir_empty(dir: &Path) -> bool { + read_dir(dir).is_ok_and(|mut entries| entries.next().is_none()) +} /// Returns `true` if the directory contains a `.git` entry. -#[must_use] -pub fn is_git_repo(dir: &Path) -> bool { +fn is_git_repo(dir: &Path) -> bool { dir.join(".git").exists() } -/// Converts a repository path or URL to a local directory name (`owner-repo`). -#[must_use] -pub fn repo_dir_name(path: &str) -> String { - let clean = path.trim_end_matches('/').trim_end_matches(".git"); - let mut parts = clean.rsplit('/'); - let repo = parts.next().unwrap_or(clean); - match parts.next() { - Some(owner) => { - let owner = owner.rsplit_once(':').map_or(owner, |(_, o)| o); - format!("{owner}-{repo}") - } - None => clean.to_string(), - } -} - -/// Converts repository input to a full GitHub URL. -/// Accepts either 'username/repo' shorthand or a full URL. -#[must_use] -pub fn resolve_repo_url(repo_input: &str, use_ssh: bool) -> String { - if repo_input.starts_with("http") || repo_input.starts_with("git@") { - return repo_input.to_string(); - } - let clean = repo_input.trim_end_matches('/').trim_end_matches(".git"); - if use_ssh { - format!("git@github.com:{clean}.git") - } else { - format!("https://github.com/{clean}.git") - } -} - -fn is_dir_empty(dir: &Path) -> bool { - fs::read_dir(dir).is_ok_and(|mut entries| entries.next().is_none()) -} - /// Clones a single repository into the target directory. /// Skips if the repository already exists. /// # Errors @@ -62,7 +36,7 @@ pub fn clone_repo( writeln!(ctx.io.output, " Repository already exists").ok(); if !on_exists_skip && confirm(" Update existing repository?", yes, &mut ctx.io)? { crate::time!(ctx.flags.timing, ctx.io.output, "Update", { - pull_repository(&repo_dir, ctx)?; + pull_repo(&repo_dir, ctx)?; }); } return Ok(()); @@ -94,7 +68,7 @@ pub fn clone_repo( ) .ok(); } else { - fs::remove_dir_all(&repo_dir) + remove_dir_all(&repo_dir) .map_err(|e| format!("Failed to remove {}: {e}", repo_dir.display()))?; } } @@ -147,12 +121,3 @@ pub fn clone_repos( writeln!(ctx.io.output).ok(); Ok(()) } - -/// Pulls the latest changes for an existing repository. -/// # Errors -/// Returns an error if the `git pull` command fails. -pub fn pull_repository(repo_path: &Path, ctx: &mut RunCtx<'_, '_>) -> Result<(), String> { - ctx - .runner - .run(&["git", "pull"], Some(repo_path), ctx.flags, ctx.io.output) -} diff --git a/src/repository/mod.rs b/src/repository/mod.rs new file mode 100644 index 0000000..7c1ba10 --- /dev/null +++ b/src/repository/mod.rs @@ -0,0 +1,6 @@ +pub mod clone; +pub use clone::{clone_repo, clone_repos}; +pub mod pull; +pub use pull::pull_repo; +pub mod resolve; +pub use resolve::{repo_dir_name, resolve_repo_url}; diff --git a/src/repository/pull.rs b/src/repository/pull.rs new file mode 100644 index 0000000..9994f36 --- /dev/null +++ b/src/repository/pull.rs @@ -0,0 +1,11 @@ +use crate::ctx::RunCtx; +use std::path::Path; + +/// Pulls the latest changes for an existing repository. +/// # Errors +/// Returns an error if the `git pull` command fails. +pub fn pull_repo(repo_path: &Path, ctx: &mut RunCtx<'_, '_>) -> Result<(), String> { + ctx + .runner + .run(&["git", "pull"], Some(repo_path), ctx.flags, ctx.io.output) +} diff --git a/src/repository/resolve.rs b/src/repository/resolve.rs new file mode 100644 index 0000000..6f482d2 --- /dev/null +++ b/src/repository/resolve.rs @@ -0,0 +1,29 @@ +/// Converts a repository path or URL to a local directory name (`owner-repo`). +#[must_use] +pub fn repo_dir_name(path: &str) -> String { + let clean = path.trim_end_matches('/').trim_end_matches(".git"); + let mut parts = clean.rsplit('/'); + let repo = parts.next().unwrap_or(clean); + match parts.next() { + Some(owner) => { + let owner = owner.rsplit_once(':').map_or(owner, |(_, o)| o); + format!("{owner}-{repo}") + } + None => clean.to_string(), + } +} + +/// Converts repository input to a full GitHub URL. +/// Accepts either 'username/repo' shorthand or a full URL. +#[must_use] +pub fn resolve_repo_url(repo_input: &str, use_ssh: bool) -> String { + if repo_input.starts_with("http") || repo_input.starts_with("git@") { + return repo_input.to_string(); + } + let clean = repo_input.trim_end_matches('/').trim_end_matches(".git"); + if use_ssh { + format!("git@github.com:{clean}.git") + } else { + format!("https://github.com/{clean}.git") + } +} diff --git a/src/workspace/update.rs b/src/workspace/update.rs index 8c3381a..f1d8088 100644 --- a/src/workspace/update.rs +++ b/src/workspace/update.rs @@ -1,4 +1,4 @@ -use crate::{ctx::RunCtx, repository::pull_repository, workspace::Workspace}; +use crate::{ctx::RunCtx, repository::pull_repo, workspace::Workspace}; impl Workspace { /// Pulls latest changes for all repositories. @@ -28,7 +28,7 @@ impl Workspace { writeln!(ctx.io.output, " Updating {name}").ok(); crate::time!(ctx.flags.timing, ctx.io.output, &name, { - if let Err(e) = pull_repository(repo_dir, ctx) { + if let Err(e) = pull_repo(repo_dir, ctx) { writeln!(ctx.io.output, " Failed to update {name}: {e}").ok(); errors.push(format!("{name}: {e}")); } diff --git a/tests/repository.rs b/tests/repository.rs index e27e9e8..7b89301 100644 --- a/tests/repository.rs +++ b/tests/repository.rs @@ -1,144 +1,7 @@ +#[path = "common/mod.rs"] mod common; -use common::{with_ctx_runner, MockRunner}; -use star_setup::{ - ctx::ProcessRunner, - repository::{clone_repo, clone_repos, repo_dir_name, resolve_repo_url}, -}; -use std::fs::create_dir_all; -use tempfile::TempDir; -use crate::common::with_ctx; - -/* =========== REPO_DIR_NAME ========== */ -#[test] -fn test_repo_dir_name() { - let cases = [ - "owner/repo", - "owner/repo.git", - "git@github.com:owner/repo.git", - "https://github.com/owner/repo", - "https://github.com/owner/repo.git", - "owner/repo/", - ]; - - for input in cases { - assert_eq!( - repo_dir_name(input), - "owner-repo", - "Failed for input: {input}" - ); - } -} - -#[test] -fn test_repo_dir_name_no_owner() { - assert_eq!(repo_dir_name("repo"), "repo"); -} - -/* =========== RESOLVE_REPO_URL ========== */ -#[test] -fn test_resolve_repo_url() { - let cases = vec![ - ("owner/repo", false, "https://github.com/owner/repo.git"), - ("owner/repo/", false, "https://github.com/owner/repo.git"), - ("owner/repo", true, "git@github.com:owner/repo.git"), - ("owner/repo/", true, "git@github.com:owner/repo.git"), - ( - "https://github.com/owner/repo.git", - false, - "https://github.com/owner/repo.git", - ), - ( - "https://github.com/owner/repo.git", - true, - "https://github.com/owner/repo.git", - ), - ( - "git@github.com:owner/repo.git", - true, - "git@github.com:owner/repo.git", - ), - ("owner/repo.git", false, "https://github.com/owner/repo.git"), - ]; - - for (input, use_ssh, expected) in cases { - assert_eq!( - resolve_repo_url(input, use_ssh), - expected, - "Failed for input: {input} (use_ssh: {use_ssh})" - ); - } -} - -/* =========== CLONE_REPOSITORY ========== */ -#[test] -fn test_clone_skips_existing_directory() { - with_ctx_runner(ProcessRunner, |tmp_path, ctx| { - let repo_dir = tmp_path.join("owner-repo"); - create_dir_all(repo_dir.join(".git")).unwrap(); - - let result = clone_repo("owner/repo", tmp_path, false, true, false, ctx); - assert!(result.is_ok()); - assert!(repo_dir.exists()); - }); -} - -#[test] -fn test_clone_repo_calls_git_clone() { - let tmp = TempDir::new().unwrap(); - - let runner = with_ctx_runner(MockRunner::new(), |_, ctx| { - clone_repo("user/repo", tmp.path(), false, true, false, ctx).unwrap(); - }); - - assert_eq!(runner.calls.len(), 1); - let (cmd, cwd) = &runner.calls[0]; - assert_eq!(cmd[0], "git"); - assert_eq!(cmd[1], "clone"); - assert!(cmd[2].contains("user/repo")); - assert_eq!(cwd.as_deref(), Some(tmp.path())); -} - -#[test] -fn test_clone_repos_calls_clone_for_each_repo() { - let runner = with_ctx_runner(MockRunner::new(), |tmp_path, ctx| { - let repos = vec!["user/repo1".to_string(), "user/repo2".to_string()]; - clone_repos(&repos, tmp_path, false, ctx).unwrap(); - }); - - assert_eq!(runner.calls.len(), 2); - assert!(runner - .calls - .iter() - .all(|(cmd, _)| cmd[0] == "git" && cmd[1] == "clone")); -} - -#[test] -fn test_clone_repos_empty() { - let runner = with_ctx_runner(MockRunner::new(), |tmp_path, ctx| { - clone_repos(&[], tmp_path, false, ctx).unwrap(); - }); - - assert!(runner.calls.is_empty()); -} - -#[test] -fn test_clone_repos_per_repo_lines_require_verbose() { - let repos = vec!["user/repo1".to_string(), "user/repo2".to_string()]; - - let (_, output) = with_ctx(MockRunner::new(), |tmp_path, ctx| { - clone_repos(&repos, tmp_path, false, ctx).unwrap(); - }); - let out = String::from_utf8(output).unwrap(); - assert!(out.contains("Cloning repositories")); - assert!(!out.contains("Cloning user-repo1")); - assert!(!out.contains("Finished cloning (2 repositories)")); - - let (_, output) = with_ctx(MockRunner::new(), |tmp_path, ctx| { - ctx.flags.verbose = true; - clone_repos(&repos, tmp_path, false, ctx).unwrap(); - }); - let out = String::from_utf8(output).unwrap(); - assert!(out.contains("Cloning user-repo1")); - assert!(out.contains("Finished cloning (2 repositories)")); -} +#[path = "repository/clone.rs"] +mod clone; +#[path = "repository/resolve.rs"] +mod resolve; diff --git a/tests/repository/clone.rs b/tests/repository/clone.rs new file mode 100644 index 0000000..22e0932 --- /dev/null +++ b/tests/repository/clone.rs @@ -0,0 +1,81 @@ +use std::fs::create_dir_all; + +use star_setup::{ + ctx::ProcessRunner, + repository::{clone_repo, clone_repos}, +}; +use tempfile::TempDir; + +use crate::common::{with_ctx, with_ctx_runner, MockRunner}; + +#[test] +fn test_clone_skips_existing_directory() { + with_ctx_runner(ProcessRunner, |tmp_path, ctx| { + let repo_dir = tmp_path.join("owner-repo"); + create_dir_all(repo_dir.join(".git")).unwrap(); + + let result = clone_repo("owner/repo", tmp_path, false, true, false, ctx); + assert!(result.is_ok()); + assert!(repo_dir.exists()); + }); +} + +#[test] +fn test_clone_repo_calls_git_clone() { + let tmp = TempDir::new().unwrap(); + + let runner = with_ctx_runner(MockRunner::new(), |_, ctx| { + clone_repo("user/repo", tmp.path(), false, true, false, ctx).unwrap(); + }); + + assert_eq!(runner.calls.len(), 1); + let (cmd, cwd) = &runner.calls[0]; + assert_eq!(cmd[0], "git"); + assert_eq!(cmd[1], "clone"); + assert!(cmd[2].contains("user/repo")); + assert_eq!(cwd.as_deref(), Some(tmp.path())); +} + +#[test] +fn test_clone_repos_calls_clone_for_each_repo() { + let runner = with_ctx_runner(MockRunner::new(), |tmp_path, ctx| { + let repos = vec!["user/repo1".to_string(), "user/repo2".to_string()]; + clone_repos(&repos, tmp_path, false, ctx).unwrap(); + }); + + assert_eq!(runner.calls.len(), 2); + assert!(runner + .calls + .iter() + .all(|(cmd, _)| cmd[0] == "git" && cmd[1] == "clone")); +} + +#[test] +fn test_clone_repos_empty() { + let runner = with_ctx_runner(MockRunner::new(), |tmp_path, ctx| { + clone_repos(&[], tmp_path, false, ctx).unwrap(); + }); + + assert!(runner.calls.is_empty()); +} + +#[test] +fn test_clone_repos_per_repo_lines_require_verbose() { + let repos = vec!["user/repo1".to_string(), "user/repo2".to_string()]; + + let (_, output) = with_ctx(MockRunner::new(), |tmp_path, ctx| { + clone_repos(&repos, tmp_path, false, ctx).unwrap(); + }); + let out = String::from_utf8(output).unwrap(); + assert!(out.contains("Cloning repositories")); + assert!(!out.contains("Cloning user-repo1")); + assert!(!out.contains("Finished cloning (2 repositories)")); + + let (_, output) = with_ctx(MockRunner::new(), |tmp_path, ctx| { + ctx.flags.verbose = true; + clone_repos(&repos, tmp_path, false, ctx).unwrap(); + }); + let out = String::from_utf8(output).unwrap(); + assert!(out.contains("Cloning user-repo1")); + assert!(out.contains("Finished cloning (2 repositories)")); +} diff --git a/tests/repository/resolve.rs b/tests/repository/resolve.rs new file mode 100644 index 0000000..714c64c --- /dev/null +++ b/tests/repository/resolve.rs @@ -0,0 +1,60 @@ +use star_setup::repository::{repo_dir_name, resolve_repo_url}; + +#[test] +fn test_repo_dir_name() { + let cases = [ + "owner/repo", + "owner/repo.git", + "git@github.com:owner/repo.git", + "https://github.com/owner/repo", + "https://github.com/owner/repo.git", + "owner/repo/", + ]; + + for input in cases { + assert_eq!( + repo_dir_name(input), + "owner-repo", + "Failed for input: {input}" + ); + } +} + +#[test] +fn test_repo_dir_name_no_owner() { + assert_eq!(repo_dir_name("repo"), "repo"); +} + +#[test] +fn test_resolve_repo_url() { + let cases = vec![ + ("owner/repo", false, "https://github.com/owner/repo.git"), + ("owner/repo/", false, "https://github.com/owner/repo.git"), + ("owner/repo", true, "git@github.com:owner/repo.git"), + ("owner/repo/", true, "git@github.com:owner/repo.git"), + ( + "https://github.com/owner/repo.git", + false, + "https://github.com/owner/repo.git", + ), + ( + "https://github.com/owner/repo.git", + true, + "https://github.com/owner/repo.git", + ), + ( + "git@github.com:owner/repo.git", + true, + "git@github.com:owner/repo.git", + ), + ("owner/repo.git", false, "https://github.com/owner/repo.git"), + ]; + + for (input, use_ssh, expected) in cases { + assert_eq!( + resolve_repo_url(input, use_ssh), + expected, + "Failed for input: {input} (use_ssh: {use_ssh})" + ); + } +} From 9afb20efd3cdf4e3bbe73d9c23fb1bd85b8a3902 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Fri, 3 Jul 2026 17:04:49 -0400 Subject: [PATCH 06/11] refactor: replace clone_repo exists flag with an on_exists callback --- src/commands/single.rs | 17 +++++++++++++++-- src/repository/clone.rs | 20 +++++++++++++------- src/repository/mod.rs | 2 +- tests/repository/clone.rs | 19 ++++++++++++++++--- 4 files changed, 45 insertions(+), 13 deletions(-) diff --git a/src/commands/single.rs b/src/commands/single.rs index 6cef2d0..52afafc 100644 --- a/src/commands/single.rs +++ b/src/commands/single.rs @@ -2,7 +2,8 @@ use crate::{ cli::{detect_build_system, BuildSystem, ResolvedArgs}, commands::{build_project, extract_repo_input, prepare_build_dir, print_mode_header, ModeHeader}, ctx::RunCtx, - repository::{clone_repo, repo_dir_name}, + prompts::confirm, + repository::{ExistsAction, clone_repo, repo_dir_name}, utils::dry_run::detect_or_dry_run, }; use std::path::Path; @@ -35,7 +36,19 @@ pub fn single_repo_mode( ); writeln!(ctx.io.output, "Cloning repository").ok(); - clone_repo(repo, base_dir, args.connection.ssh, false, args.yes, ctx)?; + clone_repo( + repo, + base_dir, + args.connection.ssh, + |io| { + Ok(if confirm(" Update existing repository?", args.yes, io)? { + ExistsAction::Update + } else { + ExistsAction::Skip + }) + }, + ctx, + )?; writeln!(ctx.io.output).ok(); let build_path = repo_path.join(&args.build.build_dir); diff --git a/src/repository/clone.rs b/src/repository/clone.rs index 5c65b62..9fbfecf 100644 --- a/src/repository/clone.rs +++ b/src/repository/clone.rs @@ -1,5 +1,5 @@ use crate::{ - ctx::RunCtx, + ctx::{IoCtx, RunCtx}, prompts::confirm, repository::{pull_repo, repo_dir_name, resolve_repo_url}, }; @@ -17,16 +17,21 @@ fn is_git_repo(dir: &Path) -> bool { dir.join(".git").exists() } +pub enum ExistsAction { + Skip, + Update, +} + /// Clones a single repository into the target directory. /// Skips if the repository already exists. /// # Errors -/// Returns an error if the git clone command fails or an existing invalid directory cannot be removed. +/// Returns an error if the git clone command fails, an existing invalid directory +/// cannot be removed, or the `on_exists` callback returns an error. pub fn clone_repo( repo_path: &str, target_dir: &Path, use_ssh: bool, - on_exists_skip: bool, - yes: bool, + on_exists: impl FnOnce(&mut IoCtx<'_>) -> Result, ctx: &mut RunCtx<'_, '_>, ) -> Result<(), String> { let repo_name = repo_dir_name(repo_path); @@ -34,7 +39,7 @@ pub fn clone_repo( if repo_dir.exists() && is_git_repo(&repo_dir) { writeln!(ctx.io.output, " Repository already exists").ok(); - if !on_exists_skip && confirm(" Update existing repository?", yes, &mut ctx.io)? { + if matches!(on_exists(&mut ctx.io)?, ExistsAction::Update) { crate::time!(ctx.flags.timing, ctx.io.output, "Update", { pull_repo(&repo_dir, ctx)?; }); @@ -93,7 +98,8 @@ pub fn clone_repo( /// Clones all repositories into the given directory. /// # Errors -/// Returns an error if any repository fails to clone. +/// Returns an error if any repository fails to clone or an existing invalid +/// directory cannot be removed. pub fn clone_repos( repos: &[String], target_dir: &Path, @@ -106,7 +112,7 @@ pub fn clone_repos( if ctx.flags.verbose { writeln!(ctx.io.output, " Cloning {}", repo_dir_name(repo)).ok(); } - clone_repo(repo, target_dir, ssh, true, false, ctx)?; + clone_repo(repo, target_dir, ssh, |_| Ok(ExistsAction::Skip), ctx)?; } if ctx.flags.verbose { writeln!( diff --git a/src/repository/mod.rs b/src/repository/mod.rs index 7c1ba10..6b8a9fa 100644 --- a/src/repository/mod.rs +++ b/src/repository/mod.rs @@ -1,5 +1,5 @@ pub mod clone; -pub use clone::{clone_repo, clone_repos}; +pub use clone::{clone_repo, clone_repos, ExistsAction}; pub mod pull; pub use pull::pull_repo; pub mod resolve; diff --git a/tests/repository/clone.rs b/tests/repository/clone.rs index 22e0932..6b8bf61 100644 --- a/tests/repository/clone.rs +++ b/tests/repository/clone.rs @@ -2,7 +2,7 @@ use std::fs::create_dir_all; use star_setup::{ ctx::ProcessRunner, - repository::{clone_repo, clone_repos}, + repository::{ExistsAction, clone_repo, clone_repos}, }; use tempfile::TempDir; @@ -14,7 +14,13 @@ fn test_clone_skips_existing_directory() { let repo_dir = tmp_path.join("owner-repo"); create_dir_all(repo_dir.join(".git")).unwrap(); - let result = clone_repo("owner/repo", tmp_path, false, true, false, ctx); + let result = clone_repo( + "owner/repo", + tmp_path, + false, + |_| Ok(ExistsAction::Skip), + ctx, + ); assert!(result.is_ok()); assert!(repo_dir.exists()); }); @@ -25,7 +31,14 @@ fn test_clone_repo_calls_git_clone() { let tmp = TempDir::new().unwrap(); let runner = with_ctx_runner(MockRunner::new(), |_, ctx| { - clone_repo("user/repo", tmp.path(), false, true, false, ctx).unwrap(); + clone_repo( + "user/repo", + tmp.path(), + false, + |_| Ok(ExistsAction::Skip), + ctx, + ) + .unwrap(); }); assert_eq!(runner.calls.len(), 1); From dff94bfe92e9a60e33c36a57924367729979053a Mon Sep 17 00:00:00 2001 From: Masonlet Date: Fri, 3 Jul 2026 17:11:39 -0400 Subject: [PATCH 07/11] feat: prompt before updating existing repos during batch clone --- src/commands/mono/mode.rs | 2 +- src/commands/single.rs | 2 +- src/prompts/mod.rs | 2 +- src/repository/clone.rs | 45 ++++++++++++++++++++++++++++++++++----- tests/repository/clone.rs | 10 ++++----- 5 files changed, 48 insertions(+), 13 deletions(-) diff --git a/src/commands/mono/mode.rs b/src/commands/mono/mode.rs index 169974b..a3d7508 100644 --- a/src/commands/mono/mode.rs +++ b/src/commands/mono/mode.rs @@ -69,7 +69,7 @@ pub fn mono_repo_mode( writeln!(ctx.io.output).ok(); } - clone_repos(&repos, &repos_path, args.connection.ssh, ctx)?; + clone_repos(&repos, &repos_path, args.connection.ssh, args.yes, ctx)?; let repo_dirs: Vec = repos .iter() diff --git a/src/commands/single.rs b/src/commands/single.rs index 52afafc..2d8d2ec 100644 --- a/src/commands/single.rs +++ b/src/commands/single.rs @@ -3,7 +3,7 @@ use crate::{ commands::{build_project, extract_repo_input, prepare_build_dir, print_mode_header, ModeHeader}, ctx::RunCtx, prompts::confirm, - repository::{ExistsAction, clone_repo, repo_dir_name}, + repository::{clone_repo, repo_dir_name, ExistsAction}, utils::dry_run::detect_or_dry_run, }; use std::path::Path; diff --git a/src/prompts/mod.rs b/src/prompts/mod.rs index 02bcbe7..1b39a85 100644 --- a/src/prompts/mod.rs +++ b/src/prompts/mod.rs @@ -1,6 +1,6 @@ pub mod ask; pub use ask::{ask, ask_bool_if, ask_choice, ask_default, ask_required, ask_yesno}; pub mod confirm; -pub use confirm::{confirm, confirm_abort}; +pub use confirm::{confirm, confirm_abort, confirm_batch, BatchConfirm}; pub mod helper; pub use helper::read_input_line; diff --git a/src/repository/clone.rs b/src/repository/clone.rs index 9fbfecf..3e363fa 100644 --- a/src/repository/clone.rs +++ b/src/repository/clone.rs @@ -1,6 +1,6 @@ use crate::{ ctx::{IoCtx, RunCtx}, - prompts::confirm, + prompts::{confirm, confirm_batch, BatchConfirm}, repository::{pull_repo, repo_dir_name, resolve_repo_url}, }; use std::{ @@ -96,23 +96,58 @@ pub fn clone_repo( Ok(()) } -/// Clones all repositories into the given directory. +/// Clones all repositories into the given directory, prompting before updating +/// any that already exist (with all/skip-all shortcuts for 5 or more repos). /// # Errors -/// Returns an error if any repository fails to clone or an existing invalid -/// directory cannot be removed. +/// Returns an error if any repository fails to clone, an existing invalid +/// directory cannot be removed, or a confirmation prompt reaches end of input. pub fn clone_repos( repos: &[String], target_dir: &Path, ssh: bool, + yes: bool, ctx: &mut RunCtx<'_, '_>, ) -> Result<(), String> { + let allow_all = repos.len() >= 5; + let mut remembered: Option = None; writeln!(ctx.io.output, "Cloning repositories").ok(); crate::time!(ctx.flags.timing, ctx.io.output, "Clone", { for repo in repos { if ctx.flags.verbose { writeln!(ctx.io.output, " Cloning {}", repo_dir_name(repo)).ok(); } - clone_repo(repo, target_dir, ssh, |_| Ok(ExistsAction::Skip), ctx)?; + clone_repo( + repo, + target_dir, + ssh, + |io| { + if yes { + return Ok(ExistsAction::Update); + } + if let Some(update) = remembered { + return Ok(if update { + ExistsAction::Update + } else { + ExistsAction::Skip + }); + } + Ok( + match confirm_batch(" Update existing repository?", allow_all, io)? { + BatchConfirm::Yes => ExistsAction::Update, + BatchConfirm::No => ExistsAction::Skip, + BatchConfirm::YesAll => { + remembered = Some(true); + ExistsAction::Update + } + BatchConfirm::NoAll => { + remembered = Some(false); + ExistsAction::Skip + } + }, + ) + }, + ctx, + )?; } if ctx.flags.verbose { writeln!( diff --git a/tests/repository/clone.rs b/tests/repository/clone.rs index 6b8bf61..d530544 100644 --- a/tests/repository/clone.rs +++ b/tests/repository/clone.rs @@ -2,7 +2,7 @@ use std::fs::create_dir_all; use star_setup::{ ctx::ProcessRunner, - repository::{ExistsAction, clone_repo, clone_repos}, + repository::{clone_repo, clone_repos, ExistsAction}, }; use tempfile::TempDir; @@ -53,7 +53,7 @@ fn test_clone_repo_calls_git_clone() { fn test_clone_repos_calls_clone_for_each_repo() { let runner = with_ctx_runner(MockRunner::new(), |tmp_path, ctx| { let repos = vec!["user/repo1".to_string(), "user/repo2".to_string()]; - clone_repos(&repos, tmp_path, false, ctx).unwrap(); + clone_repos(&repos, tmp_path, false, false, ctx).unwrap(); }); assert_eq!(runner.calls.len(), 2); @@ -66,7 +66,7 @@ fn test_clone_repos_calls_clone_for_each_repo() { #[test] fn test_clone_repos_empty() { let runner = with_ctx_runner(MockRunner::new(), |tmp_path, ctx| { - clone_repos(&[], tmp_path, false, ctx).unwrap(); + clone_repos(&[], tmp_path, false, false, ctx).unwrap(); }); assert!(runner.calls.is_empty()); @@ -77,7 +77,7 @@ fn test_clone_repos_per_repo_lines_require_verbose() { let repos = vec!["user/repo1".to_string(), "user/repo2".to_string()]; let (_, output) = with_ctx(MockRunner::new(), |tmp_path, ctx| { - clone_repos(&repos, tmp_path, false, ctx).unwrap(); + clone_repos(&repos, tmp_path, false, false, ctx).unwrap(); }); let out = String::from_utf8(output).unwrap(); assert!(out.contains("Cloning repositories")); @@ -86,7 +86,7 @@ fn test_clone_repos_per_repo_lines_require_verbose() { let (_, output) = with_ctx(MockRunner::new(), |tmp_path, ctx| { ctx.flags.verbose = true; - clone_repos(&repos, tmp_path, false, ctx).unwrap(); + clone_repos(&repos, tmp_path, false, false, ctx).unwrap(); }); let out = String::from_utf8(output).unwrap(); assert!(out.contains("Cloning user-repo1")); From c16aa465ca692ca073510901bafb29a7e5069693 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Fri, 3 Jul 2026 17:27:19 -0400 Subject: [PATCH 08/11] test: cover confirm_batch input handling and allow_all gating --- src/prompts/confirm.rs | 1 + tests/prompts/confirm.rs | 36 ++++++++++++++++++++++++++++++++++-- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/src/prompts/confirm.rs b/src/prompts/confirm.rs index d7206ca..cc045c8 100644 --- a/src/prompts/confirm.rs +++ b/src/prompts/confirm.rs @@ -22,6 +22,7 @@ pub fn confirm_abort(warning_msg: &str, yes: bool, io: &mut IoCtx<'_>) -> Result Ok(true) } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum BatchConfirm { Yes, No, diff --git a/tests/prompts/confirm.rs b/tests/prompts/confirm.rs index 5abad60..1d4d747 100644 --- a/tests/prompts/confirm.rs +++ b/tests/prompts/confirm.rs @@ -1,5 +1,5 @@ -use crate::common::with_io_input; -use star_setup::prompts::confirm; +use crate::common::{with_io_input, with_io_input_output}; +use star_setup::prompts::{confirm, confirm_batch, BatchConfirm}; #[test] fn test_confirm_input_cases() { @@ -28,3 +28,35 @@ fn test_confirm_errors_on_eof() { assert!(result.is_err()); assert!(result.unwrap_err().contains("unexpected end of input")); } + +#[test] +fn test_confirm_batch_input_cases() { + let cases = [ + (b"y\n" as &[u8], true, BatchConfirm::Yes, "y accepts"), + (b"Y\n", true, BatchConfirm::Yes, "Y accepts"), + (b"n\n", true, BatchConfirm::No, "n rejects"), + (b"\n", true, BatchConfirm::No, "empty defaults to no"), + (b"a\n", true, BatchConfirm::YesAll, "a accepts all"), + (b"s\n", true, BatchConfirm::NoAll, "s skips all"), + (b"x\ny\n", true, BatchConfirm::Yes, "invalid input reprompts"), + (b"a\ny\n", false, BatchConfirm::Yes, "a invalid without allow_all"), + (b"s\nn\n", false, BatchConfirm::No, "s invalid without allow_all"), + ]; + for (input, allow_all, expected, name) in cases { + let result = with_io_input(input, |io| confirm_batch("prompt", allow_all, io)); + assert_eq!(result.unwrap(), expected, "Failed: {name}"); + } +} + +#[test] +fn test_confirm_batch_errors_on_eof() { + assert!(with_io_input(b"", |io| confirm_batch("prompt", true, io)).is_err()); +} + +#[test] +fn test_confirm_batch_prompt_suffix() { + let (_, out) = with_io_input_output(b"y\n", |io| confirm_batch("prompt", true, io)); + assert!(out.contains("(y/n/a/s)")); + let (_, out) = with_io_input_output(b"y\n", |io| confirm_batch("prompt", false, io)); + assert!(out.contains("(y/n)") && !out.contains("(y/n/a/s)")); +} From b796816663184d7fc8c89801e8568aa892313cc1 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Fri, 3 Jul 2026 19:24:39 -0400 Subject: [PATCH 09/11] refactor: shorten import paths and add test section dividers --- src/cli/flags.rs | 6 +- src/commands/build.rs | 2 - src/commands/handlers.rs | 23 +++---- src/commands/header.rs | 2 - src/commands/mod.rs | 4 +- src/commands/mono/config.rs | 10 ++- src/commands/mono/display.rs | 17 ++--- src/commands/mono/mod.rs | 2 +- src/commands/mono/mode.rs | 11 ++-- src/commands/mono/resolve.rs | 48 +++++++-------- src/commands/mono/setup.rs | 23 ++++--- src/commands/mono/watch.rs | 23 ++++--- src/commands/mono/wraps.rs | 15 +++-- src/commands/setup.rs | 11 ++-- src/commands/single.rs | 19 +++--- src/config/crud.rs | 9 +-- src/config/io.rs | 20 +++--- src/ctx.rs | 3 +- src/interactive.rs | 2 - src/main.rs | 6 +- src/repository/clone.rs | 13 ++-- src/run.rs | 17 ++--- src/utils/process.rs | 13 ++-- src/workspace/clean.rs | 4 +- src/workspace/resolve.rs | 4 +- tests/cli/build/detect.rs | 62 ++++++++++--------- tests/cli/resolve.rs | 4 +- tests/commands/build.rs | 52 ++++++++-------- tests/commands/display.rs | 2 +- tests/commands/header.rs | 2 +- tests/commands/mono/config.rs | 33 +++++----- tests/commands/mono/mode.rs | 12 ++-- tests/commands/mono/resolve.rs | 29 +++++---- tests/commands/mono/watch.rs | 27 ++++---- tests/commands/mono/wraps.rs | 17 +++-- tests/common/args.rs | 13 ++-- tests/common/io.rs | 6 +- tests/common/runner.rs | 9 ++- tests/config/crud.rs | 39 +++++++----- tests/config/display.rs | 2 +- tests/config/io.rs | 25 ++++---- tests/config/types.rs | 2 + tests/profile/crud.rs | 109 ++++++++++++++++++--------------- tests/prompts/ask.rs | 15 +++-- tests/prompts/confirm.rs | 23 ++++++- tests/repository/clone.rs | 8 +-- tests/repository/resolve.rs | 2 + tests/utils/process.rs | 3 +- tests/workspace/clean.rs | 8 +-- tests/workspace/resolve.rs | 21 +++---- 50 files changed, 463 insertions(+), 369 deletions(-) diff --git a/src/cli/flags.rs b/src/cli/flags.rs index a1c79ed..d645fdb 100644 --- a/src/cli/flags.rs +++ b/src/cli/flags.rs @@ -1,5 +1,5 @@ use crate::cli::BuildSystem; -use clap::Args as ClapArgs; +use clap::{ArgAction::Append, Args as ClapArgs}; #[allow(clippy::struct_excessive_bools)] #[derive(ClapArgs)] @@ -44,11 +44,11 @@ pub struct BuildFlags { pub no_clean: bool, /// Additional `CMake` arguments - #[arg(long = "cmake-arg", action = clap::ArgAction::Append)] + #[arg(long = "cmake-arg", action = Append)] pub cmake_flags: Vec, /// Additional Meson arguments - #[arg(long = "meson-arg", action = clap::ArgAction::Append)] + #[arg(long = "meson-arg", action = Append)] pub meson_flags: Vec, /// Automatically open watch scripts for npm mono-repo mode. diff --git a/src/commands/build.rs b/src/commands/build.rs index bb596af..ef85824 100644 --- a/src/commands/build.rs +++ b/src/commands/build.rs @@ -1,5 +1,3 @@ -//! Build system dispatch and per-system build functions. - use crate::{ cli::{BuildSystem, ResolvedArgs}, ctx::RunCtx, diff --git a/src/commands/handlers.rs b/src/commands/handlers.rs index 7422c72..d0941b8 100644 --- a/src/commands/handlers.rs +++ b/src/commands/handlers.rs @@ -1,5 +1,8 @@ use crate::{ - cli::{ConfigAction, ProfileAction, WorkspaceAction}, + cli::{ + ConfigAction, ProfileAction, WorkspaceAction, + WorkspaceAction::{Clean, Status, Update}, + }, config::{ add_config, create_default_config, list_configs, remove_config, ConfigEntry, SetupConfig, }, @@ -7,7 +10,7 @@ use crate::{ profile::{add_profile, list_profiles, remove_profile}, workspace::resolve_workspace, }; -use std::{error::Error, path::PathBuf}; +use std::{error::Error, iter::once, path::PathBuf}; /// Handles configuration-related subcommands. /// # Errors @@ -52,7 +55,7 @@ pub fn handle_profile_cmd( ProfileAction::List => list_profiles(config, io), ProfileAction::Remove { name } => remove_profile(config, &name, yes, io, flags)?, ProfileAction::Add { name, repos } => { - let vals = std::iter::once(name).chain(repos).collect::>(); + let vals = once(name).chain(repos).collect::>(); add_profile(config, &vals, yes, io, flags)?; } } @@ -68,9 +71,7 @@ pub fn handle_workspace_cmd( flags: RunFlags, ) -> Result<(), Box> { let target = match &action { - WorkspaceAction::Update { target } - | WorkspaceAction::Clean { target } - | WorkspaceAction::Status { target, .. } => target, + Update { target } | Clean { target } | Status { target, .. } => target, }; let ws = resolve_workspace( target.path.as_deref(), @@ -81,14 +82,10 @@ pub fn handle_workspace_cmd( )?; match action { - WorkspaceAction::Update { .. } => { - with_runner(io, flags, |ctx| ws.update(ctx).map_err(Into::into)) - } - WorkspaceAction::Status { fetch, .. } => { + Update { .. } => with_runner(io, flags, |ctx| ws.update(ctx).map_err(Into::into)), + Status { fetch, .. } => { with_runner(io, flags, |ctx| ws.status(*fetch, ctx).map_err(Into::into)) } - WorkspaceAction::Clean { .. } => { - with_runner(io, flags, |ctx| ws.clean(ctx).map_err(Into::into)) - } + Clean { .. } => with_runner(io, flags, |ctx| ws.clean(ctx).map_err(Into::into)), } } diff --git a/src/commands/header.rs b/src/commands/header.rs index 3f8b4fd..624b2ed 100644 --- a/src/commands/header.rs +++ b/src/commands/header.rs @@ -1,5 +1,3 @@ -//! Mode header rendering - use crate::ctx::IoCtx; /// Header information printed at the start of each command mode. diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 2e1b928..f7106fa 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -5,8 +5,8 @@ pub use header::{print_mode_header, ModeHeader}; pub mod mono; pub use mono::{ build_repo_list, create_mono_repo_cmakelists, create_mono_repo_mesonbuild, - create_mono_repo_package_json, hoist_wraps, mono_repo_mode, resolve_repos_for_mono, - resolve_test_repo, + create_mono_repo_package_json, generate_mono_config, generate_watch_scripts, hoist_wraps, + mono_repo_mode, resolve_repos_for_mono, resolve_setup_paths, resolve_test_repo, wraps::{parse_project_name, parse_provide_pairs}, }; pub mod single; diff --git a/src/commands/mono/config.rs b/src/commands/mono/config.rs index 52a90ac..d008135 100644 --- a/src/commands/mono/config.rs +++ b/src/commands/mono/config.rs @@ -3,7 +3,11 @@ use crate::{ repository::repo_dir_name, utils::dry_run_or_do, }; -use std::{fs, path::Path}; +use serde_json::{from_str, Value}; +use std::{ + fs::{self, read_to_string}, + path::Path, +}; /// Shared helper to generate, write, and log monorepo build configuration files. fn write_mono_repo_config( @@ -170,8 +174,8 @@ pub fn create_mono_repo_package_json( continue; } let pkg_path = repos_path.join(dir).join("package.json"); - if let Ok(content) = fs::read_to_string(&pkg_path) { - match serde_json::from_str::(&content) { + if let Ok(content) = read_to_string(&pkg_path) { + match from_str::(&content) { Ok(json) => { if let Some(name) = json.get("name").and_then(|n| n.as_str()) { overrides.push(format!(" \"{name}\": \"*\"")); diff --git a/src/commands/mono/display.rs b/src/commands/mono/display.rs index 69aa62e..1b1b9ce 100644 --- a/src/commands/mono/display.rs +++ b/src/commands/mono/display.rs @@ -1,11 +1,14 @@ use crate::{ - cli::BuildSystem, + cli::{BuildSystem, BuildSystem::Npm}, ctx::{IoCtx, RunFlags}, repository::repo_dir_name, }; +use dunce::canonicalize; use std::{ collections::HashMap, + hash::BuildHasher, path::{Path, PathBuf}, + time::Instant, }; /// Resolved display paths for the setup completion summary. @@ -20,7 +23,7 @@ pub struct SetupPaths { /// Resolves display paths for setup completion summary. #[must_use] -pub fn resolve_setup_paths( +pub fn resolve_setup_paths( canonical_map: Option<&HashMap>, mono_repo_path: &Path, build_path: &Path, @@ -28,7 +31,7 @@ pub fn resolve_setup_paths( build_system: Option, ) -> SetupPaths { let mono_repo_disp = - dunce::canonicalize(mono_repo_path).unwrap_or_else(|_| mono_repo_path.to_path_buf()); + canonicalize(mono_repo_path).unwrap_or_else(|_| mono_repo_path.to_path_buf()); let (exe_path, build_disp) = if let Some(map) = canonical_map { let test_repo_name = repo_dir_name(test_repo); @@ -45,14 +48,14 @@ pub fn resolve_setup_paths( .join("repos") .join(&test_repo_name) .join(&exe_name); - dunce::canonicalize(&p).unwrap_or(p) + canonicalize(&p).unwrap_or(p) }); (exe_path, None) } else { - let build_disp = if build_system == Some(BuildSystem::Npm) { + let build_disp = if build_system == Some(Npm) { None } else { - Some(dunce::canonicalize(build_path).unwrap_or_else(|_| build_path.to_path_buf())) + Some(canonicalize(build_path).unwrap_or_else(|_| build_path.to_path_buf())) }; (None, build_disp) }; @@ -67,7 +70,7 @@ pub fn resolve_setup_paths( /// Prints the setup completion summary. pub fn print_setup_complete( paths: &SetupPaths, - total: std::time::Instant, + total: Instant, io: &mut IoCtx<'_>, flags: RunFlags, ) { diff --git a/src/commands/mono/mod.rs b/src/commands/mono/mod.rs index d271f15..ed0aaa4 100644 --- a/src/commands/mono/mod.rs +++ b/src/commands/mono/mod.rs @@ -3,7 +3,7 @@ pub use config::{ create_mono_repo_cmakelists, create_mono_repo_mesonbuild, create_mono_repo_package_json, }; pub mod display; -pub use display::print_setup_complete; +pub use display::{print_setup_complete, resolve_setup_paths}; pub mod mode; pub use mode::mono_repo_mode; pub mod resolve; diff --git a/src/commands/mono/mode.rs b/src/commands/mono/mode.rs index a3d7508..7ab2fd4 100644 --- a/src/commands/mono/mode.rs +++ b/src/commands/mono/mode.rs @@ -1,5 +1,5 @@ use crate::{ - cli::{detect_mono_build_system, BuildSystem, ResolvedArgs}, + cli::{detect_mono_build_system, BuildSystem::Npm, ResolvedArgs}, commands::{ build_project, build_repo_list, extract_repo_input, mono::{ @@ -16,6 +16,7 @@ use crate::{ use std::{ fs, path::{Path, PathBuf}, + time::Instant, }; /// Clones and configures a mono-repo ecosystem from a profile or explicit repository list. @@ -27,7 +28,7 @@ pub fn mono_repo_mode( base_dir: &Path, ctx: &mut RunCtx<'_, '_>, ) -> Result<(), String> { - let total = std::time::Instant::now(); + let total = Instant::now(); let repo_input = extract_repo_input(args)?; let test_repo = resolve_test_repo(repo_input)?; let deps = resolve_repos_for_mono(args, config, &mut ctx.io)?; @@ -83,7 +84,7 @@ pub fn mono_repo_mode( let canonical_map = if let Some(bs) = build_system { let map = generate_mono_config(bs, &mono_repo_path, &repos_path, &repo_dirs, &repos, ctx)?; - if bs != BuildSystem::Npm { + if bs != Npm { prepare_build_dir(build_path.as_path(), args.build.clean, ctx)?; } else if args.build.clean && ctx.flags.verbose { writeln!(ctx.io.output, " --clean has no effect for npm projects").ok(); @@ -94,7 +95,7 @@ pub fn mono_repo_mode( None }; - if build_system == Some(BuildSystem::Npm) + if build_system == Some(Npm) && !args.build.no_watch && generate_watch_scripts(&mono_repo_path, &repos_path, &repos, &mut ctx.io, ctx.flags)? && args.build.watch @@ -106,7 +107,7 @@ pub fn mono_repo_mode( SetupPaths { mono_repo_disp: mono_repo_path.clone(), exe_path: None, - build_disp: if build_system == Some(BuildSystem::Npm) { + build_disp: if build_system == Some(Npm) { None } else { Some(build_path.clone()) diff --git a/src/commands/mono/resolve.rs b/src/commands/mono/resolve.rs index 17ae900..ad1d9db 100644 --- a/src/commands/mono/resolve.rs +++ b/src/commands/mono/resolve.rs @@ -1,29 +1,5 @@ use crate::{cli::ResolvedArgs, config::SetupConfig, ctx::IoCtx, profile::list_profiles}; -/// Resolves the list of repositories for mono-repo mode from a profile or explicit repo list. -/// # Errors -/// Returns an error if the specified profile does not exist, or has no repositories. -pub fn resolve_repos_for_mono( - args: &ResolvedArgs, - config: &SetupConfig, - io: &mut IoCtx<'_>, -) -> Result, String> { - if let Some(profile_name) = &args.mono.profile { - let profile_repos = config.profiles.get(profile_name).ok_or_else(|| { - list_profiles(config, io); - format!("Profile '{profile_name}' not found") - })?; - if profile_repos.is_empty() { - return Err(format!("Profile '{profile_name}' has no repositories")); - } - Ok(profile_repos.clone()) - } else if let Some(r) = &args.mono.repos { - Ok(r.clone()) - } else { - Err("No repos or profile specified for mono-repo mode".to_string()) - } -} - /// Normalizes a repository input to `username/repo` format. /// # Errors /// Returns an error if the input is not a recognizable GitHub URL or `username/repo` format. @@ -47,3 +23,27 @@ pub fn resolve_test_repo(repo_input: &str) -> Result { Err("Repository must be in format 'username/repo' for mono-repo mode".to_string()) } } + +/// Resolves the list of repositories for mono-repo mode from a profile or explicit repo list. +/// # Errors +/// Returns an error if the specified profile does not exist, or has no repositories. +pub fn resolve_repos_for_mono( + args: &ResolvedArgs, + config: &SetupConfig, + io: &mut IoCtx<'_>, +) -> Result, String> { + if let Some(profile_name) = &args.mono.profile { + let profile_repos = config.profiles.get(profile_name).ok_or_else(|| { + list_profiles(config, io); + format!("Profile '{profile_name}' not found") + })?; + if profile_repos.is_empty() { + return Err(format!("Profile '{profile_name}' has no repositories")); + } + Ok(profile_repos.clone()) + } else if let Some(r) = &args.mono.repos { + Ok(r.clone()) + } else { + Err("No repos or profile specified for mono-repo mode".to_string()) + } +} diff --git a/src/commands/mono/setup.rs b/src/commands/mono/setup.rs index e409549..d77b548 100644 --- a/src/commands/mono/setup.rs +++ b/src/commands/mono/setup.rs @@ -1,5 +1,8 @@ use crate::{ - cli::BuildSystem, + cli::{ + BuildSystem, + BuildSystem::{Cmake, Meson, Npm}, + }, commands::{ create_mono_repo_cmakelists, create_mono_repo_mesonbuild, hoist_wraps, mono::create_mono_repo_package_json, @@ -7,7 +10,11 @@ use crate::{ ctx::RunCtx, repository::repo_dir_name, }; -use std::path::PathBuf; +use std::{ + collections::{HashMap, HashSet}, + iter::once, + path::PathBuf, +}; /// Generates root build configuration files for the mono-repo. /// # Errors @@ -19,14 +26,14 @@ pub fn generate_mono_config( repo_dirs: &[PathBuf], repos: &[String], ctx: &mut RunCtx<'_, '_>, -) -> Result>, String> { +) -> Result>, String> { writeln!(ctx.io.output, " Creating mono-repo configuration").ok(); match build_system { - BuildSystem::Cmake => { + Cmake => { create_mono_repo_cmakelists(mono_repo_path, repos, &mut ctx.io, ctx.flags)?; Ok(None) } - BuildSystem::Meson => { + Meson => { let map = hoist_wraps(repos_path, repo_dirs, &mut ctx.io, ctx.flags)?; let subproject_names: Vec = repos .iter() @@ -42,7 +49,7 @@ pub fn generate_mono_config( create_mono_repo_mesonbuild(mono_repo_path, &subproject_names, &mut ctx.io, ctx.flags)?; Ok(Some(map)) } - BuildSystem::Npm => { + Npm => { create_mono_repo_package_json(mono_repo_path, repos_path, repos, &mut ctx.io, ctx.flags)?; Ok(None) } @@ -52,8 +59,8 @@ pub fn generate_mono_config( /// Builds the full ordered list of repositories, deduplicating by directory name. #[must_use] pub fn build_repo_list(test_repo: &str, deps: &[String]) -> Vec { - let mut seen = std::collections::HashSet::new(); - std::iter::once(test_repo.to_string()) + let mut seen = HashSet::new(); + once(test_repo.to_string()) .chain(deps.iter().cloned()) .filter(|r| seen.insert(repo_dir_name(r))) .collect() diff --git a/src/commands/mono/watch.rs b/src/commands/mono/watch.rs index 1c4e312..33fbda1 100644 --- a/src/commands/mono/watch.rs +++ b/src/commands/mono/watch.rs @@ -3,7 +3,13 @@ use crate::{ repository::repo_dir_name, utils::dry_run_or_do, }; -use std::{fs, path::Path}; +use dunce::canonicalize; +use serde_json::{from_str, Value}; +use std::process::Command; +use std::{ + fs::{read_to_string, write}, + path::Path, +}; /// Reads a lib's package.json and returns the appropriate watch command. fn get_watch_command( @@ -13,7 +19,7 @@ fn get_watch_command( flags: RunFlags, ) -> Option { let pkg_path = repos_path.join(dir).join("package.json"); - match fs::read_to_string(&pkg_path) { + match read_to_string(&pkg_path) { Err(_) => { if flags.verbose { writeln!( @@ -24,7 +30,7 @@ fn get_watch_command( } None } - Ok(content) => match serde_json::from_str::(&content) { + Ok(content) => match from_str::(&content) { Err(_) => { if flags.verbose { writeln!( @@ -106,9 +112,9 @@ pub fn generate_watch_scripts( flags, "Write scripts", || { - fs::write(mono_dir.join("watch.ps1"), ps1_content) + write(mono_dir.join("watch.ps1"), ps1_content) .map_err(|e| format!("Failed to write watch.ps1: {e}"))?; - fs::write(mono_dir.join("watch.sh"), sh_content) + write(mono_dir.join("watch.sh"), sh_content) .map_err(|e| format!("Failed to write watch.sh: {e}"))?; Ok(()) }, @@ -135,8 +141,7 @@ pub fn generate_watch_scripts( if flags.verbose { writeln!(io.output, " Watching {} libraries:", lib_dirs.len()).ok(); for d in &lib_dirs { - let full_path = - dunce::canonicalize(repos_path.join(d)).unwrap_or_else(|_| repos_path.join(d)); + let full_path = canonicalize(repos_path.join(d)).unwrap_or_else(|_| repos_path.join(d)); writeln!(io.output, " {d:<24} -> {}", full_path.display()).ok(); } } @@ -163,7 +168,7 @@ pub fn open_watch_scripts( #[cfg(target_os = "windows")] { let ps1_path = mono_dir.join("watch.ps1"); - std::process::Command::new("powershell") + Command::new("powershell") .args([ "-ExecutionPolicy", "Bypass", @@ -177,7 +182,7 @@ pub fn open_watch_scripts( #[cfg(not(target_os = "windows"))] { let sh_path = mono_dir.join("watch.sh"); - std::process::Command::new("bash") + Command::new("bash") .arg(sh_path.to_str().ok_or("Invalid path")?) .spawn() .map_err(|e| format!("Failed to open watch.sh: {e}"))?; diff --git a/src/commands/mono/wraps.rs b/src/commands/mono/wraps.rs index 9379fe7..0cb3626 100644 --- a/src/commands/mono/wraps.rs +++ b/src/commands/mono/wraps.rs @@ -1,13 +1,12 @@ -use std::{ - collections::HashMap, - fs, - path::{Path, PathBuf}, -}; - use crate::{ ctx::{IoCtx, RunFlags}, utils::dry_run_or_do, }; +use std::{ + collections::HashMap, + fs::{self, read_dir, read_to_string}, + path::{Path, PathBuf}, +}; /// Parses the `project()` name from `meson.build` content. /// Returns the name with hyphens replaced by underscores, or `None` if not found. @@ -97,12 +96,12 @@ pub fn hoist_wraps( if !subprojects_dir.exists() { continue; } - for entry in fs::read_dir(&subprojects_dir).map_err(|e| e.to_string())? { + for entry in read_dir(&subprojects_dir).map_err(|e| e.to_string())? { let path = entry.map_err(|e| e.to_string())?.path(); if path.extension().and_then(|e| e.to_str()) != Some("wrap") { continue; } - let content = fs::read_to_string(&path).map_err(|e| e.to_string())?; + let content = read_to_string(&path).map_err(|e| e.to_string())?; for (key, val) in parse_provide_pairs(&content) { if project_to_dir.contains_key(&key) { provides.entry(key).or_insert(val); diff --git a/src/commands/setup.rs b/src/commands/setup.rs index f4115ea..5484d00 100644 --- a/src/commands/setup.rs +++ b/src/commands/setup.rs @@ -1,11 +1,14 @@ use crate::{cli::ResolvedArgs, ctx::RunCtx, utils::dry_run_or_do}; -use std::fs; +use std::{ + fs::{create_dir_all, remove_dir_all}, + path::Path, +}; /// Prepares the build directory, optionally cleaning it first. /// # Errors /// Returns an error if the build directory cannot be created or removed. pub fn prepare_build_dir( - build_path: &std::path::Path, + build_path: &Path, clean: bool, ctx: &mut RunCtx<'_, '_>, ) -> Result<(), String> { @@ -20,7 +23,7 @@ pub fn prepare_build_dir( "Clean", || { if build_path.exists() { - fs::remove_dir_all(build_path).map_err(|e| e.to_string()) + remove_dir_all(build_path).map_err(|e| e.to_string()) } else { Ok(()) } @@ -39,7 +42,7 @@ pub fn prepare_build_dir( &mut ctx.io, ctx.flags, "Create build directory", - || fs::create_dir_all(build_path).map_err(|e| e.to_string()), + || create_dir_all(build_path).map_err(|e| e.to_string()), )?; if ctx.flags.verbose { diff --git a/src/commands/single.rs b/src/commands/single.rs index 2d8d2ec..d016866 100644 --- a/src/commands/single.rs +++ b/src/commands/single.rs @@ -1,12 +1,15 @@ use crate::{ - cli::{detect_build_system, BuildSystem, ResolvedArgs}, + cli::{detect_build_system, BuildSystem::Npm, ResolvedArgs}, commands::{build_project, extract_repo_input, prepare_build_dir, print_mode_header, ModeHeader}, ctx::RunCtx, prompts::confirm, - repository::{clone_repo, repo_dir_name, ExistsAction}, + repository::{ + clone_repo, repo_dir_name, + ExistsAction::{Skip, Update}, + }, utils::dry_run::detect_or_dry_run, }; -use std::path::Path; +use std::{path::Path, time::Instant}; /// Clones and configures a single repository. /// # Errors @@ -16,7 +19,7 @@ pub fn single_repo_mode( base_dir: &Path, ctx: &mut RunCtx<'_, '_>, ) -> Result<(), String> { - let total = std::time::Instant::now(); + let total = Instant::now(); let repo = extract_repo_input(args)?; let dir_name = repo_dir_name(repo); let repo_path = base_dir.join(&dir_name); @@ -42,9 +45,9 @@ pub fn single_repo_mode( args.connection.ssh, |io| { Ok(if confirm(" Update existing repository?", args.yes, io)? { - ExistsAction::Update + Update } else { - ExistsAction::Skip + Skip }) }, ctx, @@ -57,7 +60,7 @@ pub fn single_repo_mode( })?; if let Some(build_system) = build_system { - if build_system == BuildSystem::Npm { + if build_system == Npm { if args.build.clean && ctx.flags.verbose { writeln!(ctx.io.output, " --clean has no effect for npm projects").ok(); } @@ -70,7 +73,7 @@ pub fn single_repo_mode( if ctx.flags.dry_run || build_system.is_none() { writeln!(ctx.io.output, "Would finish in {dir_name}").ok(); - } else if build_system == Some(BuildSystem::Npm) { + } else if build_system == Some(Npm) { writeln!(ctx.io.output, "Project finished in {dir_name}").ok(); } else { writeln!( diff --git a/src/config/crud.rs b/src/config/crud.rs index 4f385a9..ad1b364 100644 --- a/src/config/crud.rs +++ b/src/config/crud.rs @@ -1,9 +1,10 @@ use crate::{ - cli::BuildType, + cli::BuildType::Debug, config::{format_entry, save_config, ConfigEntry, SetupConfig}, ctx::{IoCtx, RunFlags}, prompts::confirm_abort, }; +use dunce::canonicalize; use std::path::PathBuf; /// Inserts or overwrites a named configuration entry. @@ -47,7 +48,7 @@ pub fn create_default_config( writeln!( io.output, " Would create config file: {}", - dunce::canonicalize(&path).unwrap_or(path).display() + canonicalize(&path).unwrap_or(path).display() ) .ok(); } else { @@ -57,7 +58,7 @@ pub fn create_default_config( "default".to_string(), ConfigEntry { ssh: false, - build_type: BuildType::Debug, + build_type: Debug, build_dir: "build".to_string(), mono_dir: "build-mono".to_string(), no_build: false, @@ -75,7 +76,7 @@ pub fn create_default_config( writeln!( io.output, " Created config file: {}", - dunce::canonicalize(&path).unwrap_or(path).display() + canonicalize(&path).unwrap_or(path).display() ) .ok(); } diff --git a/src/config/io.rs b/src/config/io.rs index fd02471..83f484e 100644 --- a/src/config/io.rs +++ b/src/config/io.rs @@ -1,13 +1,14 @@ use crate::config::SetupConfig; +use serde_json::{from_str, to_string_pretty}; use std::{ - fs, - io::{self, Write}, + fs::{read_to_string, write}, + io::{Error, ErrorKind::PermissionDenied, Write}, path::{Path, PathBuf}, }; /// Returns the list of paths to search for a config file. #[must_use] -pub fn config_locations(path: &std::path::Path) -> Vec { +pub fn config_locations(path: &Path) -> Vec { [ Some(path.to_path_buf()), dirs::home_dir().map(|h| h.join(path)), @@ -17,9 +18,9 @@ pub fn config_locations(path: &std::path::Path) -> Vec { .collect() } -fn io_error_msg(verb: &str, path: &Path, e: &io::Error) -> String { +fn io_error_msg(verb: &str, path: &Path, e: &Error) -> String { match e.kind() { - io::ErrorKind::PermissionDenied => format!("Error: No permission to {verb} {}", path.display()), + PermissionDenied => format!("Error: No permission to {verb} {}", path.display()), _ => format!( "An unexpected error occurred: {verb} {}: {e}", path.display() @@ -48,8 +49,8 @@ pub fn load_config( continue; } let result = crate::time!(timing, output, "Read config", { - match fs::read_to_string(path) { - Ok(contents) => match serde_json::from_str::(&contents) { + match read_to_string(path) { + Ok(contents) => match from_str::(&contents) { Ok(mut config) => { config.path = Some(path.clone()); if verbose { @@ -109,11 +110,10 @@ pub fn save_config( ) }) .clone(); - let json = - serde_json::to_string_pretty(config).map_err(|e| format!("Failed to serialize config: {e}"))?; + let json = to_string_pretty(config).map_err(|e| format!("Failed to serialize config: {e}"))?; crate::time!(timing, output, "Write config", { - fs::write(&path, json).map_err(|e| io_error_msg("write to", &path, &e))?; + write(&path, json).map_err(|e| io_error_msg("write to", &path, &e))?; }); Ok(path) diff --git a/src/ctx.rs b/src/ctx.rs index f50cd94..13798ef 100644 --- a/src/ctx.rs +++ b/src/ctx.rs @@ -3,6 +3,7 @@ use std::{ error::Error, io::{BufRead, Write}, path::Path, + process::Command, }; /// IO context passed to functions that need input/output and behavioral flags. @@ -57,7 +58,7 @@ impl Runner for ProcessRunner { if cmd.is_empty() { return Err("No command provided".to_string()); } - let mut command = std::process::Command::new(cmd[0]); + let mut command = Command::new(cmd[0]); command.args(&cmd[1..]); if let Some(dir) = cwd { command.current_dir(dir); diff --git a/src/interactive.rs b/src/interactive.rs index 54ddfa5..e269919 100644 --- a/src/interactive.rs +++ b/src/interactive.rs @@ -1,5 +1,3 @@ -//! Interactive CLI mode. - use crate::{ cli::{BuildType, ResolvedArgs}, ctx::IoCtx, diff --git a/src/main.rs b/src/main.rs index 4aeb93d..fa39fb8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,13 +1,11 @@ -//! Entry point. Parses arguments, loads config, and dispatches to the appropriate command handler. - use star_setup::run::run; -use std::path::PathBuf; +use std::{path::PathBuf, process::exit}; fn main() { let config_path = PathBuf::from(".star-setup.json"); if let Err(e) = run(config_path) { eprintln!("Error: {e}"); - std::process::exit(1); + exit(1); } } diff --git a/src/repository/clone.rs b/src/repository/clone.rs index 3e363fa..aaf0f7f 100644 --- a/src/repository/clone.rs +++ b/src/repository/clone.rs @@ -1,6 +1,9 @@ use crate::{ ctx::{IoCtx, RunCtx}, - prompts::{confirm, confirm_batch, BatchConfirm}, + prompts::{ + confirm, confirm_batch, + BatchConfirm::{No, NoAll, Yes, YesAll}, + }, repository::{pull_repo, repo_dir_name, resolve_repo_url}, }; use std::{ @@ -133,13 +136,13 @@ pub fn clone_repos( } Ok( match confirm_batch(" Update existing repository?", allow_all, io)? { - BatchConfirm::Yes => ExistsAction::Update, - BatchConfirm::No => ExistsAction::Skip, - BatchConfirm::YesAll => { + Yes => ExistsAction::Update, + No => ExistsAction::Skip, + YesAll => { remembered = Some(true); ExistsAction::Update } - BatchConfirm::NoAll => { + NoAll => { remembered = Some(false); ExistsAction::Skip } diff --git a/src/run.rs b/src/run.rs index 08909e4..a7ae642 100644 --- a/src/run.rs +++ b/src/run.rs @@ -1,5 +1,8 @@ use crate::{ - cli::{args::Command, resolve_with_config, Args}, + cli::{ + args::Command::{Config, Profile, Workspace}, + resolve_with_config, Args, + }, commands::{ handle_config_cmd, handle_profile_cmd, handle_workspace_cmd, mono_repo_mode, single_repo_mode, }, @@ -11,7 +14,7 @@ use crate::{ use clap::Parser; use std::{ error::Error, - io::{self, IsTerminal}, + io::{stdin, stdout, IsTerminal}, path::{Path, PathBuf}, }; @@ -21,8 +24,8 @@ use std::{ /// a required tool is missing, /// or the selected mode fails. pub fn run(config_path: PathBuf) -> Result<(), Box> { - let mut stdin = io::stdin().lock(); - let mut stdout = io::stdout(); + let mut stdin = stdin().lock(); + let mut stdout = stdout(); let is_terminal = stdin.is_terminal() && stdout.is_terminal(); let mut raw = Args::parse(); @@ -45,13 +48,13 @@ pub fn run(config_path: PathBuf) -> Result<(), Box> { if let Some(cmd) = command { match cmd { - Command::Config(c) => { + Config(c) => { handle_config_cmd(c.action, &mut config, config_path, yes, &mut io, flags)?; } - Command::Profile(p) => { + Profile(p) => { handle_profile_cmd(p.action, &mut config, yes, &mut io, flags)?; } - Command::Workspace(w) => handle_workspace_cmd(&w.action, io, flags)?, + Workspace(w) => handle_workspace_cmd(&w.action, io, flags)?, } return Ok(()); } diff --git a/src/utils/process.rs b/src/utils/process.rs index dc671a7..86a4788 100644 --- a/src/utils/process.rs +++ b/src/utils/process.rs @@ -1,4 +1,5 @@ use std::{ + env::var, io::Write, path::Path, process::{Command, Stdio}, @@ -12,7 +13,7 @@ use std::{collections::HashMap, path::PathBuf}; #[cfg(target_os = "windows")] fn find_vcvars() -> Option { let program_files = - std::env::var("ProgramFiles(x86)").unwrap_or_else(|_| r"C:\Program Files (x86)".to_string()); + var("ProgramFiles(x86)").unwrap_or_else(|_| r"C:\Program Files (x86)".to_string()); let vswhere = PathBuf::from(program_files).join(r"Microsoft Visual Studio\Installer\vswhere.exe"); if !vswhere.exists() { @@ -81,8 +82,10 @@ pub fn run_command( let npm_cmd; #[cfg(target_os = "windows")] let (exe, args) = if cmd[0] == "npm" { - npm_cmd = std::iter::once("cmd") - .chain(std::iter::once("/c")) + use std::iter::once; + + npm_cmd = once("cmd") + .chain(once("/c")) .chain(cmd.iter().copied()) .collect::>(); (&npm_cmd[0], &npm_cmd[1..]) @@ -100,13 +103,13 @@ pub fn run_command( if *exe == "git" { command.env("GIT_TERMINAL_PROMPT", "0"); - if std::env::var("GIT_SSH_COMMAND").is_err() { + if var("GIT_SSH_COMMAND").is_err() { command.env("GIT_SSH_COMMAND", "ssh -o BatchMode=yes"); } } #[cfg(target_os = "windows")] - if std::env::var("VSINSTALLDIR").is_err() + if var("VSINSTALLDIR").is_err() && Path::new(exe) .file_stem() .is_some_and(|s| s.to_string_lossy().eq_ignore_ascii_case("meson")) diff --git a/src/workspace/clean.rs b/src/workspace/clean.rs index d19b1eb..96cf0b9 100644 --- a/src/workspace/clean.rs +++ b/src/workspace/clean.rs @@ -1,5 +1,5 @@ use crate::{ctx::RunCtx, utils::dry_run_or_do, workspace::Workspace}; -use std::fs; +use std::fs::remove_dir_all; impl Workspace { /// Removes the build directory. @@ -33,7 +33,7 @@ impl Workspace { ctx.flags, "Clean", || { - fs::remove_dir_all(&self.build_path) + remove_dir_all(&self.build_path) .map_err(|e| format!("Failed to remove build directory: {e}")) }, )?; diff --git a/src/workspace/resolve.rs b/src/workspace/resolve.rs index b2fe8ca..46c66d9 100644 --- a/src/workspace/resolve.rs +++ b/src/workspace/resolve.rs @@ -1,5 +1,5 @@ use crate::{ctx::IoCtx, workspace::Workspace}; -use std::{fs, path::Path}; +use std::{fs::read_dir, path::Path}; /// Resolves a workspace from optional path overrides. /// # Errors @@ -26,7 +26,7 @@ pub fn resolve_workspace( )); } - let repo_dirs = fs::read_dir(&repos_path) + let repo_dirs = read_dir(&repos_path) .map_err(|e| format!("Failed to read repos directory: {e}"))? .filter_map(Result::ok) .map(|entry| entry.path()) diff --git a/tests/cli/build/detect.rs b/tests/cli/build/detect.rs index cba3642..50b323f 100644 --- a/tests/cli/build/detect.rs +++ b/tests/cli/build/detect.rs @@ -3,19 +3,22 @@ use star_setup::{ cli::{detect_build_system, detect_mono_build_system, BuildSystem}, ctx::ProcessRunner, }; +use std::{fs::write, path::Path}; -fn create_cmake_fixture(path: &std::path::Path) { - std::fs::write(path.join("CMakeLists.txt"), "").unwrap(); +/* ===== HELPERS ===== */ +fn create_cmake_fixture(path: &Path) { + write(path.join("CMakeLists.txt"), "").unwrap(); } -fn create_meson_fixture(path: &std::path::Path) { - std::fs::write(path.join("meson.build"), "").unwrap(); +fn create_meson_fixture(path: &Path) { + write(path.join("meson.build"), "").unwrap(); } -fn create_npm_fixture(path: &std::path::Path) { - std::fs::write(path.join("package.json"), "{}").unwrap(); +fn create_npm_fixture(path: &Path) { + write(path.join("package.json"), "{}").unwrap(); } +/* ===== DETECT_BUILD_SYSTEM ===== */ #[test] fn test_detect_build_system_none() { with_ctx_input(b"", ProcessRunner, |path, ctx| { @@ -81,6 +84,30 @@ fn test_detect_build_system_timing_output() { .contains("[timing] Scanned directory:")); } +#[test] +fn test_detect_build_system_npm() { + with_ctx_input(b"", ProcessRunner, |path, ctx| { + create_npm_fixture(path); + assert!(matches!( + detect_build_system(path, ctx).unwrap(), + BuildSystem::Npm + )); + }); +} + +#[test] +fn test_detect_build_system_cmake_and_npm_picks_npm() { + with_ctx_input(b"2\n", ProcessRunner, |path, ctx| { + create_cmake_fixture(path); + create_npm_fixture(path); + assert!(matches!( + detect_build_system(path, ctx).unwrap(), + BuildSystem::Npm + )); + }); +} + +/* ===== DETECT_MONO_BUILD_SYSTEM ===== */ #[test] fn test_detect_mono_build_system_none() { with_ctx_input(b"", ProcessRunner, |path, ctx| { @@ -146,29 +173,6 @@ fn test_detect_mono_build_system_timing_output() { .contains("[timing] Scanned directories:")); } -#[test] -fn test_detect_build_system_npm() { - with_ctx_input(b"", ProcessRunner, |path, ctx| { - create_npm_fixture(path); - assert!(matches!( - detect_build_system(path, ctx).unwrap(), - BuildSystem::Npm - )); - }); -} - -#[test] -fn test_detect_build_system_cmake_and_npm_picks_npm() { - with_ctx_input(b"2\n", ProcessRunner, |path, ctx| { - create_cmake_fixture(path); - create_npm_fixture(path); - assert!(matches!( - detect_build_system(path, ctx).unwrap(), - BuildSystem::Npm - )); - }); -} - #[test] fn test_detect_mono_build_system_npm() { with_ctx_input(b"", ProcessRunner, |path, ctx| { diff --git a/tests/cli/resolve.rs b/tests/cli/resolve.rs index 57098a1..8d50a7b 100644 --- a/tests/cli/resolve.rs +++ b/tests/cli/resolve.rs @@ -4,6 +4,7 @@ use star_setup::{ config::{ConfigEntry, SetupConfig}, }; +/* ===== HELPERS ===== */ /// Generates a base `ConfigEntry` with defaults. fn create_test_config_entry() -> ConfigEntry { ConfigEntry { @@ -28,6 +29,7 @@ fn config_with_entry(name: &str, entry: ConfigEntry) -> SetupConfig { config } +/* ===== RESOLVE_BOOL ===== */ #[test] fn test_resolve_bool() { #[allow(clippy::struct_excessive_bools)] @@ -93,7 +95,7 @@ fn test_resolve_bool() { } } -// resolve_with_config tests +/* ===== RESOLVE_WITH_CONFIG ===== */ #[test] fn test_resolve_with_config_defaults_when_no_config() { let config = SetupConfig::new(); diff --git a/tests/commands/build.rs b/tests/commands/build.rs index e323ab6..16f4eef 100644 --- a/tests/commands/build.rs +++ b/tests/commands/build.rs @@ -1,13 +1,33 @@ -use super::common::{default_resolved_with_no_build, with_ctx, with_ctx_runner, MockRunner}; +use crate::common::{default_resolved_with_no_build, with_ctx, with_ctx_runner, MockRunner}; use star_setup::{ cli::BuildSystem, - commands::{build_project, cmake_build, meson_build}, + commands::{build_project, cmake_build, meson_build, npm_build}, }; +/* ===== BUILD_PROJECT ===== */ #[test] -fn test_cmake_build_configure_only() { +fn test_build_project_dispatches_meson() { let args = default_resolved_with_no_build(true); let runner = with_ctx_runner(MockRunner::new(), |tmp_path, ctx| { + build_project(&args, tmp_path, tmp_path, BuildSystem::Meson, false, ctx).unwrap(); + }); + assert!(runner.calls[0].0.contains(&"meson".to_string())); +} + +#[test] +fn test_build_project_dispatches_npm() { + let args = default_resolved_with_no_build(true); + let runner = with_ctx_runner(MockRunner::new(), |tmp_path, ctx| { + build_project(&args, tmp_path, tmp_path, BuildSystem::Npm, false, ctx).unwrap(); + }); + assert!(runner.calls[0].0.contains(&"install".to_string())); +} + +/* ===== CMAKE_BUILD ===== */ +#[test] +fn test_cmake_build_configure_only() { + let args = default_resolved_with_no_build(true); + let runner: MockRunner = with_ctx_runner(MockRunner::new(), |tmp_path, ctx| { cmake_build(&args, tmp_path, false, ctx).unwrap(); }); assert_eq!(runner.calls.len(), 1); @@ -33,6 +53,7 @@ fn test_cmake_build_mono_flag() { assert!(runner.calls[0].0.contains(&"-DBUILD_LOCAL=ON".to_string())); } +/* ===== MESON_BUILD ===== */ #[test] fn test_meson_build_configure_only() { let args = default_resolved_with_no_build(true); @@ -63,20 +84,12 @@ fn test_build_project_dispatches_cmake() { assert!(runner.calls[0].0.contains(&"cmake".to_string())); } -#[test] -fn test_build_project_dispatches_meson() { - let args = default_resolved_with_no_build(true); - let runner = with_ctx_runner(MockRunner::new(), |tmp_path, ctx| { - build_project(&args, tmp_path, tmp_path, BuildSystem::Meson, false, ctx).unwrap(); - }); - assert!(runner.calls[0].0.contains(&"meson".to_string())); -} - +/* ===== NPM_BUILD ===== */ #[test] fn test_npm_build_install_only() { let args = default_resolved_with_no_build(true); let runner = with_ctx_runner(MockRunner::new(), |tmp_path, ctx| { - star_setup::commands::npm_build(&args, tmp_path, false, ctx).unwrap(); + npm_build(&args, tmp_path, false, ctx).unwrap(); }); assert_eq!(runner.calls.len(), 1); assert!(runner.calls[0].0.contains(&"install".to_string())); @@ -86,7 +99,7 @@ fn test_npm_build_install_only() { fn test_npm_build_with_build_step() { let args = default_resolved_with_no_build(false); let runner = with_ctx_runner(MockRunner::new(), |tmp_path, ctx| { - star_setup::commands::npm_build(&args, tmp_path, false, ctx).unwrap(); + npm_build(&args, tmp_path, false, ctx).unwrap(); }); assert_eq!(runner.calls.len(), 2); assert!(runner.calls[1].0.contains(&"build".to_string())); @@ -96,17 +109,8 @@ fn test_npm_build_with_build_step() { fn test_npm_build_install_only_prints_header() { let args = default_resolved_with_no_build(true); let (_, output) = with_ctx(MockRunner::new(), |tmp_path, ctx| { - star_setup::commands::npm_build(&args, tmp_path, false, ctx).unwrap(); + npm_build(&args, tmp_path, false, ctx).unwrap(); }); let out = String::from_utf8(output).unwrap(); assert!(out.contains("Installing dependencies")); } - -#[test] -fn test_build_project_dispatches_npm() { - let args = default_resolved_with_no_build(true); - let runner = with_ctx_runner(MockRunner::new(), |tmp_path, ctx| { - build_project(&args, tmp_path, tmp_path, BuildSystem::Npm, false, ctx).unwrap(); - }); - assert!(runner.calls[0].0.contains(&"install".to_string())); -} diff --git a/tests/commands/display.rs b/tests/commands/display.rs index ad88395..728cfe4 100644 --- a/tests/commands/display.rs +++ b/tests/commands/display.rs @@ -1,6 +1,6 @@ use crate::common::{make_flags, with_io_dir, with_io_input_output}; use star_setup::{ - commands::mono::display::{print_setup_complete, resolve_setup_paths}, + commands::mono::{print_setup_complete, resolve_setup_paths}, ctx::RunFlags, }; use std::{collections::HashMap, time::Instant}; diff --git a/tests/commands/header.rs b/tests/commands/header.rs index a27f962..ffb6831 100644 --- a/tests/commands/header.rs +++ b/tests/commands/header.rs @@ -1,4 +1,4 @@ -use super::common::with_io_input_output; +use crate::common::with_io_input_output; use star_setup::commands::{print_mode_header, ModeHeader}; #[test] diff --git a/tests/commands/mono/config.rs b/tests/commands/mono/config.rs index 78409dd..85efbc5 100644 --- a/tests/commands/mono/config.rs +++ b/tests/commands/mono/config.rs @@ -1,7 +1,10 @@ use crate::common::{make_flags, with_io_dir}; -use star_setup::commands::{create_mono_repo_cmakelists, create_mono_repo_mesonbuild}; +use star_setup::commands::{ + create_mono_repo_cmakelists, create_mono_repo_mesonbuild, create_mono_repo_package_json, +}; +use std::fs::{create_dir_all, read_to_string, write}; -// create_mono_repo_cmakelists tests +/* ===== CREATE_MONO_REPO_CMAKELISTS ===== */ #[test] fn test_create_mono_repo_cmakelists_creates_file() { with_io_dir(|tmp_path, io| { @@ -15,7 +18,7 @@ fn test_create_mono_repo_cmakelists_creates_file() { let cmake_file = tmp_path.join("CMakeLists.txt"); assert!(cmake_file.exists()); - let content = std::fs::read_to_string(&cmake_file).unwrap(); + let content = read_to_string(&cmake_file).unwrap(); assert!(content.contains("user-testrepo")); assert!(content.contains("user-lib1")); assert!(content.contains("user-lib2")); @@ -31,7 +34,7 @@ fn test_create_mono_repo_cmakelists_empty_repos() { }); } -// create_mono_repo_mesonbuild tests +/* ===== CREATE_MONO_REPO_MESONBUILD ===== */ #[test] fn test_create_mono_repo_mesonbuild_creates_file() { with_io_dir(|tmp_path, io| { @@ -45,7 +48,7 @@ fn test_create_mono_repo_mesonbuild_creates_file() { let meson_file = tmp_path.join("meson.build"); assert!(meson_file.exists()); - let content = std::fs::read_to_string(&meson_file).unwrap(); + let content = read_to_string(&meson_file).unwrap(); assert!(content.contains("user-testrepo")); assert!(content.contains("user-lib1")); assert!(content.contains("user-lib2")); @@ -61,18 +64,19 @@ fn test_create_mono_repo_mesonbuild_empty_repos() { }); } +/* ===== CREATE_MONO_REPO_PACKAGE_JSON ===== */ #[test] fn test_create_mono_repo_package_json_creates_file() { with_io_dir(|tmp_path, io| { let repos_path = tmp_path.join("repos"); - std::fs::create_dir_all(repos_path.join("user-lib1")).unwrap(); - std::fs::create_dir_all(repos_path.join("user-lib2")).unwrap(); - std::fs::write( + create_dir_all(repos_path.join("user-lib1")).unwrap(); + create_dir_all(repos_path.join("user-lib2")).unwrap(); + write( repos_path.join("user-lib1").join("package.json"), r#"{"name": "@user/lib1"}"#, ) .unwrap(); - std::fs::write( + write( repos_path.join("user-lib2").join("package.json"), r#"{"name": "@user/lib2"}"#, ) @@ -83,16 +87,9 @@ fn test_create_mono_repo_package_json_creates_file() { "user/lib1".to_string(), "user/lib2".to_string(), ]; - star_setup::commands::create_mono_repo_package_json( - tmp_path, - &repos_path, - &repos, - io, - make_flags(), - ) - .unwrap(); + create_mono_repo_package_json(tmp_path, &repos_path, &repos, io, make_flags()).unwrap(); - let content = std::fs::read_to_string(tmp_path.join("package.json")).unwrap(); + let content = read_to_string(tmp_path.join("package.json")).unwrap(); assert!(content.contains("workspaces")); assert!(content.contains("repos/user-lib1")); assert!(content.contains("repos/user-lib2")); diff --git a/tests/commands/mono/mode.rs b/tests/commands/mono/mode.rs index 4328799..e626f4f 100644 --- a/tests/commands/mono/mode.rs +++ b/tests/commands/mono/mode.rs @@ -1,5 +1,7 @@ use crate::common::{default_resolved_mono, with_ctx, with_ctx_runner, MockRunner}; -use star_setup::{commands::mono_repo_mode, config::SetupConfig, ctx::DryRunRunner}; +use star_setup::{ + cli::BuildSystem, commands::mono_repo_mode, config::SetupConfig, ctx::DryRunRunner, +}; use std::{ fs::{create_dir_all, read_dir, write}, path::Path, @@ -45,11 +47,7 @@ fn test_mono_repo_mode_dry_run_makes_no_fs_changes() { #[test] fn test_mono_repo_mode_dry_run_with_build_system_makes_no_fs_changes() { - for bs in [ - star_setup::cli::BuildSystem::Npm, - star_setup::cli::BuildSystem::Cmake, - star_setup::cli::BuildSystem::Meson, - ] { + for bs in [BuildSystem::Npm, BuildSystem::Cmake, BuildSystem::Meson] { let mut args = default_resolved_mono(vec!["user/lib1".to_string()]); args.diagnostic.dry_run = true; args.build.build_system = Some(bs); @@ -71,7 +69,7 @@ fn test_mono_repo_mode_dry_run_with_build_system_makes_no_fs_changes() { #[test] fn test_mono_repo_mode_with_build_system_flag() { let mut args = default_resolved_mono(vec!["user/lib1".to_string()]); - args.build.build_system = Some(star_setup::cli::BuildSystem::Cmake); + args.build.build_system = Some(BuildSystem::Cmake); let runner = with_ctx_runner(MockRunner::new(), |tmp_path, ctx| { let repos_path = tmp_path.join(&args.mono.mono_dir).join("repos"); diff --git a/tests/commands/mono/resolve.rs b/tests/commands/mono/resolve.rs index 4e7604b..6b12c14 100644 --- a/tests/commands/mono/resolve.rs +++ b/tests/commands/mono/resolve.rs @@ -1,10 +1,15 @@ use crate::common::{default_resolved, with_ctx, with_io, MockRunner}; use star_setup::{ - commands::{mono::generate_mono_config, resolve_repos_for_mono, resolve_test_repo}, + cli::BuildSystem, + commands::{generate_mono_config, resolve_repos_for_mono, resolve_test_repo}, config::SetupConfig, }; +use std::{ + fs::{create_dir_all, read_to_string, write}, + slice::from_ref, +}; -// resolve_test_repo tests +/* ===== RESOLVE_TEST_REPO ===== */ #[test] fn test_resolve_test_repo() { let cases = [ @@ -45,6 +50,7 @@ fn test_resolve_test_repo_errors() { } } +/* ===== RESOLVE_REPOS_FOR_MONO ===== */ #[test] fn test_resolve_repos_for_mono_empty_profile_errors() { let mut config = SetupConfig::new(); @@ -116,21 +122,22 @@ fn test_resolve_repos_for_mono_profile_not_found_errors() { }); } +/* ===== GENERATE_MONO_CONFIG ===== */ #[test] fn test_generate_mono_config_meson() { with_ctx(MockRunner::new(), |tmp_path, ctx| { let repos_path = tmp_path.join("repos"); - std::fs::create_dir_all(&repos_path).unwrap(); + create_dir_all(&repos_path).unwrap(); let repo_dir = repos_path.join("user-lib1"); - std::fs::create_dir_all(&repo_dir).unwrap(); - std::fs::write(repo_dir.join("meson.build"), "project('user-lib1', 'cpp')").unwrap(); + create_dir_all(&repo_dir).unwrap(); + write(repo_dir.join("meson.build"), "project('user-lib1', 'cpp')").unwrap(); let result = generate_mono_config( - star_setup::cli::BuildSystem::Meson, + BuildSystem::Meson, tmp_path, &repos_path, - std::slice::from_ref(&repo_dir), + from_ref(&repo_dir), &["user/lib1".to_string()], ctx, ); @@ -141,7 +148,7 @@ fn test_generate_mono_config_meson() { let meson_build = tmp_path.join("meson.build"); assert!(meson_build.exists()); - let content = std::fs::read_to_string(&meson_build).unwrap(); + let content = read_to_string(&meson_build).unwrap(); assert!(content.contains("user_lib1") || content.contains("user-lib1")); }); } @@ -150,10 +157,10 @@ fn test_generate_mono_config_meson() { fn test_generate_mono_config_npm() { with_ctx(MockRunner::new(), |tmp_path, ctx| { let repos_path = tmp_path.join("repos"); - std::fs::create_dir_all(&repos_path).unwrap(); + create_dir_all(&repos_path).unwrap(); let result = generate_mono_config( - star_setup::cli::BuildSystem::Npm, + BuildSystem::Npm, tmp_path, &repos_path, &[], @@ -165,7 +172,7 @@ fn test_generate_mono_config_npm() { assert!(result.unwrap().is_none()); let pkg = tmp_path.join("package.json"); assert!(pkg.exists()); - let content = std::fs::read_to_string(&pkg).unwrap(); + let content = read_to_string(&pkg).unwrap(); assert!(content.contains("workspaces")); assert!(content.contains("repos/user-lib1")); }); diff --git a/tests/commands/mono/watch.rs b/tests/commands/mono/watch.rs index 3f47244..a66bd5d 100644 --- a/tests/commands/mono/watch.rs +++ b/tests/commands/mono/watch.rs @@ -1,9 +1,14 @@ use crate::common::{make_flags, with_io_dir, with_io_output}; -use star_setup::commands::mono::watch::generate_watch_scripts; +use star_setup::{commands::generate_watch_scripts, ctx::RunFlags}; +use std::{ + fs::{create_dir_all, read_to_string, write}, + path::Path, +}; +use tempfile::TempDir; -fn make_repo_with_scripts(repos_path: &std::path::Path, dir: &str, scripts: &str) { - std::fs::create_dir_all(repos_path.join(dir)).unwrap(); - std::fs::write( +fn make_repo_with_scripts(repos_path: &Path, dir: &str, scripts: &str) { + create_dir_all(repos_path.join(dir)).unwrap(); + write( repos_path.join(dir).join("package.json"), format!(r#"{{"name": "@user/{dir}", "scripts": {{{scripts}}}}}"#), ) @@ -33,7 +38,7 @@ fn test_generate_watch_scripts_prefers_watch_script() { ); let repos = vec!["user/game".to_string(), "user/lib1".to_string()]; generate_watch_scripts(tmp_path, &repos_path, &repos, io, make_flags()).unwrap(); - let ps1 = std::fs::read_to_string(tmp_path.join("watch.ps1")).unwrap(); + let ps1 = read_to_string(tmp_path.join("watch.ps1")).unwrap(); assert!(ps1.contains("run watch")); assert!(!ps1.contains("run build")); }); @@ -46,7 +51,7 @@ fn test_generate_watch_scripts_falls_back_to_build() { make_repo_with_scripts(&repos_path, "user-lib1", r#""build": "tsdown""#); let repos = vec!["user/game".to_string(), "user/lib1".to_string()]; generate_watch_scripts(tmp_path, &repos_path, &repos, io, make_flags()).unwrap(); - let ps1 = std::fs::read_to_string(tmp_path.join("watch.ps1")).unwrap(); + let ps1 = read_to_string(tmp_path.join("watch.ps1")).unwrap(); assert!(ps1.contains("run build -- --watch")); }); } @@ -66,15 +71,15 @@ fn test_generate_watch_scripts_empty_libs() { fn test_generate_watch_scripts_no_scripts_field() { with_io_dir(|tmp_path, io| { let repos_path = tmp_path.join("repos"); - std::fs::create_dir_all(repos_path.join("user-lib1")).unwrap(); - std::fs::write( + create_dir_all(repos_path.join("user-lib1")).unwrap(); + write( repos_path.join("user-lib1").join("package.json"), r#"{"name": "@user/lib1"}"#, ) .unwrap(); let repos = vec!["user/game".to_string(), "user/lib1".to_string()]; generate_watch_scripts(tmp_path, &repos_path, &repos, io, make_flags()).unwrap(); - let ps1 = std::fs::read_to_string(tmp_path.join("watch.ps1")).unwrap(); + let ps1 = read_to_string(tmp_path.join("watch.ps1")).unwrap(); assert!(!ps1.contains("user-lib1")); }); } @@ -82,11 +87,11 @@ fn test_generate_watch_scripts_no_scripts_field() { #[test] fn test_generate_watch_scripts_verbose_output() { let ((), out) = with_io_output(|io| { - let tmp = tempfile::TempDir::new().unwrap(); + let tmp = TempDir::new().unwrap(); let repos_path = tmp.path().join("repos"); make_repo_with_scripts(&repos_path, "user-lib1", r#""build": "tsdown""#); let repos = vec!["user/game".to_string(), "user/lib1".to_string()]; - let flags = star_setup::ctx::RunFlags { + let flags = RunFlags { verbose: true, timing: false, dry_run: false, diff --git a/tests/commands/mono/wraps.rs b/tests/commands/mono/wraps.rs index 998873a..0220fa5 100644 --- a/tests/commands/mono/wraps.rs +++ b/tests/commands/mono/wraps.rs @@ -1,7 +1,9 @@ use crate::common::{make_flags, with_io_dir}; use star_setup::commands::{hoist_wraps, parse_project_name, parse_provide_pairs}; +use std::fs::{create_dir, read_to_string, write}; use tempfile::TempDir; +/* ===== PARSE_PROJECT_NAME ===== */ #[test] fn test_parse_project_name() { let cases = [ @@ -22,6 +24,7 @@ fn test_parse_project_name() { } } +/* ===== PARSE_PROVIDE_PAIRS ===== */ #[test] fn test_parse_provide_pairs_basic() { let content = "[provide]\nmy_lib = my_lib_dep\n"; @@ -57,9 +60,10 @@ fn test_parse_provide_pairs_no_provide_section() { assert!(pairs.is_empty()); } +/* ===== HELPERS ===== */ fn make_repo(project_name: &str) -> TempDir { let tmp = TempDir::new().unwrap(); - std::fs::write( + write( tmp.path().join("meson.build"), format!("project('{project_name}', 'cpp')"), ) @@ -67,6 +71,7 @@ fn make_repo(project_name: &str) -> TempDir { tmp } +/* ===== HOIST_WRAPS ===== */ #[test] fn test_hoist_wraps_empty_repos() { with_io_dir(|repos_dir, io| { @@ -94,7 +99,7 @@ fn test_hoist_wraps_emits_wrap_without_provide() { let wrap = repos_dir.join("my_lib.wrap"); assert!(wrap.exists()); - let content = std::fs::read_to_string(&wrap).unwrap(); + let content = read_to_string(&wrap).unwrap(); assert!(content.contains("directory =")); assert!(!content.contains("[provide]")); }); @@ -105,19 +110,19 @@ fn test_hoist_wraps_emits_wrap_with_provide() { with_io_dir(|repos_dir, io| { let repo = make_repo("my-lib"); let subprojects = repo.path().join("subprojects"); - std::fs::create_dir(&subprojects).unwrap(); - std::fs::write( + create_dir(&subprojects).unwrap(); + write( subprojects.join("my_lib.wrap"), "[provide]\nmy_lib = my_lib_dep\n", ) .unwrap(); - std::fs::write(subprojects.join("readme.txt"), "ignore me").unwrap(); + write(subprojects.join("readme.txt"), "ignore me").unwrap(); let result = hoist_wraps(repos_dir, &[repo.path().to_path_buf()], io, make_flags()).unwrap(); assert!(result.contains_key("my_lib")); let wrap = repos_dir.join("my_lib.wrap"); - let content = std::fs::read_to_string(&wrap).unwrap(); + let content = read_to_string(&wrap).unwrap(); assert!(content.contains("[provide]")); assert!(content.contains("my_lib = my_lib_dep")); }); diff --git a/tests/common/args.rs b/tests/common/args.rs index 9c6eb55..f2e293d 100644 --- a/tests/common/args.rs +++ b/tests/common/args.rs @@ -1,7 +1,10 @@ #![allow(dead_code)] use star_setup::{ - cli::{resolve_with_config, Args, BuildFlags, ConnectionFlags, DiagnosticFlags, MonoRepoFlags}, + cli::{ + resolve_with_config, Args, BuildFlags, ConnectionFlags, DiagnosticFlags, MonoRepoFlags, + ResolvedArgs, + }, config::SetupConfig, }; @@ -45,25 +48,25 @@ pub fn default_args() -> Args { } } -pub fn default_resolved() -> star_setup::cli::ResolvedArgs { +pub fn default_resolved() -> ResolvedArgs { let mut args = default_args(); args.repo = Some("user/repo".to_string()); args.build.no_build = true; resolve_with_config(args, &SetupConfig::new()).unwrap() } -pub fn default_resolved_with_no_build(no_build: bool) -> star_setup::cli::ResolvedArgs { +pub fn default_resolved_with_no_build(no_build: bool) -> ResolvedArgs { let mut args = default_args(); args.repo = Some("user/repo".to_string()); args.build.no_build = no_build; resolve_with_config(args, &SetupConfig::new()).unwrap() } -pub fn default_resolved_interactive() -> star_setup::cli::ResolvedArgs { +pub fn default_resolved_interactive() -> ResolvedArgs { resolve_with_config(default_args(), &SetupConfig::new()).unwrap() } -pub fn default_resolved_mono(repos: Vec) -> star_setup::cli::ResolvedArgs { +pub fn default_resolved_mono(repos: Vec) -> ResolvedArgs { let mut args = default_args(); args.repo = Some("user/test-repo".to_string()); args.yes = true; diff --git a/tests/common/io.rs b/tests/common/io.rs index e30f8ba..02e06b4 100644 --- a/tests/common/io.rs +++ b/tests/common/io.rs @@ -1,4 +1,5 @@ use star_setup::ctx::{IoCtx, RunFlags}; +use std::io::{BufRead, Write}; pub fn sink() -> Vec { vec![] @@ -8,10 +9,7 @@ pub fn empty_input() -> &'static [u8] { b"" } -pub fn make_io<'a>( - input: &'a mut dyn std::io::BufRead, - output: &'a mut dyn std::io::Write, -) -> IoCtx<'a> { +pub fn make_io<'a>(input: &'a mut dyn BufRead, output: &'a mut dyn Write) -> IoCtx<'a> { IoCtx { input, output } } diff --git a/tests/common/runner.rs b/tests/common/runner.rs index 6e151aa..40e9a02 100644 --- a/tests/common/runner.rs +++ b/tests/common/runner.rs @@ -1,9 +1,12 @@ use star_setup::ctx::{RunFlags, Runner}; -use std::collections::VecDeque; -use std::{io::Write, path::Path}; +use std::{ + collections::VecDeque, + io::Write, + path::{Path, PathBuf}, +}; pub struct MockRunner { - pub calls: Vec<(Vec, Option)>, + pub calls: Vec<(Vec, Option)>, pub fail_on: Option, pub capture_output: String, pub capture_responses: VecDeque, diff --git a/tests/config/crud.rs b/tests/config/crud.rs index b06d068..cb54f72 100644 --- a/tests/config/crud.rs +++ b/tests/config/crud.rs @@ -1,5 +1,7 @@ -use super::fixtures::sample_entry; -use crate::common::{make_flags, with_io_dir, with_io_input_output, with_io_output}; +use crate::{ + common::{make_flags, with_io_dir, with_io_input_output, with_io_output}, + fixtures::sample_entry, +}; use star_setup::{ cli::BuildType, config::{ @@ -7,14 +9,10 @@ use star_setup::{ remove_config_entry, save_config, ConfigEntry, SetupConfig, }, }; +use std::fs::{read_to_string, write}; +use tempfile::TempDir; -#[test] -fn test_has_config_true() { - let mut config = SetupConfig::new(); - insert_config(&mut config, "myconfig", sample_entry()); - assert!(has_config(&config, "myconfig")); -} - +/* ===== HAS_CONFIG ===== */ #[test] fn test_has_config_false() { let config = SetupConfig::new(); @@ -42,10 +40,11 @@ fn test_add_config_inserts_and_saves() { }); } +/* ===== ADD_CONFIG ===== */ #[test] fn test_add_config_aborts_when_exists_and_not_confirmed() { with_io_input_output(b"n\n", |io| { - let tmp = tempfile::TempDir::new().unwrap(); + let tmp = TempDir::new().unwrap(); let mut config = SetupConfig::new(); config.path = Some(tmp.path().join(".star-setup.json")); insert_config(&mut config, "myconfig", sample_entry()); @@ -75,6 +74,14 @@ fn test_add_config_aborts_when_exists_and_not_confirmed() { }); } +/* ===== INSERT_CONFIG ===== */ +#[test] +fn test_has_config_true() { + let mut config = SetupConfig::new(); + insert_config(&mut config, "myconfig", sample_entry()); + assert!(has_config(&config, "myconfig")); +} + #[test] fn test_insert_config() { let mut config = SetupConfig::new(); @@ -91,6 +98,7 @@ fn test_remove_config_entry_exists() { assert!(!config.configs.contains_key("myconfig")); } +/* ===== CREATE_DEFAULT_CONFIG ===== */ #[test] fn test_create_default_config_creates_file() { with_io_dir(|tmp, io| { @@ -103,15 +111,16 @@ fn test_create_default_config_creates_file() { #[test] fn test_create_default_config_aborts_when_exists_and_not_confirmed() { with_io_input_output(b"n\n", |io| { - let tmp = tempfile::TempDir::new().unwrap(); + let tmp = TempDir::new().unwrap(); let path = tmp.path().join(".star-setup.json"); - std::fs::write(&path, "original").unwrap(); + write(&path, "original").unwrap(); create_default_config(path.clone(), false, io, make_flags()).unwrap(); - assert_eq!(std::fs::read_to_string(&path).unwrap(), "original"); + assert_eq!(read_to_string(&path).unwrap(), "original"); }); } +/* ===== LIST_CONFIGS ===== */ #[test] fn test_list_configs_empty() { let ((), out) = with_io_output(|io| { @@ -132,12 +141,14 @@ fn test_list_configs_with_entries() { assert!(out.contains("Configurations:")); } +/* ===== REMOVE_CONFIG_ENTRY ===== */ #[test] fn test_remove_config_entry_missing() { let mut config = SetupConfig::new(); assert!(!remove_config_entry(&mut config, "nonexistent")); } +/* ===== REMOVE_CONFIG ===== */ #[test] fn test_remove_config_removes_and_saves() { with_io_dir(|tmp, io| { @@ -163,7 +174,7 @@ fn test_remove_config_not_found() { #[test] fn test_remove_config_aborts_when_not_confirmed() { with_io_input_output(b"n\n", |io| { - let tmp = tempfile::TempDir::new().unwrap(); + let tmp = TempDir::new().unwrap(); let mut config = SetupConfig::new(); config.path = Some(tmp.path().join(".star-setup.json")); insert_config(&mut config, "myconfig", sample_entry()); diff --git a/tests/config/display.rs b/tests/config/display.rs index c0e48fb..13bd6fc 100644 --- a/tests/config/display.rs +++ b/tests/config/display.rs @@ -1,4 +1,4 @@ -use super::fixtures::sample_entry; +use crate::fixtures::sample_entry; use star_setup::config::format_entry; #[test] diff --git a/tests/config/io.rs b/tests/config/io.rs index 2a706ef..d4b0b74 100644 --- a/tests/config/io.rs +++ b/tests/config/io.rs @@ -1,15 +1,15 @@ -use crate::common::with_io_output; - -use super::fixtures::sample_entry; +use crate::{common::with_io_output, fixtures::sample_entry}; use star_setup::{ cli::BuildType, config::{insert_config, load_config, save_config, ConfigEntry, SetupConfig}, }; -use std::path::PathBuf; +use std::{fs::write, path::PathBuf}; +use tempfile::TempDir; +/* ===== SAVE_CONFIG ===== */ #[test] fn test_save_and_load_roundtrip() { - let tmp = tempfile::TempDir::new().unwrap(); + let tmp = TempDir::new().unwrap(); let path = tmp.path().join(".star-setup.json"); let mut config = SetupConfig::new(); @@ -42,6 +42,7 @@ fn test_save_and_load_roundtrip() { }); } +/* ===== LOAD_CONFIG ===== */ #[test] fn test_load_config_skips_missing_local_file() { with_io_output(|io| { @@ -52,9 +53,9 @@ fn test_load_config_skips_missing_local_file() { #[test] fn test_load_config_handles_invalid_json() { - let tmp = tempfile::TempDir::new().unwrap(); + let tmp = TempDir::new().unwrap(); let path = tmp.path().join(".star-setup.json"); - std::fs::write(&path, "{invalid json").unwrap(); + write(&path, "{invalid json").unwrap(); with_io_output(|io| { let config = load_config(&[path], false, false, &mut io.output); @@ -77,8 +78,8 @@ fn test_load_config_skips_nonexistent_path() { #[test] fn test_load_config_first_valid_wins() { - let tmp1 = tempfile::TempDir::new().unwrap(); - let tmp2 = tempfile::TempDir::new().unwrap(); + let tmp1 = TempDir::new().unwrap(); + let tmp2 = TempDir::new().unwrap(); let path1 = tmp1.path().join(".star-setup.json"); let path2 = tmp2.path().join(".star-setup.json"); @@ -102,12 +103,12 @@ fn test_load_config_first_valid_wins() { #[test] fn test_load_config_falls_through_invalid_to_valid() { - let tmp1 = tempfile::TempDir::new().unwrap(); - let tmp2 = tempfile::TempDir::new().unwrap(); + let tmp1 = TempDir::new().unwrap(); + let tmp2 = TempDir::new().unwrap(); let path1 = tmp1.path().join(".star-setup.json"); let path2 = tmp2.path().join(".star-setup.json"); - std::fs::write(&path1, "{invalid json").unwrap(); + write(&path1, "{invalid json").unwrap(); let mut config2 = SetupConfig::new(); config2.path = Some(path2.clone()); diff --git a/tests/config/types.rs b/tests/config/types.rs index 4f475d0..c74982d 100644 --- a/tests/config/types.rs +++ b/tests/config/types.rs @@ -4,6 +4,7 @@ use star_setup::{ config::ConfigEntry, }; +/* ===== FROM_FLAGS ===== */ #[test] fn test_from_flags_defaults() { let connection = ConnectionFlags { @@ -99,6 +100,7 @@ fn test_from_flags_with_values() { assert_eq!(entry.cmake_flags, vec!["-DFOO=ON"]); } +/* ===== DEFAULT_RESOLVED ===== */ #[test] fn test_from_resolved_args() { let args = default_resolved(); diff --git a/tests/profile/crud.rs b/tests/profile/crud.rs index de97b93..3471e91 100644 --- a/tests/profile/crud.rs +++ b/tests/profile/crud.rs @@ -3,7 +3,55 @@ use star_setup::{ config::{load_config, save_config, SetupConfig}, profile::{add_profile, has_profile, insert_profile, remove_profile, remove_profile_entry}, }; +use tempfile::TempDir; +/* ===== HAS_PROFILE ===== */ +#[test] +fn test_has_profile_false() { + let config = SetupConfig::new(); + assert!(!has_profile(&config, "nonexistent")); +} + +#[test] +fn test_add_profile_inserts_and_saves() { + with_io_dir(|tmp, io| { + let path = tmp.join(".star-setup.json"); + let mut config = SetupConfig::new(); + config.path = Some(path.clone()); + + let args = vec!["myprofile".to_string(), "user/repo1".to_string()]; + add_profile(&mut config, &args, true, io, make_flags()).unwrap(); + assert!(has_profile(&config, "myprofile")); + assert!(path.exists()); + }); +} + +/* ===== SAVE_CONFIG ===== */ +#[test] +fn test_save_and_load_profile_roundtrip() { + let tmp = TempDir::new().unwrap(); + let path = tmp.path().join(".star-setup.json"); + let mut config = SetupConfig::new(); + config.path = Some(path.clone()); + insert_profile( + &mut config, + "myprofile", + vec!["user/repo1".to_string(), "user/repo2".to_string()], + ); + + with_io_output(|io| { + save_config(&mut config, false, &mut io.output).unwrap(); + + let loaded = load_config(&[path], false, false, &mut io.output); + assert!(loaded.profiles.contains_key("myprofile")); + assert_eq!( + loaded.profiles["myprofile"], + vec!["user/repo1", "user/repo2"] + ); + }); +} + +/* ===== INSERT_PROFILE ===== */ #[test] fn test_insert_profile() { let mut config = SetupConfig::new(); @@ -19,12 +67,6 @@ fn test_remove_profile_entry_exists() { assert!(!config.profiles.contains_key("myprofile")); } -#[test] -fn test_remove_profile_entry_missing() { - let mut config = SetupConfig::new(); - assert!(!remove_profile_entry(&mut config, "nonexistent")); -} - #[test] fn test_has_profile_true() { let mut config = SetupConfig::new(); @@ -32,26 +74,7 @@ fn test_has_profile_true() { assert!(has_profile(&config, "myprofile")); } -#[test] -fn test_has_profile_false() { - let config = SetupConfig::new(); - assert!(!has_profile(&config, "nonexistent")); -} - -#[test] -fn test_add_profile_inserts_and_saves() { - with_io_dir(|tmp, io| { - let path = tmp.join(".star-setup.json"); - let mut config = SetupConfig::new(); - config.path = Some(path.clone()); - - let args = vec!["myprofile".to_string(), "user/repo1".to_string()]; - add_profile(&mut config, &args, true, io, make_flags()).unwrap(); - assert!(has_profile(&config, "myprofile")); - assert!(path.exists()); - }); -} - +/* ===== ADD_PROFILE ===== */ #[test] fn test_add_profile_errors_on_insufficient_args() { let mut config = SetupConfig::new(); @@ -104,7 +127,7 @@ fn test_add_profile_multiple_repos() { #[test] fn test_add_profile_aborts_when_exists_and_not_confirmed() { with_io_input_output(b"n\n", |io| { - let tmp = tempfile::TempDir::new().unwrap(); + let tmp = TempDir::new().unwrap(); let mut config = SetupConfig::new(); config.path = Some(tmp.path().join(".star-setup.json")); insert_profile(&mut config, "myprofile", vec!["old/repo".to_string()]); @@ -115,6 +138,14 @@ fn test_add_profile_aborts_when_exists_and_not_confirmed() { }); } +/* ===== REMOVE_PROFILE_ENTRY ===== */ +#[test] +fn test_remove_profile_entry_missing() { + let mut config = SetupConfig::new(); + assert!(!remove_profile_entry(&mut config, "nonexistent")); +} + +/* ===== REMOVE_PROFILE ===== */ #[test] fn test_remove_profile_removes_and_saves() { with_io_dir(|tmp, io| { @@ -147,27 +178,3 @@ fn test_remove_profile_aborts_when_not_confirmed() { assert!(has_profile(&config, "myprofile")); }); } - -#[test] -fn test_save_and_load_profile_roundtrip() { - let tmp = tempfile::TempDir::new().unwrap(); - let path = tmp.path().join(".star-setup.json"); - let mut config = SetupConfig::new(); - config.path = Some(path.clone()); - insert_profile( - &mut config, - "myprofile", - vec!["user/repo1".to_string(), "user/repo2".to_string()], - ); - - with_io_output(|io| { - save_config(&mut config, false, &mut io.output).unwrap(); - - let loaded = load_config(&[path], false, false, &mut io.output); - assert!(loaded.profiles.contains_key("myprofile")); - assert_eq!( - loaded.profiles["myprofile"], - vec!["user/repo1", "user/repo2"] - ); - }); -} diff --git a/tests/prompts/ask.rs b/tests/prompts/ask.rs index f71e3f5..905cbac 100644 --- a/tests/prompts/ask.rs +++ b/tests/prompts/ask.rs @@ -1,23 +1,26 @@ use crate::common::with_io_input; use star_setup::prompts::{ask, ask_default, ask_yesno}; +/* ===== ASK ===== */ #[test] fn test_ask_errors_on_eof() { assert!(with_io_input(b"", |io| ask("prompt", io)).is_err()); } +/* ===== ASK_DEFAULT ===== */ +#[test] +fn test_ask_default_returns_input_when_not_empty() { + let result = with_io_input(b"custom\n", |io| ask_default("prompt", "default", io)); + assert_eq!(result.unwrap(), "custom"); +} + #[test] fn test_ask_default_errors_on_eof() { assert!(with_io_input(b"", |io| ask_default("prompt", "default", io)).is_err()); } +/* ===== ASK_YESNO ===== */ #[test] fn test_ask_yesno_errors_on_eof() { assert!(with_io_input(b"", |io| ask_yesno("prompt", true, io)).is_err()); } - -#[test] -fn test_ask_default_returns_input_when_not_empty() { - let result = with_io_input(b"custom\n", |io| ask_default("prompt", "default", io)); - assert_eq!(result.unwrap(), "custom"); -} diff --git a/tests/prompts/confirm.rs b/tests/prompts/confirm.rs index 1d4d747..c29ccce 100644 --- a/tests/prompts/confirm.rs +++ b/tests/prompts/confirm.rs @@ -1,6 +1,7 @@ use crate::common::{with_io_input, with_io_input_output}; use star_setup::prompts::{confirm, confirm_batch, BatchConfirm}; +/* ===== CONFIRM ===== */ #[test] fn test_confirm_input_cases() { let cases = [ @@ -29,6 +30,7 @@ fn test_confirm_errors_on_eof() { assert!(result.unwrap_err().contains("unexpected end of input")); } +/* ===== CONFIRM_BATCH ===== */ #[test] fn test_confirm_batch_input_cases() { let cases = [ @@ -38,9 +40,24 @@ fn test_confirm_batch_input_cases() { (b"\n", true, BatchConfirm::No, "empty defaults to no"), (b"a\n", true, BatchConfirm::YesAll, "a accepts all"), (b"s\n", true, BatchConfirm::NoAll, "s skips all"), - (b"x\ny\n", true, BatchConfirm::Yes, "invalid input reprompts"), - (b"a\ny\n", false, BatchConfirm::Yes, "a invalid without allow_all"), - (b"s\nn\n", false, BatchConfirm::No, "s invalid without allow_all"), + ( + b"x\ny\n", + true, + BatchConfirm::Yes, + "invalid input reprompts", + ), + ( + b"a\ny\n", + false, + BatchConfirm::Yes, + "a invalid without allow_all", + ), + ( + b"s\nn\n", + false, + BatchConfirm::No, + "s invalid without allow_all", + ), ]; for (input, allow_all, expected, name) in cases { let result = with_io_input(input, |io| confirm_batch("prompt", allow_all, io)); diff --git a/tests/repository/clone.rs b/tests/repository/clone.rs index d530544..1e8d99b 100644 --- a/tests/repository/clone.rs +++ b/tests/repository/clone.rs @@ -1,13 +1,12 @@ -use std::fs::create_dir_all; - +use crate::common::{with_ctx, with_ctx_runner, MockRunner}; use star_setup::{ ctx::ProcessRunner, repository::{clone_repo, clone_repos, ExistsAction}, }; +use std::fs::create_dir_all; use tempfile::TempDir; -use crate::common::{with_ctx, with_ctx_runner, MockRunner}; - +/* ===== CLONE_REPO ===== */ #[test] fn test_clone_skips_existing_directory() { with_ctx_runner(ProcessRunner, |tmp_path, ctx| { @@ -49,6 +48,7 @@ fn test_clone_repo_calls_git_clone() { assert_eq!(cwd.as_deref(), Some(tmp.path())); } +/* ===== CLONE_REPOS ===== */ #[test] fn test_clone_repos_calls_clone_for_each_repo() { let runner = with_ctx_runner(MockRunner::new(), |tmp_path, ctx| { diff --git a/tests/repository/resolve.rs b/tests/repository/resolve.rs index 714c64c..199d955 100644 --- a/tests/repository/resolve.rs +++ b/tests/repository/resolve.rs @@ -1,5 +1,6 @@ use star_setup::repository::{repo_dir_name, resolve_repo_url}; +/* ===== REPO_DIR_NAME ===== */ #[test] fn test_repo_dir_name() { let cases = [ @@ -25,6 +26,7 @@ fn test_repo_dir_name_no_owner() { assert_eq!(repo_dir_name("repo"), "repo"); } +/* ===== RESOLVE_REPO_URL ===== */ #[test] fn test_resolve_repo_url() { let cases = vec![ diff --git a/tests/utils/process.rs b/tests/utils/process.rs index 9f5d0f8..dd06ec2 100644 --- a/tests/utils/process.rs +++ b/tests/utils/process.rs @@ -1,5 +1,6 @@ use crate::common::with_io_output; use star_setup::utils::process::run_command; +use tempfile::TempDir; #[test] fn test_run_command_errors_on_empty() { @@ -18,7 +19,7 @@ fn test_run_command_verbose_outputs_command() { #[test] fn test_run_command_verbose_outputs_cwd() { let ((), out) = with_io_output(|io| { - let tmp = tempfile::TempDir::new().unwrap(); + let tmp = TempDir::new().unwrap(); run_command(&["git", "--version"], Some(tmp.path()), true, io.output).unwrap(); }); assert!(out.contains("in directory:")); diff --git a/tests/workspace/clean.rs b/tests/workspace/clean.rs index 84dd0a0..9db1b3d 100644 --- a/tests/workspace/clean.rs +++ b/tests/workspace/clean.rs @@ -2,7 +2,7 @@ use crate::{ common::{with_ctx, MockRunner}, helpers::make_workspace, }; -use std::fs; +use std::fs::{create_dir_all, write}; #[test] fn test_workspace_clean_no_build_dir() { @@ -18,8 +18,8 @@ fn test_workspace_clean_no_build_dir() { fn test_workspace_clean_removes_build_dir() { with_ctx(MockRunner::new(), |tmp, ctx| { let ws = make_workspace(tmp, vec![]); - fs::create_dir_all(&ws.build_path).unwrap(); - fs::write(ws.build_path.join("dummy.txt"), "").unwrap(); + create_dir_all(&ws.build_path).unwrap(); + write(ws.build_path.join("dummy.txt"), "").unwrap(); ws.clean(ctx).unwrap(); assert!(!ws.build_path.exists()); }); @@ -32,7 +32,7 @@ fn test_workspace_clean_dry_run() { ctx.flags.verbose = true; let ws = make_workspace(tmp, vec![]); - fs::create_dir_all(&ws.build_path).unwrap(); + create_dir_all(&ws.build_path).unwrap(); ws.clean(ctx).unwrap(); diff --git a/tests/workspace/resolve.rs b/tests/workspace/resolve.rs index 657ea26..4816ffc 100644 --- a/tests/workspace/resolve.rs +++ b/tests/workspace/resolve.rs @@ -1,7 +1,6 @@ -use star_setup::workspace::resolve_workspace; -use std::fs; - use crate::common::with_io_dir; +use star_setup::workspace::resolve_workspace; +use std::fs::create_dir_all; #[test] fn test_resolve_workspace_errors_when_missing() { @@ -15,7 +14,7 @@ fn test_resolve_workspace_errors_when_missing() { #[test] fn test_resolve_workspace_errors_when_no_repos() { with_io_dir(|path, io| { - fs::create_dir_all(path.join("build-mono")).unwrap(); + create_dir_all(path.join("build-mono")).unwrap(); let result = resolve_workspace(Some(path), None, None, io, false); assert!(result.is_err()); assert!(result.unwrap_err().contains("Repos directory not found")); @@ -25,7 +24,7 @@ fn test_resolve_workspace_errors_when_no_repos() { #[test] fn test_resolve_workspace_succeeds() { with_io_dir(|path, io| { - fs::create_dir_all(path.join("build-mono").join("repos")).unwrap(); + create_dir_all(path.join("build-mono").join("repos")).unwrap(); let result = resolve_workspace(Some(path), None, None, io, false); assert!(result.is_ok()); let ws = result.unwrap(); @@ -37,8 +36,8 @@ fn test_resolve_workspace_succeeds() { fn test_resolve_workspace_finds_repos() { with_io_dir(|path, io| { let repos = path.join("build-mono").join("repos"); - fs::create_dir_all(repos.join("user-lib1").join(".git")).unwrap(); - fs::create_dir_all(repos.join("user-lib2").join(".git")).unwrap(); + create_dir_all(repos.join("user-lib1").join(".git")).unwrap(); + create_dir_all(repos.join("user-lib2").join(".git")).unwrap(); let ws = resolve_workspace(Some(path), None, None, io, false).unwrap(); assert_eq!(ws.repo_dirs.len(), 2); }); @@ -47,7 +46,7 @@ fn test_resolve_workspace_finds_repos() { #[test] fn test_resolve_workspace_custom_mono_dir() { with_io_dir(|path, io| { - fs::create_dir_all(path.join("my-workspace").join("repos")).unwrap(); + create_dir_all(path.join("my-workspace").join("repos")).unwrap(); let result = resolve_workspace(Some(path), Some("my-workspace"), None, io, false); assert!(result.is_ok()); }); @@ -56,7 +55,7 @@ fn test_resolve_workspace_custom_mono_dir() { #[test] fn test_resolve_workspace_custom_build_dir() { with_io_dir(|path, io| { - fs::create_dir_all(path.join("build-mono").join("repos")).unwrap(); + create_dir_all(path.join("build-mono").join("repos")).unwrap(); let ws = resolve_workspace(Some(path), None, Some("out"), io, false).unwrap(); assert!(ws.build_path.ends_with("out")); }); @@ -66,8 +65,8 @@ fn test_resolve_workspace_custom_build_dir() { fn test_resolve_workspace_excludes_non_git_dirs() { with_io_dir(|path, io| { let repos = path.join("build-mono").join("repos"); - fs::create_dir_all(repos.join("user-lib1").join(".git")).unwrap(); - fs::create_dir_all(repos.join("not-a-repo")).unwrap(); + create_dir_all(repos.join("user-lib1").join(".git")).unwrap(); + create_dir_all(repos.join("not-a-repo")).unwrap(); let ws = resolve_workspace(Some(path), None, None, io, false).unwrap(); assert_eq!(ws.repo_dirs.len(), 1); }); From 9b6144304cf9ce36c4a358a39b88e39761d1fa08 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Fri, 3 Jul 2026 19:43:47 -0400 Subject: [PATCH 10/11] test: cover invalid-dir handling and batch update prompts in clone --- tests/repository/clone.rs | 149 +++++++++++++++++++++++++++++++++++++- 1 file changed, 147 insertions(+), 2 deletions(-) diff --git a/tests/repository/clone.rs b/tests/repository/clone.rs index 1e8d99b..3eac8c0 100644 --- a/tests/repository/clone.rs +++ b/tests/repository/clone.rs @@ -1,9 +1,9 @@ -use crate::common::{with_ctx, with_ctx_runner, MockRunner}; +use crate::common::{with_ctx, with_ctx_input, with_ctx_runner, MockRunner}; use star_setup::{ ctx::ProcessRunner, repository::{clone_repo, clone_repos, ExistsAction}, }; -use std::fs::create_dir_all; +use std::fs::{create_dir_all, write}; use tempfile::TempDir; /* ===== CLONE_REPO ===== */ @@ -48,6 +48,89 @@ fn test_clone_repo_calls_git_clone() { assert_eq!(cwd.as_deref(), Some(tmp.path())); } +#[test] +fn test_clone_repo_invalid_dir_decline_skips() { + let (runner, output) = with_ctx_input(b"n\n", MockRunner::new(), |tmp_path, ctx| { + let repo_dir = tmp_path.join("user-repo"); + create_dir_all(&repo_dir).unwrap(); + write(repo_dir.join("keep.txt"), "data").unwrap(); + clone_repo( + "user/repo", + tmp_path, + false, + |_| Ok(ExistsAction::Skip), + ctx, + ) + .unwrap(); + assert!(repo_dir.join("keep.txt").exists()); + }); + assert!(runner.calls.is_empty()); + assert!(String::from_utf8(output).unwrap().contains("Skipping")); +} + +#[test] +fn test_clone_repo_invalid_dir_accept_removes_and_clones() { + let (runner, _) = with_ctx_input(b"y\n", MockRunner::new(), |tmp_path, ctx| { + let repo_dir = tmp_path.join("user-repo"); + create_dir_all(&repo_dir).unwrap(); + write(repo_dir.join("stale.txt"), "old").unwrap(); + clone_repo( + "user/repo", + tmp_path, + false, + |_| Ok(ExistsAction::Skip), + ctx, + ) + .unwrap(); + assert!(!repo_dir.exists()); // removed; mock clone doesn't recreate + }); + assert_eq!( + runner.calls.iter().filter(|(c, _)| c[1] == "clone").count(), + 1 + ); +} + +#[test] +fn test_clone_repo_invalid_dir_dry_run_would_remove() { + let (_, output) = with_ctx_input(b"y\n", MockRunner::new(), |tmp_path, ctx| { + ctx.flags.dry_run = true; + let repo_dir = tmp_path.join("user-repo"); + create_dir_all(&repo_dir).unwrap(); + write(repo_dir.join("keep.txt"), "data").unwrap(); + clone_repo( + "user/repo", + tmp_path, + false, + |_| Ok(ExistsAction::Skip), + ctx, + ) + .unwrap(); + assert!(repo_dir.join("keep.txt").exists()); // NOT removed in dry-run + }); + assert!(String::from_utf8(output) + .unwrap() + .contains("Would remove directory:")); +} + +#[test] +fn test_clone_repo_empty_dir_clones_without_prompt() { + let runner = with_ctx_runner(MockRunner::new(), |tmp_path, ctx| { + create_dir_all(tmp_path.join("user-repo")).unwrap(); + clone_repo( + "user/repo", + tmp_path, + false, + |_| Ok(ExistsAction::Skip), + ctx, + ) + .unwrap(); + }); + assert_eq!( + runner.calls.iter().filter(|(c, _)| c[1] == "clone").count(), + 1 + ); +} + /* ===== CLONE_REPOS ===== */ #[test] fn test_clone_repos_calls_clone_for_each_repo() { @@ -92,3 +175,65 @@ fn test_clone_repos_per_repo_lines_require_verbose() { assert!(out.contains("Cloning user-repo1")); assert!(out.contains("Finished cloning (2 repositories)")); } + +#[test] +fn test_clone_repos_yes_updates_existing_without_prompt() { + let runner = with_ctx_runner(MockRunner::new(), |tmp_path, ctx| { + create_dir_all(tmp_path.join("user-repo1").join(".git")).unwrap(); + clone_repos(&["user/repo1".to_string()], tmp_path, false, true, ctx).unwrap(); + }); + assert_eq!( + runner.calls.iter().filter(|(c, _)| c[1] == "pull").count(), + 1 + ); + assert_eq!( + runner.calls.iter().filter(|(c, _)| c[1] == "clone").count(), + 0 + ); +} + +#[test] +fn test_clone_repos_prompts_per_existing_repo() { + let (runner, _) = with_ctx_input(b"y\nn\n", MockRunner::new(), |tmp_path, ctx| { + create_dir_all(tmp_path.join("user-repo1").join(".git")).unwrap(); + create_dir_all(tmp_path.join("user-repo2").join(".git")).unwrap(); + let repos = vec!["user/repo1".to_string(), "user/repo2".to_string()]; + clone_repos(&repos, tmp_path, false, false, ctx).unwrap(); + }); + let pulls: Vec<_> = runner + .calls + .iter() + .filter(|(c, _)| c[1] == "pull") + .collect(); + assert_eq!(pulls.len(), 1); + assert!(pulls[0].1.as_deref().unwrap().ends_with("user-repo1")); // y→first, n→second +} + +#[test] +fn test_clone_repos_yes_all_updates_remaining() { + // 5 existing (>= threshold), a SINGLE "a" must drive all five — a broken latch + // would EOF on repo 2 and panic + let (runner, _) = with_ctx_input(b"a\n", MockRunner::new(), |tmp_path, ctx| { + let repos: Vec = (1..=5).map(|i| format!("user/repo{i}")).collect(); + for i in 1..=5 { + create_dir_all(tmp_path.join(format!("user-repo{i}")).join(".git")).unwrap(); + } + clone_repos(&repos, tmp_path, false, false, ctx).unwrap(); + }); + assert_eq!( + runner.calls.iter().filter(|(c, _)| c[1] == "pull").count(), + 5 + ); +} + +#[test] +fn test_clone_repos_no_all_skips_remaining() { + let (runner, _) = with_ctx_input(b"s\n", MockRunner::new(), |tmp_path, ctx| { + let repos: Vec = (1..=5).map(|i| format!("user/repo{i}")).collect(); + for i in 1..=5 { + create_dir_all(tmp_path.join(format!("user-repo{i}")).join(".git")).unwrap(); + } + clone_repos(&repos, tmp_path, false, false, ctx).unwrap(); + }); + assert!(runner.calls.is_empty()); +} From 6b7bbc22c093d5757c6df3f8a5e3ab60edf854f2 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Fri, 3 Jul 2026 19:44:33 -0400 Subject: [PATCH 11/11] chore: bump v0.4.3 --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 9a0ca10..66c536b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "star-setup" -version = "0.4.2" +version = "0.4.3" edition = "2021" repository = "https://github.com/star-setup/core" description = "Lightweight CLI to clone, configure, and wire single or multi-repo ecosystems"