From c15513686d2e54484f89199bf7eed899602f8f0c Mon Sep 17 00:00:00 2001 From: Masonlet Date: Sun, 28 Jun 2026 11:56:27 -0400 Subject: [PATCH 01/59] refactor: extract runner selection and context creation into `with_runner` helper --- src/cli/build/detect.rs | 6 ++-- src/commands/build.rs | 6 ++-- src/commands/mono/clone.rs | 2 +- src/commands/mono/mode.rs | 2 +- src/commands/mono/setup.rs | 2 +- src/commands/setup.rs | 4 +-- src/commands/single.rs | 2 +- src/ctx.rs | 6 ++-- src/repository.rs | 2 +- src/run.rs | 74 ++++++++++++++++++-------------------- src/workspace/clean.rs | 2 +- src/workspace/status.rs | 2 +- src/workspace/update.rs | 2 +- 13 files changed, 53 insertions(+), 59 deletions(-) diff --git a/src/cli/build/detect.rs b/src/cli/build/detect.rs index 265aed5..d98fe36 100644 --- a/src/cli/build/detect.rs +++ b/src/cli/build/detect.rs @@ -5,7 +5,7 @@ fn pick_build_system( has_cmake: bool, has_meson: bool, none_err: &str, - ctx: &mut RunCtx<'_>, + ctx: &mut RunCtx<'_, '_>, ) -> Result { match (has_cmake, has_meson) { (true, false) => Ok(BuildSystem::Cmake), @@ -26,7 +26,7 @@ fn pick_build_system( /// Detects the build system in use by inspecting the given directory. /// # Errors /// Returns an error on EOF during prompt, or if no supported build system is found. -pub fn detect_build_system(dir: &Path, ctx: &mut RunCtx<'_>) -> Result { +pub fn detect_build_system(dir: &Path, ctx: &mut RunCtx<'_, '_>) -> Result { crate::time!(ctx.io.timing, ctx.io.output, "Detect", { let has_cmake = dir.join("CMakeLists.txt").exists(); let has_meson = dir.join("meson.build").exists(); @@ -39,7 +39,7 @@ pub fn detect_build_system(dir: &Path, ctx: &mut RunCtx<'_>) -> Result, + ctx: &mut RunCtx<'_, '_>, ) -> Result { writeln!(ctx.io.output, "Detecting build system\n").ok(); crate::time!(ctx.io.timing, ctx.io.output, "Detect", { diff --git a/src/commands/build.rs b/src/commands/build.rs index f075044..49565e8 100644 --- a/src/commands/build.rs +++ b/src/commands/build.rs @@ -13,7 +13,7 @@ pub fn cmake_build( args: &ResolvedArgs, build_path: &Path, mono: bool, - ctx: &mut RunCtx<'_>, + ctx: &mut RunCtx<'_, '_>, ) -> Result<(), String> { let build_type_flag = format!("-DCMAKE_BUILD_TYPE={}", args.build.build_type.to_cmake()); let mut cmake_cmd = if mono { @@ -53,7 +53,7 @@ pub fn meson_build( args: &ResolvedArgs, build_path: &Path, source_path: &Path, - ctx: &mut RunCtx<'_>, + ctx: &mut RunCtx<'_, '_>, ) -> Result<(), String> { let buildtype_flag = format!("--buildtype={}", args.build.build_type.to_meson()); let mut meson_cmd = vec!["meson", "setup"]; @@ -92,7 +92,7 @@ pub fn build_project( source_path: &Path, build_system: BuildSystem, mono: bool, - ctx: &mut RunCtx<'_>, + ctx: &mut RunCtx<'_, '_>, ) -> Result<(), String> { match build_system { BuildSystem::Cmake => cmake_build(args, build_path, mono, ctx), diff --git a/src/commands/mono/clone.rs b/src/commands/mono/clone.rs index c156fd7..8d48336 100644 --- a/src/commands/mono/clone.rs +++ b/src/commands/mono/clone.rs @@ -7,7 +7,7 @@ pub fn clone_mono_repos( repos: &[String], repos_path: &std::path::Path, ssh: bool, - ctx: &mut RunCtx<'_>, + ctx: &mut RunCtx<'_, '_>, ) -> Result<(), String> { writeln!(ctx.io.output, "Cloning repositories").ok(); crate::time!(ctx.io.timing, ctx.io.output, "Clone", { diff --git a/src/commands/mono/mode.rs b/src/commands/mono/mode.rs index 5cc3e64..986cfab 100644 --- a/src/commands/mono/mode.rs +++ b/src/commands/mono/mode.rs @@ -25,7 +25,7 @@ pub fn mono_repo_mode( args: &ResolvedArgs, config: &SetupConfig, base_dir: &Path, - ctx: &mut RunCtx<'_>, + ctx: &mut RunCtx<'_, '_>, ) -> Result<(), String> { let total = std::time::Instant::now(); diff --git a/src/commands/mono/setup.rs b/src/commands/mono/setup.rs index 30d0379..8a832dc 100644 --- a/src/commands/mono/setup.rs +++ b/src/commands/mono/setup.rs @@ -15,7 +15,7 @@ pub fn generate_mono_config( repos_path: &std::path::Path, repo_dirs: &[PathBuf], repos: &[String], - ctx: &mut RunCtx<'_>, + ctx: &mut RunCtx<'_, '_>, ) -> Result>, String> { writeln!(ctx.io.output, "Creating mono-repo configuration").ok(); match build_system { diff --git a/src/commands/setup.rs b/src/commands/setup.rs index 369272b..758afc8 100644 --- a/src/commands/setup.rs +++ b/src/commands/setup.rs @@ -11,7 +11,7 @@ use std::{fs, path::Path}; pub fn prepare_build_dir( build_path: &std::path::Path, clean: bool, - ctx: &mut RunCtx<'_>, + ctx: &mut RunCtx<'_, '_>, ) -> Result<(), String> { if clean && ctx.io.dry_run { writeln!(ctx.io.output, "Cleaning build directory\n").ok(); @@ -53,7 +53,7 @@ pub fn configure_and_build( build_path: &Path, build_system: BuildSystem, is_mono: bool, - ctx: &mut RunCtx<'_>, + ctx: &mut RunCtx<'_, '_>, ) -> Result<(), String> { writeln!(ctx.io.output, "Configuring project\n").ok(); build_project(args, build_path, project_path, build_system, is_mono, ctx) diff --git a/src/commands/single.rs b/src/commands/single.rs index ed91519..82ef0f0 100644 --- a/src/commands/single.rs +++ b/src/commands/single.rs @@ -15,7 +15,7 @@ use std::path::Path; pub fn single_repo_mode( args: &ResolvedArgs, base_dir: &Path, - ctx: &mut RunCtx<'_>, + ctx: &mut RunCtx<'_, '_>, ) -> Result<(), String> { let total = std::time::Instant::now(); diff --git a/src/ctx.rs b/src/ctx.rs index 520ff47..0919408 100644 --- a/src/ctx.rs +++ b/src/ctx.rs @@ -75,7 +75,7 @@ pub struct IoCtx<'a> { } /// Full execution context combining IO and a command runner. -pub struct RunCtx<'a> { - pub io: IoCtx<'a>, - pub runner: &'a mut dyn Runner, +pub struct RunCtx<'io, 'run> { + pub io: IoCtx<'io>, + pub runner: &'run mut dyn Runner, } diff --git a/src/repository.rs b/src/repository.rs index be4d0b1..4a55f9d 100644 --- a/src/repository.rs +++ b/src/repository.rs @@ -41,7 +41,7 @@ pub fn clone_repository( repo_path: &str, target_dir: &Path, use_ssh: bool, - ctx: &mut RunCtx<'_>, + ctx: &mut RunCtx<'_, '_>, ) -> Result<(), String> { let repo_name = repo_dir_name(repo_path); let repo_dir = target_dir.join(&repo_name); diff --git a/src/run.rs b/src/run.rs index 5544120..9ae4ba5 100644 --- a/src/run.rs +++ b/src/run.rs @@ -19,6 +19,18 @@ use std::{ const CONFIG_FILE_NAME: &str = ".star-setup.json"; +/// Helper to quickly execute a workspace/repo task with the correct runner +fn with_runner(io: IoCtx, f: F) -> Result<(), Box> +where + F: FnOnce(&mut RunCtx) -> Result<(), Box>, +{ + let mut dry = DryRunRunner; + let mut real = ProcessRunner; + let runner: &mut dyn Runner = if io.dry_run { &mut dry } else { &mut real }; + let mut ctx = RunCtx { io, runner }; + f(&mut ctx) +} + /// Runs the setup process. /// # Errors /// Returns an error if the configuration file is missing or corrupted. @@ -83,17 +95,10 @@ pub fn run() -> Result<(), Box> { mono_dir, build_dir, } => { - let workspace = - resolve_workspace(path.as_deref(), mono_dir.as_deref(), build_dir.as_deref())?; - let mut dry = DryRunRunner; - let mut real = ProcessRunner; - let runner: &mut dyn Runner = if raw.diagnostic.dry_run { - &mut dry - } else { - &mut real - }; - let mut ctx = RunCtx { io, runner }; - update_workspace(&workspace, &mut ctx)?; + let ws = resolve_workspace(path.as_deref(), mono_dir.as_deref(), build_dir.as_deref())?; + with_runner(io, |ctx| { + update_workspace(&ws, ctx).map_err(Box::::from) + })?; } WorkspaceAction::Status { path, @@ -101,31 +106,22 @@ pub fn run() -> Result<(), Box> { build_dir, fetch, } => { - let workspace = - resolve_workspace(path.as_deref(), mono_dir.as_deref(), build_dir.as_deref())?; - let mut real = ProcessRunner; - let mut ctx = RunCtx { - io, - runner: &mut real, - }; - status_workspace(&workspace, fetch, &mut ctx)?; + let ws = resolve_workspace(path.as_deref(), mono_dir.as_deref(), build_dir.as_deref())?; + let mut status_io = io; + status_io.dry_run = false; + with_runner(status_io, |ctx| { + status_workspace(&ws, fetch, ctx).map_err(Box::::from) + })?; } WorkspaceAction::Clean { path, mono_dir, build_dir, } => { - let workspace = - resolve_workspace(path.as_deref(), mono_dir.as_deref(), build_dir.as_deref())?; - let mut dry = DryRunRunner; - let mut real = ProcessRunner; - let runner: &mut dyn Runner = if raw.diagnostic.dry_run { - &mut dry - } else { - &mut real - }; - let mut ctx = RunCtx { io, runner }; - clean_workspace(&workspace, &mut ctx)?; + let ws = resolve_workspace(path.as_deref(), mono_dir.as_deref(), build_dir.as_deref())?; + with_runner(io, |ctx| { + clean_workspace(&ws, ctx).map_err(Box::::from) + })?; } }, } @@ -143,16 +139,14 @@ pub fn run() -> Result<(), Box> { check_prerequisites(&mut io)?; - let mut dry = DryRunRunner; - let mut real = ProcessRunner; - let runner: &mut dyn Runner = if io.dry_run { &mut dry } else { &mut real }; - let mut ctx = RunCtx { io, runner }; - - if args.mono.mono_repo { - mono_repo_mode(&args, &config, Path::new("."), &mut ctx)?; - } else { - single_repo_mode(&args, Path::new("."), &mut ctx)?; - } + with_runner(io, |ctx| { + if args.mono.mono_repo { + mono_repo_mode(&args, &config, Path::new("."), ctx)?; + } else { + single_repo_mode(&args, Path::new("."), ctx)?; + } + Ok(()) + })?; Ok(()) } diff --git a/src/workspace/clean.rs b/src/workspace/clean.rs index cc1de27..bff9b7d 100644 --- a/src/workspace/clean.rs +++ b/src/workspace/clean.rs @@ -4,7 +4,7 @@ use std::fs; /// Removes the build directory from the workspace. /// # Errors /// Returns an error if the build directory cannot be removed. -pub fn clean_workspace(workspace: &Workspace, ctx: &mut RunCtx<'_>) -> Result<(), String> { +pub fn clean_workspace(workspace: &Workspace, ctx: &mut RunCtx<'_, '_>) -> Result<(), String> { if !workspace.build_path.exists() { writeln!( ctx.io.output, diff --git a/src/workspace/status.rs b/src/workspace/status.rs index 0b209aa..2b19f91 100644 --- a/src/workspace/status.rs +++ b/src/workspace/status.rs @@ -6,7 +6,7 @@ use crate::{ctx::RunCtx, workspace::resolve::Workspace}; pub fn status_workspace( workspace: &Workspace, fetch: bool, - ctx: &mut RunCtx<'_>, + ctx: &mut RunCtx<'_, '_>, ) -> Result<(), String> { writeln!(ctx.io.output, "Workspace status:\n").ok(); diff --git a/src/workspace/update.rs b/src/workspace/update.rs index c71c575..78f506a 100644 --- a/src/workspace/update.rs +++ b/src/workspace/update.rs @@ -3,7 +3,7 @@ use crate::{ctx::RunCtx, workspace::resolve::Workspace}; /// Pulls latest changes for all repositories in the workspace. /// # Errors /// Returns an error if any `git pull` command fails. -pub fn update_workspace(workspace: &Workspace, ctx: &mut RunCtx<'_>) -> Result<(), String> { +pub fn update_workspace(workspace: &Workspace, ctx: &mut RunCtx<'_, '_>) -> Result<(), String> { writeln!( ctx.io.output, "Updating {} repositories\n", From 9745d607ab763b274eb47eb5fce0e71a81477bdd Mon Sep 17 00:00:00 2001 From: Masonlet Date: Sun, 28 Jun 2026 12:08:23 -0400 Subject: [PATCH 02/59] chore: rename cmd var --- src/run.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/run.rs b/src/run.rs index 9ae4ba5..02c507d 100644 --- a/src/run.rs +++ b/src/run.rs @@ -89,7 +89,7 @@ pub fn run() -> Result<(), Box> { add_profile(&mut config, &vals, raw.yes, &mut io)?; } }, - Command::Workspace(cmd) => match cmd.action { + Command::Workspace(ws_cmd) => match ws_cmd.action { WorkspaceAction::Update { path, mono_dir, From da83404b0998a4a56624408e740a8812abaa9d17 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Sun, 28 Jun 2026 12:09:10 -0400 Subject: [PATCH 03/59] chore: rename command vars --- src/run.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/run.rs b/src/run.rs index 02c507d..d7e2b52 100644 --- a/src/run.rs +++ b/src/run.rs @@ -58,9 +58,9 @@ pub fn run() -> Result<(), Box> { dry_run: raw.diagnostic.dry_run, }; - if let Some(command) = raw.command { - match command { - Command::Config(cmd) => match cmd.action { + if let Some(cmd) = raw.command { + match cmd { + Command::Config(config_cmd) => match config_cmd.action { ConfigAction::Init => { create_default_config(PathBuf::from(CONFIG_FILE_NAME), raw.yes, &mut io)?; } @@ -79,7 +79,7 @@ pub fn run() -> Result<(), Box> { add_config(&mut config, &name, entry, raw.yes, &mut io)?; } }, - Command::Profile(cmd) => match cmd.action { + Command::Profile(profile_cmd) => match profile_cmd.action { ProfileAction::List => list_profiles(&config, &mut io), ProfileAction::Remove { name } => { remove_profile(&mut config, &name, raw.yes, &mut io)?; From dd850c74a87a27a7fce0b3d8db01c63eb4baaf2b Mon Sep 17 00:00:00 2001 From: Masonlet Date: Sun, 28 Jun 2026 12:11:39 -0400 Subject: [PATCH 04/59] refactor: extract common input reading logic in prompts --- src/prompts.rs | 62 +++++++++++++++++++------------------------------- 1 file changed, 24 insertions(+), 38 deletions(-) diff --git a/src/prompts.rs b/src/prompts.rs index 73a3de0..5855d6e 100644 --- a/src/prompts.rs +++ b/src/prompts.rs @@ -2,12 +2,11 @@ use crate::ctx::IoCtx; -/// Prompts the user for a required string value. -/// # Errors -/// Returns an error if stdin reaches EOF unexpectedly. -pub fn ask(prompt: &str, io: &mut IoCtx<'_>) -> Result { - write!(io.output, "{prompt}: ").ok(); +/// 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()); @@ -15,21 +14,22 @@ pub fn ask(prompt: &str, io: &mut IoCtx<'_>) -> Result { Ok(line.trim().to_string()) } +/// Prompts the user for a required string value. +/// # Errors +/// Returns an error if stdin reaches EOF unexpectedly. +pub fn ask(prompt: &str, io: &mut IoCtx<'_>) -> Result { + read_input_line(&format!("{prompt}: "), io) +} + /// Prompts the user for a string value, returning `default` if the input is empty. /// # Errors /// Returns an error if stdin reaches EOF unexpectedly. pub fn ask_default(prompt: &str, default: &str, io: &mut IoCtx<'_>) -> Result { - write!(io.output, "{prompt} [{default}]: ").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()); - } - let val = line.trim().to_string(); - Ok(if val.is_empty() { + let input = read_input_line(&format!("{prompt} [{default}]: "), io)?; + Ok(if input.is_empty() { default.to_string() } else { - val + input }) } @@ -38,14 +38,12 @@ pub fn ask_default(prompt: &str, default: &str, io: &mut IoCtx<'_>) -> Result) -> Result { let default_char = if default { "Y" } else { "N" }; - write!(io.output, "{prompt} (y/n) [{default_char}]: ").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()); - } - let val = line.trim().to_lowercase(); - Ok(if val.is_empty() { default } else { val.eq("y") }) + let input = read_input_line(&format!("{prompt} (y/n) [{default_char}]: "), io)?; + Ok(if input.is_empty() { + default + } else { + input.eq_ignore_ascii_case("y") + }) } /// Prompts the user to select from a numbered list of options. @@ -58,14 +56,8 @@ pub fn ask_choice(prompt: &str, options: &[&str], io: &mut IoCtx<'_>) -> Result< writeln!(io.output, " {}) {opt}", i + 1).ok(); } loop { - write!(io.output, "Select: ").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()); - } - let val = line.trim(); - if let Ok(n) = val.parse::() { + let input = read_input_line("Select: ", io)?; + if let Ok(n) = input.parse::() { if n >= 1 && n <= options.len() { return Ok(n - 1); } @@ -80,12 +72,6 @@ pub fn confirm(prompt: &str, yes: bool, io: &mut IoCtx<'_>) -> Result Date: Sun, 28 Jun 2026 12:17:16 -0400 Subject: [PATCH 05/59] refactor(cli): simplify config logic --- src/cli/resolve.rs | 40 +++++++++++++++++++++------------------- 1 file changed, 21 insertions(+), 19 deletions(-) diff --git a/src/cli/resolve.rs b/src/cli/resolve.rs index bb316f7..000e383 100644 --- a/src/cli/resolve.rs +++ b/src/cli/resolve.rs @@ -16,7 +16,10 @@ pub fn resolve_bool(positive: bool, negative: bool, config: Option, defaul if positive { return true; } - config.unwrap_or(default) + match config { + Some(val) => val, + None => default, + } } /// Resolves raw `Args` into `ResolvedArgs` by applying config defaults and CLI overrides. @@ -24,14 +27,12 @@ pub fn resolve_bool(positive: bool, negative: bool, config: Option, defaul /// Returns an error if the named config does not exist in the provided `SetupConfig`. pub fn resolve_with_config(mut args: Args, config: &SetupConfig) -> Result { let config_name = args.config_name.as_deref().unwrap_or("default"); - if let Some(name) = &args.config_name { - if !config.configs.contains_key(name.as_str()) { - return Err(format!("Configuration '{name}' not found")); - } - } - let default = config.configs.get(config_name); + if args.config_name.is_some() && default.is_none() { + return Err(format!("Configuration '{}' not found", config_name)); + } + let ssh = resolve_bool( args.connection.ssh, args.connection.https, @@ -68,12 +69,14 @@ pub fn resolve_with_config(mut args: Args, config: &SetupConfig) -> Result Result()? - } else { - default.map(|e| e.build_type.clone()).unwrap_or_default() + build_type: match args.build.build_type { + Some(s) => s.parse::()?, + None => default.map(|e| e.build_type.clone()).unwrap_or_default(), }, build_dir: args .build @@ -98,8 +100,8 @@ pub fn resolve_with_config(mut args: Args, config: &SetupConfig) -> Result Date: Sun, 28 Jun 2026 12:22:17 -0400 Subject: [PATCH 06/59] refactor(commands): extract `Path` to string conversion helper --- src/commands/build.rs | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/commands/build.rs b/src/commands/build.rs index 49565e8..73b865b 100644 --- a/src/commands/build.rs +++ b/src/commands/build.rs @@ -6,6 +6,12 @@ use crate::{ }; use std::path::Path; +fn to_str(path: &Path) -> Result<&str, String> { + path + .to_str() + .ok_or_else(|| format!("Invalid path: {}", path.display())) +} + /// Runs `CMake` configuration and optionally builds the project in `build_path`. /// # Errors /// Returns an error if any `CMake` command fails. @@ -58,8 +64,8 @@ pub fn meson_build( let buildtype_flag = format!("--buildtype={}", args.build.build_type.to_meson()); let mut meson_cmd = vec!["meson", "setup"]; meson_cmd.push(&buildtype_flag); - meson_cmd.push(build_path.to_str().ok_or("Invalid build path")?); - meson_cmd.push(source_path.to_str().ok_or("Invalid source path")?); + meson_cmd.push(to_str(build_path)?); + meson_cmd.push(to_str(source_path)?); meson_cmd.extend(args.build.meson_flags.iter().map(String::as_str)); crate::time!(ctx.io.timing, ctx.io.output, "Meson setup", { @@ -69,12 +75,7 @@ pub fn meson_build( writeln!(ctx.io.output, "Building project\n").ok(); crate::time!(ctx.io.timing, ctx.io.output, "Meson compile", { ctx.runner.run( - &[ - "meson", - "compile", - "-C", - build_path.to_str().ok_or("Invalid build path")?, - ], + &["meson", "compile", "-C", to_str(build_path)?], None, &mut ctx.io, )?; From 128a19de35f0769052c4d2195cb20bfe71aca484 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Sun, 28 Jun 2026 12:38:39 -0400 Subject: [PATCH 07/59] refactor(single): use shared clone_repo helper --- src/commands/single.rs | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/src/commands/single.rs b/src/commands/single.rs index 82ef0f0..10e2d66 100644 --- a/src/commands/single.rs +++ b/src/commands/single.rs @@ -5,7 +5,7 @@ use crate::{ }, ctx::RunCtx, prompts::confirm, - repository::{repo_dir_name, resolve_repo_url}, + repository::{clone_repository, repo_dir_name}, }; use std::path::Path; @@ -20,7 +20,6 @@ pub fn single_repo_mode( let total = std::time::Instant::now(); let repo = extract_repo_input(args)?; - let repo_url = resolve_repo_url(repo, args.connection.ssh); let dir_name = repo_dir_name(repo); print_mode_header( @@ -48,12 +47,7 @@ pub fn single_repo_mode( }); } } else { - writeln!(ctx.io.output, "Cloning {dir_name}\n").ok(); - crate::time!(ctx.io.timing, ctx.io.output, "Clone", { - ctx - .runner - .run(&["git", "clone", &repo_url, &dir_name], None, &mut ctx.io)?; - }); + clone_repository(repo, base_dir, args.connection.ssh, ctx)?; } let build_path = repo_path.join(&args.build.build_dir); From 77f4f733a787a41e4c6c8a36b6ac302c3e8eee38 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Sun, 28 Jun 2026 12:44:45 -0400 Subject: [PATCH 08/59] refactor(repository): extract git pull helper --- src/cli/resolve.rs | 2 +- src/commands/single.rs | 6 ++---- src/repository.rs | 11 +++++++++++ src/workspace/update.rs | 7 ++----- 4 files changed, 16 insertions(+), 10 deletions(-) diff --git a/src/cli/resolve.rs b/src/cli/resolve.rs index 000e383..ebbc13a 100644 --- a/src/cli/resolve.rs +++ b/src/cli/resolve.rs @@ -30,7 +30,7 @@ pub fn resolve_with_config(mut args: Args, config: &SetupConfig) -> Result, +) -> Result<(), String> { + ctx.runner + .run(&["git", "pull"], Some(repo_path), &mut ctx.io) +} diff --git a/src/workspace/update.rs b/src/workspace/update.rs index 78f506a..9c1c4ad 100644 --- a/src/workspace/update.rs +++ b/src/workspace/update.rs @@ -1,4 +1,4 @@ -use crate::{ctx::RunCtx, workspace::resolve::Workspace}; +use crate::{ctx::RunCtx, repository::pull_repository, workspace::resolve::Workspace}; /// Pulls latest changes for all repositories in the workspace. /// # Errors @@ -20,10 +20,7 @@ pub fn update_workspace(workspace: &Workspace, ctx: &mut RunCtx<'_, '_>) -> Resu .unwrap_or_default(); writeln!(ctx.io.output, " Updating {name}").ok(); - if let Err(e) = ctx - .runner - .run(&["git", "pull"], Some(repo_dir), &mut ctx.io) - { + if let Err(e) = pull_repository(repo_dir, ctx) { writeln!(ctx.io.output, " Failed to update {name}: {e}").ok(); errors.push(format!("{name}: {e}")); } From 61fe79413aa1f733002878fc98f1fcd7c90c37f0 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Sun, 28 Jun 2026 12:49:08 -0400 Subject: [PATCH 09/59] refactor: improve prerequisite checks --- src/repository.rs | 10 ++++------ src/utils/prerequisites.rs | 27 +++++++++++++++------------ 2 files changed, 19 insertions(+), 18 deletions(-) diff --git a/src/repository.rs b/src/repository.rs index 9c829d2..1674bea 100644 --- a/src/repository.rs +++ b/src/repository.rs @@ -67,10 +67,8 @@ pub fn clone_repository( /// 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), &mut ctx.io) +pub fn pull_repository(repo_path: &Path, ctx: &mut RunCtx<'_, '_>) -> Result<(), String> { + ctx + .runner + .run(&["git", "pull"], Some(repo_path), &mut ctx.io) } diff --git a/src/utils/prerequisites.rs b/src/utils/prerequisites.rs index 23f3fe9..4349e8a 100644 --- a/src/utils/prerequisites.rs +++ b/src/utils/prerequisites.rs @@ -7,22 +7,25 @@ use std::process::Command; /// Returns an error if any required tool is missing from PATH. pub fn check_prerequisites(io: &mut IoCtx<'_>) -> Result<(), String> { crate::time!(io.timing, io.output, "Check prerequisites", { - let mut missing: Vec<&str> = Vec::new(); + let missing: Vec<&str> = ["git", "cmake", "meson"] + .into_iter() + .filter(|&tool| { + let is_missing = Command::new(tool) + .arg("--version") + .output() + .map_or(true, |o| !o.status.success()); + + if !is_missing && io.verbose { + let _ = writeln!(io.output, " Found {tool}"); + } + is_missing + }) + .collect(); - for tool in &["git", "cmake", "meson"] { - if Command::new(tool) - .arg("--version") - .output() - .map_or(true, |o| !o.status.success()) - { - missing.push(tool); - } else if io.verbose { - writeln!(io.output, " Found {tool}").ok(); - } - } if !missing.is_empty() { return Err(format!("Missing required tools: {}", missing.join(", "))); } + Ok(()) }) } From 8bf86afefff713e01b2e0a0371088b15425e54b8 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Sun, 28 Jun 2026 12:58:12 -0400 Subject: [PATCH 10/59] refactor(utils): simplify module and command execution --- src/utils/process.rs | 125 +++++++++++++++++++------------------------ 1 file changed, 54 insertions(+), 71 deletions(-) diff --git a/src/utils/process.rs b/src/utils/process.rs index bc8474f..f486bc9 100644 --- a/src/utils/process.rs +++ b/src/utils/process.rs @@ -1,18 +1,17 @@ use std::{ - io::{Read, Write}, - path::Path, + collections::HashMap, + io::Write, + path::{Path, PathBuf}, process::{Command, Stdio}, - thread, }; /// Finds vcvars64.bat using vswhere.exe. /// Returns None if vswhere is not found or no VS installation exists. #[cfg(target_os = "windows")] -fn find_vcvars() -> Option { - let vswhere = std::path::PathBuf::from( - std::env::var("ProgramFiles(x86)").unwrap_or_else(|_| r"C:\Program Files (x86)".to_string()), - ) - .join(r"Microsoft Visual Studio\Installer\vswhere.exe"); +fn find_vcvars() -> Option { + let program_files = + std::env::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() { return None; @@ -24,8 +23,7 @@ fn find_vcvars() -> Option { .ok()?; let install_path = String::from_utf8(output.stdout).ok()?; - let vcvars = - std::path::PathBuf::from(install_path.trim()).join(r"VC\Auxiliary\Build\vcvars64.bat"); + let vcvars = PathBuf::from(install_path.trim()).join(r"VC\Auxiliary\Build\vcvars64.bat"); vcvars.exists().then_some(vcvars) } @@ -34,15 +32,12 @@ fn find_vcvars() -> Option { /// # Errors /// Returns an error if vcvars64.bat cannot be found or run. #[cfg(target_os = "windows")] -fn get_msvc_env() -> Result, String> { +fn get_msvc_env() -> Result, String> { let vcvars = find_vcvars().ok_or("Could not find vcvars64.bat via vswhere")?; + let vcvars_str = vcvars.to_str().ok_or("Invalid vcvars path")?; + let output = Command::new("cmd") - .args([ - "/c", - vcvars.to_str().ok_or("Invalid vcvars path")?, - "&&", - "set", - ]) + .args(["/c", vcvars_str, "&&", "set"]) .output() .map_err(|e| format!("Failed to run vcvars64.bat: {e}"))?; @@ -51,8 +46,8 @@ fn get_msvc_env() -> Result, String> { stdout .lines() .filter_map(|line| { - let mut parts = line.splitn(2, '='); - Some((parts.next()?.to_string(), parts.next()?.to_string())) + let (key, val) = line.split_once('=')?; + Some((key.to_string(), val.to_string())) }) .collect(), ) @@ -68,9 +63,10 @@ pub fn run_command( verbose: bool, output: &mut (impl Write + ?Sized), ) -> Result<(), String> { - if cmd.is_empty() { - return Err("No command provided".to_string()); - } + let (exe, args) = match cmd { + [] => return Err("No command provided".to_string()), + [exe, args @ ..] => (exe, args), + }; if verbose { writeln!(output, "Running: {}", cmd.join(" ")).ok(); @@ -79,9 +75,15 @@ pub fn run_command( } } - let mut command = Command::new(cmd[0]); + let mut command = Command::new(exe); command.stdin(Stdio::null()); - if cmd[0] == "git" { + command.args(args); + + if let Some(dir) = cwd { + command.current_dir(dir); + } + + if *exe == "git" { command.env("GIT_TERMINAL_PROMPT", "0"); if std::env::var("GIT_SSH_COMMAND").is_err() { command.env("GIT_SSH_COMMAND", "ssh -o BatchMode=yes"); @@ -89,65 +91,46 @@ pub fn run_command( } #[cfg(target_os = "windows")] - if std::path::Path::new(&cmd[0]) - .file_stem() - .is_some_and(|s| s.to_string_lossy().eq_ignore_ascii_case("meson")) - && std::env::var("VSINSTALLDIR").is_err() + if std::env::var("VSINSTALLDIR").is_err() + && Path::new(exe) + .file_stem() + .is_some_and(|s| s.to_string_lossy().eq_ignore_ascii_case("meson")) { if let Ok(env) = get_msvc_env() { - for (k, v) in env { - command.env(k, v); - } + command.envs(env); } } if verbose { - command.stdout(Stdio::inherit()); - command.stderr(Stdio::inherit()); - } else { - command.stdout(Stdio::null()); - command.stderr(Stdio::piped()); - } - - command.args(&cmd[1..]); - if let Some(dir) = cwd { - command.current_dir(dir); - } - - if verbose { + command.stdout(Stdio::inherit()).stderr(Stdio::inherit()); let status = command .status() .map_err(|e| format!("Failed to run command: {e}"))?; - if status.success() { - return Ok(()); - } - return Err(format!("Command failed with exit code: {status}")); - } - let mut child = command - .spawn() - .map_err(|e| format!("Failed to start command: {e}"))?; - let stderr_handle = child.stderr.take(); - let stderr_thread = thread::spawn(move || { - let mut s = String::new(); - if let Some(mut h) = stderr_handle { - h.read_to_string(&mut s).ok(); + if !status.success() { + return Err(format!("Command failed with exit code: {status}")); } - s - }); - - let status = child - .wait() - .map_err(|e| format!("Failed to wait for command: {e}"))?; - let stderr = stderr_thread.join().unwrap_or_default(); - if status.success() { - Ok(()) } else { - let msg: &str = stderr.trim(); - if msg.is_empty() { - Err(format!("Command failed with exit code: {status}")) - } else { - Err(format!("Command failed with exit code: {status}\n{msg}")) + command.stdout(Stdio::null()).stderr(Stdio::piped()); + let child = command + .spawn() + .map_err(|e| format!("Failed to start command: {e}"))?; + + let execution_output = child + .wait_with_output() + .map_err(|e| format!("Failed to wait for command: {e}"))?; + + if !execution_output.status.success() { + let msg = String::from_utf8_lossy(&execution_output.stderr); + let msg = msg.trim(); + + let mut err_msg = format!("Command failed with exit code: {}", execution_output.status); + if !msg.is_empty() { + err_msg = format!("{err_msg}\n{msg}"); + } + return Err(err_msg); } } + + Ok(()) } From e775cdb098fb3b16ac16aaf10bd831b9d386c83d Mon Sep 17 00:00:00 2001 From: Masonlet Date: Sun, 28 Jun 2026 13:35:45 -0400 Subject: [PATCH 11/59] refactor(cli): consolidate interactive confirmation logic into confirm_abort --- src/config/crud.rs | 11 ++++------- src/profile/crud.rs | 8 +++----- src/prompts.rs | 11 +++++++++++ 3 files changed, 18 insertions(+), 12 deletions(-) diff --git a/src/config/crud.rs b/src/config/crud.rs index 621befb..349a8d8 100644 --- a/src/config/crud.rs +++ b/src/config/crud.rs @@ -2,7 +2,7 @@ use crate::{ cli::BuildType, config::{format_entry, save_config, ConfigEntry, SetupConfig}, ctx::IoCtx, - prompts::confirm, + prompts::confirm_abort, }; use std::path::PathBuf; @@ -27,13 +27,12 @@ pub fn has_config(config: &SetupConfig, name: &str) -> bool { /// Returns an error if the config file cannot be written. pub fn create_default_config(path: PathBuf, yes: bool, io: &mut IoCtx<'_>) -> Result<(), String> { if path.exists() - && !confirm( + && !confirm_abort( &format!("{} already exists. Overwrite?", path.display()), yes, io, )? { - writeln!(io.output, "Aborted.").ok(); return Ok(()); } @@ -85,13 +84,12 @@ pub fn add_config( io: &mut IoCtx<'_>, ) -> Result<(), String> { if has_config(config, name) - && !confirm( + && !confirm_abort( &format!("Warning: Configuration '{name}' already exists. Overwrite?"), yes, io, )? { - writeln!(io.output, "Aborted.").ok(); return Ok(()); } @@ -135,8 +133,7 @@ pub fn remove_config( writeln!(io.output, "Configuration details:").ok(); write!(io.output, "{}", format_entry(e)).ok(); - if !confirm("\nAre you sure you want to remove this config?", yes, io)? { - writeln!(io.output, "Aborted.").ok(); + if !confirm_abort("\nAre you sure you want to remove this config?", yes, io)? { return Ok(()); } diff --git a/src/profile/crud.rs b/src/profile/crud.rs index 747ffec..00ad319 100644 --- a/src/profile/crud.rs +++ b/src/profile/crud.rs @@ -2,7 +2,7 @@ use crate::{ config::{save_config, SetupConfig}, ctx::IoCtx, profile::print_profile_details, - prompts::confirm, + prompts::confirm_abort, }; /// Inserts or overwrites a named profile. @@ -39,13 +39,12 @@ pub fn add_profile( let repos = args[1..].to_vec(); if has_profile(config, &name) - && !confirm( + && !confirm_abort( &format!("Warning: Profile '{name}' already exists. Overwrite?"), yes, io, )? { - writeln!(io.output, "Aborted.").ok(); return Ok(()); } @@ -90,12 +89,11 @@ pub fn remove_profile( &repos, ); - if !confirm( + if !confirm_abort( &format!("Are you sure you want to remove profile '{name}'?"), yes, io, )? { - writeln!(io.output, "Aborted.").ok(); return Ok(()); } diff --git a/src/prompts.rs b/src/prompts.rs index 5855d6e..c3ad0dd 100644 --- a/src/prompts.rs +++ b/src/prompts.rs @@ -75,3 +75,14 @@ pub fn confirm(prompt: &str, yes: bool, io: &mut IoCtx<'_>) -> Result) -> Result { + if !confirm(warning_msg, yes, io)? { + writeln!(io.output, "Aborted.").ok(); + return Ok(false); + } + Ok(true) +} From 636ec7aa62cdd02a441571cdc00af814feab59e9 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Sun, 28 Jun 2026 13:38:58 -0400 Subject: [PATCH 12/59] refactor(run): extract subcomamnd handlers --- src/run.rs | 181 ++++++++++++++++++++++++++++------------------------- 1 file changed, 94 insertions(+), 87 deletions(-) diff --git a/src/run.rs b/src/run.rs index d7e2b52..026e4a8 100644 --- a/src/run.rs +++ b/src/run.rs @@ -1,14 +1,7 @@ use crate::{ - cli::{args::Command, resolve_with_config, Args, ConfigAction, ProfileAction, WorkspaceAction}, - commands::{mono_repo_mode, single_repo_mode}, - config::{ - add_config, create_default_config, list_configs, load_config, remove_config, ConfigEntry, - }, - ctx::{DryRunRunner, IoCtx, ProcessRunner, RunCtx, Runner}, - interactive::interactive_mode, - profile::{add_profile, list_profiles, remove_profile}, - utils::check_prerequisites, - workspace::{clean_workspace, resolve_workspace, status_workspace, update_workspace}, + cli::{Args, ConfigAction, ProfileAction, WorkspaceAction, args::Command, resolve_with_config}, commands::{mono_repo_mode, single_repo_mode}, config::{ + ConfigEntry, SetupConfig, add_config, create_default_config, list_configs, load_config, remove_config, + }, ctx::{DryRunRunner, IoCtx, ProcessRunner, RunCtx, Runner}, interactive::interactive_mode, profile::{add_profile, list_profiles, remove_profile}, utils::check_prerequisites, workspace::{clean_workspace, resolve_workspace, status_workspace, update_workspace}, }; use clap::Parser; use std::{ @@ -19,22 +12,9 @@ use std::{ const CONFIG_FILE_NAME: &str = ".star-setup.json"; -/// Helper to quickly execute a workspace/repo task with the correct runner -fn with_runner(io: IoCtx, f: F) -> Result<(), Box> -where - F: FnOnce(&mut RunCtx) -> Result<(), Box>, -{ - let mut dry = DryRunRunner; - let mut real = ProcessRunner; - let runner: &mut dyn Runner = if io.dry_run { &mut dry } else { &mut real }; - let mut ctx = RunCtx { io, runner }; - f(&mut ctx) -} - /// Runs the setup process. /// # Errors /// Returns an error if the configuration file is missing or corrupted. -#[allow(clippy::too_many_lines)] pub fn run() -> Result<(), Box> { let mut stdin = io::stdin().lock(); let mut stdout = io::stdout(); @@ -60,70 +40,9 @@ pub fn run() -> Result<(), Box> { if let Some(cmd) = raw.command { match cmd { - Command::Config(config_cmd) => match config_cmd.action { - ConfigAction::Init => { - create_default_config(PathBuf::from(CONFIG_FILE_NAME), raw.yes, &mut io)?; - } - ConfigAction::List => list_configs(&config, &mut io), - ConfigAction::Remove { name } => { - remove_config(&mut config, &name, raw.yes, &mut io)?; - } - ConfigAction::Add { - name, - connection, - build, - mono, - diagnostic, - } => { - let entry = ConfigEntry::from_flags(&connection, &build, &mono, &diagnostic); - add_config(&mut config, &name, entry, raw.yes, &mut io)?; - } - }, - Command::Profile(profile_cmd) => match profile_cmd.action { - ProfileAction::List => list_profiles(&config, &mut io), - ProfileAction::Remove { name } => { - remove_profile(&mut config, &name, raw.yes, &mut io)?; - } - ProfileAction::Add { name, repos } => { - let vals = std::iter::once(name).chain(repos).collect::>(); - add_profile(&mut config, &vals, raw.yes, &mut io)?; - } - }, - Command::Workspace(ws_cmd) => match ws_cmd.action { - WorkspaceAction::Update { - path, - mono_dir, - build_dir, - } => { - let ws = resolve_workspace(path.as_deref(), mono_dir.as_deref(), build_dir.as_deref())?; - with_runner(io, |ctx| { - update_workspace(&ws, ctx).map_err(Box::::from) - })?; - } - WorkspaceAction::Status { - path, - mono_dir, - build_dir, - fetch, - } => { - let ws = resolve_workspace(path.as_deref(), mono_dir.as_deref(), build_dir.as_deref())?; - let mut status_io = io; - status_io.dry_run = false; - with_runner(status_io, |ctx| { - status_workspace(&ws, fetch, ctx).map_err(Box::::from) - })?; - } - WorkspaceAction::Clean { - path, - mono_dir, - build_dir, - } => { - let ws = resolve_workspace(path.as_deref(), mono_dir.as_deref(), build_dir.as_deref())?; - with_runner(io, |ctx| { - clean_workspace(&ws, ctx).map_err(Box::::from) - })?; - } - }, + Command::Config(c) => handle_config_cmd(c.action, &mut config, raw.yes, &mut io)?, + Command::Profile(p) => handle_profile_cmd(p.action, &mut config, raw.yes, &mut io)?, + Command::Workspace(w) => handle_workspace_cmd(w.action, io)?, } return Ok(()); } @@ -150,3 +69,91 @@ pub fn run() -> Result<(), Box> { Ok(()) } + +/// Helper to quickly execute a workspace/repo task with the correct runner +fn with_runner(io: IoCtx, f: F) -> Result<(), Box> +where + F: FnOnce(&mut RunCtx) -> Result<(), Box>, +{ + let mut dry = DryRunRunner; + let mut real = ProcessRunner; + let runner: &mut dyn Runner = if io.dry_run { &mut dry } else { &mut real }; + let mut ctx = RunCtx { io, runner }; + f(&mut ctx) +} + +fn handle_config_cmd( + action: ConfigAction, + config: &mut SetupConfig, + yes: bool, + io: &mut IoCtx, +) -> Result<(), Box> { + match action { + ConfigAction::Init => create_default_config(PathBuf::from(CONFIG_FILE_NAME), yes, io)?, + ConfigAction::List => list_configs(config, io), + ConfigAction::Remove { name } => remove_config(config, &name, yes, io)?, + ConfigAction::Add { + name, + connection, + build, + mono, + diagnostic, + } => { + let entry = ConfigEntry::from_flags(&connection, &build, &mono, &diagnostic); + add_config(config, &name, entry, yes, io)?; + } + } + Ok(()) +} + +fn handle_profile_cmd( + action: ProfileAction, + config: &mut SetupConfig, + yes: bool, + io: &mut IoCtx, +) -> Result<(), Box> { + match action { + ProfileAction::List => list_profiles(config, io), + ProfileAction::Remove { name } => remove_profile(config, &name, yes, io)?, + ProfileAction::Add { name, repos } => { + let vals = std::iter::once(name).chain(repos).collect::>(); + add_profile(config, &vals, yes, io)?; + } + } + Ok(()) +} + +fn handle_workspace_cmd(action: WorkspaceAction, io: IoCtx) -> Result<(), Box> { + match action { + WorkspaceAction::Update { + path, + mono_dir, + build_dir, + } => { + let ws = resolve_workspace(path.as_deref(), mono_dir.as_deref(), build_dir.as_deref())?; + with_runner(io, |ctx| update_workspace(&ws, ctx).map_err(Into::into))?; + } + WorkspaceAction::Status { + path, + mono_dir, + build_dir, + fetch, + } => { + let ws = resolve_workspace(path.as_deref(), mono_dir.as_deref(), build_dir.as_deref())?; + let mut status_io = io; + status_io.dry_run = false; + with_runner(status_io, |ctx| { + status_workspace(&ws, fetch, ctx).map_err(Into::into) + })?; + } + WorkspaceAction::Clean { + path, + mono_dir, + build_dir, + } => { + let ws = resolve_workspace(path.as_deref(), mono_dir.as_deref(), build_dir.as_deref())?; + with_runner(io, |ctx| clean_workspace(&ws, ctx).map_err(Into::into))?; + } + } + Ok(()) +} From 681555635f1139a7c7cc75c5f554fb6686f4d284 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Sun, 28 Jun 2026 13:54:46 -0400 Subject: [PATCH 13/59] refactor: relocate subcommand handlers and with_runner helper --- src/commands/handlers.rs | 96 +++++++++++++++++++++++++++++++ src/commands/mod.rs | 2 + src/ctx.rs | 15 +++++ src/run.rs | 120 +++++++-------------------------------- 4 files changed, 135 insertions(+), 98 deletions(-) create mode 100644 src/commands/handlers.rs diff --git a/src/commands/handlers.rs b/src/commands/handlers.rs new file mode 100644 index 0000000..77f98c3 --- /dev/null +++ b/src/commands/handlers.rs @@ -0,0 +1,96 @@ +use crate::{ + cli::{ConfigAction, ProfileAction, WorkspaceAction}, + config::{ + add_config, create_default_config, list_configs, remove_config, ConfigEntry, SetupConfig, + }, + ctx::{with_runner, IoCtx}, + profile::{add_profile, list_profiles, remove_profile}, + workspace::{clean_workspace, resolve_workspace, status_workspace, update_workspace}, +}; +use std::{error::Error, path::PathBuf}; + +/// Handles configuration-related subcommands. +/// # Errors +/// Returns an error if configuration initializing, addition, or removal fails. +pub fn handle_config_cmd( + action: ConfigAction, + config: &mut SetupConfig, + config_path: PathBuf, + yes: bool, + io: &mut IoCtx, +) -> Result<(), Box> { + match action { + ConfigAction::Init => create_default_config(config_path, yes, io)?, + ConfigAction::List => list_configs(config, io), + ConfigAction::Remove { name } => remove_config(config, &name, yes, io)?, + ConfigAction::Add { + name, + connection, + build, + mono, + diagnostic, + } => { + let entry = ConfigEntry::from_flags(&connection, &build, &mono, &diagnostic); + add_config(config, &name, entry, yes, io)?; + } + } + Ok(()) +} + +/// Handles profile-related subcommands. +/// # Errors +/// Returns an error if adding or removing profiles encounters an I/O or validation failure. +pub fn handle_profile_cmd( + action: ProfileAction, + config: &mut SetupConfig, + yes: bool, + io: &mut IoCtx, +) -> Result<(), Box> { + match action { + ProfileAction::List => list_profiles(config, io), + ProfileAction::Remove { name } => remove_profile(config, &name, yes, io)?, + ProfileAction::Add { name, repos } => { + let vals = std::iter::once(name).chain(repos).collect::>(); + add_profile(config, &vals, yes, io)?; + } + } + Ok(()) +} + +/// Handles workspace-related subcommands. +/// # Errors +/// Returns an error if resolving, updating, cleaning, or fetching status for the workspace fails. +pub fn handle_workspace_cmd(action: WorkspaceAction, io: IoCtx) -> Result<(), Box> { + match action { + WorkspaceAction::Update { + path, + mono_dir, + build_dir, + } => { + let ws = resolve_workspace(path.as_deref(), mono_dir.as_deref(), build_dir.as_deref())?; + with_runner(io, |ctx| update_workspace(&ws, ctx).map_err(Into::into))?; + } + WorkspaceAction::Status { + path, + mono_dir, + build_dir, + fetch, + } => { + let ws = resolve_workspace(path.as_deref(), mono_dir.as_deref(), build_dir.as_deref())?; + let mut status_io = io; + status_io.dry_run = false; + with_runner(status_io, |ctx| { + status_workspace(&ws, fetch, ctx).map_err(Into::into) + })?; + } + WorkspaceAction::Clean { + path, + mono_dir, + build_dir, + } => { + let ws = resolve_workspace(path.as_deref(), mono_dir.as_deref(), build_dir.as_deref())?; + with_runner(io, |ctx| clean_workspace(&ws, ctx).map_err(Into::into))?; + } + } + Ok(()) +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 6194719..aa6b573 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -12,3 +12,5 @@ pub mod single; pub use single::single_repo_mode; pub mod setup; pub use setup::{configure_and_build, extract_repo_input, prepare_build_dir}; +pub mod handlers; +pub use handlers::{handle_config_cmd, handle_profile_cmd, handle_workspace_cmd}; diff --git a/src/ctx.rs b/src/ctx.rs index 0919408..d6c1c06 100644 --- a/src/ctx.rs +++ b/src/ctx.rs @@ -1,5 +1,6 @@ use crate::utils::process::run_command; use std::{ + error::Error, io::{BufRead, Write}, path::Path, }; @@ -79,3 +80,17 @@ pub struct RunCtx<'io, 'run> { pub io: IoCtx<'io>, pub runner: &'run mut dyn Runner, } + +/// Helper to quickly execute a workspace/repo task with the correct runner. +/// # Errors +/// Returns an error if the closure function `f` execution returns an error block. +pub fn with_runner(io: IoCtx, f: F) -> Result<(), Box> +where + F: FnOnce(&mut RunCtx) -> Result<(), Box>, +{ + let mut dry = DryRunRunner; + let mut real = ProcessRunner; + let runner: &mut dyn Runner = if io.dry_run { &mut dry } else { &mut real }; + let mut ctx = RunCtx { io, runner }; + f(&mut ctx) +} diff --git a/src/run.rs b/src/run.rs index 026e4a8..cd44744 100644 --- a/src/run.rs +++ b/src/run.rs @@ -1,7 +1,12 @@ use crate::{ - cli::{Args, ConfigAction, ProfileAction, WorkspaceAction, args::Command, resolve_with_config}, commands::{mono_repo_mode, single_repo_mode}, config::{ - ConfigEntry, SetupConfig, add_config, create_default_config, list_configs, load_config, remove_config, - }, ctx::{DryRunRunner, IoCtx, ProcessRunner, RunCtx, Runner}, interactive::interactive_mode, profile::{add_profile, list_profiles, remove_profile}, utils::check_prerequisites, workspace::{clean_workspace, resolve_workspace, status_workspace, update_workspace}, + cli::{args::Command, resolve_with_config, Args}, + commands::{ + handle_config_cmd, handle_profile_cmd, handle_workspace_cmd, mono_repo_mode, single_repo_mode, + }, + config::load_config, + ctx::{with_runner, IoCtx}, + interactive::interactive_mode, + utils::check_prerequisites, }; use clap::Parser; use std::{ @@ -20,12 +25,13 @@ pub fn run() -> Result<(), Box> { let mut stdout = io::stdout(); let is_terminal = stdin.is_terminal() && stdout.is_terminal(); - let locations = vec![ - PathBuf::from(CONFIG_FILE_NAME), - dirs::home_dir() - .map(|h| h.join(CONFIG_FILE_NAME)) - .unwrap_or_default(), - ]; + let locations = [ + Some(PathBuf::from(CONFIG_FILE_NAME)), + dirs::home_dir().map(|h| h.join(CONFIG_FILE_NAME)), + ] + .into_iter() + .flatten() + .collect::>(); let mut config = load_config(&locations, &mut stdout); let raw = Args::parse(); @@ -40,7 +46,13 @@ pub fn run() -> Result<(), Box> { if let Some(cmd) = raw.command { match cmd { - Command::Config(c) => handle_config_cmd(c.action, &mut config, raw.yes, &mut io)?, + Command::Config(c) => handle_config_cmd( + c.action, + &mut config, + PathBuf::from(CONFIG_FILE_NAME), + raw.yes, + &mut io, + )?, Command::Profile(p) => handle_profile_cmd(p.action, &mut config, raw.yes, &mut io)?, Command::Workspace(w) => handle_workspace_cmd(w.action, io)?, } @@ -69,91 +81,3 @@ pub fn run() -> Result<(), Box> { Ok(()) } - -/// Helper to quickly execute a workspace/repo task with the correct runner -fn with_runner(io: IoCtx, f: F) -> Result<(), Box> -where - F: FnOnce(&mut RunCtx) -> Result<(), Box>, -{ - let mut dry = DryRunRunner; - let mut real = ProcessRunner; - let runner: &mut dyn Runner = if io.dry_run { &mut dry } else { &mut real }; - let mut ctx = RunCtx { io, runner }; - f(&mut ctx) -} - -fn handle_config_cmd( - action: ConfigAction, - config: &mut SetupConfig, - yes: bool, - io: &mut IoCtx, -) -> Result<(), Box> { - match action { - ConfigAction::Init => create_default_config(PathBuf::from(CONFIG_FILE_NAME), yes, io)?, - ConfigAction::List => list_configs(config, io), - ConfigAction::Remove { name } => remove_config(config, &name, yes, io)?, - ConfigAction::Add { - name, - connection, - build, - mono, - diagnostic, - } => { - let entry = ConfigEntry::from_flags(&connection, &build, &mono, &diagnostic); - add_config(config, &name, entry, yes, io)?; - } - } - Ok(()) -} - -fn handle_profile_cmd( - action: ProfileAction, - config: &mut SetupConfig, - yes: bool, - io: &mut IoCtx, -) -> Result<(), Box> { - match action { - ProfileAction::List => list_profiles(config, io), - ProfileAction::Remove { name } => remove_profile(config, &name, yes, io)?, - ProfileAction::Add { name, repos } => { - let vals = std::iter::once(name).chain(repos).collect::>(); - add_profile(config, &vals, yes, io)?; - } - } - Ok(()) -} - -fn handle_workspace_cmd(action: WorkspaceAction, io: IoCtx) -> Result<(), Box> { - match action { - WorkspaceAction::Update { - path, - mono_dir, - build_dir, - } => { - let ws = resolve_workspace(path.as_deref(), mono_dir.as_deref(), build_dir.as_deref())?; - with_runner(io, |ctx| update_workspace(&ws, ctx).map_err(Into::into))?; - } - WorkspaceAction::Status { - path, - mono_dir, - build_dir, - fetch, - } => { - let ws = resolve_workspace(path.as_deref(), mono_dir.as_deref(), build_dir.as_deref())?; - let mut status_io = io; - status_io.dry_run = false; - with_runner(status_io, |ctx| { - status_workspace(&ws, fetch, ctx).map_err(Into::into) - })?; - } - WorkspaceAction::Clean { - path, - mono_dir, - build_dir, - } => { - let ws = resolve_workspace(path.as_deref(), mono_dir.as_deref(), build_dir.as_deref())?; - with_runner(io, |ctx| clean_workspace(&ws, ctx).map_err(Into::into))?; - } - } - Ok(()) -} From 31817c32a3bb2158c8cb1bf9c63f48d14eddd453 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Sun, 28 Jun 2026 14:03:19 -0400 Subject: [PATCH 14/59] refactor(cli): extract interactive prompt helpers to reduce boilerplate --- src/interactive.rs | 83 +++++++++++++++++++++------------------------- 1 file changed, 38 insertions(+), 45 deletions(-) diff --git a/src/interactive.rs b/src/interactive.rs index 5701b1c..f8e5c90 100644 --- a/src/interactive.rs +++ b/src/interactive.rs @@ -13,34 +13,22 @@ pub fn interactive_mode(args: &mut ResolvedArgs, io: &mut IoCtx<'_>) -> Result<( writeln!(io.output, "Star Setup Interactive Mode").ok(); if args.repo.is_none() { - loop { - let repo = ask("Enter repository (user/repo or URL)", io)?; - if !repo.is_empty() { - args.repo = Some(repo); - break; - } - } + args.repo = Some(ask_required("Enter repository (user/repo or URL)", io)?); } - if !args.connection.ssh { - args.connection.ssh = ask_yesno("Use SSH?", false, io)?; - } - if !args.connection.verbose { - args.connection.verbose = ask_yesno("Verbose?", false, io)?; - io.verbose = args.connection.verbose; - } - if !args.diagnostic.timing { - args.diagnostic.timing = ask_yesno("Show timing?", false, io)?; - io.timing = args.diagnostic.timing; - } - if !args.build.clean { - args.build.clean = ask_yesno("Clean build directory if exists?", false, io)?; - } + args.connection.ssh = ask_bool_if("Use SSH?", args.connection.ssh, io)?; + + args.connection.verbose = ask_bool_if("Verbose?", args.connection.verbose, io)?; + io.verbose = args.connection.verbose; + + args.diagnostic.timing = ask_bool_if("Show timing?", args.diagnostic.timing, io)?; + io.timing = args.diagnostic.timing; + + args.build.clean = ask_bool_if("Clean build directory if exists?", args.build.clean, io)?; if !args.mono.mono_repo { loop { - let mode = ask("Select mode: (1) Single Repo (2) Mono-Repo", io)?; - match mode.as_str() { + match ask("Select mode: (1) Single Repo (2) Mono-Repo", io)?.as_str() { "1" => break, "2" => { args.mono.mono_repo = true; @@ -53,29 +41,17 @@ pub fn interactive_mode(args: &mut ResolvedArgs, io: &mut IoCtx<'_>) -> Result<( if args.mono.mono_repo && args.mono.profile.is_none() && args.mono.repos.is_none() { loop { - let choice = ask("Mono-repo: (1) Use profile (2) Manual repo list", io)?; - match choice.as_str() { + match ask("Mono-repo: (1) Use profile (2) Manual repo list", io)?.as_str() { "1" => { - loop { - let profile = ask("Profile name", io)?; - if !profile.is_empty() { - args.mono.profile = Some(profile); - break; - } - } + args.mono.profile = Some(ask_required("Profile name", io)?); break; } "2" => { - loop { - let repo_list = ask( - "Enter repos (space separated 'username/lib1 username/lib2')", - io, - )?; - if !repo_list.is_empty() { - args.mono.repos = Some(repo_list.split_whitespace().map(String::from).collect()); - break; - } - } + let repo_list = ask_required( + "Enter repos (space separated 'username/lib1 username/lib2')", + io, + )?; + args.mono.repos = Some(repo_list.split_whitespace().map(String::from).collect()); break; } _ => {} @@ -87,10 +63,27 @@ pub fn interactive_mode(args: &mut ResolvedArgs, io: &mut IoCtx<'_>) -> Result<( args.build.build_type = build_type_str.parse::()?; args.build.build_dir = ask_default("Build directory", &args.build.build_dir, io)?; - if !args.build.no_build { - args.build.no_build = ask_yesno("Configure only (skip build)?", false, io)?; - } + args.build.no_build = ask_bool_if("Configure only (skip build)?", args.build.no_build, io)?; writeln!(io.output, "\nInteractive mode complete").ok(); Ok(()) } + +/// Helper to ask a boolean question only if the condition isn't already met. +fn ask_bool_if(prompt: &str, current_val: bool, io: &mut IoCtx<'_>) -> Result { + if !current_val { + ask_yesno(prompt, false, io) + } else { + Ok(current_val) + } +} + +/// Helper to repeatedly prompt until a non-empty string is provided. +fn ask_required(prompt: &str, io: &mut IoCtx<'_>) -> Result { + loop { + let response = ask(prompt, io)?; + if !response.is_empty() { + return Ok(response); + } + } +} From 6edc773499557719a6ff74259825dead3767bcd9 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Sun, 28 Jun 2026 14:19:18 -0400 Subject: [PATCH 15/59] refactor: move workspace functions to Workspace methods --- src/commands/handlers.rs | 8 +-- src/interactive.rs | 6 +- src/workspace/clean.rs | 60 ++++++++++---------- src/workspace/mod.rs | 9 ++- src/workspace/resolve.rs | 19 +------ src/workspace/status.rs | 114 +++++++++++++++++++------------------- src/workspace/types.rs | 14 +++++ src/workspace/update.rs | 64 ++++++++++----------- tests/workspace/clean.rs | 14 ++--- tests/workspace/status.rs | 8 +-- tests/workspace/update.rs | 8 +-- 11 files changed, 164 insertions(+), 160 deletions(-) create mode 100644 src/workspace/types.rs diff --git a/src/commands/handlers.rs b/src/commands/handlers.rs index 77f98c3..32df684 100644 --- a/src/commands/handlers.rs +++ b/src/commands/handlers.rs @@ -5,7 +5,7 @@ use crate::{ }, ctx::{with_runner, IoCtx}, profile::{add_profile, list_profiles, remove_profile}, - workspace::{clean_workspace, resolve_workspace, status_workspace, update_workspace}, + workspace::{resolve_workspace}, }; use std::{error::Error, path::PathBuf}; @@ -68,7 +68,7 @@ pub fn handle_workspace_cmd(action: WorkspaceAction, io: IoCtx) -> Result<(), Bo build_dir, } => { let ws = resolve_workspace(path.as_deref(), mono_dir.as_deref(), build_dir.as_deref())?; - with_runner(io, |ctx| update_workspace(&ws, ctx).map_err(Into::into))?; + with_runner(io, |ctx| ws.update(ctx).map_err(Into::into))?; } WorkspaceAction::Status { path, @@ -80,7 +80,7 @@ pub fn handle_workspace_cmd(action: WorkspaceAction, io: IoCtx) -> Result<(), Bo let mut status_io = io; status_io.dry_run = false; with_runner(status_io, |ctx| { - status_workspace(&ws, fetch, ctx).map_err(Into::into) + ws.status(fetch, ctx).map_err(Into::into) })?; } WorkspaceAction::Clean { @@ -89,7 +89,7 @@ pub fn handle_workspace_cmd(action: WorkspaceAction, io: IoCtx) -> Result<(), Bo build_dir, } => { let ws = resolve_workspace(path.as_deref(), mono_dir.as_deref(), build_dir.as_deref())?; - with_runner(io, |ctx| clean_workspace(&ws, ctx).map_err(Into::into))?; + with_runner(io, |ctx| ws.clean(ctx).map_err(Into::into))?; } } Ok(()) diff --git a/src/interactive.rs b/src/interactive.rs index f8e5c90..c9ecae1 100644 --- a/src/interactive.rs +++ b/src/interactive.rs @@ -71,10 +71,10 @@ pub fn interactive_mode(args: &mut ResolvedArgs, io: &mut IoCtx<'_>) -> Result<( /// Helper to ask a boolean question only if the condition isn't already met. fn ask_bool_if(prompt: &str, current_val: bool, io: &mut IoCtx<'_>) -> Result { - if !current_val { - ask_yesno(prompt, false, io) - } else { + if current_val { Ok(current_val) + } else { + ask_yesno(prompt, false, io) } } diff --git a/src/workspace/clean.rs b/src/workspace/clean.rs index bff9b7d..37d8493 100644 --- a/src/workspace/clean.rs +++ b/src/workspace/clean.rs @@ -1,39 +1,41 @@ -use crate::{ctx::RunCtx, workspace::resolve::Workspace}; +use crate::{ctx::RunCtx, workspace::Workspace}; use std::fs; -/// Removes the build directory from the workspace. -/// # Errors -/// Returns an error if the build directory cannot be removed. -pub fn clean_workspace(workspace: &Workspace, ctx: &mut RunCtx<'_, '_>) -> Result<(), String> { - if !workspace.build_path.exists() { +impl Workspace { + /// Removes the build directory. + /// # Errors + /// Returns an error if the build directory cannot be removed. + pub fn clean(&self, ctx: &mut RunCtx<'_, '_>) -> Result<(), String> { + if !self.build_path.exists() { + writeln!( + ctx.io.output, + "Build directory does not exist: {}", + self.build_path.display() + ) + .ok(); + return Ok(()); + } + writeln!( ctx.io.output, - "Build directory does not exist: {}", - workspace.build_path.display() + "Removing build directory: {}", + self.build_path.display() ) .ok(); - return Ok(()); - } - writeln!( - ctx.io.output, - "Removing build directory: {}", - workspace.build_path.display() - ) - .ok(); + if ctx.io.dry_run { + writeln!( + ctx.io.output, + "Would remove directory: {}", + self.build_path.display() + ) + .ok(); + } else { + fs::remove_dir_all(&self.build_path) + .map_err(|e| format!("Failed to remove build directory: {e}"))?; + writeln!(ctx.io.output, "Done").ok(); + } - if ctx.io.dry_run { - writeln!( - ctx.io.output, - "Would remove directory: {}", - workspace.build_path.display() - ) - .ok(); - } else { - fs::remove_dir_all(&workspace.build_path) - .map_err(|e| format!("Failed to remove build directory: {e}"))?; - writeln!(ctx.io.output, "Done").ok(); + Ok(()) } - - Ok(()) } diff --git a/src/workspace/mod.rs b/src/workspace/mod.rs index 1558217..04fcc01 100644 --- a/src/workspace/mod.rs +++ b/src/workspace/mod.rs @@ -1,8 +1,7 @@ -pub mod clean; +pub mod types; +pub use types::Workspace; pub mod resolve; +pub use resolve::resolve_workspace; +pub mod clean; pub mod status; pub mod update; -pub use clean::clean_workspace; -pub use resolve::{resolve_workspace, Workspace}; -pub use status::status_workspace; -pub use update::update_workspace; diff --git a/src/workspace/resolve.rs b/src/workspace/resolve.rs index 5836afc..83441d6 100644 --- a/src/workspace/resolve.rs +++ b/src/workspace/resolve.rs @@ -1,20 +1,5 @@ -use std::{ - fs, - path::{Path, PathBuf}, -}; - -/// A resolved workspace with paths to key directories and cloned repos. -#[derive(Debug)] -pub struct Workspace { - /// Workspace root directory (e.g. `./build-mono`). - pub root: PathBuf, - /// Path to the cloned repositories directory. - pub repos_path: PathBuf, - /// Path to the build output directory. - pub build_path: PathBuf, - /// Paths to each cloned repository directory. - pub repo_dirs: Vec, -} +use std::{fs, path::Path}; +use crate::workspace::Workspace; /// Resolves a workspace from optional path overrides. /// # Errors diff --git a/src/workspace/status.rs b/src/workspace/status.rs index 2b19f91..56fdd5e 100644 --- a/src/workspace/status.rs +++ b/src/workspace/status.rs @@ -1,68 +1,70 @@ -use crate::{ctx::RunCtx, workspace::resolve::Workspace}; +use crate::{ctx::RunCtx, workspace::Workspace}; -/// Shows the status of all repositories in the workspace. -/// # Errors -/// Returns an error if any git command fails. -pub fn status_workspace( - workspace: &Workspace, - fetch: bool, - ctx: &mut RunCtx<'_, '_>, -) -> Result<(), String> { - writeln!(ctx.io.output, "Workspace status:\n").ok(); +impl Workspace { + /// Shows the status of all repositories. + /// # Errors + /// Returns an error if any git command fails. + pub fn status( + &self, + fetch: bool, + ctx: &mut RunCtx<'_, '_>, + ) -> Result<(), String> { + writeln!(ctx.io.output, "Workspace status:\n").ok(); - for repo_dir in &workspace.repo_dirs { - let name = repo_dir - .file_name() - .map(|n| n.to_string_lossy().into_owned()) - .unwrap_or_default(); + for repo_dir in &self.repo_dirs { + let name = repo_dir + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default(); - if fetch { - ctx - .runner - .run(&["git", "fetch"], Some(repo_dir), &mut ctx.io)?; - } - - let branch = ctx - .runner - .run_capture( - &["git", "rev-parse", "--abbrev-ref", "HEAD"], - Some(repo_dir), - ) - .unwrap_or_else(|_| "(unknown)".to_string()); + if fetch { + ctx + .runner + .run(&["git", "fetch"], Some(repo_dir), &mut ctx.io)?; + } - let dirty = !ctx - .runner - .run_capture(&["git", "status", "--porcelain"], Some(repo_dir))? - .is_empty(); - - let status_str = if dirty { "dirty" } else { "clean" }; - - let ahead_behind = if fetch { - let ahead = ctx + let branch = ctx .runner .run_capture( - &["git", "rev-list", "--count", "@{u}..HEAD"], + &["git", "rev-parse", "--abbrev-ref", "HEAD"], Some(repo_dir), ) - .unwrap_or_else(|_| "?".to_string()); - let behind = ctx + .unwrap_or_else(|_| "(unknown)".to_string()); + + let dirty = !ctx .runner - .run_capture( - &["git", "rev-list", "--count", "HEAD..@{u}"], - Some(repo_dir), - ) - .unwrap_or_else(|_| "?".to_string()); - format!(" ↑{ahead} ↓{behind}") - } else { - String::new() - }; + .run_capture(&["git", "status", "--porcelain"], Some(repo_dir))? + .is_empty(); - writeln!( - ctx.io.output, - " {name:<20} {branch:<12} {status_str}{ahead_behind}" - ) - .ok(); - } + let status_str = if dirty { "dirty" } else { "clean" }; + + let ahead_behind = if fetch { + let ahead = ctx + .runner + .run_capture( + &["git", "rev-list", "--count", "@{u}..HEAD"], + Some(repo_dir), + ) + .unwrap_or_else(|_| "?".to_string()); + let behind = ctx + .runner + .run_capture( + &["git", "rev-list", "--count", "HEAD..@{u}"], + Some(repo_dir), + ) + .unwrap_or_else(|_| "?".to_string()); + format!(" ↑{ahead} ↓{behind}") + } else { + String::new() + }; - Ok(()) + writeln!( + ctx.io.output, + " {name:<20} {branch:<12} {status_str}{ahead_behind}" + ) + .ok(); + } + + Ok(()) + } } diff --git a/src/workspace/types.rs b/src/workspace/types.rs new file mode 100644 index 0000000..ca99003 --- /dev/null +++ b/src/workspace/types.rs @@ -0,0 +1,14 @@ +use std::path::PathBuf; + +/// A resolved workspace with paths to key directories and cloned repos. +#[derive(Debug)] +pub struct Workspace { + /// Workspace root directory (e.g. `./build-mono`). + pub root: PathBuf, + /// Path to the cloned repositories directory. + pub repos_path: PathBuf, + /// Path to the build output directory. + pub build_path: PathBuf, + /// Paths to each cloned repository directory. + pub repo_dirs: Vec, +} diff --git a/src/workspace/update.rs b/src/workspace/update.rs index 9c1c4ad..c49f749 100644 --- a/src/workspace/update.rs +++ b/src/workspace/update.rs @@ -1,39 +1,41 @@ -use crate::{ctx::RunCtx, repository::pull_repository, workspace::resolve::Workspace}; +use crate::{ctx::RunCtx, repository::pull_repository, workspace::Workspace}; -/// Pulls latest changes for all repositories in the workspace. -/// # Errors -/// Returns an error if any `git pull` command fails. -pub fn update_workspace(workspace: &Workspace, ctx: &mut RunCtx<'_, '_>) -> Result<(), String> { - writeln!( - ctx.io.output, - "Updating {} repositories\n", - workspace.repo_dirs.len() - ) - .ok(); +impl Workspace { + /// Pulls latest changes for all repositories. + /// # Errors + /// Returns an error if any `git pull` command fails. + pub fn update(&self, ctx: &mut RunCtx<'_, '_>) -> Result<(), String> { + writeln!( + ctx.io.output, + "Updating {} repositories\n", + self.repo_dirs.len() + ) + .ok(); - let mut errors: Vec = Vec::new(); + let mut errors: Vec = Vec::new(); - for repo_dir in &workspace.repo_dirs { - let name = repo_dir - .file_name() - .map(|n| n.to_string_lossy()) - .unwrap_or_default(); + for repo_dir in &self.repo_dirs { + let name = repo_dir + .file_name() + .map(|n| n.to_string_lossy()) + .unwrap_or_default(); - writeln!(ctx.io.output, " Updating {name}").ok(); - if let Err(e) = pull_repository(repo_dir, ctx) { - writeln!(ctx.io.output, " Failed to update {name}: {e}").ok(); - errors.push(format!("{name}: {e}")); + writeln!(ctx.io.output, " Updating {name}").ok(); + if let Err(e) = pull_repository(repo_dir, ctx) { + writeln!(ctx.io.output, " Failed to update {name}: {e}").ok(); + errors.push(format!("{name}: {e}")); + } } - } - if errors.is_empty() { - writeln!(ctx.io.output, "\nDone").ok(); - Ok(()) - } else { - Err(format!( - "{} repository(s) failed to update:\n{}", - errors.len(), - errors.join("\n") - )) + if errors.is_empty() { + writeln!(ctx.io.output, "\nDone").ok(); + Ok(()) + } else { + Err(format!( + "{} repository(s) failed to update:\n{}", + errors.len(), + errors.join("\n") + )) + } } } diff --git a/tests/workspace/clean.rs b/tests/workspace/clean.rs index a5e54cf..00668c0 100644 --- a/tests/workspace/clean.rs +++ b/tests/workspace/clean.rs @@ -1,10 +1,10 @@ use crate::common::{empty_input, make_io, sink, MockRunner}; -use star_setup::{ctx::RunCtx, workspace::resolve::Workspace}; +use star_setup::{ctx::RunCtx, workspace::types::Workspace}; use std::fs; use tempfile::TempDir; #[test] -fn test_clean_workspace_no_build_dir() { +fn test_workspace_clean_no_build_dir() { let tmp = TempDir::new().unwrap(); let ws = Workspace { root: tmp.path().to_path_buf(), @@ -19,13 +19,13 @@ fn test_clean_workspace_no_build_dir() { io: make_io(&mut input, &mut output), runner: &mut runner, }; - star_setup::workspace::clean_workspace(&ws, &mut ctx).unwrap(); + ws.clean(&mut ctx).unwrap(); let out = String::from_utf8(output).unwrap(); assert!(out.contains("does not exist")); } #[test] -fn test_clean_workspace_removes_build_dir() { +fn test_workspace_clean_removes_build_dir() { let tmp = TempDir::new().unwrap(); let build = tmp.path().join("build"); fs::create_dir_all(&build).unwrap(); @@ -43,12 +43,12 @@ fn test_clean_workspace_removes_build_dir() { io: make_io(&mut input, &mut output), runner: &mut runner, }; - star_setup::workspace::clean_workspace(&ws, &mut ctx).unwrap(); + ws.clean(&mut ctx).unwrap(); assert!(!build.exists()); } #[test] -fn test_clean_workspace_dry_run() { +fn test_workspace_clean_dry_run() { let tmp = TempDir::new().unwrap(); let build = tmp.path().join("build"); fs::create_dir_all(&build).unwrap(); @@ -71,7 +71,7 @@ fn test_clean_workspace_dry_run() { }, runner: &mut runner, }; - star_setup::workspace::clean_workspace(&ws, &mut ctx).unwrap(); + ws.clean(&mut ctx).unwrap(); assert!(build.exists()); let out = String::from_utf8(output).unwrap(); assert!(out.contains("Would remove directory:")); diff --git a/tests/workspace/status.rs b/tests/workspace/status.rs index c027e3b..3475fbf 100644 --- a/tests/workspace/status.rs +++ b/tests/workspace/status.rs @@ -1,5 +1,5 @@ use crate::common::{empty_input, make_io, MockRunner}; -use star_setup::{ctx::RunCtx, workspace::resolve::Workspace}; +use star_setup::{ctx::RunCtx, workspace::Workspace}; use std::path::PathBuf; fn make_workspace(repo_dirs: Vec) -> Workspace { @@ -21,7 +21,7 @@ fn test_status_workspace_empty() { io: make_io(&mut input, &mut output), runner: &mut runner, }; - star_setup::workspace::status_workspace(&ws, false, &mut ctx).unwrap(); + ws.status(false, &mut ctx).unwrap(); let out = String::from_utf8(output).unwrap(); assert!(out.contains("Workspace status:")); } @@ -38,7 +38,7 @@ fn test_status_workspace_shows_repos() { io: make_io(&mut input, &mut output), runner: &mut runner, }; - star_setup::workspace::status_workspace(&ws, false, &mut ctx).unwrap(); + ws.status(false, &mut ctx).unwrap(); let out = String::from_utf8(output).unwrap(); assert!(out.contains("user-lib1")); assert!(out.contains("clean")); @@ -58,7 +58,7 @@ fn test_status_workspace_with_fetch() { io: make_io(&mut input, &mut output), runner: &mut runner, }; - star_setup::workspace::status_workspace(&ws, true, &mut ctx).unwrap(); + ws.status(true, &mut ctx).unwrap(); let out = String::from_utf8(output).unwrap(); assert!(out.contains("↑2 ↓1")); assert!(runner diff --git a/tests/workspace/update.rs b/tests/workspace/update.rs index 79ad1a0..a47f08c 100644 --- a/tests/workspace/update.rs +++ b/tests/workspace/update.rs @@ -1,5 +1,5 @@ use crate::common::{empty_input, make_io, sink, MockRunner}; -use star_setup::{ctx::RunCtx, workspace::resolve::Workspace}; +use star_setup::{ctx::RunCtx, workspace::types::Workspace}; use std::path::PathBuf; fn make_workspace(repo_dirs: Vec) -> Workspace { @@ -21,7 +21,7 @@ fn test_update_workspace_empty() { io: make_io(&mut input, &mut output), runner: &mut runner, }; - star_setup::workspace::update_workspace(&ws, &mut ctx).unwrap(); + ws.update(&mut ctx).unwrap(); assert!(runner.calls.is_empty()); } @@ -38,7 +38,7 @@ fn test_update_workspace_pulls_each_repo() { io: make_io(&mut input, &mut output), runner: &mut runner, }; - star_setup::workspace::update_workspace(&ws, &mut ctx).unwrap(); + ws.update(&mut ctx).unwrap(); assert_eq!(runner.calls.len(), 2); assert!(runner .calls @@ -60,7 +60,7 @@ fn test_update_workspace_continues_on_failure() { io: make_io(&mut input, &mut output), runner: &mut runner, }; - let result = star_setup::workspace::update_workspace(&ws, &mut ctx); + let result = ws.update(&mut ctx); assert!(result.is_err()); assert_eq!(runner.calls.len(), 2); let out = String::from_utf8(output).unwrap(); From e90456da42cb90d4110f81a9273affc006adc830 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Sun, 28 Jun 2026 14:34:46 -0400 Subject: [PATCH 16/59] refactor: extract CONFIG_FILE_NAME into main and pass as PathBuf --- src/commands/handlers.rs | 6 ++---- src/main.rs | 5 ++++- src/run.rs | 22 ++++++++++------------ src/workspace/resolve.rs | 2 +- src/workspace/status.rs | 6 +----- 5 files changed, 18 insertions(+), 23 deletions(-) diff --git a/src/commands/handlers.rs b/src/commands/handlers.rs index 32df684..9b8e16e 100644 --- a/src/commands/handlers.rs +++ b/src/commands/handlers.rs @@ -5,7 +5,7 @@ use crate::{ }, ctx::{with_runner, IoCtx}, profile::{add_profile, list_profiles, remove_profile}, - workspace::{resolve_workspace}, + workspace::resolve_workspace, }; use std::{error::Error, path::PathBuf}; @@ -79,9 +79,7 @@ pub fn handle_workspace_cmd(action: WorkspaceAction, io: IoCtx) -> Result<(), Bo let ws = resolve_workspace(path.as_deref(), mono_dir.as_deref(), build_dir.as_deref())?; let mut status_io = io; status_io.dry_run = false; - with_runner(status_io, |ctx| { - ws.status(fetch, ctx).map_err(Into::into) - })?; + with_runner(status_io, |ctx| ws.status(fetch, ctx).map_err(Into::into))?; } WorkspaceAction::Clean { path, diff --git a/src/main.rs b/src/main.rs index 4e4d1ca..4aeb93d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,9 +1,12 @@ //! Entry point. Parses arguments, loads config, and dispatches to the appropriate command handler. use star_setup::run::run; +use std::path::PathBuf; fn main() { - if let Err(e) = run() { + let config_path = PathBuf::from(".star-setup.json"); + + if let Err(e) = run(config_path) { eprintln!("Error: {e}"); std::process::exit(1); } diff --git a/src/run.rs b/src/run.rs index cd44744..e0ccd30 100644 --- a/src/run.rs +++ b/src/run.rs @@ -15,22 +15,24 @@ use std::{ path::{Path, PathBuf}, }; -const CONFIG_FILE_NAME: &str = ".star-setup.json"; - /// Runs the setup process. /// # Errors /// Returns an error if the configuration file is missing or corrupted. -pub fn run() -> Result<(), Box> { +pub fn run(config_path: PathBuf) -> Result<(), Box> { let mut stdin = io::stdin().lock(); let mut stdout = io::stdout(); let is_terminal = stdin.is_terminal() && stdout.is_terminal(); let locations = [ - Some(PathBuf::from(CONFIG_FILE_NAME)), - dirs::home_dir().map(|h| h.join(CONFIG_FILE_NAME)), + Some(config_path.as_path()), + dirs::home_dir() + .as_deref() + .map(|h| h.join(&config_path)) + .as_deref(), ] .into_iter() .flatten() + .map(Path::to_path_buf) .collect::>(); let mut config = load_config(&locations, &mut stdout); @@ -46,13 +48,9 @@ pub fn run() -> Result<(), Box> { if let Some(cmd) = raw.command { match cmd { - Command::Config(c) => handle_config_cmd( - c.action, - &mut config, - PathBuf::from(CONFIG_FILE_NAME), - raw.yes, - &mut io, - )?, + Command::Config(c) => { + handle_config_cmd(c.action, &mut config, config_path, raw.yes, &mut io)?; + } Command::Profile(p) => handle_profile_cmd(p.action, &mut config, raw.yes, &mut io)?, Command::Workspace(w) => handle_workspace_cmd(w.action, io)?, } diff --git a/src/workspace/resolve.rs b/src/workspace/resolve.rs index 83441d6..44821d4 100644 --- a/src/workspace/resolve.rs +++ b/src/workspace/resolve.rs @@ -1,5 +1,5 @@ -use std::{fs, path::Path}; use crate::workspace::Workspace; +use std::{fs, path::Path}; /// Resolves a workspace from optional path overrides. /// # Errors diff --git a/src/workspace/status.rs b/src/workspace/status.rs index 56fdd5e..0b24b84 100644 --- a/src/workspace/status.rs +++ b/src/workspace/status.rs @@ -4,11 +4,7 @@ impl Workspace { /// Shows the status of all repositories. /// # Errors /// Returns an error if any git command fails. - pub fn status( - &self, - fetch: bool, - ctx: &mut RunCtx<'_, '_>, - ) -> Result<(), String> { + pub fn status(&self, fetch: bool, ctx: &mut RunCtx<'_, '_>) -> Result<(), String> { writeln!(ctx.io.output, "Workspace status:\n").ok(); for repo_dir in &self.repo_dirs { From 599b23d4d03d0d11c1c15cc99f6ad4c3afbd4bf1 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Sun, 28 Jun 2026 15:06:24 -0400 Subject: [PATCH 17/59] chore: move ask_bool_if, ask_required into prompts module --- src/interactive.rs | 21 +-------------------- src/prompts.rs | 23 +++++++++++++++++++++++ 2 files changed, 24 insertions(+), 20 deletions(-) diff --git a/src/interactive.rs b/src/interactive.rs index c9ecae1..9ef3d7e 100644 --- a/src/interactive.rs +++ b/src/interactive.rs @@ -3,7 +3,7 @@ use crate::{ cli::{BuildType, ResolvedArgs}, ctx::IoCtx, - prompts::{ask, ask_default, ask_yesno}, + prompts::{ask, ask_default, ask_required, ask_bool_if}, }; /// Interactive CLI mode — prompts for any unset arguments. @@ -68,22 +68,3 @@ pub fn interactive_mode(args: &mut ResolvedArgs, io: &mut IoCtx<'_>) -> Result<( writeln!(io.output, "\nInteractive mode complete").ok(); Ok(()) } - -/// Helper to ask a boolean question only if the condition isn't already met. -fn ask_bool_if(prompt: &str, current_val: bool, io: &mut IoCtx<'_>) -> Result { - if current_val { - Ok(current_val) - } else { - ask_yesno(prompt, false, io) - } -} - -/// Helper to repeatedly prompt until a non-empty string is provided. -fn ask_required(prompt: &str, io: &mut IoCtx<'_>) -> Result { - loop { - let response = ask(prompt, io)?; - if !response.is_empty() { - return Ok(response); - } - } -} diff --git a/src/prompts.rs b/src/prompts.rs index c3ad0dd..faabc55 100644 --- a/src/prompts.rs +++ b/src/prompts.rs @@ -65,6 +65,29 @@ pub fn ask_choice(prompt: &str, options: &[&str], io: &mut IoCtx<'_>) -> Result< } } +/// Prompts ask_yesno only if the condition isn't already met. +/// # Errors +/// Returns an error on EOF or if the selection is out of range. +pub fn ask_bool_if(prompt: &str, current_val: bool, io: &mut IoCtx<'_>) -> Result { + if current_val { + Ok(current_val) + } else { + ask_yesno(prompt, false, io) + } +} + +/// Repeatedly ask until a non-empty string is provided. +/// # Errors +/// Returns an error on EOF or if the selection is out of range. +pub fn ask_required(prompt: &str, io: &mut IoCtx<'_>) -> Result { + loop { + let response = ask(prompt, io)?; + if !response.is_empty() { + return Ok(response); + } + } +} + /// Returns `true` if `yes` is set or the user enters `y`/`Y`. /// # Errors /// Returns an error if stdin reaches EOF unexpectedly. From 545e29cc891a6865fa03d2604d2701c8f5c63564 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Sun, 28 Jun 2026 15:06:53 -0400 Subject: [PATCH 18/59] chore: cargo fmt --- src/interactive.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/interactive.rs b/src/interactive.rs index 9ef3d7e..80220ed 100644 --- a/src/interactive.rs +++ b/src/interactive.rs @@ -3,7 +3,7 @@ use crate::{ cli::{BuildType, ResolvedArgs}, ctx::IoCtx, - prompts::{ask, ask_default, ask_required, ask_bool_if}, + prompts::{ask, ask_bool_if, ask_default, ask_required}, }; /// Interactive CLI mode — prompts for any unset arguments. From d2a435719ea1184c5ef39ca64c5ad3f8da78168d Mon Sep 17 00:00:00 2001 From: Masonlet Date: Sun, 28 Jun 2026 15:20:01 -0400 Subject: [PATCH 19/59] refactor(cli): optimize and clean up build system and type parsing --- src/cli/build/types.rs | 70 +++++++++++++++++++++++++++--------------- 1 file changed, 45 insertions(+), 25 deletions(-) diff --git a/src/cli/build/types.rs b/src/cli/build/types.rs index 27fd7f3..03e6c58 100644 --- a/src/cli/build/types.rs +++ b/src/cli/build/types.rs @@ -1,6 +1,8 @@ use serde::{Deserialize, Serialize}; +use std::str::FromStr; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] pub enum BuildSystem { /// `CMake` build system (`CMakeLists.txt`). Cmake, @@ -8,7 +10,26 @@ pub enum BuildSystem { Meson, } -#[derive(Default, Clone, Serialize, Deserialize, PartialEq, Debug)] +impl FromStr for BuildSystem { + type Err = String; + + /// Parses a build system string. + /// # Errors + /// Returns an error if the string does not match `cmake` or `meson`. + fn from_str(s: &str) -> Result { + let systems = [("cmake", Self::Cmake), ("meson", Self::Meson)]; + + for (name, variant) in systems { + if s.eq_ignore_ascii_case(name) { + return Ok(variant); + } + } + + Err(format!("Unknown build system '{s}'. Valid: cmake, meson")) + } +} + +#[derive(Default, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum BuildType { /// Debug build with no optimizations. @@ -17,8 +38,10 @@ pub enum BuildType { /// Optimized release build. Release, /// Release build with debug info. + #[serde(alias = "relwithdebinfo", alias = "debugoptimized")] RelWithDebInfo, /// Minimized binary size release build. + #[serde(alias = "minsizerel", alias = "minsize")] MinSizeRel, } @@ -44,36 +67,33 @@ impl BuildType { } } -impl std::str::FromStr for BuildSystem { - type Err = String; - - /// Parses a build system string. - /// # Errors - /// Returns an error if the string does not match `cmake` or `meson`. - fn from_str(s: &str) -> Result { - match s.to_lowercase().as_str() { - "cmake" => Ok(Self::Cmake), - "meson" => Ok(Self::Meson), - _ => Err(format!("Unknown build system '{s}'. Valid: cmake, meson")), - } - } -} - -impl std::str::FromStr for BuildType { +impl FromStr for BuildType { type Err = String; /// Parses a build type string, accepting canonical and system-specific aliases. /// # Errors /// Returns an error if the string does not match any known build type. fn from_str(s: &str) -> Result { - match s.to_lowercase().as_str() { - "debug" => Ok(Self::Debug), - "release" => Ok(Self::Release), - "rel-with-deb-info" | "relwithdebinfo" | "debugoptimized" => Ok(Self::RelWithDebInfo), - "min-size-rel" | "minsizerel" | "minsize" => Ok(Self::MinSizeRel), - _ => Err(format!( - "Unknown build type '{s}'. Canonical: debug, release, rel-with-deb-info, min-size-rel" - )), + let types: &[(&[&str], Self)] = &[ + (&["debug"], Self::Debug), + (&["release"], Self::Release), + ( + &["rel-with-deb-info", "relwithdebinfo", "debugoptimized"], + Self::RelWithDebInfo, + ), + (&["min-size-rel", "minsizerel", "minsize"], Self::MinSizeRel), + ]; + + for (aliases, variant) in types { + for alias in *aliases { + if s.eq_ignore_ascii_case(alias) { + return Ok(*variant); + } + } } + + Err(format!( + "Unknown build type '{s}'. Canonical: debug, release, rel-with-deb-info, min-size-rel" + )) } } From 14d93478fbd88aa6e75d943ce24e7019eaf0a716 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Sun, 28 Jun 2026 16:46:19 -0400 Subject: [PATCH 20/59] test(cli): refactor build detection tests to use a shared ctx helper --- tests/cli/build/detect.rs | 229 ++++++++++++++++---------------------- 1 file changed, 99 insertions(+), 130 deletions(-) diff --git a/tests/cli/build/detect.rs b/tests/cli/build/detect.rs index 54d0001..54243fa 100644 --- a/tests/cli/build/detect.rs +++ b/tests/cli/build/detect.rs @@ -1,3 +1,5 @@ +use std::io::BufRead; + use star_setup::{ cli::{detect_build_system, detect_mono_build_system, BuildSystem}, ctx::{IoCtx, ProcessRunner, RunCtx}, @@ -16,20 +18,33 @@ fn meson_dir() -> TempDir { tmp } -#[test] -fn test_detect_build_system_none() { - let dir = TempDir::new().unwrap(); - let mut runner = ProcessRunner; - let mut ctx = RunCtx { +fn setup_test_ctx<'a, 'b>( + input: &'a mut (dyn BufRead + 'static), + output: &'a mut Vec, + timing: bool, + runner: &'b mut ProcessRunner, +) -> RunCtx<'a, 'b> { + RunCtx { io: IoCtx { - input: &mut b"".as_ref(), - output: &mut Vec::new(), + input, + output, verbose: false, - timing: false, + timing, dry_run: false, }, - runner: &mut runner, - }; + runner, + } +} + +#[test] +fn test_detect_build_system_none() { + let dir = TempDir::new().unwrap(); + + let mut runner = ProcessRunner; + let mut output = Vec::new(); + let mut input_slice = b"".as_ref(); + + let mut ctx = setup_test_ctx(&mut input_slice, &mut output, false, &mut runner); let result = detect_build_system(dir.path(), &mut ctx); assert!(result.is_err()); } @@ -37,17 +52,12 @@ fn test_detect_build_system_none() { #[test] fn test_detect_build_system_cmake() { let dir = cmake_dir(); + let mut runner = ProcessRunner; - let mut ctx = RunCtx { - io: IoCtx { - input: &mut b"".as_ref(), - output: &mut Vec::new(), - verbose: false, - timing: false, - dry_run: false, - }, - runner: &mut runner, - }; + let mut output = Vec::new(); + let mut input_slice = b"".as_ref(); + + let mut ctx = setup_test_ctx(&mut input_slice, &mut output, false, &mut runner); let result = detect_build_system(dir.path(), &mut ctx).unwrap(); assert!(matches!(result, BuildSystem::Cmake)); } @@ -55,17 +65,12 @@ fn test_detect_build_system_cmake() { #[test] fn test_detect_build_system_meson() { let dir = meson_dir(); + let mut runner = ProcessRunner; - let mut ctx = RunCtx { - io: IoCtx { - input: &mut b"".as_ref(), - output: &mut Vec::new(), - verbose: false, - timing: false, - dry_run: false, - }, - runner: &mut runner, - }; + let mut output = Vec::new(); + let mut input_slice = b"".as_ref(); + + let mut ctx = setup_test_ctx(&mut input_slice, &mut output, false, &mut runner); let result = detect_build_system(dir.path(), &mut ctx).unwrap(); assert!(matches!(result, BuildSystem::Meson)); } @@ -74,17 +79,12 @@ fn test_detect_build_system_meson() { fn test_detect_build_system_both_picks_cmake() { let dir = cmake_dir(); std::fs::write(dir.path().join("meson.build"), "").unwrap(); + let mut runner = ProcessRunner; - let mut ctx = RunCtx { - io: IoCtx { - input: &mut b"1\n".as_ref(), - output: &mut Vec::new(), - verbose: false, - timing: false, - dry_run: false, - }, - runner: &mut runner, - }; + let mut output = Vec::new(); + let mut input_slice = b"1\n".as_ref(); + + let mut ctx = setup_test_ctx(&mut input_slice, &mut output, false, &mut runner); let result = detect_build_system(dir.path(), &mut ctx).unwrap(); assert!(matches!(result, BuildSystem::Cmake)); } @@ -92,18 +92,15 @@ fn test_detect_build_system_both_picks_cmake() { #[test] fn test_detect_build_system_both_picks_meson() { let dir = cmake_dir(); + std::fs::write(dir.path().join("meson.build"), "").unwrap(); + let mut runner = ProcessRunner; - let mut ctx = RunCtx { - io: IoCtx { - input: &mut b"2\n".as_ref(), - output: &mut Vec::new(), - verbose: false, - timing: false, - dry_run: false, - }, - runner: &mut runner, - }; + let mut output = Vec::new(); + let mut input_slice = b"2\n".as_ref(); + + let mut ctx = setup_test_ctx(&mut input_slice, &mut output, false, &mut runner); + let result = detect_build_system(dir.path(), &mut ctx).unwrap(); assert!(matches!(result, BuildSystem::Meson)); } @@ -111,37 +108,42 @@ fn test_detect_build_system_both_picks_meson() { #[test] fn test_detect_build_system_timing_output() { let dir = cmake_dir(); - let mut output = Vec::new(); + let mut runner = ProcessRunner; - let mut ctx = RunCtx { - io: IoCtx { - input: &mut b"".as_ref(), - output: &mut output, - verbose: false, - timing: true, - dry_run: false, - }, - runner: &mut runner, - }; + let mut output = Vec::new(); + let mut input_slice = b"".as_ref(); + + let mut ctx = setup_test_ctx(&mut input_slice, &mut output, true, &mut runner); detect_build_system(dir.path(), &mut ctx).unwrap(); + let out = String::from_utf8(output).unwrap(); assert!(out.contains("[timing] Detect:")); } +#[test] +fn test_detect_mono_build_system_none() { + let dir = TempDir::new().unwrap(); + + let mut runner = ProcessRunner; + let mut output = Vec::new(); + let mut input_slice = b"".as_ref(); + + let mut ctx = setup_test_ctx(&mut input_slice, &mut output, false, &mut runner); + + let result = detect_mono_build_system(&[dir.path().to_path_buf()], &mut ctx); + assert!(result.is_err()); +} + #[test] fn test_detect_mono_build_system_cmake() { let dir = cmake_dir(); + let mut runner = ProcessRunner; - let mut ctx = RunCtx { - io: IoCtx { - input: &mut b"".as_ref(), - output: &mut Vec::new(), - verbose: false, - timing: false, - dry_run: false, - }, - runner: &mut runner, - }; + let mut output = Vec::new(); + let mut input_slice = b"".as_ref(); + + let mut ctx = setup_test_ctx(&mut input_slice, &mut output, false, &mut runner); + let result = detect_mono_build_system(&[dir.path().to_path_buf()], &mut ctx).unwrap(); assert!(matches!(result, BuildSystem::Cmake)); } @@ -149,54 +151,29 @@ fn test_detect_mono_build_system_cmake() { #[test] fn test_detect_mono_build_system_meson() { let dir = meson_dir(); + let mut runner = ProcessRunner; - let mut ctx = RunCtx { - io: IoCtx { - input: &mut b"".as_ref(), - output: &mut Vec::new(), - verbose: false, - timing: false, - dry_run: false, - }, - runner: &mut runner, - }; + let mut output = Vec::new(); + let mut input_slice = b"".as_ref(); + + let mut ctx = setup_test_ctx(&mut input_slice, &mut output, false, &mut runner); + let result = detect_mono_build_system(&[dir.path().to_path_buf()], &mut ctx).unwrap(); assert!(matches!(result, BuildSystem::Meson)); } -#[test] -fn test_detect_mono_build_system_none() { - let dir = TempDir::new().unwrap(); - let mut runner = ProcessRunner; - let mut ctx = RunCtx { - io: IoCtx { - input: &mut b"".as_ref(), - output: &mut Vec::new(), - verbose: false, - timing: false, - dry_run: false, - }, - runner: &mut runner, - }; - let result = detect_mono_build_system(&[dir.path().to_path_buf()], &mut ctx); - assert!(result.is_err()); -} - #[test] fn test_detect_mono_build_system_both_picks_cmake() { let dir = cmake_dir(); + std::fs::write(dir.path().join("meson.build"), "").unwrap(); + let mut runner = ProcessRunner; - let mut ctx = RunCtx { - io: IoCtx { - input: &mut b"1\n".as_ref(), - output: &mut Vec::new(), - verbose: false, - timing: false, - dry_run: false, - }, - runner: &mut runner, - }; + let mut output = Vec::new(); + let mut input_slice = b"1\n".as_ref(); + + let mut ctx = setup_test_ctx(&mut input_slice, &mut output, false, &mut runner); + let result = detect_mono_build_system(&[dir.path().to_path_buf()], &mut ctx).unwrap(); assert!(matches!(result, BuildSystem::Cmake)); } @@ -204,18 +181,15 @@ fn test_detect_mono_build_system_both_picks_cmake() { #[test] fn test_detect_mono_build_system_both_picks_meson() { let dir = cmake_dir(); + std::fs::write(dir.path().join("meson.build"), "").unwrap(); + let mut runner = ProcessRunner; - let mut ctx = RunCtx { - io: IoCtx { - input: &mut b"2\n".as_ref(), - output: &mut Vec::new(), - verbose: false, - timing: false, - dry_run: false, - }, - runner: &mut runner, - }; + let mut output = Vec::new(); + let mut input_slice = b"2\n".as_ref(); + + let mut ctx = setup_test_ctx(&mut input_slice, &mut output, false, &mut runner); + let result = detect_mono_build_system(&[dir.path().to_path_buf()], &mut ctx).unwrap(); assert!(matches!(result, BuildSystem::Meson)); } @@ -223,19 +197,14 @@ fn test_detect_mono_build_system_both_picks_meson() { #[test] fn test_detect_mono_build_system_timing_output() { let dir = cmake_dir(); - let mut output = Vec::new(); + let mut runner = ProcessRunner; - let mut ctx = RunCtx { - io: IoCtx { - input: &mut b"".as_ref(), - output: &mut output, - verbose: false, - timing: true, - dry_run: false, - }, - runner: &mut runner, - }; + let mut output = Vec::new(); + let mut input_slice = b"".as_ref(); + + let mut ctx = setup_test_ctx(&mut input_slice, &mut output, true, &mut runner); detect_mono_build_system(&[dir.path().to_path_buf()], &mut ctx).unwrap(); + let out = String::from_utf8(output).unwrap(); assert!(out.contains("[timing] Detect:")); } From e32cf51145b0906eb6f8fe5ed5ed9acd19885b85 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Sun, 28 Jun 2026 16:48:16 -0400 Subject: [PATCH 21/59] chore: cargo clip --- src/cli/resolve.rs | 2 +- src/config/types.rs | 2 +- src/prompts.rs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/cli/resolve.rs b/src/cli/resolve.rs index ebbc13a..7c35c5d 100644 --- a/src/cli/resolve.rs +++ b/src/cli/resolve.rs @@ -90,7 +90,7 @@ pub fn resolve_with_config(mut args: Args, config: &SetupConfig) -> Result s.parse::()?, - None => default.map(|e| e.build_type.clone()).unwrap_or_default(), + None => default.map(|e| e.build_type).unwrap_or_default(), }, build_dir: args .build diff --git a/src/config/types.rs b/src/config/types.rs index e6e1615..56a76f8 100644 --- a/src/config/types.rs +++ b/src/config/types.rs @@ -74,7 +74,7 @@ impl From<&ResolvedArgs> for ConfigEntry { fn from(args: &ResolvedArgs) -> Self { Self { ssh: args.connection.ssh, - build_type: args.build.build_type.clone(), + build_type: args.build.build_type, build_dir: args.build.build_dir.clone(), mono_dir: args.mono.mono_dir.clone(), no_build: args.build.no_build, diff --git a/src/prompts.rs b/src/prompts.rs index faabc55..524ce13 100644 --- a/src/prompts.rs +++ b/src/prompts.rs @@ -65,7 +65,7 @@ pub fn ask_choice(prompt: &str, options: &[&str], io: &mut IoCtx<'_>) -> Result< } } -/// Prompts ask_yesno only if the condition isn't already met. +/// Prompts `ask_yesno` only if the condition isn't already met. /// # Errors /// Returns an error on EOF or if the selection is out of range. pub fn ask_bool_if(prompt: &str, current_val: bool, io: &mut IoCtx<'_>) -> Result { From 2949b9e9e8b0d34cd3711a1d32063ef4cf8e367d Mon Sep 17 00:00:00 2001 From: Masonlet Date: Sun, 28 Jun 2026 16:56:54 -0400 Subject: [PATCH 22/59] refactor(tests): streamline build system detection tests --- tests/cli/build/detect.rs | 183 +++++++++++++------------------------- 1 file changed, 61 insertions(+), 122 deletions(-) diff --git a/tests/cli/build/detect.rs b/tests/cli/build/detect.rs index 54243fa..518925d 100644 --- a/tests/cli/build/detect.rs +++ b/tests/cli/build/detect.rs @@ -1,4 +1,3 @@ -use std::io::BufRead; use star_setup::{ cli::{detect_build_system, detect_mono_build_system, BuildSystem}, @@ -18,104 +17,75 @@ fn meson_dir() -> TempDir { tmp } -fn setup_test_ctx<'a, 'b>( - input: &'a mut (dyn BufRead + 'static), - output: &'a mut Vec, - timing: bool, - runner: &'b mut ProcessRunner, -) -> RunCtx<'a, 'b> { - RunCtx { - io: IoCtx { - input, - output, - verbose: false, - timing, - dry_run: false, - }, - runner, - } +fn run_test(input: &[u8], timing: bool, test_logic: F) -> (T, Vec) +where + F: FnOnce(&mut RunCtx) -> T, +{ + let mut runner = ProcessRunner; + let mut output = Vec::new(); + let mut input_slice = input; + + let result = { + let mut ctx = RunCtx { + io: IoCtx { + input: &mut input_slice, + output: &mut output, + verbose: false, + timing, + dry_run: false, + }, + runner: &mut runner, + }; + + test_logic(&mut ctx) + }; + + (result, output) } #[test] fn test_detect_build_system_none() { let dir = TempDir::new().unwrap(); - - let mut runner = ProcessRunner; - let mut output = Vec::new(); - let mut input_slice = b"".as_ref(); - - let mut ctx = setup_test_ctx(&mut input_slice, &mut output, false, &mut runner); - let result = detect_build_system(dir.path(), &mut ctx); + let (result, _) = run_test(b"", false, |ctx| detect_build_system(dir.path(), ctx)); assert!(result.is_err()); } #[test] fn test_detect_build_system_cmake() { let dir = cmake_dir(); - - let mut runner = ProcessRunner; - let mut output = Vec::new(); - let mut input_slice = b"".as_ref(); - - let mut ctx = setup_test_ctx(&mut input_slice, &mut output, false, &mut runner); - let result = detect_build_system(dir.path(), &mut ctx).unwrap(); - assert!(matches!(result, BuildSystem::Cmake)); + let (result, _) = run_test(b"", false, |ctx| detect_build_system(dir.path(), ctx)); + assert!(matches!(result.unwrap(), BuildSystem::Cmake)); } #[test] fn test_detect_build_system_meson() { let dir = meson_dir(); - - let mut runner = ProcessRunner; - let mut output = Vec::new(); - let mut input_slice = b"".as_ref(); - - let mut ctx = setup_test_ctx(&mut input_slice, &mut output, false, &mut runner); - let result = detect_build_system(dir.path(), &mut ctx).unwrap(); - assert!(matches!(result, BuildSystem::Meson)); + let (result, _) = run_test(b"", false, |ctx| detect_build_system(dir.path(), ctx)); + assert!(matches!(result.unwrap(), BuildSystem::Meson)); } #[test] fn test_detect_build_system_both_picks_cmake() { let dir = cmake_dir(); std::fs::write(dir.path().join("meson.build"), "").unwrap(); - - let mut runner = ProcessRunner; - let mut output = Vec::new(); - let mut input_slice = b"1\n".as_ref(); - - let mut ctx = setup_test_ctx(&mut input_slice, &mut output, false, &mut runner); - let result = detect_build_system(dir.path(), &mut ctx).unwrap(); - assert!(matches!(result, BuildSystem::Cmake)); + let (result, _) = run_test(b"1\n", false, |ctx| detect_build_system(dir.path(), ctx)); + assert!(matches!(result.unwrap(), BuildSystem::Cmake)); } #[test] fn test_detect_build_system_both_picks_meson() { let dir = cmake_dir(); - std::fs::write(dir.path().join("meson.build"), "").unwrap(); - - let mut runner = ProcessRunner; - let mut output = Vec::new(); - let mut input_slice = b"2\n".as_ref(); - - let mut ctx = setup_test_ctx(&mut input_slice, &mut output, false, &mut runner); - - let result = detect_build_system(dir.path(), &mut ctx).unwrap(); - assert!(matches!(result, BuildSystem::Meson)); + let (result, _) = run_test(b"2\n", false, |ctx| detect_build_system(dir.path(), ctx)); + assert!(matches!(result.unwrap(), BuildSystem::Meson)); } #[test] fn test_detect_build_system_timing_output() { let dir = cmake_dir(); - - let mut runner = ProcessRunner; - let mut output = Vec::new(); - let mut input_slice = b"".as_ref(); - - let mut ctx = setup_test_ctx(&mut input_slice, &mut output, true, &mut runner); - detect_build_system(dir.path(), &mut ctx).unwrap(); - + let ((), output) = run_test(b"", true, |ctx| { + detect_build_system(dir.path(), ctx).unwrap(); + }); let out = String::from_utf8(output).unwrap(); assert!(out.contains("[timing] Detect:")); } @@ -123,88 +93,57 @@ fn test_detect_build_system_timing_output() { #[test] fn test_detect_mono_build_system_none() { let dir = TempDir::new().unwrap(); - - let mut runner = ProcessRunner; - let mut output = Vec::new(); - let mut input_slice = b"".as_ref(); - - let mut ctx = setup_test_ctx(&mut input_slice, &mut output, false, &mut runner); - - let result = detect_mono_build_system(&[dir.path().to_path_buf()], &mut ctx); + let (result, _) = run_test(b"", false, |ctx| { + detect_mono_build_system(&[dir.path().to_path_buf()], ctx) + }); assert!(result.is_err()); } #[test] fn test_detect_mono_build_system_cmake() { let dir = cmake_dir(); - - let mut runner = ProcessRunner; - let mut output = Vec::new(); - let mut input_slice = b"".as_ref(); - - let mut ctx = setup_test_ctx(&mut input_slice, &mut output, false, &mut runner); - - let result = detect_mono_build_system(&[dir.path().to_path_buf()], &mut ctx).unwrap(); - assert!(matches!(result, BuildSystem::Cmake)); + let (result, _) = run_test(b"", false, |ctx| { + detect_mono_build_system(&[dir.path().to_path_buf()], ctx) + }); + assert!(matches!(result.unwrap(), BuildSystem::Cmake)); } #[test] fn test_detect_mono_build_system_meson() { let dir = meson_dir(); - - let mut runner = ProcessRunner; - let mut output = Vec::new(); - let mut input_slice = b"".as_ref(); - - let mut ctx = setup_test_ctx(&mut input_slice, &mut output, false, &mut runner); - - let result = detect_mono_build_system(&[dir.path().to_path_buf()], &mut ctx).unwrap(); - assert!(matches!(result, BuildSystem::Meson)); + let (result, _) = run_test(b"", false, |ctx| { + detect_mono_build_system(&[dir.path().to_path_buf()], ctx) + }); + assert!(matches!(result.unwrap(), BuildSystem::Meson)); } #[test] fn test_detect_mono_build_system_both_picks_cmake() { let dir = cmake_dir(); - std::fs::write(dir.path().join("meson.build"), "").unwrap(); - - let mut runner = ProcessRunner; - let mut output = Vec::new(); - let mut input_slice = b"1\n".as_ref(); - - let mut ctx = setup_test_ctx(&mut input_slice, &mut output, false, &mut runner); - - let result = detect_mono_build_system(&[dir.path().to_path_buf()], &mut ctx).unwrap(); - assert!(matches!(result, BuildSystem::Cmake)); + let (result, _) = run_test(b"1\n", false, |ctx| { + detect_mono_build_system(&[dir.path().to_path_buf()], ctx) + }); + assert!(matches!(result.unwrap(), BuildSystem::Cmake)); } #[test] fn test_detect_mono_build_system_both_picks_meson() { let dir = cmake_dir(); - std::fs::write(dir.path().join("meson.build"), "").unwrap(); - - let mut runner = ProcessRunner; - let mut output = Vec::new(); - let mut input_slice = b"2\n".as_ref(); - - let mut ctx = setup_test_ctx(&mut input_slice, &mut output, false, &mut runner); - - let result = detect_mono_build_system(&[dir.path().to_path_buf()], &mut ctx).unwrap(); - assert!(matches!(result, BuildSystem::Meson)); + let (result, _) = run_test(b"2\n", false, |ctx| { + detect_mono_build_system(&[dir.path().to_path_buf()], ctx) + }); + assert!(matches!(result.unwrap(), BuildSystem::Meson)); } #[test] fn test_detect_mono_build_system_timing_output() { let dir = cmake_dir(); - - let mut runner = ProcessRunner; - let mut output = Vec::new(); - let mut input_slice = b"".as_ref(); - - let mut ctx = setup_test_ctx(&mut input_slice, &mut output, true, &mut runner); - detect_mono_build_system(&[dir.path().to_path_buf()], &mut ctx).unwrap(); - - let out = String::from_utf8(output).unwrap(); - assert!(out.contains("[timing] Detect:")); + let ((), output) = run_test(b"", true, |ctx| { + detect_mono_build_system(&[dir.path().to_path_buf()], ctx).unwrap(); + }); + assert!(String::from_utf8(output) + .unwrap() + .contains("[timing] Detect:")); } From be9555f5c9f7f06d2535e7ab6bfd2951544f4690 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Sun, 28 Jun 2026 17:02:28 -0400 Subject: [PATCH 23/59] refactor(tests): convert build type and system parsing tests to tables --- tests/cli/build/detect.rs | 1 - tests/cli/build/types.rs | 75 ++++++++++++++++++++------------------- 2 files changed, 39 insertions(+), 37 deletions(-) diff --git a/tests/cli/build/detect.rs b/tests/cli/build/detect.rs index 518925d..f42570a 100644 --- a/tests/cli/build/detect.rs +++ b/tests/cli/build/detect.rs @@ -1,4 +1,3 @@ - use star_setup::{ cli::{detect_build_system, detect_mono_build_system, BuildSystem}, ctx::{IoCtx, ProcessRunner, RunCtx}, diff --git a/tests/cli/build/types.rs b/tests/cli/build/types.rs index bd366c6..e722134 100644 --- a/tests/cli/build/types.rs +++ b/tests/cli/build/types.rs @@ -1,4 +1,5 @@ -use star_setup::cli::BuildType; +use star_setup::cli::{BuildSystem, BuildType}; +use std::str::FromStr; #[test] fn test_to_cmake_all_variants() { @@ -18,48 +19,50 @@ fn test_to_meson_all_variants() { #[test] fn test_from_str_all_variants() { - use std::str::FromStr; - assert_eq!(BuildType::from_str("debug").unwrap(), BuildType::Debug); - assert_eq!(BuildType::from_str("release").unwrap(), BuildType::Release); - assert_eq!( - BuildType::from_str("rel-with-deb-info").unwrap(), - BuildType::RelWithDebInfo - ); - assert_eq!( - BuildType::from_str("relwithdebinfo").unwrap(), - BuildType::RelWithDebInfo - ); - assert_eq!( - BuildType::from_str("debugoptimized").unwrap(), - BuildType::RelWithDebInfo - ); - assert_eq!( - BuildType::from_str("min-size-rel").unwrap(), - BuildType::MinSizeRel - ); - assert_eq!( - BuildType::from_str("minsizerel").unwrap(), - BuildType::MinSizeRel - ); - assert_eq!( - BuildType::from_str("minsize").unwrap(), - BuildType::MinSizeRel - ); + let cases = [ + ("debug", BuildType::Debug), + ("release", BuildType::Release), + ("rel-with-deb-info", BuildType::RelWithDebInfo), + ("relwithdebinfo", BuildType::RelWithDebInfo), + ("debugoptimized", BuildType::RelWithDebInfo), + ("min-size-rel", BuildType::MinSizeRel), + ("minsizerel", BuildType::MinSizeRel), + ("minsize", BuildType::MinSizeRel), + ]; + + for (input, expected) in cases { + assert_eq!( + BuildType::from_str(input).unwrap(), + expected, + "Failed on input: {input}" + ); + } } #[test] fn test_from_str_error() { - use std::str::FromStr; assert!(BuildType::from_str("unknown").is_err()); } #[test] fn test_build_system_from_str() { - use star_setup::cli::BuildSystem; - use std::str::FromStr; - assert_eq!(BuildSystem::from_str("cmake").unwrap(), BuildSystem::Cmake); - assert_eq!(BuildSystem::from_str("CMAKE").unwrap(), BuildSystem::Cmake); - assert_eq!(BuildSystem::from_str("meson").unwrap(), BuildSystem::Meson); - assert_eq!(BuildSystem::from_str("MESON").unwrap(), BuildSystem::Meson); - assert!(BuildSystem::from_str("ninja").is_err()); + let success_cases = [ + ("cmake", BuildSystem::Cmake), + ("CMAKE", BuildSystem::Cmake), + ("meson", BuildSystem::Meson), + ("MESON", BuildSystem::Meson), + ]; + + for (input, expected) in success_cases { + assert_eq!( + BuildSystem::from_str(input).unwrap(), + expected, + "Failed on valid input: {input}" + ); + } + + assert!( + BuildSystem::from_str("ninja").is_err(), + "Expected error for invalid input: ninja" + ); } From 9f43c91eb3bcf11824d098c3df5aa0e049da8d45 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Sun, 28 Jun 2026 17:08:38 -0400 Subject: [PATCH 24/59] refactor(tests): streamline configuration resolution tests --- tests/cli/resolve.rs | 88 +++++++++++++++++++------------------------- 1 file changed, 38 insertions(+), 50 deletions(-) diff --git a/tests/cli/resolve.rs b/tests/cli/resolve.rs index 708e1a5..224178c 100644 --- a/tests/cli/resolve.rs +++ b/tests/cli/resolve.rs @@ -4,6 +4,23 @@ use star_setup::{ config::{ConfigEntry, SetupConfig}, }; +/// Generates a base `ConfigEntry` with defaults. +fn create_test_config_entry() -> ConfigEntry { + ConfigEntry { + ssh: false, + verbose: false, + build_type: BuildType::Debug, + build_dir: "build".to_string(), + mono_dir: "build-mono".to_string(), + no_build: false, + clean: false, + timing: false, + dry_run: false, + cmake_flags: vec![], + meson_flags: vec![], + } +} + #[test] fn test_resolve_bool() { #[allow(clippy::struct_excessive_bools)] @@ -93,13 +110,10 @@ fn test_resolve_with_config_applies_config_defaults() { verbose: true, build_type: BuildType::Release, build_dir: "out".to_string(), - mono_dir: "mono".to_string(), no_build: true, clean: true, - timing: false, - dry_run: false, cmake_flags: vec!["-DTEST=ON".to_string()], - meson_flags: vec![], + ..create_test_config_entry() }, ); let resolved = resolve_with_config(default_args(), &config).unwrap(); @@ -115,25 +129,14 @@ fn test_resolve_with_config_applies_config_defaults() { #[test] fn test_resolve_with_config_cli_overrides_config() { let mut config = SetupConfig::new(); - config.configs.insert( - "default".to_string(), - ConfigEntry { - ssh: false, - verbose: false, - build_type: BuildType::Debug, - build_dir: "build".to_string(), - mono_dir: "build-mono".to_string(), - no_build: false, - clean: false, - timing: false, - dry_run: false, - cmake_flags: vec![], - meson_flags: vec![], - }, - ); + config + .configs + .insert("default".to_string(), create_test_config_entry()); + let mut args = default_args(); args.connection.ssh = true; args.build.build_type = Some("Release".to_string()); + let resolved = resolve_with_config(args, &config).unwrap(); assert!(resolved.connection.ssh); assert_eq!(resolved.build.build_type, BuildType::Release); @@ -144,8 +147,8 @@ fn test_resolve_with_config_errors_on_missing_config_name() { let config = SetupConfig::new(); let mut args = default_args(); args.config_name = Some("nonexistent".to_string()); - let result = resolve_with_config(args, &config); - assert!(result.is_err()); + + assert!(resolve_with_config(args, &config).is_err()); } #[test] @@ -153,6 +156,7 @@ fn test_resolve_with_config_mono_repo_from_repos() { let config = SetupConfig::new(); let mut args = default_args(); args.mono.repos = Some(vec!["user/lib1".to_string()]); + let resolved = resolve_with_config(args, &config).unwrap(); assert!(resolved.mono.mono_repo); } @@ -162,6 +166,7 @@ fn test_resolve_with_config_mono_repo_from_profile() { let config = SetupConfig::new(); let mut args = default_args(); args.mono.profile = Some("myprofile".to_string()); + let resolved = resolve_with_config(args, &config).unwrap(); assert!(resolved.mono.mono_repo); } @@ -173,20 +178,16 @@ fn test_resolve_with_config_named_config_pulls_correct_values() { "myconfig".to_string(), ConfigEntry { ssh: true, - verbose: false, build_type: BuildType::RelWithDebInfo, build_dir: "out".to_string(), - mono_dir: "mono".to_string(), - no_build: false, clean: true, - timing: false, - dry_run: false, - cmake_flags: vec![], - meson_flags: vec![], + ..create_test_config_entry() }, ); + let mut args = default_args(); args.config_name = Some("myconfig".to_string()); + let resolved = resolve_with_config(args, &config).unwrap(); assert!(resolved.connection.ssh); assert_eq!(resolved.build.build_type, BuildType::RelWithDebInfo); @@ -200,21 +201,14 @@ fn test_resolve_with_config_cli_cmake_flags_not_overwritten_by_config() { config.configs.insert( "default".to_string(), ConfigEntry { - ssh: false, - verbose: false, - build_type: BuildType::Debug, - build_dir: "build".to_string(), - mono_dir: "build-mono".to_string(), - no_build: false, - clean: false, - timing: false, - dry_run: false, cmake_flags: vec!["-DCONFIG_FLAG=ON".to_string()], - meson_flags: vec![], + ..create_test_config_entry() }, ); + let mut args = default_args(); args.build.cmake_flags = vec!["-DCLI_FLAG=ON".to_string()]; + let resolved = resolve_with_config(args, &config).unwrap(); assert_eq!(resolved.build.cmake_flags, vec!["-DCLI_FLAG=ON"]); } @@ -227,23 +221,17 @@ fn test_resolve_with_config_negative_flags_override_config() { ConfigEntry { ssh: true, verbose: true, - build_type: BuildType::Debug, - build_dir: "build".to_string(), - mono_dir: "build-mono".to_string(), no_build: true, clean: true, - timing: false, - dry_run: false, - cmake_flags: vec![], - meson_flags: vec![], + ..create_test_config_entry() }, ); let mut args = default_args(); - args.connection.https = true; // negates ssh - args.connection.no_verbose = true; // negates verbose - args.build.build = true; // negates no_build - args.build.no_clean = true; // negates clean + args.connection.https = true; + args.connection.no_verbose = true; + args.build.build = true; + args.build.no_clean = true; let resolved = resolve_with_config(args, &config).unwrap(); assert!( From 6a6559a4a904a63e6188e7a2a1035b7238daab0a Mon Sep 17 00:00:00 2001 From: Masonlet Date: Sun, 28 Jun 2026 17:16:52 -0400 Subject: [PATCH 25/59] refactor(tests): streamline prompts test file boilerplate --- tests/prompts.rs | 57 ++++++++++++++++++++---------------------------- 1 file changed, 24 insertions(+), 33 deletions(-) diff --git a/tests/prompts.rs b/tests/prompts.rs index 2782f21..d99dab5 100644 --- a/tests/prompts.rs +++ b/tests/prompts.rs @@ -1,37 +1,39 @@ -use star_setup::prompts::{ask, ask_default, ask_yesno, confirm}; +use star_setup::{ + ctx::IoCtx, + prompts::{ask, ask_default, ask_yesno, confirm}, +}; mod common; use common::make_io; +fn run_prompt_test(input: &[u8], test_logic: F) -> T +where + F: FnOnce(&mut IoCtx<'_>) -> T, +{ + let mut input_slice = input; + let mut output = Vec::new(); + let mut io = make_io(&mut input_slice, &mut output); + test_logic(&mut io) +} + #[test] fn test_ask_errors_on_eof() { - let mut input = b"".as_ref(); - let mut output = Vec::new(); - let mut io = make_io(&mut input, &mut output); - assert!(ask("prompt", &mut io).is_err()); + assert!(run_prompt_test(b"", |io| ask("prompt", io)).is_err()); } #[test] fn test_ask_default_errors_on_eof() { - let mut input = b"".as_ref(); - let mut output = Vec::new(); - let mut io = make_io(&mut input, &mut output); - assert!(ask_default("prompt", "default", &mut io).is_err()); + assert!(run_prompt_test(b"", |io| ask_default("prompt", "default", io)).is_err()); } #[test] fn test_ask_yesno_errors_on_eof() { - let mut input = b"".as_ref(); - let mut output = Vec::new(); - let mut io = make_io(&mut input, &mut output); - assert!(ask_yesno("prompt", true, &mut io).is_err()); + assert!(run_prompt_test(b"", |io| ask_yesno("prompt", true, io)).is_err()); } #[test] fn test_ask_default_returns_input_when_not_empty() { - let mut input = b"custom\n".as_ref(); - let mut output = Vec::new(); - let mut io = make_io(&mut input, &mut output); - assert_eq!(ask_default("prompt", "default", &mut io).unwrap(), "custom"); + let result = run_prompt_test(b"custom\n", |io| ask_default("prompt", "default", io)); + assert_eq!(result.unwrap(), "custom"); } #[test] @@ -43,32 +45,21 @@ fn test_confirm_input_cases() { (b"n\n", false, "n rejects"), (b"yes\n", false, "yes rejects"), ]; + for (input, expected, name) in cases { - let mut input = input; - let mut output = Vec::new(); - let mut io = make_io(&mut input, &mut output); - assert_eq!( - confirm("prompt", false, &mut io).unwrap(), - expected, - "Failed: {name}" - ); + let result = run_prompt_test(input, |io| confirm("prompt", false, io)); + assert_eq!(result.unwrap(), expected, "Failed: {name}"); } } #[test] fn test_confirm_yes_flag_returns_true() { - let mut input = b"".as_ref(); - let mut output = Vec::new(); - let mut io = make_io(&mut input, &mut output); - assert!(confirm("prompt", true, &mut io).unwrap()); + assert!(run_prompt_test(b"", |io| confirm("prompt", true, io)).unwrap()); } #[test] fn test_confirm_errors_on_eof() { - let mut input = b"".as_ref(); - let mut output = Vec::new(); - let mut io = make_io(&mut input, &mut output); - let result = confirm("prompt", false, &mut io); + let result = run_prompt_test(b"", |io| confirm("prompt", false, io)); assert!(result.is_err()); assert!(result.unwrap_err().contains("unexpected end of input")); } From 99407b65add7ea2fcad753d6fb551a4a43ad393f Mon Sep 17 00:00:00 2001 From: Masonlet Date: Sun, 28 Jun 2026 17:27:53 -0400 Subject: [PATCH 26/59] test(interactive): streamline interactive mode tests --- tests/interactive.rs | 85 +++++++++++++++++++------------------------- 1 file changed, 36 insertions(+), 49 deletions(-) diff --git a/tests/interactive.rs b/tests/interactive.rs index 5f55bf7..8525a21 100644 --- a/tests/interactive.rs +++ b/tests/interactive.rs @@ -2,6 +2,20 @@ use star_setup::interactive::interactive_mode; mod common; use common::{default_resolved, default_resolved_interactive, make_io}; +fn run_interactive_test( + input_bytes: &[u8], + mut args: star_setup::cli::ResolvedArgs, +) -> (star_setup::cli::ResolvedArgs, String) { + let mut input_slice = input_bytes; + let mut output = Vec::new(); + + let mut io = make_io(&mut input_slice, &mut output); + interactive_mode(&mut args, &mut io).unwrap(); + + let out_str = String::from_utf8(output).unwrap_or_default(); + (args, out_str) +} + fn input_with_suffix(prefix: &[u8]) -> Vec { let mut v = prefix.to_vec(); v.extend_from_slice(b"\n\n\n\n\nn\n"); @@ -11,11 +25,8 @@ fn input_with_suffix(prefix: &[u8]) -> Vec { #[test] fn test_interactive_mode_single_repo() { let input = input_with_suffix(b"user/repo\nn\nn\nn\nn\n1"); - let mut input = input.as_ref(); - let mut output = Vec::new(); - let mut io = make_io(&mut input, &mut output); - let mut args = default_resolved(); - interactive_mode(&mut args, &mut io).unwrap(); + let (args, _) = run_interactive_test(&input, default_resolved()); + assert_eq!(args.repo, Some("user/repo".to_string())); assert!(!args.connection.ssh); assert!(!args.mono.mono_repo); @@ -24,22 +35,16 @@ fn test_interactive_mode_single_repo() { #[test] fn test_interactive_mode_ssh_enabled() { let input = input_with_suffix(b"user/repo\ny\nn\nn\nn\n1"); - let mut input = input.as_ref(); - let mut output = Vec::new(); - let mut io = make_io(&mut input, &mut output); - let mut args = default_resolved_interactive(); - interactive_mode(&mut args, &mut io).unwrap(); + let (args, _) = run_interactive_test(&input, default_resolved_interactive()); + assert!(args.connection.ssh); } #[test] fn test_interactive_mode_mono_repo_with_profile() { let input = input_with_suffix(b"user/repo\nn\nn\nn\nn\n2\n1\nmyprofile"); - let mut input = input.as_ref(); - let mut output = Vec::new(); - let mut io = make_io(&mut input, &mut output); - let mut args = default_resolved(); - interactive_mode(&mut args, &mut io).unwrap(); + let (args, _) = run_interactive_test(&input, default_resolved()); + assert!(args.mono.mono_repo); assert_eq!(args.mono.profile, Some("myprofile".to_string())); } @@ -47,11 +52,8 @@ fn test_interactive_mode_mono_repo_with_profile() { #[test] fn test_interactive_mode_mono_repo_with_manual_repos() { let input = input_with_suffix(b"user/repo\nn\nn\nn\nn\n2\n2\nuser/lib1 user/lib2"); - let mut input = input.as_ref(); - let mut output = Vec::new(); - let mut io = make_io(&mut input, &mut output); - let mut args = default_resolved(); - interactive_mode(&mut args, &mut io).unwrap(); + let (args, _) = run_interactive_test(&input, default_resolved()); + assert!(args.mono.mono_repo); assert_eq!( args.mono.repos, @@ -62,24 +64,18 @@ fn test_interactive_mode_mono_repo_with_manual_repos() { #[test] fn test_interactive_mode_skips_repo_prompt_when_set() { let input = input_with_suffix(b"n\nn\nn\nn\n1"); - let mut input = input.as_ref(); - let mut output = Vec::new(); - let mut io = make_io(&mut input, &mut output); - let mut args = default_resolved(); - args.repo = Some("already/set".to_string()); - interactive_mode(&mut args, &mut io).unwrap(); + let mut initial_args = default_resolved(); + initial_args.repo = Some("already/set".to_string()); + + let (args, _) = run_interactive_test(&input, initial_args); assert_eq!(args.repo, Some("already/set".to_string())); } #[test] fn test_interactive_mode_output_contains_header() { let input = input_with_suffix(b"user/repo\nn\nn\nn\nn\n1"); - let mut input = input.as_ref(); - let mut output = Vec::new(); - let mut io = make_io(&mut input, &mut output); - let mut args = default_resolved(); - interactive_mode(&mut args, &mut io).unwrap(); - let out_str = String::from_utf8(output).unwrap(); + let (_, out_str) = run_interactive_test(&input, default_resolved()); + assert!(out_str.contains("Star Setup Interactive Mode")); assert!(out_str.contains("Interactive mode complete")); } @@ -87,45 +83,36 @@ fn test_interactive_mode_output_contains_header() { #[test] fn test_interactive_mode_yes_word_not_accepted_for_ssh() { let input = input_with_suffix(b"user/repo\nyes\nn\nn\nn\n1"); - let mut input = input.as_ref(); - let mut output = Vec::new(); - let mut io = make_io(&mut input, &mut output); - let mut args = default_resolved(); - interactive_mode(&mut args, &mut io).unwrap(); + let (args, _) = run_interactive_test(&input, default_resolved()); + assert!(!args.connection.ssh); } #[test] fn test_interactive_mode_invalid_mode_then_valid() { let input = input_with_suffix(b"user/repo\nn\nn\nn\nn\nfoo\n1"); - let mut input = input.as_ref(); - let mut output = Vec::new(); - let mut io = make_io(&mut input, &mut output); - let mut args = default_resolved(); - interactive_mode(&mut args, &mut io).unwrap(); + let (args, _) = run_interactive_test(&input, default_resolved()); + assert!(!args.mono.mono_repo); } #[test] fn test_interactive_mode_invalid_mono_choice_then_valid() { let input = input_with_suffix(b"user/repo\nn\nn\nn\nn\n2\nfoo\n1\nmyprofile"); - let mut input = input.as_ref(); - let mut output = Vec::new(); - let mut io = make_io(&mut input, &mut output); - let mut args = default_resolved(); - interactive_mode(&mut args, &mut io).unwrap(); + let (args, _) = run_interactive_test(&input, default_resolved()); + assert!(args.mono.mono_repo); assert_eq!(args.mono.profile, Some("myprofile".to_string())); } #[test] fn test_interactive_mode_errors_on_eof() { - let input = b""; - let mut input = input.as_ref(); + let mut input = b"".as_ref(); let mut output = Vec::new(); let mut io = make_io(&mut input, &mut output); let mut args = default_resolved(); args.repo = None; + let result = interactive_mode(&mut args, &mut io); assert!(result.is_err()); assert!(result.unwrap_err().contains("unexpected end of input")); From afea7f8be185c185899970c53baa78d125a491e5 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Sun, 28 Jun 2026 17:33:38 -0400 Subject: [PATCH 27/59] refactor(tests): streamline runner tests --- tests/ctx.rs | 73 ++++++++++++++++++++++------------------------------ 1 file changed, 31 insertions(+), 42 deletions(-) diff --git a/tests/ctx.rs b/tests/ctx.rs index db891dd..47601e1 100644 --- a/tests/ctx.rs +++ b/tests/ctx.rs @@ -1,58 +1,47 @@ use star_setup::ctx::{DryRunRunner, IoCtx, ProcessRunner, Runner}; use std::path::Path; +mod common; +use common::make_io; -#[test] -fn test_process_runner_runs_command() { +fn run_runner_test(dry_run: bool, mut runner: R, test_logic: F) -> String +where + R: Runner, + F: FnOnce(&mut R, &mut IoCtx<'_>), +{ let mut input = b"".as_ref(); let mut output = Vec::new(); - let mut runner = ProcessRunner; - let mut io = IoCtx { - input: &mut input, - output: &mut output, - verbose: false, - timing: false, - dry_run: false, - }; - assert!(runner.run(&["git", "--version"], None, &mut io).is_ok()); + + let mut io = make_io(&mut input, &mut output); + io.dry_run = dry_run; + + test_logic(&mut runner, &mut io); + String::from_utf8(output).unwrap_or_default() +} + +#[test] +fn test_process_runner_runs_command() { + run_runner_test(false, ProcessRunner, |runner, io| { + assert!(runner.run(&["git", "--version"], None, io).is_ok()); + }); } #[test] fn test_dry_run_runner_prints_command() { - let mut input = b"".as_ref(); - let mut output = Vec::new(); - let mut runner = DryRunRunner; - let mut io = IoCtx { - input: &mut input, - output: &mut output, - verbose: false, - timing: false, - dry_run: true, - }; - runner.run(&["git", "clone", "foo"], None, &mut io).unwrap(); - assert_eq!( - String::from_utf8(output).unwrap(), - "Would run: git clone foo\n" - ); + let output = run_runner_test(true, DryRunRunner, |runner, io| { + runner.run(&["git", "clone", "foo"], None, io).unwrap(); + }); + assert_eq!(output, "Would run: git clone foo\n"); } #[test] fn test_dry_run_runner_prints_cwd() { - let mut input = b"".as_ref(); - let mut output = Vec::new(); - let mut runner = DryRunRunner; - let mut io = IoCtx { - input: &mut input, - output: &mut output, - verbose: false, - timing: false, - dry_run: true, - }; - runner - .run(&["cmake", ".."], Some(Path::new("/tmp/build")), &mut io) - .unwrap(); - let out = String::from_utf8(output).unwrap(); - assert!(out.contains("Would run: cmake ..")); - assert!(out.contains(" in directory: /tmp/build")); + let output = run_runner_test(true, DryRunRunner, |runner, io| { + runner + .run(&["cmake", ".."], Some(Path::new("/tmp/build")), io) + .unwrap(); + }); + assert!(output.contains("Would run: cmake ..")); + assert!(output.contains(" in directory: /tmp/build")); } #[test] From ab1efcbc1c4662efbf555f5a106b778b07e8774a Mon Sep 17 00:00:00 2001 From: Masonlet Date: Sun, 28 Jun 2026 17:40:03 -0400 Subject: [PATCH 28/59] refactor(tests): streamline repository tests --- tests/repository.rs | 55 ++++++++++++++++++++++++--------------------- 1 file changed, 29 insertions(+), 26 deletions(-) diff --git a/tests/repository.rs b/tests/repository.rs index 0987076..561574d 100644 --- a/tests/repository.rs +++ b/tests/repository.rs @@ -1,10 +1,28 @@ mod common; use common::{empty_input, make_io, sink, MockRunner}; use star_setup::{ - ctx::{IoCtx, ProcessRunner, RunCtx}, + ctx::{ProcessRunner, RunCtx, Runner}, repository::{clone_repository, repo_dir_name, resolve_repo_url}, }; +fn run_repo_test(mut runner: R, test_logic: F) -> R +where + R: Runner, + F: FnOnce(&std::path::Path, &mut RunCtx<'_, '_>), +{ + let tmp = tempfile::TempDir::new().unwrap(); + let mut input = empty_input(); + let mut output = sink(); + + let mut ctx = RunCtx { + io: make_io(&mut input, &mut output), + runner: &mut runner, + }; + + test_logic(tmp.path(), &mut ctx); + runner +} + #[test] fn test_repo_dir_name() { let cases = [ @@ -66,38 +84,23 @@ fn test_resolve_repo_url() { #[test] fn test_clone_skips_existing_directory() { - let tmp = tempfile::TempDir::new().unwrap(); - let repo_dir = tmp.path().join("owner-repo"); - std::fs::create_dir_all(&repo_dir).unwrap(); + run_repo_test(ProcessRunner, |tmp_path, ctx| { + let repo_dir = tmp_path.join("owner-repo"); + std::fs::create_dir_all(&repo_dir).unwrap(); - let mut runner = ProcessRunner; - let mut ctx = RunCtx { - io: IoCtx { - input: &mut b"".as_ref(), - output: &mut Vec::new(), - verbose: false, - timing: false, - dry_run: false, - }, - runner: &mut runner, - }; - let result = clone_repository("owner/repo", tmp.path(), false, &mut ctx); - assert!(result.is_ok()); - assert!(repo_dir.exists()); + let result = clone_repository("owner/repo", tmp_path, false, ctx); + assert!(result.is_ok()); + assert!(repo_dir.exists()); + }); } #[test] fn test_clone_repository_calls_git_clone() { let tmp = tempfile::TempDir::new().unwrap(); - let mut input = empty_input(); - let mut output = sink(); - let mut runner = MockRunner::new(); - let mut ctx = RunCtx { - io: make_io(&mut input, &mut output), - runner: &mut runner, - }; - clone_repository("user/repo", tmp.path(), false, &mut ctx).unwrap(); + let runner = run_repo_test(MockRunner::new(), |_, ctx| { + clone_repository("user/repo", tmp.path(), false, ctx).unwrap(); + }); assert_eq!(runner.calls.len(), 1); let (cmd, cwd) = &runner.calls[0]; From f742177e5e2616dd6264dff516dff88aaa55aef4 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Sun, 28 Jun 2026 17:47:01 -0400 Subject: [PATCH 29/59] refactor(tests): clean up resolve tests with config builder --- tests/cli/resolve.rs | 33 +++++++++++++++++---------------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/tests/cli/resolve.rs b/tests/cli/resolve.rs index 224178c..a182764 100644 --- a/tests/cli/resolve.rs +++ b/tests/cli/resolve.rs @@ -21,6 +21,13 @@ fn create_test_config_entry() -> ConfigEntry { } } +/// Helper to quickly build a `SetupConfig` with a populated profile entry. +fn config_with_entry(name: &str, entry: ConfigEntry) -> SetupConfig { + let mut config = SetupConfig::new(); + config.configs.insert(name.to_string(), entry); + config +} + #[test] fn test_resolve_bool() { #[allow(clippy::struct_excessive_bools)] @@ -102,9 +109,8 @@ fn test_resolve_with_config_defaults_when_no_config() { #[test] fn test_resolve_with_config_applies_config_defaults() { - let mut config = SetupConfig::new(); - config.configs.insert( - "default".to_string(), + let config = config_with_entry( + "default", ConfigEntry { ssh: true, verbose: true, @@ -116,6 +122,7 @@ fn test_resolve_with_config_applies_config_defaults() { ..create_test_config_entry() }, ); + let resolved = resolve_with_config(default_args(), &config).unwrap(); assert!(resolved.connection.ssh); assert!(resolved.connection.verbose); @@ -128,10 +135,7 @@ fn test_resolve_with_config_applies_config_defaults() { #[test] fn test_resolve_with_config_cli_overrides_config() { - let mut config = SetupConfig::new(); - config - .configs - .insert("default".to_string(), create_test_config_entry()); + let config = config_with_entry("default", create_test_config_entry()); let mut args = default_args(); args.connection.ssh = true; @@ -173,9 +177,8 @@ fn test_resolve_with_config_mono_repo_from_profile() { #[test] fn test_resolve_with_config_named_config_pulls_correct_values() { - let mut config = SetupConfig::new(); - config.configs.insert( - "myconfig".to_string(), + let config = config_with_entry( + "myconfig", ConfigEntry { ssh: true, build_type: BuildType::RelWithDebInfo, @@ -197,9 +200,8 @@ fn test_resolve_with_config_named_config_pulls_correct_values() { #[test] fn test_resolve_with_config_cli_cmake_flags_not_overwritten_by_config() { - let mut config = SetupConfig::new(); - config.configs.insert( - "default".to_string(), + let config = config_with_entry( + "default", ConfigEntry { cmake_flags: vec!["-DCONFIG_FLAG=ON".to_string()], ..create_test_config_entry() @@ -215,9 +217,8 @@ fn test_resolve_with_config_cli_cmake_flags_not_overwritten_by_config() { #[test] fn test_resolve_with_config_negative_flags_override_config() { - let mut config = SetupConfig::new(); - config.configs.insert( - "default".to_string(), + let config = config_with_entry( + "default", ConfigEntry { ssh: true, verbose: true, From 9f0e6176c98b61be7f76eb3b3bb6afed3a501e7c Mon Sep 17 00:00:00 2001 From: Masonlet Date: Sun, 28 Jun 2026 17:51:17 -0400 Subject: [PATCH 30/59] refactor(tests): streamline monorepo clone tests --- tests/commands/mono/clone.rs | 39 +++++++++++++++++++----------------- 1 file changed, 21 insertions(+), 18 deletions(-) diff --git a/tests/commands/mono/clone.rs b/tests/commands/mono/clone.rs index c64908b..6056033 100644 --- a/tests/commands/mono/clone.rs +++ b/tests/commands/mono/clone.rs @@ -1,21 +1,31 @@ use super::super::common::{empty_input, make_io, sink, MockRunner}; use star_setup::commands::mono::clone::clone_mono_repos; -use star_setup::ctx::RunCtx; -use tempfile::TempDir; +use star_setup::ctx::{RunCtx, Runner}; -#[test] -fn test_clone_mono_repos_calls_clone_for_each_repo() { - let tmp = TempDir::new().unwrap(); - let repos = vec!["user/repo1".to_string(), "user/repo2".to_string()]; +fn run_mono_test(mut runner: R, test_logic: F) -> R +where + R: Runner, + F: FnOnce(&std::path::Path, &mut RunCtx<'_, '_>), +{ + let tmp = tempfile::TempDir::new().unwrap(); let mut input = empty_input(); let mut output = sink(); - let mut runner = MockRunner::new(); + let mut ctx = RunCtx { io: make_io(&mut input, &mut output), runner: &mut runner, }; - clone_mono_repos(&repos, tmp.path(), false, &mut ctx).unwrap(); + test_logic(tmp.path(), &mut ctx); + runner +} + +#[test] +fn test_clone_mono_repos_calls_clone_for_each_repo() { + let runner = run_mono_test(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 @@ -26,16 +36,9 @@ fn test_clone_mono_repos_calls_clone_for_each_repo() { #[test] fn test_clone_mono_repos_empty() { - let tmp = TempDir::new().unwrap(); - let mut input = empty_input(); - let mut output = sink(); - let mut runner = MockRunner::new(); - let mut ctx = RunCtx { - io: make_io(&mut input, &mut output), - runner: &mut runner, - }; - - clone_mono_repos(&[], tmp.path(), false, &mut ctx).unwrap(); + let runner = run_mono_test(MockRunner::new(), |tmp_path, ctx| { + clone_mono_repos(&[], tmp_path, false, ctx).unwrap(); + }); assert!(runner.calls.is_empty()); } From 8d1f4c3ce3521cb68b16de06cb41932e56acbe9f Mon Sep 17 00:00:00 2001 From: Masonlet Date: Sun, 28 Jun 2026 17:57:01 -0400 Subject: [PATCH 31/59] refactor(tests): streamline mono config tests --- tests/commands/mono/config.rs | 102 ++++++++++++++++++---------------- 1 file changed, 55 insertions(+), 47 deletions(-) diff --git a/tests/commands/mono/config.rs b/tests/commands/mono/config.rs index a5bfbd5..0f765b5 100644 --- a/tests/commands/mono/config.rs +++ b/tests/commands/mono/config.rs @@ -1,69 +1,77 @@ use super::super::common::{empty_input, make_io, sink}; -use star_setup::commands::{create_mono_repo_cmakelists, create_mono_repo_mesonbuild}; +use star_setup::{ + commands::{create_mono_repo_cmakelists, create_mono_repo_mesonbuild}, + ctx::IoCtx, +}; -// create_mono_repo_cmakelists tests -#[test] -fn test_create_mono_repo_cmakelists_creates_file() { +fn run_build_file_test(test_logic: F) +where + F: FnOnce(&std::path::Path, &mut IoCtx<'_>), +{ let tmp = tempfile::TempDir::new().unwrap(); - - let repos = vec![ - "user-testrepo".to_string(), - "user/lib1".to_string(), - "user/lib2".to_string(), - ]; let mut input = empty_input(); let mut output = sink(); let mut io = make_io(&mut input, &mut output); - create_mono_repo_cmakelists(tmp.path(), &repos, &mut io).unwrap(); - let cmake_file = tmp.path().join("CMakeLists.txt"); - assert!(cmake_file.exists()); + test_logic(tmp.path(), &mut io); +} + +// create_mono_repo_cmakelists tests +#[test] +fn test_create_mono_repo_cmakelists_creates_file() { + run_build_file_test(|tmp_path, io| { + let repos = vec![ + "user-testrepo".to_string(), + "user/lib1".to_string(), + "user/lib2".to_string(), + ]; + create_mono_repo_cmakelists(tmp_path, &repos, io).unwrap(); - let content = std::fs::read_to_string(&cmake_file).unwrap(); - assert!(content.contains("user-testrepo")); - assert!(content.contains("user-lib1")); - assert!(content.contains("user-lib2")); + let cmake_file = tmp_path.join("CMakeLists.txt"); + assert!(cmake_file.exists()); + + let content = std::fs::read_to_string(&cmake_file).unwrap(); + assert!(content.contains("user-testrepo")); + assert!(content.contains("user-lib1")); + assert!(content.contains("user-lib2")); + }); } #[test] fn test_create_mono_repo_cmakelists_empty_repos() { - let tmp = tempfile::TempDir::new().unwrap(); - let repos = vec!["user-testrepo".to_string()]; - let mut input = empty_input(); - let mut output = sink(); - let mut io = make_io(&mut input, &mut output); - create_mono_repo_cmakelists(tmp.path(), &repos, &mut io).unwrap(); - assert!(tmp.path().join("CMakeLists.txt").exists()); + run_build_file_test(|tmp_path, io| { + let repos = vec!["user-testrepo".to_string()]; + create_mono_repo_cmakelists(tmp_path, &repos, io).unwrap(); + assert!(tmp_path.join("CMakeLists.txt").exists()); + }); } // create_mono_repo_mesonbuild tests #[test] fn test_create_mono_repo_mesonbuild_creates_file() { - let tmp = tempfile::TempDir::new().unwrap(); - let repos = vec![ - "user-testrepo".to_string(), - "user/lib1".to_string(), - "user/lib2".to_string(), - ]; - let mut input = empty_input(); - let mut output = sink(); - let mut io = make_io(&mut input, &mut output); - create_mono_repo_mesonbuild(tmp.path(), &repos, &mut io).unwrap(); - let meson_file = tmp.path().join("meson.build"); - assert!(meson_file.exists()); - let content = std::fs::read_to_string(&meson_file).unwrap(); - assert!(content.contains("user-testrepo")); - assert!(content.contains("user-lib1")); - assert!(content.contains("user-lib2")); + run_build_file_test(|tmp_path, io| { + let repos = vec![ + "user-testrepo".to_string(), + "user/lib1".to_string(), + "user/lib2".to_string(), + ]; + create_mono_repo_mesonbuild(tmp_path, &repos, io).unwrap(); + + let meson_file = tmp_path.join("meson.build"); + assert!(meson_file.exists()); + + let content = std::fs::read_to_string(&meson_file).unwrap(); + assert!(content.contains("user-testrepo")); + assert!(content.contains("user-lib1")); + assert!(content.contains("user-lib2")); + }); } #[test] fn test_create_mono_repo_mesonbuild_empty_repos() { - let tmp = tempfile::TempDir::new().unwrap(); - let repos = vec!["user-testrepo".to_string()]; - let mut input = empty_input(); - let mut output = sink(); - let mut io = make_io(&mut input, &mut output); - create_mono_repo_mesonbuild(tmp.path(), &repos, &mut io).unwrap(); - assert!(tmp.path().join("meson.build").exists()); + run_build_file_test(|tmp_path, io| { + let repos = vec!["user-testrepo".to_string()]; + create_mono_repo_mesonbuild(tmp_path, &repos, io).unwrap(); + assert!(tmp_path.join("meson.build").exists()); + }); } From c704dec6bf1b19a44a91ec4905158df0afc7a0c8 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Sun, 28 Jun 2026 18:00:29 -0400 Subject: [PATCH 32/59] refactor(tests): streamline mono repo mode tests --- tests/commands/mono/mode.rs | 92 +++++++++++++++++-------------------- 1 file changed, 43 insertions(+), 49 deletions(-) diff --git a/tests/commands/mono/mode.rs b/tests/commands/mono/mode.rs index db00f31..b6bdcda 100644 --- a/tests/commands/mono/mode.rs +++ b/tests/commands/mono/mode.rs @@ -1,10 +1,29 @@ -use crate::common::{default_resolved_mono, empty_input, make_io, sink, MockRunner}; +use crate::common::{default_resolved_mono, empty_input, make_io, MockRunner}; use star_setup::{ commands::mono_repo_mode, config::SetupConfig, - ctx::{DryRunRunner, RunCtx}, + ctx::{DryRunRunner, RunCtx, Runner}, }; -use tempfile::TempDir; + +fn run_mode_test(mut runner: R, test_logic: F) -> (R, Vec) +where + R: Runner, + F: FnOnce(&std::path::Path, &mut RunCtx<'_, '_>), +{ + let tmp = tempfile::TempDir::new().unwrap(); + let mut input = empty_input(); + let mut output = Vec::new(); + + { + let mut ctx = RunCtx { + io: make_io(&mut input, &mut output), + runner: &mut runner, + }; + test_logic(tmp.path(), &mut ctx); + } + + (runner, output) +} fn make_cmake_repo(repos_path: &std::path::Path, name: &str) { let dir = repos_path.join(name); @@ -14,23 +33,16 @@ fn make_cmake_repo(repos_path: &std::path::Path, name: &str) { #[test] fn test_mono_repo_mode_clones_and_configures() { - let tmp = TempDir::new().unwrap(); let args = default_resolved_mono(vec!["user/lib1".to_string()]); - let repos_path = tmp.path().join(&args.mono.mono_dir).join("repos"); - std::fs::create_dir_all(&repos_path).unwrap(); - make_cmake_repo(&repos_path, "user-lib1"); - make_cmake_repo(&repos_path, "user-test-repo"); + let (_, output) = run_mode_test(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(); + make_cmake_repo(&repos_path, "user-lib1"); + make_cmake_repo(&repos_path, "user-test-repo"); - let mut input = empty_input(); - let mut output = sink(); - let mut runner = MockRunner::new(); - let mut ctx = RunCtx { - io: make_io(&mut input, &mut output), - runner: &mut runner, - }; - - mono_repo_mode(&args, &SetupConfig::new(), tmp.path(), &mut ctx).unwrap(); + mono_repo_mode(&args, &SetupConfig::new(), tmp_path, ctx).unwrap(); + }); let out = String::from_utf8(output).unwrap(); assert!(out.contains("Setup complete")); @@ -39,49 +51,31 @@ fn test_mono_repo_mode_clones_and_configures() { #[test] fn test_mono_repo_mode_dry_run_makes_no_fs_changes() { - let tmp = TempDir::new().unwrap(); let mut args = default_resolved_mono(vec!["user/lib1".to_string()]); args.diagnostic.dry_run = true; - let mut input = empty_input(); - let mut output = sink(); - let mut runner = DryRunRunner; - let mut ctx = RunCtx { - io: star_setup::ctx::IoCtx { - input: &mut input, - output: &mut output, - verbose: false, - timing: false, - dry_run: true, - }, - runner: &mut runner, - }; - - mono_repo_mode(&args, &SetupConfig::new(), tmp.path(), &mut ctx).unwrap(); - - assert!(std::fs::read_dir(tmp.path()).unwrap().next().is_none()); + run_mode_test(DryRunRunner, |tmp_path, ctx| { + ctx.io.dry_run = true; + + mono_repo_mode(&args, &SetupConfig::new(), tmp_path, ctx).unwrap(); + + assert!(std::fs::read_dir(tmp_path).unwrap().next().is_none()); + }); } #[test] fn test_mono_repo_mode_with_build_system_flag() { - let tmp = TempDir::new().unwrap(); let mut args = default_resolved_mono(vec!["user/lib1".to_string()]); args.build.build_system = Some(star_setup::cli::BuildSystem::Cmake); - let repos_path = tmp.path().join(&args.mono.mono_dir).join("repos"); - std::fs::create_dir_all(&repos_path).unwrap(); - make_cmake_repo(&repos_path, "user-lib1"); - make_cmake_repo(&repos_path, "user-test-repo"); + let (runner, _) = run_mode_test(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(); + make_cmake_repo(&repos_path, "user-lib1"); + make_cmake_repo(&repos_path, "user-test-repo"); - let mut input = empty_input(); - let mut output = sink(); - let mut runner = MockRunner::new(); - let mut ctx = RunCtx { - io: make_io(&mut input, &mut output), - runner: &mut runner, - }; - - mono_repo_mode(&args, &SetupConfig::new(), tmp.path(), &mut ctx).unwrap(); + mono_repo_mode(&args, &SetupConfig::new(), tmp_path, ctx).unwrap(); + }); assert!(runner.calls.iter().any(|(cmd, _)| cmd[0] == "cmake")); } From 2e96388c3c93ef957cae48e46c66714fb2a34452 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Sun, 28 Jun 2026 18:05:08 -0400 Subject: [PATCH 33/59] refactor(tests): streamline mono resolve tests --- tests/commands/mono/resolve.rs | 79 +++++++++++++++++----------------- 1 file changed, 39 insertions(+), 40 deletions(-) diff --git a/tests/commands/mono/resolve.rs b/tests/commands/mono/resolve.rs index 2500d3c..40f4ed3 100644 --- a/tests/commands/mono/resolve.rs +++ b/tests/commands/mono/resolve.rs @@ -2,9 +2,18 @@ use crate::common::{default_resolved, empty_input, make_io, sink, MockRunner}; use star_setup::{ commands::{mono::generate_mono_config, resolve_repos_for_mono, resolve_test_repo}, config::SetupConfig, - ctx::RunCtx, + ctx::{IoCtx, RunCtx}, }; -use tempfile::TempDir; + +fn run_mono_resolve_test(test_logic: F) +where + F: FnOnce(&mut IoCtx<'_>), +{ + let mut input = empty_input(); + let mut output = sink(); + let mut io = make_io(&mut input, &mut output); + test_logic(&mut io); +} // resolve_test_repo tests #[test] @@ -54,13 +63,11 @@ fn test_resolve_repos_for_mono_empty_profile_errors() { let mut args = default_resolved(); args.mono.profile = Some("emptyprofile".to_string()); - let mut input = empty_input(); - let mut output = sink(); - let mut io = make_io(&mut input, &mut output); - - let result = resolve_repos_for_mono(&args, &config, "user/repo", &mut io); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("has no repositories")); + run_mono_resolve_test(|io| { + let result = resolve_repos_for_mono(&args, &config, "user/repo", io); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("has no repositories")); + }); } #[test] @@ -73,13 +80,11 @@ fn test_resolve_repos_for_mono_with_profile() { let mut args = default_resolved(); args.mono.profile = Some("myprofile".to_string()); - let mut input = empty_input(); - let mut output = sink(); - let mut io = make_io(&mut input, &mut output); - - let result = resolve_repos_for_mono(&args, &config, "user/repo", &mut io); - assert!(result.is_ok()); - assert_eq!(result.unwrap(), vec!["user/lib1", "user/lib2"]); + run_mono_resolve_test(|io| { + let result = resolve_repos_for_mono(&args, &config, "user/repo", io); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), vec!["user/lib1", "user/lib2"]); + }); } #[test] @@ -88,13 +93,11 @@ fn test_resolve_repos_for_mono_with_explicit_repos() { let mut args = default_resolved(); args.mono.repos = Some(vec!["user/lib1".to_string(), "user/lib2".to_string()]); - let mut input = empty_input(); - let mut output = sink(); - let mut io = make_io(&mut input, &mut output); - - let result = resolve_repos_for_mono(&args, &config, "user/repo", &mut io); - assert!(result.is_ok()); - assert_eq!(result.unwrap(), vec!["user/lib1", "user/lib2"]); + run_mono_resolve_test(|io| { + let result = resolve_repos_for_mono(&args, &config, "user/repo", io); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), vec!["user/lib1", "user/lib2"]); + }); } #[test] @@ -102,15 +105,13 @@ fn test_resolve_repos_for_mono_no_repos_or_profile_errors() { let config = SetupConfig::new(); let args = default_resolved(); - let mut input = empty_input(); - let mut output = sink(); - let mut io = make_io(&mut input, &mut output); - - let result = resolve_repos_for_mono(&args, &config, "user/repo", &mut io); - assert!(result.is_err()); - assert!(result - .unwrap_err() - .contains("No repos or profile specified")); + run_mono_resolve_test(|io| { + let result = resolve_repos_for_mono(&args, &config, "user/repo", io); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .contains("No repos or profile specified")); + }); } #[test] @@ -119,18 +120,16 @@ fn test_resolve_repos_for_mono_profile_not_found_errors() { let mut args = default_resolved(); args.mono.profile = Some("nonexistent".to_string()); - let mut input = empty_input(); - let mut output = sink(); - let mut io = make_io(&mut input, &mut output); - - let result = resolve_repos_for_mono(&args, &config, "user/repo", &mut io); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("not found")); + run_mono_resolve_test(|io| { + let result = resolve_repos_for_mono(&args, &config, "user/repo", io); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("not found")); + }); } #[test] fn test_generate_mono_config_meson() { - let tmp = TempDir::new().unwrap(); + let tmp = tempfile::TempDir::new().unwrap(); let repos_path = tmp.path().join("repos"); std::fs::create_dir_all(&repos_path).unwrap(); From b4709b7ef8b62f5c6abd0392c38b7bdff8942f3e Mon Sep 17 00:00:00 2001 From: Masonlet Date: Sun, 28 Jun 2026 18:11:51 -0400 Subject: [PATCH 34/59] test(commands): streamline build_repo_list tests --- tests/commands/mono/setup.rs | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/tests/commands/mono/setup.rs b/tests/commands/mono/setup.rs index 502aff4..6e89724 100644 --- a/tests/commands/mono/setup.rs +++ b/tests/commands/mono/setup.rs @@ -1,36 +1,39 @@ use star_setup::commands::build_repo_list; +fn to_string_vec(slice: &[&str]) -> Vec { + slice.iter().map(ToString::to_string).collect() +} + #[test] fn test_build_repo_list_test_repo_first() { - let deps = vec!["user/lib1".to_string(), "user/lib2".to_string()]; + let deps = to_string_vec(&["user/lib1", "user/lib2"]); let result = build_repo_list("user/testrepo", &deps); assert_eq!(result[0], "user/testrepo"); } #[test] fn test_build_repo_list_includes_deps() { - let deps = vec!["user/lib1".to_string(), "user/lib2".to_string()]; + let deps = to_string_vec(&["user/lib1", "user/lib2"]); let result = build_repo_list("user/testrepo", &deps); assert_eq!(result.len(), 3); } #[test] fn test_build_repo_list_dedupes_test_repo_in_deps() { - let deps = vec!["user/lib1".to_string(), "user/testrepo".to_string()]; + let deps = to_string_vec(&["user/lib1", "user/testrepo"]); let result = build_repo_list("user/testrepo", &deps); - assert_eq!(result.len(), 2); - assert_eq!(result[0], "user/testrepo"); + assert_eq!(result, to_string_vec(&["user/testrepo", "user/lib1"])); } #[test] fn test_build_repo_list_dedupes_duplicate_deps() { - let deps = vec!["user/lib1".to_string(), "user/lib1".to_string()]; + let deps = to_string_vec(&["user/lib1", "user/lib1"]); let result = build_repo_list("user/testrepo", &deps); - assert_eq!(result.len(), 2); + assert_eq!(result, to_string_vec(&["user/testrepo", "user/lib1"])); } #[test] fn test_build_repo_list_no_deps() { let result = build_repo_list("user/testrepo", &[]); - assert_eq!(result, vec!["user/testrepo"]); + assert_eq!(result, to_string_vec(&["user/testrepo"])); } From 064f0998749adaa06a607ead65ea35f28f21c4bc Mon Sep 17 00:00:00 2001 From: Masonlet Date: Sun, 28 Jun 2026 18:16:28 -0400 Subject: [PATCH 35/59] refactor(tests): streamline mono wraps tests --- tests/commands/mono/wraps.rs | 155 +++++++++++++++++------------------ 1 file changed, 73 insertions(+), 82 deletions(-) diff --git a/tests/commands/mono/wraps.rs b/tests/commands/mono/wraps.rs index 11d2225..fc85586 100644 --- a/tests/commands/mono/wraps.rs +++ b/tests/commands/mono/wraps.rs @@ -1,47 +1,30 @@ +use std::path::Path; + use super::super::common::{empty_input, make_io, sink}; -use star_setup::commands::{hoist_wraps, parse_project_name, parse_provide_pairs}; +use star_setup::{ + commands::{hoist_wraps, parse_project_name, parse_provide_pairs}, + ctx::IoCtx, +}; use tempfile::TempDir; #[test] -fn test_parse_project_name_single_quoted() { - assert_eq!( - parse_project_name("project('my-lib', 'cpp')"), - Some("my_lib".to_string()) - ); -} - -#[test] -fn test_parse_project_name_double_quoted() { - assert_eq!( - parse_project_name(r#"project("my-lib", "cpp")"#), - Some("my_lib".to_string()) - ); -} - -#[test] -fn test_parse_project_name_no_hyphens() { - assert_eq!( - parse_project_name("project('mylib', 'cpp')"), - Some("mylib".to_string()) - ); -} - -#[test] -fn test_parse_project_name_prefix_guard() { - assert_eq!(parse_project_name("myproject('mylib', 'cpp')"), None); -} - -#[test] -fn test_parse_project_name_missing() { - assert_eq!( - parse_project_name("cmake_minimum_required(VERSION 3.20)"), - None - ); -} - -#[test] -fn test_parse_project_name_no_quotes() { - assert_eq!(parse_project_name("project(mylib, cpp)"), None); +fn test_parse_project_name() { + let cases = [ + ("project('my-lib', 'cpp')", Some("my_lib")), + (r#"project("my-lib", "cpp")"#, Some("my_lib")), + ("project('mylib', 'cpp')", Some("mylib")), + ("myproject('mylib', 'cpp')", None), + ("cmake_minimum_required(VERSION 3.20)", None), + ("project(mylib, cpp)", None), + ]; + + for (input, expected) in cases { + assert_eq!( + parse_project_name(input), + expected.map(String::from), + "Failed on input: {input}" + ); + } } #[test] @@ -89,62 +72,70 @@ fn make_repo(project_name: &str) -> TempDir { tmp } -#[test] -fn test_hoist_wraps_empty_repos() { +fn run_hoist_test(test_logic: F) +where + F: FnOnce(&Path, &mut IoCtx<'_>), +{ let repos_dir = TempDir::new().unwrap(); let mut input = empty_input(); let mut output = sink(); let mut io = make_io(&mut input, &mut output); - let result = hoist_wraps(repos_dir.path(), &[], &mut io).unwrap(); - assert!(result.is_empty()); + + test_logic(repos_dir.path(), &mut io); +} + +#[test] +fn test_hoist_wraps_empty_repos() { + run_hoist_test(|repos_dir, io| { + let result = hoist_wraps(repos_dir, &[], io).unwrap(); + assert!(result.is_empty()); + }); } #[test] fn test_hoist_wraps_skips_repo_without_meson_build() { - let repos_dir = TempDir::new().unwrap(); - let repo = TempDir::new().unwrap(); - let mut input = empty_input(); - let mut output = sink(); - let mut io = make_io(&mut input, &mut output); - let result = hoist_wraps(repos_dir.path(), &[repo.path().to_path_buf()], &mut io).unwrap(); - assert!(result.is_empty()); + run_hoist_test(|repos_dir, io| { + let repo = TempDir::new().unwrap(); + let result = hoist_wraps(repos_dir, &[repo.path().to_path_buf()], io).unwrap(); + assert!(result.is_empty()); + }); } #[test] fn test_hoist_wraps_emits_wrap_without_provide() { - let repos_dir = TempDir::new().unwrap(); - let repo = make_repo("my-lib"); - let mut input = empty_input(); - let mut output = sink(); - let mut io = make_io(&mut input, &mut output); - let result = hoist_wraps(repos_dir.path(), &[repo.path().to_path_buf()], &mut io).unwrap(); - assert!(result.contains_key("my_lib")); - let wrap = repos_dir.path().join("my_lib.wrap"); - assert!(wrap.exists()); - let content = std::fs::read_to_string(&wrap).unwrap(); - assert!(content.contains("directory =")); - assert!(!content.contains("[provide]")); + run_hoist_test(|repos_dir, io| { + let repo = make_repo("my-lib"); + let result = hoist_wraps(repos_dir, &[repo.path().to_path_buf()], io).unwrap(); + + assert!(result.contains_key("my_lib")); + let wrap = repos_dir.join("my_lib.wrap"); + assert!(wrap.exists()); + + let content = std::fs::read_to_string(&wrap).unwrap(); + assert!(content.contains("directory =")); + assert!(!content.contains("[provide]")); + }); } #[test] fn test_hoist_wraps_emits_wrap_with_provide() { - let repos_dir = TempDir::new().unwrap(); - let repo = make_repo("my-lib"); - let subprojects = repo.path().join("subprojects"); - std::fs::create_dir(&subprojects).unwrap(); - std::fs::write( - subprojects.join("my_lib.wrap"), - "[provide]\nmy_lib = my_lib_dep\n", - ) - .unwrap(); - std::fs::write(subprojects.join("readme.txt"), "ignore me").unwrap(); - let mut input = empty_input(); - let mut output = sink(); - let mut io = make_io(&mut input, &mut output); - let result = hoist_wraps(repos_dir.path(), &[repo.path().to_path_buf()], &mut io).unwrap(); - assert!(result.contains_key("my_lib")); - let wrap = repos_dir.path().join("my_lib.wrap"); - let content = std::fs::read_to_string(&wrap).unwrap(); - assert!(content.contains("[provide]")); - assert!(content.contains("my_lib = my_lib_dep")); + run_hoist_test(|repos_dir, io| { + let repo = make_repo("my-lib"); + let subprojects = repo.path().join("subprojects"); + std::fs::create_dir(&subprojects).unwrap(); + std::fs::write( + subprojects.join("my_lib.wrap"), + "[provide]\nmy_lib = my_lib_dep\n", + ) + .unwrap(); + std::fs::write(subprojects.join("readme.txt"), "ignore me").unwrap(); + + let result = hoist_wraps(repos_dir, &[repo.path().to_path_buf()], io).unwrap(); + + assert!(result.contains_key("my_lib")); + let wrap = repos_dir.join("my_lib.wrap"); + let content = std::fs::read_to_string(&wrap).unwrap(); + assert!(content.contains("[provide]")); + assert!(content.contains("my_lib = my_lib_dep")); + }); } From 81749aefcea7ae8c507b63a3cf60048537bae1f3 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Sun, 28 Jun 2026 18:28:00 -0400 Subject: [PATCH 36/59] chore: cargo fmt --- tests/commands/mono/wraps.rs | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/tests/commands/mono/wraps.rs b/tests/commands/mono/wraps.rs index fc85586..9e69faa 100644 --- a/tests/commands/mono/wraps.rs +++ b/tests/commands/mono/wraps.rs @@ -9,22 +9,22 @@ use tempfile::TempDir; #[test] fn test_parse_project_name() { - let cases = [ - ("project('my-lib', 'cpp')", Some("my_lib")), - (r#"project("my-lib", "cpp")"#, Some("my_lib")), - ("project('mylib', 'cpp')", Some("mylib")), - ("myproject('mylib', 'cpp')", None), - ("cmake_minimum_required(VERSION 3.20)", None), - ("project(mylib, cpp)", None), - ]; - - for (input, expected) in cases { - assert_eq!( - parse_project_name(input), - expected.map(String::from), - "Failed on input: {input}" - ); - } + let cases = [ + ("project('my-lib', 'cpp')", Some("my_lib")), + (r#"project("my-lib", "cpp")"#, Some("my_lib")), + ("project('mylib', 'cpp')", Some("mylib")), + ("myproject('mylib', 'cpp')", None), + ("cmake_minimum_required(VERSION 3.20)", None), + ("project(mylib, cpp)", None), + ]; + + for (input, expected) in cases { + assert_eq!( + parse_project_name(input), + expected.map(String::from), + "Failed on input: {input}" + ); + } } #[test] From 876e68f69af9409a295baac846475be043d6ea43 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Sun, 28 Jun 2026 19:27:37 -0400 Subject: [PATCH 37/59] refactor(tests): add shared ctx/io harness, migrate clone + repository --- tests/commands/mono/clone.rs | 25 +++----------------- tests/common/harness.rs | 44 ++++++++++++++++++++++++++++++++++++ tests/common/mod.rs | 3 +++ tests/common/runner.rs | 1 - tests/repository.rs | 26 ++++----------------- 5 files changed, 54 insertions(+), 45 deletions(-) create mode 100644 tests/common/harness.rs diff --git a/tests/commands/mono/clone.rs b/tests/commands/mono/clone.rs index 6056033..e91a02e 100644 --- a/tests/commands/mono/clone.rs +++ b/tests/commands/mono/clone.rs @@ -1,28 +1,9 @@ -use super::super::common::{empty_input, make_io, sink, MockRunner}; +use super::super::common::{with_runner_ctx, MockRunner}; use star_setup::commands::mono::clone::clone_mono_repos; -use star_setup::ctx::{RunCtx, Runner}; - -fn run_mono_test(mut runner: R, test_logic: F) -> R -where - R: Runner, - F: FnOnce(&std::path::Path, &mut RunCtx<'_, '_>), -{ - let tmp = tempfile::TempDir::new().unwrap(); - let mut input = empty_input(); - let mut output = sink(); - - let mut ctx = RunCtx { - io: make_io(&mut input, &mut output), - runner: &mut runner, - }; - - test_logic(tmp.path(), &mut ctx); - runner -} #[test] fn test_clone_mono_repos_calls_clone_for_each_repo() { - let runner = run_mono_test(MockRunner::new(), |tmp_path, ctx| { + let runner = with_runner_ctx(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(); }); @@ -36,7 +17,7 @@ fn test_clone_mono_repos_calls_clone_for_each_repo() { #[test] fn test_clone_mono_repos_empty() { - let runner = run_mono_test(MockRunner::new(), |tmp_path, ctx| { + let runner = with_runner_ctx(MockRunner::new(), |tmp_path, ctx| { clone_mono_repos(&[], tmp_path, false, ctx).unwrap(); }); diff --git a/tests/common/harness.rs b/tests/common/harness.rs new file mode 100644 index 0000000..f71cb72 --- /dev/null +++ b/tests/common/harness.rs @@ -0,0 +1,44 @@ +use super::io::{empty_input, make_io, sink}; +use star_setup::ctx::{IoCtx, RunCtx, Runner}; +use std::path::Path; +use tempfile::TempDir; + +#[allow(dead_code)] +pub fn with_io(f: impl FnOnce(&mut IoCtx<'_>)) { + let mut input = empty_input(); + let mut output = sink(); + let mut io = make_io(&mut input, &mut output); + f(&mut io); +} + +#[allow(dead_code)] +pub fn with_io_dir(f: impl FnOnce(&Path, &mut IoCtx<'_>)) { + let tmp = TempDir::new().unwrap(); + let mut input = empty_input(); + let mut output = sink(); + let mut io = make_io(&mut input, &mut output); + f(tmp.path(), &mut io); +} + +#[allow(dead_code)] +pub fn with_ctx( + mut runner: R, + f: impl FnOnce(&Path, &mut RunCtx<'_, '_>), +) -> (R, Vec) { + let tmp = TempDir::new().unwrap(); + let mut input = empty_input(); + let mut output = Vec::new(); + { + let mut ctx = RunCtx { + io: make_io(&mut input, &mut output), + runner: &mut runner, + }; + f(tmp.path(), &mut ctx); + } + (runner, output) +} + +#[allow(dead_code)] +pub fn with_runner_ctx(runner: R, f: impl FnOnce(&Path, &mut RunCtx<'_, '_>)) -> R { + with_ctx(runner, f).0 +} diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 7930bb7..1751577 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -10,3 +10,6 @@ pub use args::{ default_args, default_resolved, default_resolved_interactive, default_resolved_mono, default_resolved_with_no_build, }; +pub mod harness; +#[allow(unused_imports)] +pub use harness::{with_ctx, with_io, with_io_dir, with_runner_ctx}; diff --git a/tests/common/runner.rs b/tests/common/runner.rs index a265562..6a7718a 100644 --- a/tests/common/runner.rs +++ b/tests/common/runner.rs @@ -2,7 +2,6 @@ use star_setup::ctx::{IoCtx, Runner}; use std::collections::VecDeque; use std::path::Path; -#[allow(dead_code)] pub struct MockRunner { pub calls: Vec<(Vec, Option)>, pub fail_on: Option, diff --git a/tests/repository.rs b/tests/repository.rs index 561574d..091f323 100644 --- a/tests/repository.rs +++ b/tests/repository.rs @@ -1,28 +1,10 @@ mod common; -use common::{empty_input, make_io, sink, MockRunner}; +use common::{with_runner_ctx, MockRunner}; use star_setup::{ - ctx::{ProcessRunner, RunCtx, Runner}, + ctx::ProcessRunner, repository::{clone_repository, repo_dir_name, resolve_repo_url}, }; -fn run_repo_test(mut runner: R, test_logic: F) -> R -where - R: Runner, - F: FnOnce(&std::path::Path, &mut RunCtx<'_, '_>), -{ - let tmp = tempfile::TempDir::new().unwrap(); - let mut input = empty_input(); - let mut output = sink(); - - let mut ctx = RunCtx { - io: make_io(&mut input, &mut output), - runner: &mut runner, - }; - - test_logic(tmp.path(), &mut ctx); - runner -} - #[test] fn test_repo_dir_name() { let cases = [ @@ -84,7 +66,7 @@ fn test_resolve_repo_url() { #[test] fn test_clone_skips_existing_directory() { - run_repo_test(ProcessRunner, |tmp_path, ctx| { + with_runner_ctx(ProcessRunner, |tmp_path, ctx| { let repo_dir = tmp_path.join("owner-repo"); std::fs::create_dir_all(&repo_dir).unwrap(); @@ -98,7 +80,7 @@ fn test_clone_skips_existing_directory() { fn test_clone_repository_calls_git_clone() { let tmp = tempfile::TempDir::new().unwrap(); - let runner = run_repo_test(MockRunner::new(), |_, ctx| { + let runner = with_runner_ctx(MockRunner::new(), |_, ctx| { clone_repository("user/repo", tmp.path(), false, ctx).unwrap(); }); From 327f3de8ee3224a9f6b0e025ee4326b081b0b66d Mon Sep 17 00:00:00 2001 From: Masonlet Date: Sun, 28 Jun 2026 19:46:39 -0400 Subject: [PATCH 38/59] refactor(tests): migrate wraps, config, resolve to with_io/with_io_dir --- tests/commands/mono/config.rs | 27 ++++++--------------------- tests/commands/mono/resolve.rs | 24 +++++++----------------- tests/commands/mono/wraps.rs | 29 ++++++----------------------- 3 files changed, 19 insertions(+), 61 deletions(-) diff --git a/tests/commands/mono/config.rs b/tests/commands/mono/config.rs index 0f765b5..aac2886 100644 --- a/tests/commands/mono/config.rs +++ b/tests/commands/mono/config.rs @@ -1,25 +1,10 @@ -use super::super::common::{empty_input, make_io, sink}; -use star_setup::{ - commands::{create_mono_repo_cmakelists, create_mono_repo_mesonbuild}, - ctx::IoCtx, -}; - -fn run_build_file_test(test_logic: F) -where - F: FnOnce(&std::path::Path, &mut IoCtx<'_>), -{ - let tmp = tempfile::TempDir::new().unwrap(); - let mut input = empty_input(); - let mut output = sink(); - let mut io = make_io(&mut input, &mut output); - - test_logic(tmp.path(), &mut io); -} +use crate::common::with_io_dir; +use star_setup::commands::{create_mono_repo_cmakelists, create_mono_repo_mesonbuild}; // create_mono_repo_cmakelists tests #[test] fn test_create_mono_repo_cmakelists_creates_file() { - run_build_file_test(|tmp_path, io| { + with_io_dir(|tmp_path, io| { let repos = vec![ "user-testrepo".to_string(), "user/lib1".to_string(), @@ -39,7 +24,7 @@ fn test_create_mono_repo_cmakelists_creates_file() { #[test] fn test_create_mono_repo_cmakelists_empty_repos() { - run_build_file_test(|tmp_path, io| { + with_io_dir(|tmp_path, io| { let repos = vec!["user-testrepo".to_string()]; create_mono_repo_cmakelists(tmp_path, &repos, io).unwrap(); assert!(tmp_path.join("CMakeLists.txt").exists()); @@ -49,7 +34,7 @@ fn test_create_mono_repo_cmakelists_empty_repos() { // create_mono_repo_mesonbuild tests #[test] fn test_create_mono_repo_mesonbuild_creates_file() { - run_build_file_test(|tmp_path, io| { + with_io_dir(|tmp_path, io| { let repos = vec![ "user-testrepo".to_string(), "user/lib1".to_string(), @@ -69,7 +54,7 @@ fn test_create_mono_repo_mesonbuild_creates_file() { #[test] fn test_create_mono_repo_mesonbuild_empty_repos() { - run_build_file_test(|tmp_path, io| { + with_io_dir(|tmp_path, io| { let repos = vec!["user-testrepo".to_string()]; create_mono_repo_mesonbuild(tmp_path, &repos, io).unwrap(); assert!(tmp_path.join("meson.build").exists()); diff --git a/tests/commands/mono/resolve.rs b/tests/commands/mono/resolve.rs index 40f4ed3..812ef81 100644 --- a/tests/commands/mono/resolve.rs +++ b/tests/commands/mono/resolve.rs @@ -1,20 +1,10 @@ -use crate::common::{default_resolved, empty_input, make_io, sink, MockRunner}; +use crate::common::{default_resolved, empty_input, make_io, sink, with_io, MockRunner}; use star_setup::{ commands::{mono::generate_mono_config, resolve_repos_for_mono, resolve_test_repo}, config::SetupConfig, - ctx::{IoCtx, RunCtx}, + ctx::RunCtx, }; -fn run_mono_resolve_test(test_logic: F) -where - F: FnOnce(&mut IoCtx<'_>), -{ - let mut input = empty_input(); - let mut output = sink(); - let mut io = make_io(&mut input, &mut output); - test_logic(&mut io); -} - // resolve_test_repo tests #[test] fn test_resolve_test_repo() { @@ -63,7 +53,7 @@ fn test_resolve_repos_for_mono_empty_profile_errors() { let mut args = default_resolved(); args.mono.profile = Some("emptyprofile".to_string()); - run_mono_resolve_test(|io| { + with_io(|io| { let result = resolve_repos_for_mono(&args, &config, "user/repo", io); assert!(result.is_err()); assert!(result.unwrap_err().contains("has no repositories")); @@ -80,7 +70,7 @@ fn test_resolve_repos_for_mono_with_profile() { let mut args = default_resolved(); args.mono.profile = Some("myprofile".to_string()); - run_mono_resolve_test(|io| { + with_io(|io| { let result = resolve_repos_for_mono(&args, &config, "user/repo", io); assert!(result.is_ok()); assert_eq!(result.unwrap(), vec!["user/lib1", "user/lib2"]); @@ -93,7 +83,7 @@ fn test_resolve_repos_for_mono_with_explicit_repos() { let mut args = default_resolved(); args.mono.repos = Some(vec!["user/lib1".to_string(), "user/lib2".to_string()]); - run_mono_resolve_test(|io| { + with_io(|io| { let result = resolve_repos_for_mono(&args, &config, "user/repo", io); assert!(result.is_ok()); assert_eq!(result.unwrap(), vec!["user/lib1", "user/lib2"]); @@ -105,7 +95,7 @@ fn test_resolve_repos_for_mono_no_repos_or_profile_errors() { let config = SetupConfig::new(); let args = default_resolved(); - run_mono_resolve_test(|io| { + with_io(|io| { let result = resolve_repos_for_mono(&args, &config, "user/repo", io); assert!(result.is_err()); assert!(result @@ -120,7 +110,7 @@ fn test_resolve_repos_for_mono_profile_not_found_errors() { let mut args = default_resolved(); args.mono.profile = Some("nonexistent".to_string()); - run_mono_resolve_test(|io| { + with_io(|io| { let result = resolve_repos_for_mono(&args, &config, "user/repo", io); assert!(result.is_err()); assert!(result.unwrap_err().contains("not found")); diff --git a/tests/commands/mono/wraps.rs b/tests/commands/mono/wraps.rs index 9e69faa..b43e0a1 100644 --- a/tests/commands/mono/wraps.rs +++ b/tests/commands/mono/wraps.rs @@ -1,10 +1,5 @@ -use std::path::Path; - -use super::super::common::{empty_input, make_io, sink}; -use star_setup::{ - commands::{hoist_wraps, parse_project_name, parse_provide_pairs}, - ctx::IoCtx, -}; +use crate::common::with_io_dir; +use star_setup::commands::{hoist_wraps, parse_project_name, parse_provide_pairs}; use tempfile::TempDir; #[test] @@ -72,21 +67,9 @@ fn make_repo(project_name: &str) -> TempDir { tmp } -fn run_hoist_test(test_logic: F) -where - F: FnOnce(&Path, &mut IoCtx<'_>), -{ - let repos_dir = TempDir::new().unwrap(); - let mut input = empty_input(); - let mut output = sink(); - let mut io = make_io(&mut input, &mut output); - - test_logic(repos_dir.path(), &mut io); -} - #[test] fn test_hoist_wraps_empty_repos() { - run_hoist_test(|repos_dir, io| { + with_io_dir(|repos_dir, io| { let result = hoist_wraps(repos_dir, &[], io).unwrap(); assert!(result.is_empty()); }); @@ -94,7 +77,7 @@ fn test_hoist_wraps_empty_repos() { #[test] fn test_hoist_wraps_skips_repo_without_meson_build() { - run_hoist_test(|repos_dir, io| { + with_io_dir(|repos_dir, io| { let repo = TempDir::new().unwrap(); let result = hoist_wraps(repos_dir, &[repo.path().to_path_buf()], io).unwrap(); assert!(result.is_empty()); @@ -103,7 +86,7 @@ fn test_hoist_wraps_skips_repo_without_meson_build() { #[test] fn test_hoist_wraps_emits_wrap_without_provide() { - run_hoist_test(|repos_dir, io| { + with_io_dir(|repos_dir, io| { let repo = make_repo("my-lib"); let result = hoist_wraps(repos_dir, &[repo.path().to_path_buf()], io).unwrap(); @@ -119,7 +102,7 @@ fn test_hoist_wraps_emits_wrap_without_provide() { #[test] fn test_hoist_wraps_emits_wrap_with_provide() { - run_hoist_test(|repos_dir, io| { + with_io_dir(|repos_dir, io| { let repo = make_repo("my-lib"); let subprojects = repo.path().join("subprojects"); std::fs::create_dir(&subprojects).unwrap(); From 429a60cbbbe8947c31ac01afcfa9a5a2f1d9cc12 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Sun, 28 Jun 2026 19:46:52 -0400 Subject: [PATCH 39/59] refactor(tests): migrate mode tests to with_ctx/with_runner_ctx --- tests/commands/mono/mode.rs | 34 +++++----------------------------- 1 file changed, 5 insertions(+), 29 deletions(-) diff --git a/tests/commands/mono/mode.rs b/tests/commands/mono/mode.rs index b6bdcda..b2fc868 100644 --- a/tests/commands/mono/mode.rs +++ b/tests/commands/mono/mode.rs @@ -1,29 +1,5 @@ -use crate::common::{default_resolved_mono, empty_input, make_io, MockRunner}; -use star_setup::{ - commands::mono_repo_mode, - config::SetupConfig, - ctx::{DryRunRunner, RunCtx, Runner}, -}; - -fn run_mode_test(mut runner: R, test_logic: F) -> (R, Vec) -where - R: Runner, - F: FnOnce(&std::path::Path, &mut RunCtx<'_, '_>), -{ - let tmp = tempfile::TempDir::new().unwrap(); - let mut input = empty_input(); - let mut output = Vec::new(); - - { - let mut ctx = RunCtx { - io: make_io(&mut input, &mut output), - runner: &mut runner, - }; - test_logic(tmp.path(), &mut ctx); - } - - (runner, output) -} +use crate::common::{default_resolved_mono, with_ctx, with_runner_ctx, MockRunner}; +use star_setup::{commands::mono_repo_mode, config::SetupConfig, ctx::DryRunRunner}; fn make_cmake_repo(repos_path: &std::path::Path, name: &str) { let dir = repos_path.join(name); @@ -35,7 +11,7 @@ fn make_cmake_repo(repos_path: &std::path::Path, name: &str) { fn test_mono_repo_mode_clones_and_configures() { let args = default_resolved_mono(vec!["user/lib1".to_string()]); - let (_, output) = run_mode_test(MockRunner::new(), |tmp_path, ctx| { + 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(); make_cmake_repo(&repos_path, "user-lib1"); @@ -54,7 +30,7 @@ fn test_mono_repo_mode_dry_run_makes_no_fs_changes() { let mut args = default_resolved_mono(vec!["user/lib1".to_string()]); args.diagnostic.dry_run = true; - run_mode_test(DryRunRunner, |tmp_path, ctx| { + with_ctx(DryRunRunner, |tmp_path, ctx| { ctx.io.dry_run = true; mono_repo_mode(&args, &SetupConfig::new(), tmp_path, ctx).unwrap(); @@ -68,7 +44,7 @@ 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); - let (runner, _) = run_mode_test(MockRunner::new(), |tmp_path, ctx| { + let runner = with_runner_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(); make_cmake_repo(&repos_path, "user-lib1"); From 6a56b9191a98965ad524d1b4c88941f5eb572b31 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Sun, 28 Jun 2026 19:57:53 -0400 Subject: [PATCH 40/59] refactor(tests): extract `with_tmp_dir` helper in workspace resolve tests --- tests/workspace/resolve.rs | 84 ++++++++++++++++++++++---------------- 1 file changed, 48 insertions(+), 36 deletions(-) diff --git a/tests/workspace/resolve.rs b/tests/workspace/resolve.rs index 003fba6..b8605e9 100644 --- a/tests/workspace/resolve.rs +++ b/tests/workspace/resolve.rs @@ -1,66 +1,78 @@ use star_setup::workspace::resolve_workspace; -use std::fs; +use std::{fs, path::Path}; use tempfile::TempDir; +fn with_tmp_dir(f: impl FnOnce(&Path)) { + let tmp = TempDir::new().unwrap(); + f(tmp.path()); +} + #[test] fn test_resolve_workspace_errors_when_missing() { - let tmp = TempDir::new().unwrap(); - let result = resolve_workspace(Some(tmp.path()), None, None); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("Workspace not found")); + with_tmp_dir(|path| { + let result = resolve_workspace(Some(path), None, None); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Workspace not found")); + }); } #[test] fn test_resolve_workspace_errors_when_no_repos() { - let tmp = TempDir::new().unwrap(); - fs::create_dir_all(tmp.path().join("build-mono")).unwrap(); - let result = resolve_workspace(Some(tmp.path()), None, None); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("Repos directory not found")); + with_tmp_dir(|path| { + fs::create_dir_all(path.join("build-mono")).unwrap(); + let result = resolve_workspace(Some(path), None, None); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Repos directory not found")); + }); } #[test] fn test_resolve_workspace_succeeds() { - let tmp = TempDir::new().unwrap(); - fs::create_dir_all(tmp.path().join("build-mono").join("repos")).unwrap(); - let result = resolve_workspace(Some(tmp.path()), None, None); - assert!(result.is_ok()); - let ws = result.unwrap(); - assert_eq!(ws.repo_dirs.len(), 0); + with_tmp_dir(|path| { + fs::create_dir_all(path.join("build-mono").join("repos")).unwrap(); + let result = resolve_workspace(Some(path), None, None); + assert!(result.is_ok()); + let ws = result.unwrap(); + assert_eq!(ws.repo_dirs.len(), 0); + }); } #[test] fn test_resolve_workspace_finds_repos() { - let tmp = TempDir::new().unwrap(); - let repos = tmp.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(); - let ws = resolve_workspace(Some(tmp.path()), None, None).unwrap(); - assert_eq!(ws.repo_dirs.len(), 2); + with_tmp_dir(|path| { + 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(); + let ws = resolve_workspace(Some(path), None, None).unwrap(); + assert_eq!(ws.repo_dirs.len(), 2); + }); } #[test] fn test_resolve_workspace_custom_mono_dir() { - let tmp = TempDir::new().unwrap(); - fs::create_dir_all(tmp.path().join("my-workspace").join("repos")).unwrap(); - let result = resolve_workspace(Some(tmp.path()), Some("my-workspace"), None); - assert!(result.is_ok()); + with_tmp_dir(|path| { + fs::create_dir_all(path.join("my-workspace").join("repos")).unwrap(); + let result = resolve_workspace(Some(path), Some("my-workspace"), None); + assert!(result.is_ok()); + }); } #[test] fn test_resolve_workspace_custom_build_dir() { - let tmp = TempDir::new().unwrap(); - fs::create_dir_all(tmp.path().join("build-mono").join("repos")).unwrap(); - let ws = resolve_workspace(Some(tmp.path()), None, Some("out")).unwrap(); - assert!(ws.build_path.ends_with("out")); + with_tmp_dir(|path| { + fs::create_dir_all(path.join("build-mono").join("repos")).unwrap(); + let ws = resolve_workspace(Some(path), None, Some("out")).unwrap(); + assert!(ws.build_path.ends_with("out")); + }); } #[test] fn test_resolve_workspace_excludes_non_git_dirs() { - let tmp = TempDir::new().unwrap(); - let repos = tmp.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(); - let ws = resolve_workspace(Some(tmp.path()), None, None).unwrap(); - assert_eq!(ws.repo_dirs.len(), 1); + with_tmp_dir(|path| { + 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(); + let ws = resolve_workspace(Some(path), None, None).unwrap(); + assert_eq!(ws.repo_dirs.len(), 1); + }); } From 2d3c9b4a30758b4c1f4c7d9aa1729ed47515e558 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Sun, 28 Jun 2026 20:12:30 -0400 Subject: [PATCH 41/59] refactor(tests): extract shared workspace helper and deduplicate boilerplate --- tests/workspace.rs | 7 ++- tests/workspace/clean.rs | 87 ++++++++++++-------------------------- tests/workspace/helpers.rs | 11 +++++ tests/workspace/status.rs | 58 +++++++++---------------- tests/workspace/update.rs | 71 ++++++++++++------------------- 5 files changed, 89 insertions(+), 145 deletions(-) create mode 100644 tests/workspace/helpers.rs diff --git a/tests/workspace.rs b/tests/workspace.rs index 1d06a17..d571bb1 100644 --- a/tests/workspace.rs +++ b/tests/workspace.rs @@ -1,7 +1,10 @@ -#[path = "workspace/clean.rs"] -mod clean; #[path = "common/mod.rs"] mod common; +#[path = "workspace/helpers.rs"] +mod helpers; + +#[path = "workspace/clean.rs"] +mod clean; #[path = "workspace/resolve.rs"] mod resolve; #[path = "workspace/status.rs"] diff --git a/tests/workspace/clean.rs b/tests/workspace/clean.rs index 00668c0..9f2c47d 100644 --- a/tests/workspace/clean.rs +++ b/tests/workspace/clean.rs @@ -1,78 +1,43 @@ -use crate::common::{empty_input, make_io, sink, MockRunner}; -use star_setup::{ctx::RunCtx, workspace::types::Workspace}; +use crate::{ + common::{with_ctx, MockRunner}, + helpers::make_workspace, +}; use std::fs; -use tempfile::TempDir; #[test] fn test_workspace_clean_no_build_dir() { - let tmp = TempDir::new().unwrap(); - let ws = Workspace { - root: tmp.path().to_path_buf(), - repos_path: tmp.path().join("repos"), - build_path: tmp.path().join("build"), - repo_dirs: vec![], - }; - let mut input = empty_input(); - let mut output = Vec::new(); - let mut runner = MockRunner::new(); - let mut ctx = RunCtx { - io: make_io(&mut input, &mut output), - runner: &mut runner, - }; - ws.clean(&mut ctx).unwrap(); + let (_, output) = with_ctx(MockRunner::new(), |tmp, ctx| { + let ws = make_workspace(tmp, vec![]); + ws.clean(ctx).unwrap(); + }); let out = String::from_utf8(output).unwrap(); assert!(out.contains("does not exist")); } #[test] fn test_workspace_clean_removes_build_dir() { - let tmp = TempDir::new().unwrap(); - let build = tmp.path().join("build"); - fs::create_dir_all(&build).unwrap(); - fs::write(build.join("dummy.txt"), "").unwrap(); - let ws = Workspace { - root: tmp.path().to_path_buf(), - repos_path: tmp.path().join("repos"), - build_path: build.clone(), - repo_dirs: vec![], - }; - let mut input = empty_input(); - let mut output = sink(); - let mut runner = MockRunner::new(); - let mut ctx = RunCtx { - io: make_io(&mut input, &mut output), - runner: &mut runner, - }; - ws.clean(&mut ctx).unwrap(); - assert!(!build.exists()); + 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(); + ws.clean(ctx).unwrap(); + assert!(!ws.build_path.exists()); + }); } #[test] fn test_workspace_clean_dry_run() { - let tmp = TempDir::new().unwrap(); - let build = tmp.path().join("build"); - fs::create_dir_all(&build).unwrap(); - let ws = Workspace { - root: tmp.path().to_path_buf(), - repos_path: tmp.path().join("repos"), - build_path: build.clone(), - repo_dirs: vec![], - }; - let mut input = empty_input(); - let mut output = Vec::new(); - let mut runner = MockRunner::new(); - let mut ctx = RunCtx { - io: star_setup::ctx::IoCtx { - input: &mut input, - output: &mut output, - verbose: false, - timing: false, - dry_run: true, - }, - runner: &mut runner, - }; - ws.clean(&mut ctx).unwrap(); - assert!(build.exists()); + let (_, output) = with_ctx(MockRunner::new(), |tmp, ctx| { + ctx.io.dry_run = true; + + let ws = make_workspace(tmp, vec![]); + fs::create_dir_all(&ws.build_path).unwrap(); + + ws.clean(ctx).unwrap(); + + assert!(ws.build_path.exists()); + }); + let out = String::from_utf8(output).unwrap(); assert!(out.contains("Would remove directory:")); } diff --git a/tests/workspace/helpers.rs b/tests/workspace/helpers.rs new file mode 100644 index 0000000..30c6d34 --- /dev/null +++ b/tests/workspace/helpers.rs @@ -0,0 +1,11 @@ +use star_setup::workspace::Workspace; +use std::path::{Path, PathBuf}; + +pub fn make_workspace(root: &Path, repo_dirs: Vec) -> Workspace { + Workspace { + root: root.to_path_buf(), + repos_path: root.join("repos"), + build_path: root.join("build"), + repo_dirs, + } +} diff --git a/tests/workspace/status.rs b/tests/workspace/status.rs index 3475fbf..4d6fc5b 100644 --- a/tests/workspace/status.rs +++ b/tests/workspace/status.rs @@ -1,44 +1,30 @@ -use crate::common::{empty_input, make_io, MockRunner}; -use star_setup::{ctx::RunCtx, workspace::Workspace}; -use std::path::PathBuf; - -fn make_workspace(repo_dirs: Vec) -> Workspace { - Workspace { - root: PathBuf::from("build-mono"), - repos_path: PathBuf::from("build-mono/repos"), - build_path: PathBuf::from("build-mono/build"), - repo_dirs, - } -} +use crate::{ + common::{with_ctx, MockRunner}, + helpers::make_workspace, +}; #[test] fn test_status_workspace_empty() { - let ws = make_workspace(vec![]); - let mut input = empty_input(); - let mut output = Vec::new(); - let mut runner = MockRunner::new(); - let mut ctx = RunCtx { - io: make_io(&mut input, &mut output), - runner: &mut runner, - }; - ws.status(false, &mut ctx).unwrap(); + let (_, output) = with_ctx(MockRunner::new(), |tmp, ctx| { + let ws = make_workspace(tmp, vec![]); + ws.status(false, ctx).unwrap(); + }); + let out = String::from_utf8(output).unwrap(); assert!(out.contains("Workspace status:")); } #[test] fn test_status_workspace_shows_repos() { - let ws = make_workspace(vec![PathBuf::from("build-mono/repos/user-lib1")]); - let mut input = empty_input(); - let mut output = Vec::new(); let mut runner = MockRunner::new(); runner.capture_responses.push_back("main".to_string()); runner.capture_responses.push_back(String::new()); - let mut ctx = RunCtx { - io: make_io(&mut input, &mut output), - runner: &mut runner, - }; - ws.status(false, &mut ctx).unwrap(); + + let (_, output) = with_ctx(runner, |tmp, ctx| { + let ws = make_workspace(tmp, vec![tmp.join("repos/user-lib1")]); + ws.status(false, ctx).unwrap(); + }); + let out = String::from_utf8(output).unwrap(); assert!(out.contains("user-lib1")); assert!(out.contains("clean")); @@ -46,19 +32,17 @@ fn test_status_workspace_shows_repos() { #[test] fn test_status_workspace_with_fetch() { - let ws = make_workspace(vec![PathBuf::from("build-mono/repos/user-lib1")]); - let mut input = empty_input(); - let mut output = Vec::new(); let mut runner = MockRunner::new(); runner.capture_responses.push_back("main".to_string()); runner.capture_responses.push_back(String::new()); runner.capture_responses.push_back("2".to_string()); runner.capture_responses.push_back("1".to_string()); - let mut ctx = RunCtx { - io: make_io(&mut input, &mut output), - runner: &mut runner, - }; - ws.status(true, &mut ctx).unwrap(); + + let (runner, output) = with_ctx(runner, |tmp, ctx| { + let ws = make_workspace(tmp, vec![tmp.join("repos/user-lib1")]); + ws.status(true, ctx).unwrap(); + }); + let out = String::from_utf8(output).unwrap(); assert!(out.contains("↑2 ↓1")); assert!(runner diff --git a/tests/workspace/update.rs b/tests/workspace/update.rs index a47f08c..94302aa 100644 --- a/tests/workspace/update.rs +++ b/tests/workspace/update.rs @@ -1,44 +1,27 @@ -use crate::common::{empty_input, make_io, sink, MockRunner}; -use star_setup::{ctx::RunCtx, workspace::types::Workspace}; -use std::path::PathBuf; - -fn make_workspace(repo_dirs: Vec) -> Workspace { - Workspace { - root: PathBuf::from("build-mono"), - repos_path: PathBuf::from("build-mono/repos"), - build_path: PathBuf::from("build-mono/build"), - repo_dirs, - } -} +use crate::{ + common::{with_ctx, MockRunner}, + helpers::make_workspace, +}; #[test] fn test_update_workspace_empty() { - let ws = make_workspace(vec![]); - let mut input = empty_input(); - let mut output = sink(); - let mut runner = MockRunner::new(); - let mut ctx = RunCtx { - io: make_io(&mut input, &mut output), - runner: &mut runner, - }; - ws.update(&mut ctx).unwrap(); + let (runner, _) = with_ctx(MockRunner::new(), |tmp, ctx| { + let ws = make_workspace(tmp, vec![]); + ws.update(ctx).unwrap(); + }); assert!(runner.calls.is_empty()); } #[test] fn test_update_workspace_pulls_each_repo() { - let ws = make_workspace(vec![ - PathBuf::from("build-mono/repos/user-lib1"), - PathBuf::from("build-mono/repos/user-lib2"), - ]); - let mut input = empty_input(); - let mut output = sink(); - let mut runner = MockRunner::new(); - let mut ctx = RunCtx { - io: make_io(&mut input, &mut output), - runner: &mut runner, - }; - ws.update(&mut ctx).unwrap(); + let (runner, _) = with_ctx(MockRunner::new(), |tmp, ctx| { + let ws = make_workspace( + tmp, + vec![tmp.join("repos/user-lib1"), tmp.join("repos/user-lib2")], + ); + ws.update(ctx).unwrap(); + }); + assert_eq!(runner.calls.len(), 2); assert!(runner .calls @@ -48,20 +31,18 @@ fn test_update_workspace_pulls_each_repo() { #[test] fn test_update_workspace_continues_on_failure() { - let ws = make_workspace(vec![ - PathBuf::from("build-mono/repos/user-lib1"), - PathBuf::from("build-mono/repos/user-lib2"), - ]); - let mut input = empty_input(); - let mut output = Vec::new(); let mut runner = MockRunner::new(); runner.fail_on = Some("pull".to_string()); - let mut ctx = RunCtx { - io: make_io(&mut input, &mut output), - runner: &mut runner, - }; - let result = ws.update(&mut ctx); - assert!(result.is_err()); + + let (runner, output) = with_ctx(runner, |tmp, ctx| { + let ws = make_workspace( + tmp, + vec![tmp.join("repos/user-lib1"), tmp.join("repos/user-lib2")], + ); + let result = ws.update(ctx); + assert!(result.is_err()); + }); + assert_eq!(runner.calls.len(), 2); let out = String::from_utf8(output).unwrap(); assert!(out.contains("Failed to update")); From 82c569dcecb44c594b21cfaeb7758cacbb16d3e2 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Sun, 28 Jun 2026 20:14:21 -0400 Subject: [PATCH 42/59] refactor(tests): migrate prerequisite tests to with_ctx --- tests/utils/prerequisites.rs | 27 +++++++++++---------------- 1 file changed, 11 insertions(+), 16 deletions(-) diff --git a/tests/utils/prerequisites.rs b/tests/utils/prerequisites.rs index 1b9f3b4..9aea47e 100644 --- a/tests/utils/prerequisites.rs +++ b/tests/utils/prerequisites.rs @@ -1,26 +1,21 @@ -use super::common::make_io; -use star_setup::{ctx::IoCtx, utils::check_prerequisites}; +use crate::common::{with_ctx, MockRunner}; +use star_setup::utils::check_prerequisites; #[test] fn test_check_prerequisites_succeeds_with_tools_present() { - let mut input = b"".as_ref(); - let mut output = Vec::new(); - let mut io = make_io(&mut input, &mut output); - assert!(check_prerequisites(&mut io).is_ok()); + with_ctx(MockRunner::new(), |_, ctx| { + assert!(check_prerequisites(&mut ctx.io).is_ok()); + }); } #[test] fn test_check_prerequisites_verbose_outputs_found() { - let mut input = b"".as_ref(); - let mut output = Vec::new(); - let mut io = IoCtx { - input: &mut input, - output: &mut output, - verbose: true, - timing: false, - dry_run: false, - }; - check_prerequisites(&mut io).unwrap(); + let (_, output) = with_ctx(MockRunner::new(), |_, ctx| { + ctx.io.verbose = true; + + check_prerequisites(&mut ctx.io).unwrap(); + }); + let out = String::from_utf8(output).unwrap(); assert!(out.contains("Found git")); assert!(out.contains("Found cmake")); From 3894a105af8896a9668e1271f0d4fffa8413754d Mon Sep 17 00:00:00 2001 From: Masonlet Date: Sun, 28 Jun 2026 20:43:55 -0400 Subject: [PATCH 43/59] refactor(tests): use shared input io helpers in interactive and prompt tests --- tests/common/harness.rs | 17 ++++++++++ tests/common/mod.rs | 4 ++- tests/interactive.rs | 73 ++++++++++++++++++++++++++--------------- tests/prompts.rs | 31 +++++------------ 4 files changed, 76 insertions(+), 49 deletions(-) diff --git a/tests/common/harness.rs b/tests/common/harness.rs index f71cb72..4d93a2e 100644 --- a/tests/common/harness.rs +++ b/tests/common/harness.rs @@ -20,6 +20,23 @@ pub fn with_io_dir(f: impl FnOnce(&Path, &mut IoCtx<'_>)) { f(tmp.path(), &mut io); } +#[allow(dead_code)] +pub fn with_io_input(input: &[u8], f: impl FnOnce(&mut IoCtx<'_>) -> T) -> T { + let mut input_slice = input; + let mut output = sink(); + let mut io = make_io(&mut input_slice, &mut output); + f(&mut io) +} + +#[allow(dead_code)] +pub fn with_io_input_output(input: &[u8], f: impl FnOnce(&mut IoCtx<'_>) -> T) -> (T, String) { + let mut input_slice = input; + let mut output = Vec::new(); + let mut io = make_io(&mut input_slice, &mut output); + let result = f(&mut io); + (result, String::from_utf8(output).unwrap_or_default()) +} + #[allow(dead_code)] pub fn with_ctx( mut runner: R, diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 1751577..913de0d 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -12,4 +12,6 @@ pub use args::{ }; pub mod harness; #[allow(unused_imports)] -pub use harness::{with_ctx, with_io, with_io_dir, with_runner_ctx}; +pub use harness::{ + with_ctx, with_io, with_io_dir, with_io_input, with_io_input_output, with_runner_ctx, +}; diff --git a/tests/interactive.rs b/tests/interactive.rs index 8525a21..6178140 100644 --- a/tests/interactive.rs +++ b/tests/interactive.rs @@ -1,20 +1,6 @@ use star_setup::interactive::interactive_mode; mod common; -use common::{default_resolved, default_resolved_interactive, make_io}; - -fn run_interactive_test( - input_bytes: &[u8], - mut args: star_setup::cli::ResolvedArgs, -) -> (star_setup::cli::ResolvedArgs, String) { - let mut input_slice = input_bytes; - let mut output = Vec::new(); - - let mut io = make_io(&mut input_slice, &mut output); - interactive_mode(&mut args, &mut io).unwrap(); - - let out_str = String::from_utf8(output).unwrap_or_default(); - (args, out_str) -} +use common::{default_resolved, default_resolved_interactive, make_io, with_io_input_output}; fn input_with_suffix(prefix: &[u8]) -> Vec { let mut v = prefix.to_vec(); @@ -25,7 +11,11 @@ fn input_with_suffix(prefix: &[u8]) -> Vec { #[test] fn test_interactive_mode_single_repo() { let input = input_with_suffix(b"user/repo\nn\nn\nn\nn\n1"); - let (args, _) = run_interactive_test(&input, default_resolved()); + let (args, _) = with_io_input_output(&input, |io| { + let mut args = default_resolved(); + interactive_mode(&mut args, io).unwrap(); + args + }); assert_eq!(args.repo, Some("user/repo".to_string())); assert!(!args.connection.ssh); @@ -35,7 +25,11 @@ fn test_interactive_mode_single_repo() { #[test] fn test_interactive_mode_ssh_enabled() { let input = input_with_suffix(b"user/repo\ny\nn\nn\nn\n1"); - let (args, _) = run_interactive_test(&input, default_resolved_interactive()); + let (args, _) = with_io_input_output(&input, |io| { + let mut args = default_resolved_interactive(); + interactive_mode(&mut args, io).unwrap(); + args + }); assert!(args.connection.ssh); } @@ -43,7 +37,11 @@ fn test_interactive_mode_ssh_enabled() { #[test] fn test_interactive_mode_mono_repo_with_profile() { let input = input_with_suffix(b"user/repo\nn\nn\nn\nn\n2\n1\nmyprofile"); - let (args, _) = run_interactive_test(&input, default_resolved()); + let (args, _) = with_io_input_output(&input, |io| { + let mut args = default_resolved(); + interactive_mode(&mut args, io).unwrap(); + args + }); assert!(args.mono.mono_repo); assert_eq!(args.mono.profile, Some("myprofile".to_string())); @@ -52,7 +50,11 @@ fn test_interactive_mode_mono_repo_with_profile() { #[test] fn test_interactive_mode_mono_repo_with_manual_repos() { let input = input_with_suffix(b"user/repo\nn\nn\nn\nn\n2\n2\nuser/lib1 user/lib2"); - let (args, _) = run_interactive_test(&input, default_resolved()); + let (args, _) = with_io_input_output(&input, |io| { + let mut args = default_resolved(); + interactive_mode(&mut args, io).unwrap(); + args + }); assert!(args.mono.mono_repo); assert_eq!( @@ -64,17 +66,24 @@ fn test_interactive_mode_mono_repo_with_manual_repos() { #[test] fn test_interactive_mode_skips_repo_prompt_when_set() { let input = input_with_suffix(b"n\nn\nn\nn\n1"); - let mut initial_args = default_resolved(); - initial_args.repo = Some("already/set".to_string()); + let (args, _) = with_io_input_output(&input, |io| { + let mut args = default_resolved(); + args.repo = Some("already/set".to_string()); + interactive_mode(&mut args, io).unwrap(); + args + }); - let (args, _) = run_interactive_test(&input, initial_args); assert_eq!(args.repo, Some("already/set".to_string())); } #[test] fn test_interactive_mode_output_contains_header() { let input = input_with_suffix(b"user/repo\nn\nn\nn\nn\n1"); - let (_, out_str) = run_interactive_test(&input, default_resolved()); + let (_, out_str) = with_io_input_output(&input, |io| { + let mut args = default_resolved(); + interactive_mode(&mut args, io).unwrap(); + args + }); assert!(out_str.contains("Star Setup Interactive Mode")); assert!(out_str.contains("Interactive mode complete")); @@ -83,7 +92,11 @@ fn test_interactive_mode_output_contains_header() { #[test] fn test_interactive_mode_yes_word_not_accepted_for_ssh() { let input = input_with_suffix(b"user/repo\nyes\nn\nn\nn\n1"); - let (args, _) = run_interactive_test(&input, default_resolved()); + let (args, _) = with_io_input_output(&input, |io| { + let mut args = default_resolved(); + interactive_mode(&mut args, io).unwrap(); + args + }); assert!(!args.connection.ssh); } @@ -91,7 +104,11 @@ fn test_interactive_mode_yes_word_not_accepted_for_ssh() { #[test] fn test_interactive_mode_invalid_mode_then_valid() { let input = input_with_suffix(b"user/repo\nn\nn\nn\nn\nfoo\n1"); - let (args, _) = run_interactive_test(&input, default_resolved()); + let (args, _) = with_io_input_output(&input, |io| { + let mut args = default_resolved(); + interactive_mode(&mut args, io).unwrap(); + args + }); assert!(!args.mono.mono_repo); } @@ -99,7 +116,11 @@ fn test_interactive_mode_invalid_mode_then_valid() { #[test] fn test_interactive_mode_invalid_mono_choice_then_valid() { let input = input_with_suffix(b"user/repo\nn\nn\nn\nn\n2\nfoo\n1\nmyprofile"); - let (args, _) = run_interactive_test(&input, default_resolved()); + let (args, _) = with_io_input_output(&input, |io| { + let mut args = default_resolved(); + interactive_mode(&mut args, io).unwrap(); + args + }); assert!(args.mono.mono_repo); assert_eq!(args.mono.profile, Some("myprofile".to_string())); diff --git a/tests/prompts.rs b/tests/prompts.rs index d99dab5..1255811 100644 --- a/tests/prompts.rs +++ b/tests/prompts.rs @@ -1,38 +1,25 @@ -use star_setup::{ - ctx::IoCtx, - prompts::{ask, ask_default, ask_yesno, confirm}, -}; +use star_setup::prompts::{ask, ask_default, ask_yesno, confirm}; mod common; -use common::make_io; - -fn run_prompt_test(input: &[u8], test_logic: F) -> T -where - F: FnOnce(&mut IoCtx<'_>) -> T, -{ - let mut input_slice = input; - let mut output = Vec::new(); - let mut io = make_io(&mut input_slice, &mut output); - test_logic(&mut io) -} +use common::with_io_input; #[test] fn test_ask_errors_on_eof() { - assert!(run_prompt_test(b"", |io| ask("prompt", io)).is_err()); + assert!(with_io_input(b"", |io| ask("prompt", io)).is_err()); } #[test] fn test_ask_default_errors_on_eof() { - assert!(run_prompt_test(b"", |io| ask_default("prompt", "default", io)).is_err()); + assert!(with_io_input(b"", |io| ask_default("prompt", "default", io)).is_err()); } #[test] fn test_ask_yesno_errors_on_eof() { - assert!(run_prompt_test(b"", |io| ask_yesno("prompt", true, io)).is_err()); + 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 = run_prompt_test(b"custom\n", |io| ask_default("prompt", "default", io)); + let result = with_io_input(b"custom\n", |io| ask_default("prompt", "default", io)); assert_eq!(result.unwrap(), "custom"); } @@ -47,19 +34,19 @@ fn test_confirm_input_cases() { ]; for (input, expected, name) in cases { - let result = run_prompt_test(input, |io| confirm("prompt", false, io)); + 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!(run_prompt_test(b"", |io| confirm("prompt", true, io)).unwrap()); + assert!(with_io_input(b"", |io| confirm("prompt", true, io)).unwrap()); } #[test] fn test_confirm_errors_on_eof() { - let result = run_prompt_test(b"", |io| confirm("prompt", false, io)); + 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 79a558cd03b5e5e05da7ca89fa6d75ceb6cc6610 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Sun, 28 Jun 2026 20:53:43 -0400 Subject: [PATCH 44/59] refactor(tests): replace manual build test setup w/ with_runner_ctx --- tests/commands/build.rs | 118 ++++++++-------------------------------- 1 file changed, 22 insertions(+), 96 deletions(-) diff --git a/tests/commands/build.rs b/tests/commands/build.rs index 9c7b591..89fd791 100644 --- a/tests/commands/build.rs +++ b/tests/commands/build.rs @@ -1,78 +1,44 @@ -use super::common::{default_resolved_with_no_build, empty_input, make_io, sink, MockRunner}; +use super::common::{default_resolved_with_no_build, with_runner_ctx, MockRunner}; use star_setup::{ cli::BuildSystem, commands::{build_project, cmake_build, meson_build}, - ctx::RunCtx, }; -use tempfile::TempDir; #[test] fn test_cmake_build_configure_only() { - let tmp = TempDir::new().unwrap(); let args = default_resolved_with_no_build(true); - let mut input = empty_input(); - let mut output = sink(); - let mut runner = MockRunner::new(); - let mut ctx = RunCtx { - io: make_io(&mut input, &mut output), - runner: &mut runner, - }; - - cmake_build(&args, tmp.path(), false, &mut ctx).unwrap(); - + let runner = with_runner_ctx(MockRunner::new(), |tmp_path, ctx| { + cmake_build(&args, tmp_path, false, ctx).unwrap(); + }); assert_eq!(runner.calls.len(), 1); assert!(runner.calls[0].0.contains(&"cmake".to_string())); } #[test] fn test_cmake_build_with_build_step() { - let tmp = TempDir::new().unwrap(); let args = default_resolved_with_no_build(false); - let mut input = empty_input(); - let mut output = sink(); - let mut runner = MockRunner::new(); - let mut ctx = RunCtx { - io: make_io(&mut input, &mut output), - runner: &mut runner, - }; - - cmake_build(&args, tmp.path(), false, &mut ctx).unwrap(); - + let runner = with_runner_ctx(MockRunner::new(), |tmp_path, ctx| { + cmake_build(&args, tmp_path, false, ctx).unwrap(); + }); assert_eq!(runner.calls.len(), 2); assert!(runner.calls[1].0.contains(&"--build".to_string())); } #[test] fn test_cmake_build_mono_flag() { - let tmp = TempDir::new().unwrap(); let args = default_resolved_with_no_build(true); - let mut input = empty_input(); - let mut output = sink(); - let mut runner = MockRunner::new(); - let mut ctx = RunCtx { - io: make_io(&mut input, &mut output), - runner: &mut runner, - }; - - cmake_build(&args, tmp.path(), true, &mut ctx).unwrap(); - + let runner = with_runner_ctx(MockRunner::new(), |tmp_path, ctx| { + cmake_build(&args, tmp_path, true, ctx).unwrap(); + }); assert!(runner.calls[0].0.contains(&"-DBUILD_LOCAL=ON".to_string())); } #[test] fn test_meson_build_configure_only() { - let tmp = TempDir::new().unwrap(); let args = default_resolved_with_no_build(true); - let mut input = empty_input(); - let mut output = sink(); - let mut runner = MockRunner::new(); - let mut ctx = RunCtx { - io: make_io(&mut input, &mut output), - runner: &mut runner, - }; - - meson_build(&args, tmp.path(), tmp.path(), &mut ctx).unwrap(); - + let runner = with_runner_ctx(MockRunner::new(), |tmp_path, ctx| { + meson_build(&args, tmp_path, tmp_path, ctx).unwrap(); + }); assert_eq!(runner.calls.len(), 1); assert!(runner.calls[0].0.contains(&"meson".to_string())); assert!(runner.calls[0].0.contains(&"setup".to_string())); @@ -80,68 +46,28 @@ fn test_meson_build_configure_only() { #[test] fn test_meson_build_with_build_step() { - let tmp = TempDir::new().unwrap(); let args = default_resolved_with_no_build(false); - let mut input = empty_input(); - let mut output = sink(); - let mut runner = MockRunner::new(); - let mut ctx = RunCtx { - io: make_io(&mut input, &mut output), - runner: &mut runner, - }; - - meson_build(&args, tmp.path(), tmp.path(), &mut ctx).unwrap(); - + let runner = with_runner_ctx(MockRunner::new(), |tmp_path, ctx| { + meson_build(&args, tmp_path, tmp_path, ctx).unwrap(); + }); assert_eq!(runner.calls.len(), 2); assert!(runner.calls[1].0.contains(&"compile".to_string())); } #[test] fn test_build_project_dispatches_cmake() { - let tmp = TempDir::new().unwrap(); let args = default_resolved_with_no_build(true); - let mut input = empty_input(); - let mut output = sink(); - let mut runner = MockRunner::new(); - let mut ctx = RunCtx { - io: make_io(&mut input, &mut output), - runner: &mut runner, - }; - - build_project( - &args, - tmp.path(), - tmp.path(), - BuildSystem::Cmake, - false, - &mut ctx, - ) - .unwrap(); - + let runner = with_runner_ctx(MockRunner::new(), |tmp_path, ctx| { + build_project(&args, tmp_path, tmp_path, BuildSystem::Cmake, false, ctx).unwrap(); + }); assert!(runner.calls[0].0.contains(&"cmake".to_string())); } #[test] fn test_build_project_dispatches_meson() { - let tmp = TempDir::new().unwrap(); let args = default_resolved_with_no_build(true); - let mut input = empty_input(); - let mut output = sink(); - let mut runner = MockRunner::new(); - let mut ctx = RunCtx { - io: make_io(&mut input, &mut output), - runner: &mut runner, - }; - - build_project( - &args, - tmp.path(), - tmp.path(), - BuildSystem::Meson, - false, - &mut ctx, - ) - .unwrap(); - + let runner = with_runner_ctx(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())); } From 9370b048dff4c0fd0395e7a67440d18badc8a11d Mon Sep 17 00:00:00 2001 From: Masonlet Date: Sun, 28 Jun 2026 20:56:13 -0400 Subject: [PATCH 45/59] refactor(tests): use shared io helpers in mono display tests --- tests/commands/display.rs | 83 +++++++++++++++++---------------------- 1 file changed, 37 insertions(+), 46 deletions(-) diff --git a/tests/commands/display.rs b/tests/commands/display.rs index 8c02962..6712288 100644 --- a/tests/commands/display.rs +++ b/tests/commands/display.rs @@ -1,65 +1,56 @@ -use super::common::{empty_input, make_io, sink}; +use crate::common::{with_io_dir, with_io_input_output}; use star_setup::commands::mono::display::{print_setup_complete, resolve_setup_paths}; use std::collections::HashMap; -use tempfile::TempDir; #[test] fn test_print_setup_complete_no_map() { - let tmp = TempDir::new().unwrap(); - let mut input = empty_input(); - let mut output = sink(); - let mut io = make_io(&mut input, &mut output); - let paths = resolve_setup_paths( - None::<&HashMap>, - tmp.path(), - &tmp.path().join("build"), - "user/repo", - ); - print_setup_complete(&paths, std::time::Instant::now(), &mut io); - let out = String::from_utf8(output).unwrap(); + let ((), out) = with_io_input_output(b"", |io| { + with_io_dir(|tmp_path, _| { + let paths = resolve_setup_paths( + None::<&HashMap>, + tmp_path, + &tmp_path.join("build"), + "user/repo", + ); + print_setup_complete(&paths, std::time::Instant::now(), io); + }); + }); + assert!(out.contains("Setup complete")); assert!(out.contains("Build output in:")); } #[test] fn test_print_setup_complete_with_map() { - let tmp = TempDir::new().unwrap(); - let mut input = empty_input(); - let mut output = sink(); - let mut io = make_io(&mut input, &mut output); - let mut map = HashMap::new(); - map.insert("my_lib".to_string(), "user-repo".to_string()); - let paths = resolve_setup_paths( - Some(&map), - tmp.path(), - &tmp.path().join("build"), - "user/repo", - ); - print_setup_complete(&paths, std::time::Instant::now(), &mut io); - let out = String::from_utf8(output).unwrap(); + let ((), out) = with_io_input_output(b"", |io| { + with_io_dir(|tmp_path, _| { + let mut map = HashMap::new(); + map.insert("my_lib".to_string(), "user-repo".to_string()); + + let paths = resolve_setup_paths(Some(&map), tmp_path, &tmp_path.join("build"), "user/repo"); + print_setup_complete(&paths, std::time::Instant::now(), io); + }); + }); + assert!(out.contains("Setup complete")); assert!(out.contains("Executable:")); } #[test] fn test_print_setup_complete_timing() { - let tmp = TempDir::new().unwrap(); - let mut input = empty_input(); - let mut output = sink(); - let mut io = star_setup::ctx::IoCtx { - input: &mut input, - output: &mut output, - verbose: false, - timing: true, - dry_run: false, - }; - let paths = resolve_setup_paths( - None::<&HashMap>, - tmp.path(), - &tmp.path().join("build"), - "user/repo", - ); - print_setup_complete(&paths, std::time::Instant::now(), &mut io); - let out = String::from_utf8(output).unwrap(); + let ((), out) = with_io_input_output(b"", |io| { + io.timing = true; + + with_io_dir(|tmp_path, _| { + let paths = resolve_setup_paths( + None::<&HashMap>, + tmp_path, + &tmp_path.join("build"), + "user/repo", + ); + print_setup_complete(&paths, std::time::Instant::now(), io); + }); + }); + assert!(out.contains("[timing] Total:")); } From 0e070ed079f9fe3682e74a1bd93da17145f4f49f Mon Sep 17 00:00:00 2001 From: Masonlet Date: Sun, 28 Jun 2026 20:58:24 -0400 Subject: [PATCH 46/59] refactor(tests): use shared io helper in mode header test --- tests/commands/header.rs | 33 ++++++++++++++++----------------- 1 file changed, 16 insertions(+), 17 deletions(-) diff --git a/tests/commands/header.rs b/tests/commands/header.rs index d2809a1..8250845 100644 --- a/tests/commands/header.rs +++ b/tests/commands/header.rs @@ -1,23 +1,22 @@ -use super::common::{empty_input, make_io, sink}; +use super::common::with_io_input_output; use star_setup::commands::{print_mode_header, ModeHeader}; #[test] fn test_print_mode_header_repo_name_without_test_repo() { - let mut input = empty_input(); - let mut output = sink(); - let mut io = make_io(&mut input, &mut output); - print_mode_header( - &ModeHeader { - mode: "Single Repository Mode", - test_repo: None, - repo_name: Some("myrepo"), - use_ssh: false, - mono_dir: None, - profile: None, - lib_count: None, - }, - &mut io, - ); - let out = String::from_utf8(output).unwrap(); + let ((), out) = with_io_input_output(b"", |io| { + print_mode_header( + &ModeHeader { + mode: "Single Repository Mode", + test_repo: None, + repo_name: Some("myrepo"), + use_ssh: false, + mono_dir: None, + profile: None, + lib_count: None, + }, + io, + ); + }); + assert!(out.contains("Repository: myrepo")); } From 2ad293673ba12f35bbf23e8a446b3e793b4a733e Mon Sep 17 00:00:00 2001 From: Masonlet Date: Sun, 28 Jun 2026 21:43:44 -0400 Subject: [PATCH 47/59] refactor(tests): add shared IO output helper and streamline test harnesses --- tests/cli.rs | 5 +++-- tests/commands.rs | 5 +++-- tests/common/harness.rs | 9 +++++++++ tests/common/mod.rs | 3 ++- tests/config.rs | 1 + tests/ctx.rs | 35 ++++++++++++----------------------- tests/profile.rs | 1 + tests/workspace.rs | 1 + 8 files changed, 32 insertions(+), 28 deletions(-) diff --git a/tests/cli.rs b/tests/cli.rs index e7b2e01..6003801 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -1,6 +1,7 @@ -#[path = "cli/build.rs"] -mod build; #[path = "common/mod.rs"] mod common; + +#[path = "cli/build.rs"] +mod build; #[path = "cli/resolve.rs"] mod resolve; diff --git a/tests/commands.rs b/tests/commands.rs index 23d9dbd..9aa7a0e 100644 --- a/tests/commands.rs +++ b/tests/commands.rs @@ -1,7 +1,8 @@ -#[path = "commands/build.rs"] -mod build; #[path = "common/mod.rs"] mod common; + +#[path = "commands/build.rs"] +mod build; #[path = "commands/display.rs"] mod display; #[path = "commands/header.rs"] diff --git a/tests/common/harness.rs b/tests/common/harness.rs index 4d93a2e..3391031 100644 --- a/tests/common/harness.rs +++ b/tests/common/harness.rs @@ -28,6 +28,15 @@ pub fn with_io_input(input: &[u8], f: impl FnOnce(&mut IoCtx<'_>) -> T) -> T f(&mut io) } +#[allow(dead_code)] +pub fn with_io_output(f: impl FnOnce(&mut IoCtx<'_>) -> T) -> (T, String) { + let mut input = empty_input(); + let mut output = Vec::new(); + let mut io = make_io(&mut input, &mut output); + let result = f(&mut io); + (result, String::from_utf8(output).unwrap_or_default()) +} + #[allow(dead_code)] pub fn with_io_input_output(input: &[u8], f: impl FnOnce(&mut IoCtx<'_>) -> T) -> (T, String) { let mut input_slice = input; diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 913de0d..667b994 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -13,5 +13,6 @@ pub use args::{ pub mod harness; #[allow(unused_imports)] pub use harness::{ - with_ctx, with_io, with_io_dir, with_io_input, with_io_input_output, with_runner_ctx, + with_ctx, with_io, with_io_dir, with_io_input, with_io_input_output, with_io_output, + with_runner_ctx, }; diff --git a/tests/config.rs b/tests/config.rs index b0d5811..5d1498e 100644 --- a/tests/config.rs +++ b/tests/config.rs @@ -1,5 +1,6 @@ #[path = "common/mod.rs"] mod common; + #[path = "config/crud.rs"] mod crud; #[path = "config/display.rs"] diff --git a/tests/ctx.rs b/tests/ctx.rs index 47601e1..dc23135 100644 --- a/tests/ctx.rs +++ b/tests/ctx.rs @@ -1,42 +1,31 @@ -use star_setup::ctx::{DryRunRunner, IoCtx, ProcessRunner, Runner}; +use star_setup::ctx::{DryRunRunner, ProcessRunner, Runner}; use std::path::Path; mod common; -use common::make_io; - -fn run_runner_test(dry_run: bool, mut runner: R, test_logic: F) -> String -where - R: Runner, - F: FnOnce(&mut R, &mut IoCtx<'_>), -{ - let mut input = b"".as_ref(); - let mut output = Vec::new(); - - let mut io = make_io(&mut input, &mut output); - io.dry_run = dry_run; - - test_logic(&mut runner, &mut io); - String::from_utf8(output).unwrap_or_default() -} +use common::{with_io, with_io_output}; #[test] fn test_process_runner_runs_command() { - run_runner_test(false, ProcessRunner, |runner, io| { - assert!(runner.run(&["git", "--version"], None, io).is_ok()); + with_io(|io| { + assert!(ProcessRunner.run(&["git", "--version"], None, io).is_ok()); }); } #[test] fn test_dry_run_runner_prints_command() { - let output = run_runner_test(true, DryRunRunner, |runner, io| { - runner.run(&["git", "clone", "foo"], None, io).unwrap(); + let (_, output) = with_io_output(|io| { + io.dry_run = true; + DryRunRunner + .run(&["git", "clone", "foo"], None, io) + .unwrap(); }); assert_eq!(output, "Would run: git clone foo\n"); } #[test] fn test_dry_run_runner_prints_cwd() { - let output = run_runner_test(true, DryRunRunner, |runner, io| { - runner + let (_, output) = with_io_output(|io| { + io.dry_run = true; + DryRunRunner .run(&["cmake", ".."], Some(Path::new("/tmp/build")), io) .unwrap(); }); diff --git a/tests/profile.rs b/tests/profile.rs index ba1b3ee..e08ad54 100644 --- a/tests/profile.rs +++ b/tests/profile.rs @@ -1,5 +1,6 @@ #[path = "common/mod.rs"] mod common; + #[path = "profile/crud.rs"] mod crud; #[path = "profile/display.rs"] diff --git a/tests/workspace.rs b/tests/workspace.rs index d571bb1..4117c50 100644 --- a/tests/workspace.rs +++ b/tests/workspace.rs @@ -1,5 +1,6 @@ #[path = "common/mod.rs"] mod common; + #[path = "workspace/helpers.rs"] mod helpers; From 60da53ae0630f213611f90e2006716c78d3b9829 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Sun, 28 Jun 2026 21:57:16 -0400 Subject: [PATCH 48/59] refactor(tests): streamline mono resolve mono meson test --- tests/commands/mono/resolve.rs | 58 +++++++++++++++------------------- tests/ctx.rs | 4 +-- 2 files changed, 28 insertions(+), 34 deletions(-) diff --git a/tests/commands/mono/resolve.rs b/tests/commands/mono/resolve.rs index 812ef81..e129fd9 100644 --- a/tests/commands/mono/resolve.rs +++ b/tests/commands/mono/resolve.rs @@ -1,8 +1,7 @@ -use crate::common::{default_resolved, empty_input, make_io, sink, with_io, MockRunner}; +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}, config::SetupConfig, - ctx::RunCtx, }; // resolve_test_repo tests @@ -119,35 +118,30 @@ fn test_resolve_repos_for_mono_profile_not_found_errors() { #[test] fn test_generate_mono_config_meson() { - let tmp = tempfile::TempDir::new().unwrap(); - let repos_path = tmp.path().join("repos"); - std::fs::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(); - - let mut input = empty_input(); - let mut output = sink(); - let mut runner = MockRunner::new(); - let mut ctx = RunCtx { - io: make_io(&mut input, &mut output), - runner: &mut runner, - }; - - let result = generate_mono_config( - star_setup::cli::BuildSystem::Meson, - tmp.path(), - &repos_path, - &[repo_dir], - &["user/lib1".to_string()], - &mut ctx, - ); + with_ctx(MockRunner::new(), |tmp_path, ctx| { + let repos_path = tmp_path.join("repos"); + std::fs::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(); + + let result = generate_mono_config( + star_setup::cli::BuildSystem::Meson, + tmp_path, + &repos_path, + std::slice::from_ref(&repo_dir), + &["user/lib1".to_string()], + ctx, + ); + + assert!(result.is_ok()); + assert!(result.unwrap().is_some()); + + let meson_build = tmp_path.join("meson.build"); + assert!(meson_build.exists()); - assert!(result.is_ok()); - assert!(result.unwrap().is_some()); - let meson_build = tmp.path().join("meson.build"); - assert!(meson_build.exists()); - let content = std::fs::read_to_string(&meson_build).unwrap(); - assert!(content.contains("user_lib1") || content.contains("user-lib1")); + let content = std::fs::read_to_string(&meson_build).unwrap(); + assert!(content.contains("user_lib1") || content.contains("user-lib1")); + }); } diff --git a/tests/ctx.rs b/tests/ctx.rs index dc23135..cdce039 100644 --- a/tests/ctx.rs +++ b/tests/ctx.rs @@ -12,7 +12,7 @@ fn test_process_runner_runs_command() { #[test] fn test_dry_run_runner_prints_command() { - let (_, output) = with_io_output(|io| { + let ((), output) = with_io_output(|io| { io.dry_run = true; DryRunRunner .run(&["git", "clone", "foo"], None, io) @@ -23,7 +23,7 @@ fn test_dry_run_runner_prints_command() { #[test] fn test_dry_run_runner_prints_cwd() { - let (_, output) = with_io_output(|io| { + let ((), output) = with_io_output(|io| { io.dry_run = true; DryRunRunner .run(&["cmake", ".."], Some(Path::new("/tmp/build")), io) From 66ef4d67e8ea29a2750a940951dc81b5c04b0b6d Mon Sep 17 00:00:00 2001 From: Masonlet Date: Sun, 28 Jun 2026 22:02:21 -0400 Subject: [PATCH 49/59] chore: whitespace --- tests/utils.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/utils.rs b/tests/utils.rs index 724e05b..14bc18f 100644 --- a/tests/utils.rs +++ b/tests/utils.rs @@ -1,5 +1,6 @@ #[path = "common/mod.rs"] mod common; + #[path = "utils/prerequisites.rs"] mod prerequisites; #[path = "utils/process.rs"] From 034c0269ef825eaef69a8d0fbbb645812ada7e53 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Sun, 28 Jun 2026 22:10:16 -0400 Subject: [PATCH 50/59] refactor(tests): streamline process runner tests with io harness --- tests/utils/process.rs | 44 +++++++++++++++--------------------------- 1 file changed, 16 insertions(+), 28 deletions(-) diff --git a/tests/utils/process.rs b/tests/utils/process.rs index ebb2173..9f5d0f8 100644 --- a/tests/utils/process.rs +++ b/tests/utils/process.rs @@ -1,61 +1,49 @@ -use super::common::sink; +use crate::common::with_io_output; use star_setup::utils::process::run_command; #[test] fn test_run_command_errors_on_empty() { - let mut output = sink(); - assert!(run_command(&[], None, false, &mut output).is_err()); + let (result, _) = with_io_output(|io| run_command(&[], None, false, io.output)); + assert!(result.is_err()); } #[test] fn test_run_command_verbose_outputs_command() { - let mut output = Vec::new(); - star_setup::utils::run_command(&["git", "--version"], None, true, &mut output).unwrap(); - let out = String::from_utf8(output).unwrap(); + let ((), out) = with_io_output(|io| { + run_command(&["git", "--version"], None, true, io.output).unwrap(); + }); assert!(out.contains("Running: git --version")); } #[test] fn test_run_command_verbose_outputs_cwd() { - let tmp = tempfile::TempDir::new().unwrap(); - let mut output = Vec::new(); - star_setup::utils::run_command(&["git", "--version"], Some(tmp.path()), true, &mut output) - .unwrap(); - let out = String::from_utf8(output).unwrap(); + let ((), out) = with_io_output(|io| { + let tmp = tempfile::TempDir::new().unwrap(); + run_command(&["git", "--version"], Some(tmp.path()), true, io.output).unwrap(); + }); assert!(out.contains("in directory:")); } #[test] fn test_run_command_fails_with_stderr() { - let mut output = Vec::new(); - let result = star_setup::utils::run_command( - &["git", "clone", "not-a-real-repo"], - None, - false, - &mut output, - ); + let (result, _) = + with_io_output(|io| run_command(&["git", "clone", "not-a-real-repo"], None, false, io.output)); assert!(result.is_err()); assert!(result.unwrap_err().contains("Command failed")); } #[test] fn test_run_command_fails_no_stderr() { - let mut output = Vec::new(); - let result = - star_setup::utils::run_command(&["git", "invalid-command-xyz"], None, false, &mut output); + let (result, _) = + with_io_output(|io| run_command(&["git", "invalid-command-xyz"], None, false, io.output)); assert!(result.is_err()); assert!(result.unwrap_err().contains("Command failed")); } #[test] fn test_run_command_verbose_fails_with_exit_code() { - let mut output = Vec::new(); - let result = star_setup::utils::run_command( - &["git", "clone", "not-a-real-repo"], - None, - true, - &mut output, - ); + let (result, _) = + with_io_output(|io| run_command(&["git", "clone", "not-a-real-repo"], None, true, io.output)); assert!(result.is_err()); assert!(result.unwrap_err().contains("Command failed")); } From 4e47b237b03d86bd78b817a91892ac10c66b82be Mon Sep 17 00:00:00 2001 From: Masonlet Date: Sun, 28 Jun 2026 22:21:56 -0400 Subject: [PATCH 51/59] refactor(tessts): streamline interactive EOF test --- tests/interactive.rs | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/tests/interactive.rs b/tests/interactive.rs index 6178140..571f1d9 100644 --- a/tests/interactive.rs +++ b/tests/interactive.rs @@ -1,6 +1,6 @@ use star_setup::interactive::interactive_mode; mod common; -use common::{default_resolved, default_resolved_interactive, make_io, with_io_input_output}; +use common::{default_resolved, default_resolved_interactive, with_io_input_output}; fn input_with_suffix(prefix: &[u8]) -> Vec { let mut v = prefix.to_vec(); @@ -128,13 +128,12 @@ fn test_interactive_mode_invalid_mono_choice_then_valid() { #[test] fn test_interactive_mode_errors_on_eof() { - let mut input = b"".as_ref(); - let mut output = Vec::new(); - let mut io = make_io(&mut input, &mut output); - let mut args = default_resolved(); - args.repo = None; + let (result, _) = with_io_input_output(b"", |io| { + let mut args = default_resolved(); + args.repo = None; + interactive_mode(&mut args, io) + }); - let result = interactive_mode(&mut args, &mut io); assert!(result.is_err()); assert!(result.unwrap_err().contains("unexpected end of input")); } From d7a41474ffe0dfa7f716283e5a0e7b300401d605 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Sun, 28 Jun 2026 22:27:34 -0400 Subject: [PATCH 52/59] refactor(tests): streamline workspace resolution tests --- tests/workspace/resolve.rs | 22 +++++++++------------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/tests/workspace/resolve.rs b/tests/workspace/resolve.rs index b8605e9..e234ee5 100644 --- a/tests/workspace/resolve.rs +++ b/tests/workspace/resolve.rs @@ -1,15 +1,11 @@ use star_setup::workspace::resolve_workspace; -use std::{fs, path::Path}; -use tempfile::TempDir; +use std::fs; -fn with_tmp_dir(f: impl FnOnce(&Path)) { - let tmp = TempDir::new().unwrap(); - f(tmp.path()); -} +use crate::common::with_io_dir; #[test] fn test_resolve_workspace_errors_when_missing() { - with_tmp_dir(|path| { + with_io_dir(|path, _| { let result = resolve_workspace(Some(path), None, None); assert!(result.is_err()); assert!(result.unwrap_err().contains("Workspace not found")); @@ -18,7 +14,7 @@ fn test_resolve_workspace_errors_when_missing() { #[test] fn test_resolve_workspace_errors_when_no_repos() { - with_tmp_dir(|path| { + with_io_dir(|path, _| { fs::create_dir_all(path.join("build-mono")).unwrap(); let result = resolve_workspace(Some(path), None, None); assert!(result.is_err()); @@ -28,7 +24,7 @@ fn test_resolve_workspace_errors_when_no_repos() { #[test] fn test_resolve_workspace_succeeds() { - with_tmp_dir(|path| { + with_io_dir(|path, _| { fs::create_dir_all(path.join("build-mono").join("repos")).unwrap(); let result = resolve_workspace(Some(path), None, None); assert!(result.is_ok()); @@ -39,7 +35,7 @@ fn test_resolve_workspace_succeeds() { #[test] fn test_resolve_workspace_finds_repos() { - with_tmp_dir(|path| { + with_io_dir(|path, _| { 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(); @@ -50,7 +46,7 @@ fn test_resolve_workspace_finds_repos() { #[test] fn test_resolve_workspace_custom_mono_dir() { - with_tmp_dir(|path| { + with_io_dir(|path, _| { fs::create_dir_all(path.join("my-workspace").join("repos")).unwrap(); let result = resolve_workspace(Some(path), Some("my-workspace"), None); assert!(result.is_ok()); @@ -59,7 +55,7 @@ fn test_resolve_workspace_custom_mono_dir() { #[test] fn test_resolve_workspace_custom_build_dir() { - with_tmp_dir(|path| { + with_io_dir(|path, _| { fs::create_dir_all(path.join("build-mono").join("repos")).unwrap(); let ws = resolve_workspace(Some(path), None, Some("out")).unwrap(); assert!(ws.build_path.ends_with("out")); @@ -68,7 +64,7 @@ fn test_resolve_workspace_custom_build_dir() { #[test] fn test_resolve_workspace_excludes_non_git_dirs() { - with_tmp_dir(|path| { + with_io_dir(|path, _| { 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(); From be51ac616418d6dd561a4b1235d0248994ad34d7 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Sun, 28 Jun 2026 22:37:04 -0400 Subject: [PATCH 53/59] refactor(tests): migrate config test submodules to harness helpers --- tests/config/crud.rs | 193 +++++++++++++++++++------------------------ tests/config/io.rs | 56 ++++++++----- 2 files changed, 118 insertions(+), 131 deletions(-) diff --git a/tests/config/crud.rs b/tests/config/crud.rs index 17c2e48..728f47f 100644 --- a/tests/config/crud.rs +++ b/tests/config/crud.rs @@ -1,7 +1,5 @@ -use super::{ - common::{empty_input, make_io, sink}, - fixtures::sample_entry, -}; +use super::fixtures::sample_entry; +use crate::common::{with_io_dir, with_io_input_output, with_io_output}; use star_setup::{ cli::BuildType, config::{ @@ -25,52 +23,47 @@ fn test_has_config_false() { #[test] fn test_add_config_inserts_and_saves() { - let tmp = tempfile::TempDir::new().unwrap(); - let path = tmp.path().join(".star-setup.json"); - let mut config = SetupConfig::new(); - config.path = Some(path.clone()); - - let mut input = empty_input(); - let mut output = sink(); - let mut io = make_io(&mut input, &mut output); - - add_config(&mut config, "myconfig", sample_entry(), true, &mut io).unwrap(); - assert!(has_config(&config, "myconfig")); - assert!(path.exists()); + with_io_dir(|tmp, io| { + let path = tmp.join(".star-setup.json"); + let mut config = SetupConfig::new(); + config.path = Some(path.clone()); + + add_config(&mut config, "myconfig", sample_entry(), true, io).unwrap(); + assert!(has_config(&config, "myconfig")); + assert!(path.exists()); + }); } #[test] fn test_add_config_aborts_when_exists_and_not_confirmed() { - let tmp = tempfile::TempDir::new().unwrap(); - let mut config = SetupConfig::new(); - config.path = Some(tmp.path().join(".star-setup.json")); - insert_config(&mut config, "myconfig", sample_entry()); - - let mut input = b"n\n".as_ref(); - let mut output = sink(); - let mut io = make_io(&mut input, &mut output); - - add_config( - &mut config, - "myconfig", - ConfigEntry { - ssh: false, // different from sample_entry's ssh: true - build_type: BuildType::Debug, - build_dir: "build".to_string(), - mono_dir: "mono".to_string(), - no_build: false, - clean: false, - verbose: false, - timing: false, - dry_run: false, - cmake_flags: vec![], - meson_flags: vec![], - }, - false, - &mut io, - ) - .unwrap(); - assert!(config.configs["myconfig"].ssh); + with_io_input_output(b"n\n", |io| { + let tmp = tempfile::TempDir::new().unwrap(); + let mut config = SetupConfig::new(); + config.path = Some(tmp.path().join(".star-setup.json")); + insert_config(&mut config, "myconfig", sample_entry()); + + add_config( + &mut config, + "myconfig", + ConfigEntry { + ssh: false, + build_type: BuildType::Debug, + build_dir: "build".to_string(), + mono_dir: "mono".to_string(), + no_build: false, + clean: false, + verbose: false, + timing: false, + dry_run: false, + cmake_flags: vec![], + meson_flags: vec![], + }, + false, + io, + ) + .unwrap(); + assert!(config.configs["myconfig"].ssh); + }); } #[test] @@ -91,53 +84,41 @@ fn test_remove_config_entry_exists() { #[test] fn test_create_default_config_creates_file() { - let tmp = tempfile::TempDir::new().unwrap(); - let path = tmp.path().join(".star-setup.json"); - - let mut input = empty_input(); - let mut output = sink(); - let mut io = make_io(&mut input, &mut output); - - create_default_config(path.clone(), true, &mut io).unwrap(); - assert!(path.exists()); + with_io_dir(|tmp, io| { + let path = tmp.join(".star-setup.json"); + create_default_config(path.clone(), true, io).unwrap(); + assert!(path.exists()); + }); } #[test] fn test_create_default_config_aborts_when_exists_and_not_confirmed() { - let tmp = tempfile::TempDir::new().unwrap(); - let path = tmp.path().join(".star-setup.json"); - std::fs::write(&path, "original").unwrap(); - - let mut input = b"n\n".as_ref(); - let mut output = sink(); - let mut io = make_io(&mut input, &mut output); - - create_default_config(path.clone(), false, &mut io).unwrap(); - assert_eq!(std::fs::read_to_string(&path).unwrap(), "original"); + with_io_input_output(b"n\n", |io| { + let tmp = tempfile::TempDir::new().unwrap(); + let path = tmp.path().join(".star-setup.json"); + std::fs::write(&path, "original").unwrap(); + + create_default_config(path.clone(), false, io).unwrap(); + assert_eq!(std::fs::read_to_string(&path).unwrap(), "original"); + }); } #[test] fn test_list_configs_empty() { - let config = SetupConfig::new(); - let mut input = empty_input(); - let mut output = sink(); - let mut io = make_io(&mut input, &mut output); - list_configs(&config, &mut io); - let out = String::from_utf8(output).unwrap(); + let ((), out) = with_io_output(|io| { + let config = SetupConfig::new(); + list_configs(&config, io); + }); assert!(out.contains("No configurations created")); } #[test] fn test_list_configs_with_entries() { - let mut config = SetupConfig::new(); - insert_config(&mut config, "myconfig", sample_entry()); - - let mut input = empty_input(); - let mut output = sink(); - let mut io = make_io(&mut input, &mut output); - - list_configs(&config, &mut io); - let out = String::from_utf8(output).unwrap(); + let ((), out) = with_io_output(|io| { + let mut config = SetupConfig::new(); + insert_config(&mut config, "myconfig", sample_entry()); + list_configs(&config, io); + }); assert!(out.contains("myconfig")); assert!(out.contains("Configurations:")); } @@ -150,43 +131,35 @@ fn test_remove_config_entry_missing() { #[test] fn test_remove_config_removes_and_saves() { - 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_config(&mut config, "myconfig", sample_entry()); - save_config(&mut config).unwrap(); - - let mut input = empty_input(); - let mut output = sink(); - let mut io = make_io(&mut input, &mut output); - - remove_config(&mut config, "myconfig", true, &mut io).unwrap(); - assert!(!has_config(&config, "myconfig")); + with_io_dir(|tmp, io| { + let path = tmp.join(".star-setup.json"); + let mut config = SetupConfig::new(); + config.path = Some(path.clone()); + insert_config(&mut config, "myconfig", sample_entry()); + save_config(&mut config).unwrap(); + + remove_config(&mut config, "myconfig", true, io).unwrap(); + assert!(!has_config(&config, "myconfig")); + }); } #[test] fn test_remove_config_not_found() { let mut config = SetupConfig::new(); - - let mut input = empty_input(); - let mut output = sink(); - let mut io = make_io(&mut input, &mut output); - - remove_config(&mut config, "nonexistent", true, &mut io).unwrap(); + with_io_output(|io| { + remove_config(&mut config, "nonexistent", true, io).unwrap(); + }); } #[test] fn test_remove_config_aborts_when_not_confirmed() { - let tmp = tempfile::TempDir::new().unwrap(); - let mut config = SetupConfig::new(); - config.path = Some(tmp.path().join(".star-setup.json")); - insert_config(&mut config, "myconfig", sample_entry()); - - let mut input = b"n\n".as_ref(); - let mut output = sink(); - let mut io = make_io(&mut input, &mut output); - - remove_config(&mut config, "myconfig", false, &mut io).unwrap(); - assert!(has_config(&config, "myconfig")); + with_io_input_output(b"n\n", |io| { + let tmp = tempfile::TempDir::new().unwrap(); + let mut config = SetupConfig::new(); + config.path = Some(tmp.path().join(".star-setup.json")); + insert_config(&mut config, "myconfig", sample_entry()); + + remove_config(&mut config, "myconfig", false, io).unwrap(); + assert!(has_config(&config, "myconfig")); + }); } diff --git a/tests/config/io.rs b/tests/config/io.rs index 0b83232..0baaccb 100644 --- a/tests/config/io.rs +++ b/tests/config/io.rs @@ -1,4 +1,6 @@ -use super::{common::sink, fixtures::sample_entry}; +use crate::common::with_io_output; + +use super::fixtures::sample_entry; use star_setup::{ cli::BuildType, config::{insert_config, load_config, save_config, ConfigEntry, SetupConfig}, @@ -30,18 +32,22 @@ fn test_save_and_load_roundtrip() { ); save_config(&mut config).unwrap(); - let loaded = load_config(&[path], &mut sink()); - assert!(loaded.configs.contains_key("default")); - assert!(loaded.configs["default"].ssh); - assert_eq!(loaded.configs["default"].build_type, BuildType::Release); - assert_eq!(loaded.configs["default"].mono_dir, "mono"); - assert_eq!(loaded.configs["default"].cmake_flags, Vec::::new()); + with_io_output(|io| { + let loaded = load_config(&[path], &mut io.output); + assert!(loaded.configs.contains_key("default")); + assert!(loaded.configs["default"].ssh); + assert_eq!(loaded.configs["default"].build_type, BuildType::Release); + assert_eq!(loaded.configs["default"].mono_dir, "mono"); + assert_eq!(loaded.configs["default"].cmake_flags, Vec::::new()); + }); } #[test] fn test_load_config_skips_missing_local_file() { - let config = load_config(&[], &mut sink()); - assert!(config.configs.is_empty()); + with_io_output(|io| { + let config = load_config(&[], &mut io.output); + assert!(config.configs.is_empty()); + }); } #[test] @@ -50,17 +56,21 @@ fn test_load_config_handles_invalid_json() { let path = tmp.path().join(".star-setup.json"); std::fs::write(&path, "{invalid json").unwrap(); - let config = load_config(&[path], &mut sink()); - assert!(config.configs.is_empty()); + with_io_output(|io| { + let config = load_config(&[path], &mut io.output); + assert!(config.configs.is_empty()); + }); } #[test] fn test_load_config_skips_nonexistent_path() { - let config = load_config( - &[PathBuf::from("/nonexistent/path/.star-setup.json")], - &mut sink(), - ); - assert!(config.configs.is_empty()); + with_io_output(|io| { + let config = load_config( + &[PathBuf::from("/nonexistent/path/.star-setup.json")], + &mut io.output, + ); + assert!(config.configs.is_empty()); + }); } #[test] @@ -80,9 +90,11 @@ fn test_load_config_first_valid_wins() { insert_config(&mut config2, "second", sample_entry()); save_config(&mut config2).unwrap(); - let loaded = load_config(&[path1, path2], &mut sink()); - assert!(loaded.configs.contains_key("first")); - assert!(!loaded.configs.contains_key("second")); + with_io_output(|io| { + let loaded = load_config(&[path1, path2], &mut io.output); + assert!(loaded.configs.contains_key("first")); + assert!(!loaded.configs.contains_key("second")); + }); } #[test] @@ -99,6 +111,8 @@ fn test_load_config_falls_through_invalid_to_valid() { insert_config(&mut config2, "second", sample_entry()); save_config(&mut config2).unwrap(); - let loaded = load_config(&[path1, path2], &mut sink()); - assert!(loaded.configs.contains_key("second")); + with_io_output(|io| { + let loaded = load_config(&[path1, path2], &mut io.output); + assert!(loaded.configs.contains_key("second")); + }); } From 77bf972db0ab440321f08271c3693db6c748bd3d Mon Sep 17 00:00:00 2001 From: Masonlet Date: Sun, 28 Jun 2026 22:41:53 -0400 Subject: [PATCH 54/59] refactor(tests): migrate profile test submodules to harness helpers --- tests/profile/crud.rs | 173 ++++++++++++++++++--------------------- tests/profile/display.rs | 28 +++---- 2 files changed, 88 insertions(+), 113 deletions(-) diff --git a/tests/profile/crud.rs b/tests/profile/crud.rs index cc19431..06b3388 100644 --- a/tests/profile/crud.rs +++ b/tests/profile/crud.rs @@ -1,4 +1,4 @@ -use super::common::{empty_input, make_io, sink}; +use crate::common::{with_io_dir, with_io_input_output, with_io_output}; use star_setup::{ config::{load_config, save_config, SetupConfig}, profile::{add_profile, has_profile, insert_profile, remove_profile, remove_profile_entry}, @@ -40,131 +40,112 @@ fn test_has_profile_false() { #[test] fn test_add_profile_inserts_and_saves() { - let tmp = tempfile::TempDir::new().unwrap(); - let path = tmp.path().join(".star-setup.json"); - let mut config = SetupConfig::new(); - config.path = Some(path.clone()); - - let args = vec!["myprofile".to_string(), "user/repo1".to_string()]; - let mut input = empty_input(); - let mut output = sink(); - let mut io = make_io(&mut input, &mut output); - - add_profile(&mut config, &args, true, &mut io).unwrap(); - assert!(has_profile(&config, "myprofile")); - assert!(path.exists()); + 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).unwrap(); + assert!(has_profile(&config, "myprofile")); + assert!(path.exists()); + }); } #[test] fn test_add_profile_errors_on_insufficient_args() { let mut config = SetupConfig::new(); let args = vec!["myprofile".to_string()]; - let mut input = empty_input(); - let mut output = sink(); - let mut io = make_io(&mut input, &mut output); - let result = add_profile(&mut config, &args, true, &mut io); - assert!(result.is_err()); + with_io_output(|io| { + let result = add_profile(&mut config, &args, true, io); + assert!(result.is_err()); + }); } #[test] fn test_add_profile_errors_on_empty_args() { let mut config = SetupConfig::new(); - let mut input = empty_input(); - let mut output = sink(); - let mut io = make_io(&mut input, &mut output); - let result = add_profile(&mut config, &[], true, &mut io); - assert!(result.is_err()); + with_io_output(|io| { + let result = add_profile(&mut config, &[], true, io); + assert!(result.is_err()); + }); } #[test] fn test_add_profile_overwrites_existing() { - let tmp = tempfile::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()]); - - let args = vec!["myprofile".to_string(), "new/repo".to_string()]; - let mut input = empty_input(); - let mut output = sink(); - let mut io = make_io(&mut input, &mut output); - - add_profile(&mut config, &args, true, &mut io).unwrap(); - assert_eq!(config.profiles["myprofile"], vec!["new/repo"]); + with_io_dir(|tmp, io| { + let mut config = SetupConfig::new(); + config.path = Some(tmp.join(".star-setup.json")); + insert_profile(&mut config, "myprofile", vec!["old/repo".to_string()]); + + let args = vec!["myprofile".to_string(), "new/repo".to_string()]; + add_profile(&mut config, &args, true, io).unwrap(); + assert_eq!(config.profiles["myprofile"], vec!["new/repo"]); + }); } #[test] fn test_add_profile_multiple_repos() { - let tmp = tempfile::TempDir::new().unwrap(); - let mut config = SetupConfig::new(); - config.path = Some(tmp.path().join(".star-setup.json")); - - let args = vec![ - "myprofile".to_string(), - "user/repo1".to_string(), - "user/repo2".to_string(), - "user/repo3".to_string(), - ]; - let mut input = empty_input(); - let mut output = sink(); - let mut io = make_io(&mut input, &mut output); - - add_profile(&mut config, &args, true, &mut io).unwrap(); - assert_eq!(config.profiles["myprofile"].len(), 3); + with_io_dir(|tmp, io| { + let mut config = SetupConfig::new(); + config.path = Some(tmp.join(".star-setup.json")); + + let args = vec![ + "myprofile".to_string(), + "user/repo1".to_string(), + "user/repo2".to_string(), + "user/repo3".to_string(), + ]; + add_profile(&mut config, &args, true, io).unwrap(); + assert_eq!(config.profiles["myprofile"].len(), 3); + }); } #[test] fn test_add_profile_aborts_when_exists_and_not_confirmed() { - let tmp = tempfile::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()]); - - let args = vec!["myprofile".to_string(), "new/repo".to_string()]; - let mut input = b"n\n".as_ref(); - let mut output = sink(); - let mut io = make_io(&mut input, &mut output); - - add_profile(&mut config, &args, false, &mut io).unwrap(); - assert_eq!(config.profiles["myprofile"], vec!["old/repo"]); + with_io_input_output(b"n\n", |io| { + let tmp = tempfile::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()]); + + let args = vec!["myprofile".to_string(), "new/repo".to_string()]; + add_profile(&mut config, &args, false, io).unwrap(); + assert_eq!(config.profiles["myprofile"], vec!["old/repo"]); + }); } #[test] fn test_remove_profile_removes_and_saves() { - 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()]); - save_config(&mut config).unwrap(); - - let mut input = empty_input(); - let mut output = sink(); - let mut io = make_io(&mut input, &mut output); - - remove_profile(&mut config, "myprofile", true, &mut io).unwrap(); - assert!(!has_profile(&config, "myprofile")); + with_io_dir(|tmp, io| { + let path = tmp.join(".star-setup.json"); + let mut config = SetupConfig::new(); + config.path = Some(path.clone()); + insert_profile(&mut config, "myprofile", vec!["user/repo1".to_string()]); + save_config(&mut config).unwrap(); + + remove_profile(&mut config, "myprofile", true, io).unwrap(); + assert!(!has_profile(&config, "myprofile")); + }); } #[test] fn test_remove_profile_not_found() { let mut config = SetupConfig::new(); - let mut input = empty_input(); - let mut output = sink(); - let mut io = make_io(&mut input, &mut output); - remove_profile(&mut config, "nonexistent", true, &mut io).unwrap(); + with_io_output(|io| { + remove_profile(&mut config, "nonexistent", true, io).unwrap(); + }); } #[test] fn test_remove_profile_aborts_when_not_confirmed() { - let mut config = SetupConfig::new(); - insert_profile(&mut config, "myprofile", vec!["user/repo1".to_string()]); + with_io_input_output(b"n\n", |io| { + let mut config = SetupConfig::new(); + insert_profile(&mut config, "myprofile", vec!["user/repo1".to_string()]); - let mut input = b"n\n".as_ref(); - let mut output = sink(); - let mut io = make_io(&mut input, &mut output); - - remove_profile(&mut config, "myprofile", false, &mut io).unwrap(); - assert!(has_profile(&config, "myprofile")); + remove_profile(&mut config, "myprofile", false, io).unwrap(); + assert!(has_profile(&config, "myprofile")); + }); } #[test] @@ -180,10 +161,12 @@ fn test_save_and_load_profile_roundtrip() { ); save_config(&mut config).unwrap(); - let loaded = load_config(&[path], &mut sink()); - assert!(loaded.profiles.contains_key("myprofile")); - assert_eq!( - loaded.profiles["myprofile"], - vec!["user/repo1", "user/repo2"] - ); + with_io_output(|io| { + let loaded = load_config(&[path], &mut io.output); + assert!(loaded.profiles.contains_key("myprofile")); + assert_eq!( + loaded.profiles["myprofile"], + vec!["user/repo1", "user/repo2"] + ); + }); } diff --git a/tests/profile/display.rs b/tests/profile/display.rs index 4424ab4..6b127d3 100644 --- a/tests/profile/display.rs +++ b/tests/profile/display.rs @@ -1,4 +1,4 @@ -use super::common::{empty_input, make_io, sink}; +use crate::common::with_io_output; use star_setup::{ config::SetupConfig, profile::{insert_profile, list_profiles}, @@ -6,28 +6,20 @@ use star_setup::{ #[test] fn test_list_profiles_empty() { - let config = SetupConfig::new(); - - let mut input = empty_input(); - let mut output = sink(); - let mut io = make_io(&mut input, &mut output); - - list_profiles(&config, &mut io); - let out = String::from_utf8(output).unwrap(); + let ((), out) = with_io_output(|io| { + let config = SetupConfig::new(); + list_profiles(&config, io); + }); assert!(out.contains("No profiles configured")); } #[test] fn test_list_profiles_with_entries() { - let mut config = SetupConfig::new(); - insert_profile(&mut config, "myprofile", vec!["user/repo1".to_string()]); - - let mut input = empty_input(); - let mut output = sink(); - let mut io = make_io(&mut input, &mut output); - - list_profiles(&config, &mut io); - let out = String::from_utf8(output).unwrap(); + let ((), out) = with_io_output(|io| { + let mut config = SetupConfig::new(); + insert_profile(&mut config, "myprofile", vec!["user/repo1".to_string()]); + list_profiles(&config, io); + }); assert!(out.contains("myprofile")); assert!(out.contains("user/repo1")); } From 5c1e5d55dcff5b35de56ab6ed229f36284b06507 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Sun, 28 Jun 2026 22:54:15 -0400 Subject: [PATCH 55/59] refactor(tests): clean up build detection tests --- tests/cli/build/detect.rs | 104 +++++++++++++++++++------------------- 1 file changed, 51 insertions(+), 53 deletions(-) diff --git a/tests/cli/build/detect.rs b/tests/cli/build/detect.rs index f42570a..5a95652 100644 --- a/tests/cli/build/detect.rs +++ b/tests/cli/build/detect.rs @@ -2,24 +2,20 @@ use star_setup::{ cli::{detect_build_system, detect_mono_build_system, BuildSystem}, ctx::{IoCtx, ProcessRunner, RunCtx}, }; -use tempfile::TempDir; -fn cmake_dir() -> TempDir { - let tmp = TempDir::new().unwrap(); - std::fs::write(tmp.path().join("CMakeLists.txt"), "").unwrap(); - tmp +fn create_cmake_fixture(path: &std::path::Path) { + std::fs::write(path.join("CMakeLists.txt"), "").unwrap(); } -fn meson_dir() -> TempDir { - let tmp = TempDir::new().unwrap(); - std::fs::write(tmp.path().join("meson.build"), "").unwrap(); - tmp +fn create_meson_fixture(path: &std::path::Path) { + std::fs::write(path.join("meson.build"), "").unwrap(); } -fn run_test(input: &[u8], timing: bool, test_logic: F) -> (T, Vec) +fn with_detect_ctx(input: &[u8], timing: bool, test_logic: F) -> (T, String) where - F: FnOnce(&mut RunCtx) -> T, + F: FnOnce(&std::path::Path, &mut RunCtx) -> T, { + let tmp = tempfile::TempDir::new().unwrap(); let mut runner = ProcessRunner; let mut output = Vec::new(); let mut input_slice = input; @@ -35,114 +31,116 @@ where }, runner: &mut runner, }; - - test_logic(&mut ctx) + test_logic(tmp.path(), &mut ctx) }; - (result, output) + (result, String::from_utf8(output).unwrap()) } #[test] fn test_detect_build_system_none() { - let dir = TempDir::new().unwrap(); - let (result, _) = run_test(b"", false, |ctx| detect_build_system(dir.path(), ctx)); + let (result, _) = with_detect_ctx(b"", false, |path, ctx| detect_build_system(path, ctx)); assert!(result.is_err()); } #[test] fn test_detect_build_system_cmake() { - let dir = cmake_dir(); - let (result, _) = run_test(b"", false, |ctx| detect_build_system(dir.path(), ctx)); + let (result, _) = with_detect_ctx(b"", false, |path, ctx| { + create_cmake_fixture(path); + detect_build_system(path, ctx) + }); assert!(matches!(result.unwrap(), BuildSystem::Cmake)); } #[test] fn test_detect_build_system_meson() { - let dir = meson_dir(); - let (result, _) = run_test(b"", false, |ctx| detect_build_system(dir.path(), ctx)); + let (result, _) = with_detect_ctx(b"", false, |path, ctx| { + create_meson_fixture(path); + detect_build_system(path, ctx) + }); assert!(matches!(result.unwrap(), BuildSystem::Meson)); } #[test] fn test_detect_build_system_both_picks_cmake() { - let dir = cmake_dir(); - std::fs::write(dir.path().join("meson.build"), "").unwrap(); - let (result, _) = run_test(b"1\n", false, |ctx| detect_build_system(dir.path(), ctx)); + let (result, _) = with_detect_ctx(b"1\n", false, |path, ctx| { + create_cmake_fixture(path); + create_meson_fixture(path); + detect_build_system(path, ctx) + }); assert!(matches!(result.unwrap(), BuildSystem::Cmake)); } #[test] fn test_detect_build_system_both_picks_meson() { - let dir = cmake_dir(); - std::fs::write(dir.path().join("meson.build"), "").unwrap(); - let (result, _) = run_test(b"2\n", false, |ctx| detect_build_system(dir.path(), ctx)); + let (result, _) = with_detect_ctx(b"2\n", false, |path, ctx| { + create_cmake_fixture(path); + create_meson_fixture(path); + detect_build_system(path, ctx) + }); assert!(matches!(result.unwrap(), BuildSystem::Meson)); } #[test] fn test_detect_build_system_timing_output() { - let dir = cmake_dir(); - let ((), output) = run_test(b"", true, |ctx| { - detect_build_system(dir.path(), ctx).unwrap(); + let ((), out) = with_detect_ctx(b"", true, |path, ctx| { + create_cmake_fixture(path); + detect_build_system(path, ctx).unwrap(); }); - let out = String::from_utf8(output).unwrap(); assert!(out.contains("[timing] Detect:")); } #[test] fn test_detect_mono_build_system_none() { - let dir = TempDir::new().unwrap(); - let (result, _) = run_test(b"", false, |ctx| { - detect_mono_build_system(&[dir.path().to_path_buf()], ctx) + let (result, _) = with_detect_ctx(b"", false, |path, ctx| { + detect_mono_build_system(&[path.to_path_buf()], ctx) }); assert!(result.is_err()); } #[test] fn test_detect_mono_build_system_cmake() { - let dir = cmake_dir(); - let (result, _) = run_test(b"", false, |ctx| { - detect_mono_build_system(&[dir.path().to_path_buf()], ctx) + let (result, _) = with_detect_ctx(b"", false, |path, ctx| { + create_cmake_fixture(path); + detect_mono_build_system(&[path.to_path_buf()], ctx) }); assert!(matches!(result.unwrap(), BuildSystem::Cmake)); } #[test] fn test_detect_mono_build_system_meson() { - let dir = meson_dir(); - let (result, _) = run_test(b"", false, |ctx| { - detect_mono_build_system(&[dir.path().to_path_buf()], ctx) + let (result, _) = with_detect_ctx(b"", false, |path, ctx| { + create_meson_fixture(path); + detect_mono_build_system(&[path.to_path_buf()], ctx) }); assert!(matches!(result.unwrap(), BuildSystem::Meson)); } #[test] fn test_detect_mono_build_system_both_picks_cmake() { - let dir = cmake_dir(); - std::fs::write(dir.path().join("meson.build"), "").unwrap(); - let (result, _) = run_test(b"1\n", false, |ctx| { - detect_mono_build_system(&[dir.path().to_path_buf()], ctx) + let (result, _) = with_detect_ctx(b"1\n", false, |path, ctx| { + create_cmake_fixture(path); + create_meson_fixture(path); + detect_mono_build_system(&[path.to_path_buf()], ctx) }); assert!(matches!(result.unwrap(), BuildSystem::Cmake)); } #[test] fn test_detect_mono_build_system_both_picks_meson() { - let dir = cmake_dir(); - std::fs::write(dir.path().join("meson.build"), "").unwrap(); - let (result, _) = run_test(b"2\n", false, |ctx| { - detect_mono_build_system(&[dir.path().to_path_buf()], ctx) + let (result, _) = with_detect_ctx(b"2\n", false, |path, ctx| { + create_cmake_fixture(path); + create_meson_fixture(path); + detect_mono_build_system(&[path.to_path_buf()], ctx) }); assert!(matches!(result.unwrap(), BuildSystem::Meson)); } #[test] fn test_detect_mono_build_system_timing_output() { - let dir = cmake_dir(); - let ((), output) = run_test(b"", true, |ctx| { - detect_mono_build_system(&[dir.path().to_path_buf()], ctx).unwrap(); + let ((), out) = with_detect_ctx(b"", true, |path, ctx| { + create_cmake_fixture(path); + detect_mono_build_system(&[path.to_path_buf()], ctx).unwrap(); }); - assert!(String::from_utf8(output) - .unwrap() - .contains("[timing] Detect:")); + assert!(out.contains("[timing] Detect:")); } From 437853e6853ac85a6af930d1407db3e8309c3b8a Mon Sep 17 00:00:00 2001 From: Masonlet Date: Sun, 28 Jun 2026 23:00:45 -0400 Subject: [PATCH 56/59] refactor(tests): convert single repo mode tests to unified runner --- tests/commands/single.rs | 175 +++++++++++++++------------------------ 1 file changed, 65 insertions(+), 110 deletions(-) diff --git a/tests/commands/single.rs b/tests/commands/single.rs index 68167e9..e02f341 100644 --- a/tests/commands/single.rs +++ b/tests/commands/single.rs @@ -1,9 +1,8 @@ -use super::common::{default_resolved, make_io, sink, MockRunner}; +use super::common::{default_resolved, MockRunner}; use star_setup::{ commands::single_repo_mode, - ctx::{DryRunRunner, RunCtx}, + ctx::{DryRunRunner, IoCtx, RunCtx}, }; -use tempfile::TempDir; fn make_repo_fixture(base: &std::path::Path) { let repo_dir = base.join("user-repo"); @@ -11,21 +10,38 @@ fn make_repo_fixture(base: &std::path::Path) { std::fs::write(repo_dir.join("CMakeLists.txt"), "").unwrap(); } -#[test] -fn test_single_repo_mode_updates_existing_repo() { - let tmp = TempDir::new().unwrap(); - let args = default_resolved(); - make_repo_fixture(tmp.path()); - - let mut input = b"y\n".as_ref(); - let mut output = sink(); - let mut runner = MockRunner::new(); - let mut ctx = RunCtx { - io: make_io(&mut input, &mut output), - runner: &mut runner, +fn with_single_mode_ctx(input: &[u8], mut runner: R, test_logic: F) -> (T, String, R) +where + R: star_setup::ctx::Runner, + F: FnOnce(&std::path::Path, &mut RunCtx) -> T, +{ + let tmp = tempfile::TempDir::new().unwrap(); + let mut output = Vec::new(); + let mut input_slice = input; + + let result = { + let mut ctx = RunCtx { + io: IoCtx { + input: &mut input_slice, + output: &mut output, + verbose: false, + timing: false, + dry_run: false, + }, + runner: &mut runner, + }; + test_logic(tmp.path(), &mut ctx) }; - single_repo_mode(&args, tmp.path(), &mut ctx).unwrap(); + (result, String::from_utf8(output).unwrap(), runner) +} + +#[test] +fn test_single_repo_mode_updates_existing_repo() { + let ((), _, runner) = with_single_mode_ctx(b"y\n", MockRunner::new(), |tmp_path, ctx| { + make_repo_fixture(tmp_path); + single_repo_mode(&default_resolved(), tmp_path, ctx).unwrap(); + }); assert!(runner .calls @@ -35,19 +51,10 @@ fn test_single_repo_mode_updates_existing_repo() { #[test] fn test_single_repo_mode_skips_update_when_declined() { - let tmp = TempDir::new().unwrap(); - let args = default_resolved(); - make_repo_fixture(tmp.path()); - - let mut input = b"n\n".as_ref(); - let mut output = sink(); - let mut runner = MockRunner::new(); - let mut ctx = RunCtx { - io: make_io(&mut input, &mut output), - runner: &mut runner, - }; - - single_repo_mode(&args, tmp.path(), &mut ctx).unwrap(); + let ((), _, runner) = with_single_mode_ctx(b"n\n", MockRunner::new(), |tmp_path, ctx| { + make_repo_fixture(tmp_path); + single_repo_mode(&default_resolved(), tmp_path, ctx).unwrap(); + }); assert!(!runner .calls @@ -57,120 +64,68 @@ fn test_single_repo_mode_skips_update_when_declined() { #[test] fn test_single_repo_mode_cleans_build_dir() { - let tmp = TempDir::new().unwrap(); let mut args = default_resolved(); args.build.clean = true; - 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(); - - let mut input = b"n\n".as_ref(); - let mut output = sink(); - let mut runner = MockRunner::new(); - let mut ctx = RunCtx { - io: make_io(&mut input, &mut output), - runner: &mut runner, - }; - single_repo_mode(&args, tmp.path(), &mut ctx).unwrap(); - assert!(!build_dir.join("dummy.txt").exists()); - assert!(build_dir.exists()); + let ((), _, _) = with_single_mode_ctx(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(); + + single_repo_mode(&args, tmp_path, ctx).unwrap(); + assert!(!build_dir.join("dummy.txt").exists()); + assert!(build_dir.exists()); + }); } #[test] fn test_single_repo_mode_outputs_timing() { - let tmp = TempDir::new().unwrap(); - let args = default_resolved(); - make_repo_fixture(tmp.path()); - - let mut input = b"n\n".as_ref(); - let mut output = sink(); - let mut runner = MockRunner::new(); - let mut ctx = RunCtx { - io: star_setup::ctx::IoCtx { - input: &mut input, - output: &mut output, - verbose: false, - timing: true, - dry_run: false, - }, - runner: &mut runner, - }; + let ((), out, _) = with_single_mode_ctx(b"n\n", MockRunner::new(), |tmp_path, ctx| { + make_repo_fixture(tmp_path); + ctx.io.timing = true; + single_repo_mode(&default_resolved(), tmp_path, ctx).unwrap(); + }); - single_repo_mode(&args, tmp.path(), &mut ctx).unwrap(); - let out = String::from_utf8(output).unwrap(); assert!(out.contains("[timing] Total:")); } #[test] fn test_single_repo_mode_dry_run_makes_no_fs_changes() { - let tmp = TempDir::new().unwrap(); let mut args = default_resolved(); args.diagnostic.dry_run = true; - let mut input = b"".as_ref(); - let mut output = sink(); - let mut runner = DryRunRunner; - let mut ctx = RunCtx { - io: star_setup::ctx::IoCtx { - input: &mut input, - output: &mut output, - verbose: false, - timing: false, - dry_run: true, - }, - runner: &mut runner, - }; - - single_repo_mode(&args, tmp.path(), &mut ctx).unwrap(); - assert!(std::fs::read_dir(tmp.path()).unwrap().next().is_none()); + let ((), _, _) = with_single_mode_ctx(b"", DryRunRunner, |tmp_path, ctx| { + ctx.io.dry_run = true; + single_repo_mode(&args, tmp_path, ctx).unwrap(); + assert!(std::fs::read_dir(tmp_path).unwrap().next().is_none()); + }); } #[test] fn test_single_repo_mode_dry_run_clean_prints_would_remove() { - let tmp = TempDir::new().unwrap(); let mut args = default_resolved(); args.diagnostic.dry_run = true; args.build.clean = true; - let mut input = b"".as_ref(); - let mut output = Vec::new(); - let mut runner = DryRunRunner; - let mut ctx = RunCtx { - io: star_setup::ctx::IoCtx { - input: &mut input, - output: &mut output, - verbose: false, - timing: false, - dry_run: true, - }, - runner: &mut runner, - }; + let ((), out, _) = with_single_mode_ctx(b"", DryRunRunner, |tmp_path, ctx| { + ctx.io.dry_run = true; + single_repo_mode(&args, tmp_path, ctx).unwrap(); + assert!(std::fs::read_dir(tmp_path).unwrap().next().is_none()); + }); - single_repo_mode(&args, tmp.path(), &mut ctx).unwrap(); - - let out = String::from_utf8(output).unwrap(); assert!(out.contains("Would remove directory:")); - assert!(std::fs::read_dir(tmp.path()).unwrap().next().is_none()); } #[test] fn test_single_repo_mode_with_build_system_flag() { - let tmp = TempDir::new().unwrap(); let mut args = default_resolved(); args.build.build_system = Some(star_setup::cli::BuildSystem::Cmake); - make_repo_fixture(tmp.path()); - - let mut input = b"n\n".as_ref(); - let mut output = sink(); - let mut runner = MockRunner::new(); - let mut ctx = RunCtx { - io: make_io(&mut input, &mut output), - runner: &mut runner, - }; - single_repo_mode(&args, tmp.path(), &mut ctx).unwrap(); + let ((), _, runner) = with_single_mode_ctx(b"n\n", MockRunner::new(), |tmp_path, ctx| { + make_repo_fixture(tmp_path); + single_repo_mode(&args, tmp_path, ctx).unwrap(); + }); assert!(runner.calls.iter().any(|(cmd, _)| cmd[0] == "cmake")); } From 5cd86de8e818816a1fe7cb90266b5aa315048d6d Mon Sep 17 00:00:00 2001 From: Masonlet Date: Sun, 28 Jun 2026 23:13:01 -0400 Subject: [PATCH 57/59] chore: clip --- tests/cli/build/detect.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/cli/build/detect.rs b/tests/cli/build/detect.rs index 5a95652..7f71470 100644 --- a/tests/cli/build/detect.rs +++ b/tests/cli/build/detect.rs @@ -39,7 +39,7 @@ where #[test] fn test_detect_build_system_none() { - let (result, _) = with_detect_ctx(b"", false, |path, ctx| detect_build_system(path, ctx)); + let (result, _) = with_detect_ctx(b"", false, detect_build_system); assert!(result.is_err()); } From b889390c8ba0b0c3929708316728517c04cc5db9 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Sun, 28 Jun 2026 23:13:43 -0400 Subject: [PATCH 58/59] chore: bump v0.3.5 --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 956ac4a..562a763 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "star-setup" -version = "0.3.4" +version = "0.3.5" edition = "2021" repository = "https://github.com/star-setup/core" description = "Lightweight CLI to clone, configure, and wire single or multi-repo ecosystems" From 208812d7184ab40a4084b931c7dc45ef82d56c06 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Sun, 28 Jun 2026 23:21:37 -0400 Subject: [PATCH 59/59] fix: conditional windows imports --- src/utils/process.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/utils/process.rs b/src/utils/process.rs index f486bc9..a8e034b 100644 --- a/src/utils/process.rs +++ b/src/utils/process.rs @@ -1,10 +1,15 @@ use std::{ - collections::HashMap, io::Write, - path::{Path, PathBuf}, + path::Path, process::{Command, Stdio}, }; +#[cfg(target_os = "windows")] +use std::{ + collections::HashMap, + path::PathBuf, +}; + /// Finds vcvars64.bat using vswhere.exe. /// Returns None if vswhere is not found or no VS installation exists. #[cfg(target_os = "windows")]