From eb84193b0e6e7a5299a69e83472af106c145270c Mon Sep 17 00:00:00 2001 From: Masonlet Date: Mon, 22 Jun 2026 22:18:51 -0400 Subject: [PATCH 01/34] refactor(cli): split cli.rs into submodules (flags, resolved, mod) --- src/cli/flags.rs | 103 +++++++++++++++++++++++++ src/{cli.rs => cli/mod.rs} | 149 ++----------------------------------- src/cli/resolved.rs | 35 +++++++++ 3 files changed, 143 insertions(+), 144 deletions(-) create mode 100644 src/cli/flags.rs rename src/{cli.rs => cli/mod.rs} (50%) create mode 100644 src/cli/resolved.rs diff --git a/src/cli/flags.rs b/src/cli/flags.rs new file mode 100644 index 0000000..da7d7da --- /dev/null +++ b/src/cli/flags.rs @@ -0,0 +1,103 @@ +use clap::Args as ClapArgs; + +#[allow(clippy::struct_excessive_bools)] +#[derive(ClapArgs)] +pub struct ConnectionFlags { + /// Use SSH instead of HTTPS for cloning + #[arg(long, conflicts_with = "https")] + pub ssh: bool, + /// Use HTTPS instead of SSH for cloning + #[arg(long, conflicts_with = "ssh")] + pub https: bool, + + /// Show detailed command output + #[arg(short = 'v', long, conflicts_with = "no_verbose")] + pub verbose: bool, + /// Suppress detailed command output + #[arg(long, conflicts_with = "verbose")] + pub no_verbose: bool, +} + +#[allow(clippy::struct_excessive_bools)] +#[derive(ClapArgs)] +pub struct BuildFlags { + /// Build type + #[arg(short = 'b', long)] + pub build_type: Option, + + /// Build directory name + #[arg(short = 'd', long)] + pub build_dir: Option, + + /// Skip building, only configure + #[arg(short = 'n', long, conflicts_with = "build")] + pub no_build: bool, + /// Build after configuring (overrides config `no_build`) + #[arg(long, conflicts_with = "no_build")] + pub build: bool, + + /// Clean build directory before building + #[arg(short = 'c', long, conflicts_with = "no_clean")] + pub clean: bool, + /// Do not clean build directory + #[arg(long, conflicts_with = "clean")] + pub no_clean: bool, +} + +#[derive(ClapArgs)] +pub struct MonoRepoFlags { + /// Mono-repo mode + #[arg(long)] + pub mono_repo: bool, + + /// Directory name for mono-repo cloning + #[arg(long)] + pub mono_dir: Option, + + /// List of library repositories to clone in mono-repo mode + #[arg(long, num_args = 1.., conflicts_with = "profile")] + pub repos: Option>, + + /// Use saved profile for library repositories + #[arg(long, conflicts_with = "repos")] + pub profile: Option, +} + +#[derive(ClapArgs)] +#[allow(clippy::struct_excessive_bools)] +pub struct ConfigFlags { + /// Create a default config file in the current directory + #[arg(long)] + pub init_config: bool, + + /// Select a named configuration to use + #[arg(long = "config")] + pub config_name: Option, + + /// Add a new config + #[arg(long)] + pub config_add: Option, + + /// Remove a saved configuration + #[arg(long)] + pub config_remove: Option, + + /// List all saved configs + #[arg(long)] + pub list_configs: bool, +} + +#[derive(ClapArgs)] +pub struct ProfileFlags { + /// Add a new profile: NAME REPO1 [REPO2 ...] + #[arg(long, num_args = 2..)] + pub profile_add: Option>, + + /// Remove a saved profile + #[arg(long)] + pub profile_remove: Option, + + /// List all saved profiles + #[arg(long)] + pub list_profiles: bool, +} diff --git a/src/cli.rs b/src/cli/mod.rs similarity index 50% rename from src/cli.rs rename to src/cli/mod.rs index 113d027..c30e240 100644 --- a/src/cli.rs +++ b/src/cli/mod.rs @@ -1,114 +1,9 @@ -//! Command-line argument parsing. - +use clap::Parser; +pub mod flags; +pub mod resolved; use crate::config::SetupConfig; -use clap::{Args as ClapArgs, Parser}; - -/// Connection and output flags. -#[allow(clippy::struct_excessive_bools)] -#[derive(ClapArgs)] -pub struct ConnectionFlags { - /// Use SSH instead of HTTPS for cloning - #[arg(long, conflicts_with = "https")] - pub ssh: bool, - /// Use HTTPS instead of SSH for cloning - #[arg(long, conflicts_with = "ssh")] - pub https: bool, - - /// Show detailed command output - #[arg(short = 'v', long, conflicts_with = "no_verbose")] - pub verbose: bool, - /// Suppress detailed command output - #[arg(long, conflicts_with = "verbose")] - pub no_verbose: bool, -} - -/// `CMake` build flags. -#[allow(clippy::struct_excessive_bools)] -#[derive(ClapArgs)] -pub struct BuildFlags { - /// `CMake` build type - #[arg(short = 'b', long)] - pub build_type: Option, - - /// Build directory name - #[arg(short = 'd', long)] - pub build_dir: Option, - - /// Skip building, only configure - #[arg(short = 'n', long, conflicts_with = "build")] - pub no_build: bool, - /// Build after configuring (overrides config `no_build`) - #[arg(long, conflicts_with = "no_build")] - pub build: bool, - - /// Clean build directory before building - #[arg(short = 'c', long, conflicts_with = "no_clean")] - pub clean: bool, - /// Do not clean build directory - #[arg(long, conflicts_with = "clean")] - pub no_clean: bool, -} - -/// Mono-repo flags. -#[derive(ClapArgs)] -pub struct MonoRepoFlags { - /// Mono-repo mode - #[arg(long)] - pub mono_repo: bool, - - /// Directory name for mono-repo cloning - #[arg(long)] - pub mono_dir: Option, - - /// List of library repositories to clone in mono-repo mode - #[arg(long, num_args = 1.., conflicts_with = "profile")] - pub repos: Option>, - - /// Use saved profile for library repositories - #[arg(long, conflicts_with = "repos")] - pub profile: Option, -} - -/// Config management flags. -#[derive(ClapArgs)] -#[allow(clippy::struct_excessive_bools)] -pub struct ConfigFlags { - /// Create a default config file in the current directory - #[arg(long)] - pub init_config: bool, - - /// Select a named configuration to use - #[arg(long = "config")] - pub config_name: Option, - - /// Add a new config - #[arg(long)] - pub config_add: Option, - - /// Remove a saved configuration - #[arg(long)] - pub config_remove: Option, - - /// List all saved configs - #[arg(long)] - pub list_configs: bool, -} - -/// Profile management flags. -#[derive(ClapArgs)] -pub struct ProfileFlags { - /// Add a new profile: NAME REPO1 [REPO2 ...] - #[arg(long, num_args = 2..)] - pub profile_add: Option>, - - /// Remove a saved profile - #[arg(long)] - pub profile_remove: Option, - - /// List all saved profiles - #[arg(long)] - pub list_profiles: bool, -} +pub use flags::{BuildFlags, ConfigFlags, ConnectionFlags, MonoRepoFlags, ProfileFlags}; +pub use resolved::{ResolvedArgs, ResolvedBuildFlags, ResolvedConnectionFlags, ResolvedMonoFlags}; /// Top-level CLI arguments for star-setup. #[derive(Parser)] @@ -145,40 +40,6 @@ pub struct Args { pub profile: ProfileFlags, } -/// Resolved connection flags after applying config and CLI overrides. -pub struct ResolvedConnectionFlags { - pub ssh: bool, - pub verbose: bool, -} - -/// Resolved build flags after applying config and CLI overrides. -pub struct ResolvedBuildFlags { - pub build_type: String, - pub build_dir: String, - pub no_build: bool, - pub clean: bool, -} - -/// Resolved mono-repo flags after applying config and CLI overrides. -pub struct ResolvedMonoFlags { - pub mono_repo: bool, - pub mono_dir: String, - pub repos: Option>, - pub profile: Option, -} - -/// Fully resolved arguments ready for command execution. -pub struct ResolvedArgs { - pub repo: Option, - pub cmake_flags: Vec, - pub yes: bool, - pub connection: ResolvedConnectionFlags, - pub build: ResolvedBuildFlags, - pub mono: ResolvedMonoFlags, - pub config: ConfigFlags, - pub profile: ProfileFlags, -} - /// Resolves a boolean flag from CLI positive/negative flags, config value, and a default. /// Negative flag takes highest priority, then positive, then config, then default. #[must_use] diff --git a/src/cli/resolved.rs b/src/cli/resolved.rs new file mode 100644 index 0000000..062abc4 --- /dev/null +++ b/src/cli/resolved.rs @@ -0,0 +1,35 @@ +use crate::cli::flags::{ConfigFlags, ProfileFlags}; + +/// Resolved connection flags after applying config and CLI overrides. +pub struct ResolvedConnectionFlags { + pub ssh: bool, + pub verbose: bool, +} + +/// Resolved build flags after applying config and CLI overrides. +pub struct ResolvedBuildFlags { + pub build_type: String, + pub build_dir: String, + pub no_build: bool, + pub clean: bool, +} + +/// Resolved mono-repo flags after applying config and CLI overrides. +pub struct ResolvedMonoFlags { + pub mono_repo: bool, + pub mono_dir: String, + pub repos: Option>, + pub profile: Option, +} + +/// Fully resolved arguments ready for command execution. +pub struct ResolvedArgs { + pub repo: Option, + pub cmake_flags: Vec, + pub yes: bool, + pub connection: ResolvedConnectionFlags, + pub build: ResolvedBuildFlags, + pub mono: ResolvedMonoFlags, + pub config: ConfigFlags, + pub profile: ProfileFlags, +} From 14a359f331e40eb55067e1789b96a4a3e18c62ff Mon Sep 17 00:00:00 2001 From: Masonlet Date: Mon, 22 Jun 2026 23:01:00 -0400 Subject: [PATCH 02/34] feat(cli): add BuildType/BuildSystem enums and migrate build_type from String --- src/cli/build.rs | 63 +++++++++++++++++++++++++++++++++++++++++++++ src/cli/mod.rs | 12 +++++---- src/cli/resolved.rs | 3 ++- src/commands.rs | 10 +++++-- src/config.rs | 9 ++++--- src/interactive.rs | 10 +++++-- tests/cli.rs | 22 ++++++++-------- tests/commands.rs | 4 +-- tests/config.rs | 15 +++++------ 9 files changed, 113 insertions(+), 35 deletions(-) create mode 100644 src/cli/build.rs diff --git a/src/cli/build.rs b/src/cli/build.rs new file mode 100644 index 0000000..33abd96 --- /dev/null +++ b/src/cli/build.rs @@ -0,0 +1,63 @@ +use serde::{Deserialize, Serialize}; +use std::path::Path; + +pub enum BuildSystem { + /// `CMake` build system (`CMakeLists.txt`). + Cmake, +} + +#[derive(Default, Clone, Serialize, Deserialize, PartialEq, Debug)] +#[serde(rename_all = "kebab-case")] +pub enum BuildType { + /// Debug build with no optimizations. + #[default] + Debug, + /// Optimized release build. + Release, + /// Release build with debug info. + RelWithDebInfo, + /// Minimized binary size release build. + MinSizeRel, +} + +impl BuildType { + #[must_use] + pub fn to_cmake(&self) -> &'static str { + match self { + Self::Debug => "Debug", + Self::Release => "Release", + Self::RelWithDebInfo => "RelWithDebInfo", + Self::MinSizeRel => "MinSizeRel", + } + } +} + +impl std::str::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" + )), + } + } +} + +/// 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) -> Result { + if dir.join("CMakeLists.txt").exists() { + Ok(BuildSystem::Cmake) + } else { + Err("No supported build system found".into()) + } +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs index c30e240..5118f84 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1,7 +1,9 @@ use clap::Parser; +pub mod build; pub mod flags; pub mod resolved; use crate::config::SetupConfig; +pub use build::{detect_build_system, BuildSystem, BuildType}; pub use flags::{BuildFlags, ConfigFlags, ConnectionFlags, MonoRepoFlags, ProfileFlags}; pub use resolved::{ResolvedArgs, ResolvedBuildFlags, ResolvedConnectionFlags, ResolvedMonoFlags}; @@ -104,11 +106,11 @@ pub fn resolve_with_config(mut args: Args, config: &SetupConfig) -> Result()? + } else { + default.map(|e| e.build_type.clone()).unwrap_or_default() + }, build_dir: args .build .build_dir diff --git a/src/cli/resolved.rs b/src/cli/resolved.rs index 062abc4..a2c6679 100644 --- a/src/cli/resolved.rs +++ b/src/cli/resolved.rs @@ -1,3 +1,4 @@ +use crate::cli::build::BuildType; use crate::cli::flags::{ConfigFlags, ProfileFlags}; /// Resolved connection flags after applying config and CLI overrides. @@ -8,7 +9,7 @@ pub struct ResolvedConnectionFlags { /// Resolved build flags after applying config and CLI overrides. pub struct ResolvedBuildFlags { - pub build_type: String, + pub build_type: BuildType, pub build_dir: String, pub no_build: bool, pub clean: bool, diff --git a/src/commands.rs b/src/commands.rs index 705be16..32b9f16 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -55,7 +55,7 @@ fn cmake_build( mono: bool, output: &mut impl Write, ) -> Result<(), String> { - let build_type_flag = format!("-DCMAKE_BUILD_TYPE={}", args.build.build_type); + let build_type_flag = format!("-DCMAKE_BUILD_TYPE={}", args.build.build_type.to_cmake()); let mut cmake_cmd = if mono { vec!["cmake", "-DBUILD_LOCAL=ON", &build_type_flag, ".."] } else { @@ -71,7 +71,13 @@ fn cmake_build( if !args.build.no_build { writeln!(output, "Building project\n").ok(); run_command( - &["cmake", "--build", ".", "--config", &args.build.build_type], + &[ + "cmake", + "--build", + ".", + "--config", + args.build.build_type.to_cmake(), + ], Some(build_path), args.connection.verbose, output, diff --git a/src/config.rs b/src/config.rs index b3ca373..b0c55a8 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,5 +1,6 @@ //! Configuration file management. +use crate::cli::BuildType; use crate::utils::confirm; use serde::{Deserialize, Serialize}; use std::collections::HashMap; @@ -15,8 +16,8 @@ use std::path::PathBuf; pub struct ConfigEntry { /// Use SSH instead of HTTPS for cloning. pub ssh: bool, - /// `CMake` build type (e.g. `Debug`, `Release`). - pub build_type: String, + /// Build type (e.g. `Debug`, `Release`). + pub build_type: BuildType, /// Build directory name. pub build_dir: String, /// Mono-repo build directory name. @@ -74,7 +75,7 @@ pub fn has_config(config: &SetupConfig, name: &str) -> bool { pub fn format_entry(e: &ConfigEntry) -> String { let mut out = String::new(); writeln!(out, " SSH: {}", e.ssh).ok(); - writeln!(out, " Build Type: {}", e.build_type).ok(); + writeln!(out, " Build Type: {}", e.build_type.to_cmake()).ok(); writeln!(out, " Build Directory: {}", e.build_dir).ok(); writeln!(out, " Mono-build Directory: {}", e.mono_dir).ok(); writeln!(out, " No-build flag: {}", e.no_build).ok(); @@ -195,7 +196,7 @@ pub fn create_default_config( "default".to_string(), ConfigEntry { ssh: false, - build_type: "Debug".to_string(), + build_type: BuildType::Debug, build_dir: "build".to_string(), mono_dir: "build-mono".to_string(), no_build: false, diff --git a/src/interactive.rs b/src/interactive.rs index f9b4ece..5032bd3 100644 --- a/src/interactive.rs +++ b/src/interactive.rs @@ -1,6 +1,6 @@ //! Interactive CLI mode. -use crate::cli::ResolvedArgs; +use crate::cli::{BuildType, ResolvedArgs}; use std::io::{BufRead, Write}; /// Prompts the user for a required string value. @@ -134,7 +134,13 @@ pub fn interactive_mode( } } - args.build.build_type = ask_default("Build type", &args.build.build_type, input, output)?; + let build_type_str = ask_default( + "Build type", + args.build.build_type.to_cmake(), + input, + output, + )?; + args.build.build_type = build_type_str.parse::()?; args.build.build_dir = ask_default("Build directory", &args.build.build_dir, input, output)?; if args.cmake_flags.is_empty() { diff --git a/tests/cli.rs b/tests/cli.rs index 4b7d675..6ea5d6d 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -1,11 +1,11 @@ use star_setup::cli::{ - resolve_bool, resolve_with_config, Args, BuildFlags, ConfigFlags, ConnectionFlags, MonoRepoFlags, - ProfileFlags, + Args, BuildFlags, BuildType, ConfigFlags, ConnectionFlags, MonoRepoFlags, ProfileFlags, resolve_bool, resolve_with_config, }; use star_setup::config::{ConfigEntry, SetupConfig}; #[test] fn test_resolve_bool() { + #[allow(clippy::struct_excessive_bools)] struct Case { flag_pos: bool, flag_neg: bool, @@ -115,7 +115,7 @@ fn test_resolve_with_config_defaults_when_no_config() { let resolved = resolve_with_config(default_args(), &config).unwrap(); assert!(!resolved.connection.ssh); assert!(!resolved.connection.verbose); - assert_eq!(resolved.build.build_type, "Debug"); + assert_eq!(resolved.build.build_type, BuildType::Debug); assert_eq!(resolved.build.build_dir, "build"); assert_eq!(resolved.mono.mono_dir, "build-mono"); assert!(!resolved.build.no_build); @@ -130,7 +130,7 @@ fn test_resolve_with_config_applies_config_defaults() { ConfigEntry { ssh: true, verbose: true, - build_type: "Release".to_string(), + build_type: BuildType::Release, build_dir: "out".to_string(), mono_dir: "mono".to_string(), no_build: true, @@ -141,7 +141,7 @@ fn test_resolve_with_config_applies_config_defaults() { let resolved = resolve_with_config(default_args(), &config).unwrap(); assert!(resolved.connection.ssh); assert!(resolved.connection.verbose); - assert_eq!(resolved.build.build_type, "Release"); + assert_eq!(resolved.build.build_type, BuildType::Release); assert_eq!(resolved.build.build_dir, "out"); assert!(resolved.build.no_build); assert!(resolved.build.clean); @@ -156,7 +156,7 @@ fn test_resolve_with_config_cli_overrides_config() { ConfigEntry { ssh: false, verbose: false, - build_type: "Debug".to_string(), + build_type: BuildType::Debug, build_dir: "build".to_string(), mono_dir: "build-mono".to_string(), no_build: false, @@ -169,7 +169,7 @@ fn test_resolve_with_config_cli_overrides_config() { 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, "Release"); + assert_eq!(resolved.build.build_type, BuildType::Release); } #[test] @@ -207,7 +207,7 @@ fn test_resolve_with_config_named_config_pulls_correct_values() { ConfigEntry { ssh: true, verbose: false, - build_type: "RelWithDebInfo".to_string(), + build_type: BuildType::RelWithDebInfo, build_dir: "out".to_string(), mono_dir: "mono".to_string(), no_build: false, @@ -219,7 +219,7 @@ fn test_resolve_with_config_named_config_pulls_correct_values() { args.config.config_name = Some("myconfig".to_string()); let resolved = resolve_with_config(args, &config).unwrap(); assert!(resolved.connection.ssh); - assert_eq!(resolved.build.build_type, "RelWithDebInfo"); + assert_eq!(resolved.build.build_type, BuildType::RelWithDebInfo); assert_eq!(resolved.build.build_dir, "out"); assert!(resolved.build.clean); } @@ -232,7 +232,7 @@ fn test_resolve_with_config_cli_cmake_flags_not_overwritten_by_config() { ConfigEntry { ssh: false, verbose: false, - build_type: "Debug".to_string(), + build_type: BuildType::Debug, build_dir: "build".to_string(), mono_dir: "build-mono".to_string(), no_build: false, @@ -254,7 +254,7 @@ fn test_resolve_with_config_negative_flags_override_config() { ConfigEntry { ssh: true, verbose: true, - build_type: "Debug".to_string(), + build_type: BuildType::Debug, build_dir: "build".to_string(), mono_dir: "build-mono".to_string(), no_build: true, diff --git a/tests/commands.rs b/tests/commands.rs index 8ec7bfe..e79ca21 100644 --- a/tests/commands.rs +++ b/tests/commands.rs @@ -24,7 +24,7 @@ fn test_resolve_test_repo() { resolve_test_repo(input), Ok("user/repo".to_string()), "Failed for input: {input}" - ) + ); } } @@ -45,7 +45,7 @@ fn test_resolve_test_repo_errors() { ), ]; for (input, error) in cases { - assert_eq!(resolve_test_repo(input), Err(error.to_string())) + assert_eq!(resolve_test_repo(input), Err(error.to_string())); } } diff --git a/tests/config.rs b/tests/config.rs index 1922de5..f382cfd 100644 --- a/tests/config.rs +++ b/tests/config.rs @@ -1,7 +1,6 @@ -use star_setup::config::{ - format_entry, has_config, insert_config, load_config, remove_config_entry, save_config, - ConfigEntry, SetupConfig, -}; +use star_setup::{cli::BuildType, config::{ + ConfigEntry, SetupConfig, format_entry, has_config, insert_config, load_config, remove_config_entry, save_config, +}}; use std::path::PathBuf; mod common; use common::{empty_input, sink}; @@ -9,7 +8,7 @@ use common::{empty_input, sink}; fn sample_entry() -> ConfigEntry { ConfigEntry { ssh: true, - build_type: "Release".to_string(), + build_type: BuildType::Release, build_dir: "build".to_string(), mono_dir: "mono".to_string(), no_build: false, @@ -91,7 +90,7 @@ fn test_save_and_load_roundtrip() { "default".to_string(), ConfigEntry { ssh: true, - build_type: "Release".to_string(), + build_type: BuildType::Release, build_dir: "build".to_string(), mono_dir: "mono".to_string(), no_build: false, @@ -105,7 +104,7 @@ fn test_save_and_load_roundtrip() { 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, "Release"); + 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()); } @@ -211,7 +210,7 @@ fn test_add_config_aborts_when_exists_and_not_confirmed() { "myconfig", ConfigEntry { ssh: false, // different from sample_entry's ssh: true - build_type: "Debug".to_string(), + build_type: BuildType::Debug, build_dir: "build".to_string(), mono_dir: "mono".to_string(), no_build: false, From 09dffa0851e8493409c60555890d54a424585c08 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Mon, 22 Jun 2026 23:19:09 -0400 Subject: [PATCH 03/34] refactor: extract prompts into their own module --- src/interactive.rs | 51 +-------------------------------------- src/lib.rs | 1 + src/prompts.rs | 59 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 61 insertions(+), 50 deletions(-) create mode 100644 src/prompts.rs diff --git a/src/interactive.rs b/src/interactive.rs index 5032bd3..c921946 100644 --- a/src/interactive.rs +++ b/src/interactive.rs @@ -2,56 +2,7 @@ use crate::cli::{BuildType, ResolvedArgs}; use std::io::{BufRead, Write}; - -/// Prompts the user for a required string value. -fn ask(prompt: &str, input: &mut impl BufRead, output: &mut impl Write) -> Result { - write!(output, "{prompt}: ").ok(); - output.flush().ok(); - let mut line = String::new(); - if input.read_line(&mut line).unwrap_or(0) == 0 { - return Err("unexpected end of input".to_string()); - } - Ok(line.trim().to_string()) -} - -/// Prompts the user for a string value, returning `default` if the input is empty. -fn ask_default( - prompt: &str, - default: &str, - input: &mut impl BufRead, - output: &mut impl Write, -) -> Result { - write!(output, "{prompt} [{default}]: ").ok(); - output.flush().ok(); - let mut line = String::new(); - if 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() { - default.to_string() - } else { - val - }) -} - -/// Prompts the user for a yes/no answer, returning `default` if the input is empty. -fn ask_yesno( - prompt: &str, - default: bool, - input: &mut impl BufRead, - output: &mut impl Write, -) -> Result { - let default_char = if default { "Y" } else { "N" }; - write!(output, "{prompt} (y/n) [{default_char}]: ").ok(); - output.flush().ok(); - let mut line = String::new(); - if 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") }) -} +use crate::prompts::{ask, ask_yesno, ask_default}; /// Interactive CLI mode — prompts for any unset arguments. /// # Errors diff --git a/src/lib.rs b/src/lib.rs index c71ebea..6841e7a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -7,3 +7,4 @@ pub mod interactive; pub mod profiles; pub mod repository; pub mod utils; +pub mod prompts; diff --git a/src/prompts.rs b/src/prompts.rs new file mode 100644 index 0000000..094dc85 --- /dev/null +++ b/src/prompts.rs @@ -0,0 +1,59 @@ +//! Interactive prompt helpers. + +use std::io::{BufRead, Write}; + +/// Prompts the user for a required string value. +/// # Errors +/// Returns an error if stdin reaches EOF unexpectedly. +pub fn ask(prompt: &str, input: &mut impl BufRead, output: &mut impl Write) -> Result { + write!(output, "{prompt}: ").ok(); + output.flush().ok(); + let mut line = String::new(); + if input.read_line(&mut line).unwrap_or(0) == 0 { + return Err("unexpected end of input".to_string()); + } + Ok(line.trim().to_string()) +} + +/// 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, + input: &mut impl BufRead, + output: &mut impl Write, +) -> Result { + write!(output, "{prompt} [{default}]: ").ok(); + output.flush().ok(); + let mut line = String::new(); + if 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() { + default.to_string() + } else { + val + }) +} + +/// Prompts the user for a yes/no answer, returning `default` if the input is empty. +/// # Errors +/// Returns an error if stdin reaches EOF unexpectedly. +pub fn ask_yesno( + prompt: &str, + default: bool, + input: &mut impl BufRead, + output: &mut impl Write, +) -> Result { + let default_char = if default { "Y" } else { "N" }; + write!(output, "{prompt} (y/n) [{default_char}]: ").ok(); + output.flush().ok(); + let mut line = String::new(); + if 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") }) +} From f7d2d68a10f67d99ba78e7d7557c8f745d3d044f Mon Sep 17 00:00:00 2001 From: Masonlet Date: Mon, 22 Jun 2026 23:48:56 -0400 Subject: [PATCH 04/34] refactor(commands): split commands.rs into submodules (build, header, mono, single) --- src/cli/flags.rs | 4 + src/cli/mod.rs | 10 +- src/cli/resolved.rs | 2 +- src/commands/build.rs | 46 ++++++ src/commands/header.rs | 39 +++++ src/commands/mod.rs | 8 + src/{commands.rs => commands/mono.rs} | 207 ++++---------------------- src/commands/single.rs | 81 ++++++++++ src/interactive.rs | 6 +- src/lib.rs | 2 +- src/main.rs | 2 +- src/prompts.rs | 6 +- tests/cli.rs | 11 +- tests/commands.rs | 2 +- tests/config.rs | 10 +- tests/interactive.rs | 2 +- 16 files changed, 235 insertions(+), 203 deletions(-) create mode 100644 src/commands/build.rs create mode 100644 src/commands/header.rs create mode 100644 src/commands/mod.rs rename src/{commands.rs => commands/mono.rs} (61%) create mode 100644 src/commands/single.rs diff --git a/src/cli/flags.rs b/src/cli/flags.rs index da7d7da..3e501bf 100644 --- a/src/cli/flags.rs +++ b/src/cli/flags.rs @@ -42,6 +42,10 @@ pub struct BuildFlags { /// Do not clean build directory #[arg(long, conflicts_with = "clean")] pub no_clean: bool, + + /// Additional `CMake` arguments + #[arg(long = "cmake-arg", action = clap::ArgAction::Append)] + pub cmake_flags: Vec, } #[derive(ClapArgs)] diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 5118f84..679f69f 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -18,10 +18,6 @@ pub struct Args { /// Repository name (username/repo) or full GitHub URL pub repo: Option, - /// Additional `CMake` arguments - #[arg(long = "cmake-arg", action = clap::ArgAction::Append)] - pub cmake_flags: Vec, - /// Skip confirmation prompts (non-interactive mode) #[arg(short = 'y', long)] pub yes: bool, @@ -92,8 +88,8 @@ pub fn resolve_with_config(mut args: Args, config: &SetupConfig) -> Result Result Result, } /// Resolved mono-repo flags after applying config and CLI overrides. @@ -26,7 +27,6 @@ pub struct ResolvedMonoFlags { /// Fully resolved arguments ready for command execution. pub struct ResolvedArgs { pub repo: Option, - pub cmake_flags: Vec, pub yes: bool, pub connection: ResolvedConnectionFlags, pub build: ResolvedBuildFlags, diff --git a/src/commands/build.rs b/src/commands/build.rs new file mode 100644 index 0000000..44aee77 --- /dev/null +++ b/src/commands/build.rs @@ -0,0 +1,46 @@ +//! Build system dispatch and per-system build functions. + +use crate::cli::ResolvedArgs; +use crate::utils::run_command; +use std::io::Write; +use std::path::Path; + +/// Runs `CMake` configuration and optionally builds the project in `build_path`. +/// # Errors +/// Returns an error if any `CMake` command fails. +pub fn cmake_build( + args: &ResolvedArgs, + build_path: &Path, + mono: bool, + output: &mut impl Write, +) -> Result<(), String> { + let build_type_flag = format!("-DCMAKE_BUILD_TYPE={}", args.build.build_type.to_cmake()); + let mut cmake_cmd = if mono { + vec!["cmake", "-DBUILD_LOCAL=ON", &build_type_flag, ".."] + } else { + vec!["cmake", "..", &build_type_flag] + }; + cmake_cmd.extend(args.build.cmake_flags.iter().map(String::as_str)); + run_command( + &cmake_cmd, + Some(build_path), + args.connection.verbose, + output, + )?; + if !args.build.no_build { + writeln!(output, "Building project\n").ok(); + run_command( + &[ + "cmake", + "--build", + ".", + "--config", + args.build.build_type.to_cmake(), + ], + Some(build_path), + args.connection.verbose, + output, + )?; + } + Ok(()) +} diff --git a/src/commands/header.rs b/src/commands/header.rs new file mode 100644 index 0000000..16ff187 --- /dev/null +++ b/src/commands/header.rs @@ -0,0 +1,39 @@ +//! Mode header rendering +use std::io::Write; + +/// Header information printed at the start of each command mode. +pub struct ModeHeader<'a> { + pub mode: &'a str, + pub test_repo: Option<&'a str>, + pub repo_name: Option<&'a str>, + pub use_ssh: bool, + pub mono_dir: Option<&'a str>, + pub profile: Option<&'a str>, + pub lib_count: Option, +} + +/// Prints a formatted header summarizing the current mode and configuration. +pub fn print_mode_header(header: &ModeHeader<'_>, output: &mut impl Write) { + writeln!(output, "Star Setup: {}", header.mode).ok(); + if let Some(p) = header.profile { + writeln!(output, " Profile: {p}").ok(); + } + if let Some(r) = header.test_repo { + writeln!(output, " Test Repository: {r}").ok(); + } else if let Some(r) = header.repo_name { + writeln!(output, " Repository: {r}").ok(); + } + writeln!( + output, + " Clone Method: {}", + if header.use_ssh { "SSH" } else { "HTTPS" } + ) + .ok(); + if let Some(d) = header.mono_dir { + writeln!(output, " Directory: {d}").ok(); + } + if let Some(c) = header.lib_count { + writeln!(output, " Libraries: {c}").ok(); + } + writeln!(output).ok(); +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs new file mode 100644 index 0000000..a4a5b55 --- /dev/null +++ b/src/commands/mod.rs @@ -0,0 +1,8 @@ +pub mod build; +pub mod header; +pub mod mono; +pub mod single; +pub use mono::{ + create_mono_repo_cmakelists, mono_repo_mode, resolve_repos_for_mono, resolve_test_repo, +}; +pub use single::single_repo_mode; diff --git a/src/commands.rs b/src/commands/mono.rs similarity index 61% rename from src/commands.rs rename to src/commands/mono.rs index 32b9f16..3fc2c36 100644 --- a/src/commands.rs +++ b/src/commands/mono.rs @@ -1,188 +1,13 @@ -//! Command handlers for single and mono-repo modes. - use crate::cli::ResolvedArgs; +use crate::commands::build::cmake_build; +use crate::commands::header::{print_mode_header, ModeHeader}; use crate::config::SetupConfig; use crate::profiles::list_profiles; -use crate::repository::{clone_repository, repo_dir_name, resolve_repo_url}; -use crate::utils::{confirm, run_command}; +use crate::repository::{clone_repository, repo_dir_name}; use std::fs; -use std::io::{BufRead, Write}; +use std::io::Write; use std::path::{Path, PathBuf}; -/// Header information printed at the start of each command mode. -struct ModeHeader<'a> { - mode: &'a str, - test_repo: Option<&'a str>, - repo_name: Option<&'a str>, - use_ssh: bool, - mono_dir: Option<&'a str>, - profile: Option<&'a str>, - lib_count: Option, -} - -/// Prints a formatted header summarizing the current mode and configuration. -fn print_mode_header(header: &ModeHeader<'_>, output: &mut impl Write) { - writeln!(output, "Star Setup: {}", header.mode).ok(); - if let Some(p) = header.profile { - writeln!(output, " Profile: {p}").ok(); - } - if let Some(r) = header.test_repo { - writeln!(output, " Test Repository: {r}").ok(); - } else if let Some(r) = header.repo_name { - writeln!(output, " Repository: {r}").ok(); - } - writeln!( - output, - " Clone Method: {}", - if header.use_ssh { "SSH" } else { "HTTPS" } - ) - .ok(); - if let Some(d) = header.mono_dir { - writeln!(output, " Directory: {d}").ok(); - } - if let Some(c) = header.lib_count { - writeln!(output, " Libraries: {c}").ok(); - } - writeln!(output).ok(); -} - -/// Runs `CMake` configuration and optionally builds the project in `build_path`. -/// # Errors -/// Returns an error if any `CMake` command fails. -fn cmake_build( - args: &ResolvedArgs, - build_path: &Path, - mono: bool, - output: &mut impl Write, -) -> Result<(), String> { - let build_type_flag = format!("-DCMAKE_BUILD_TYPE={}", args.build.build_type.to_cmake()); - let mut cmake_cmd = if mono { - vec!["cmake", "-DBUILD_LOCAL=ON", &build_type_flag, ".."] - } else { - vec!["cmake", "..", &build_type_flag] - }; - cmake_cmd.extend(args.cmake_flags.iter().map(String::as_str)); - run_command( - &cmake_cmd, - Some(build_path), - args.connection.verbose, - output, - )?; - if !args.build.no_build { - writeln!(output, "Building project\n").ok(); - run_command( - &[ - "cmake", - "--build", - ".", - "--config", - args.build.build_type.to_cmake(), - ], - Some(build_path), - args.connection.verbose, - output, - )?; - } - Ok(()) -} - -/// Clones and configures a single repository. -/// # Errors -/// Returns an error if no repository is specified, or if any git or `CMake` command fails. -pub fn single_repo_mode( - args: &ResolvedArgs, - input: &mut impl BufRead, - output: &mut impl Write, -) -> Result<(), String> { - let repo = args.repo.as_deref().ok_or("No repository specified")?; - let repo_url = resolve_repo_url(repo, args.connection.ssh); - let dir_name = repo_dir_name(repo); - - print_mode_header( - &ModeHeader { - mode: "Single Repository Mode", - test_repo: None, - repo_name: Some(&dir_name), - use_ssh: args.connection.ssh, - mono_dir: None, - profile: None, - lib_count: None, - }, - output, - ); - - let repo_path = Path::new(&dir_name); - if repo_path.exists() { - writeln!(output, "Repository {dir_name} already exists").ok(); - if confirm("Update existing repository?", args.yes, input, output)? { - writeln!(output, "Updating {dir_name}\n").ok(); - run_command( - &["git", "pull"], - Some(Path::new(&dir_name)), - args.connection.verbose, - output, - )?; - } - } else { - writeln!(output, "Cloning {dir_name}\n").ok(); - run_command( - &["git", "clone", &repo_url, &dir_name], - None, - args.connection.verbose, - output, - )?; - } - - let build_path = PathBuf::from(&dir_name).join(&args.build.build_dir); - if args.build.clean && build_path.exists() { - writeln!(output, "Cleaning build directory\n").ok(); - fs::remove_dir_all(&build_path).map_err(|e| e.to_string())?; - } - - writeln!( - output, - "Creating build directory: {}\n", - args.build.build_dir - ) - .ok(); - fs::create_dir_all(&build_path).map_err(|e| e.to_string())?; - - writeln!(output, "Configuring with CMake\n").ok(); - cmake_build(args, build_path.as_path(), false, output)?; - - writeln!( - output, - "Project finished in {dir_name}/{}", - args.build.build_dir - ) - .ok(); - Ok(()) -} - -/// Normalizes a repository input to `username/repo` format. -/// # Errors -/// Returns an error if the input is not a recognizable GitHub URL or `username/repo` format. -pub fn resolve_test_repo(repo_input: &str) -> Result { - let repo_input = repo_input.trim_end_matches('/'); - if repo_input.starts_with("http") || repo_input.starts_with("git@") { - if repo_input.contains("github.com/") || repo_input.contains("github.com:") { - let parts: Vec<&str> = repo_input.split('/').collect(); - if parts.len() < 2 { - return Err("Repository URL missing repository name".to_string()); - } - let user = parts[parts.len() - 2].split(':').next_back().unwrap_or(""); - let repo = parts[parts.len() - 1].trim_end_matches(".git"); - Ok(format!("{user}/{repo}")) - } else { - Err("Could not parse repository URL".to_string()) - } - } else if repo_input.contains('/') { - Ok(repo_input.to_string()) - } else { - Err("Repository must be in format 'username/repo' for mono-repo mode".to_string()) - } -} - /// Generates a root `CMakeLists.txt` wiring all repositories as subdirectories. /// # Errors /// Returns an error if the `CMakeLists.txt` file cannot be written to `mono_dir` @@ -286,6 +111,30 @@ pub fn resolve_repos_for_mono( } } +/// Normalizes a repository input to `username/repo` format. +/// # Errors +/// Returns an error if the input is not a recognizable GitHub URL or `username/repo` format. +pub fn resolve_test_repo(repo_input: &str) -> Result { + let repo_input = repo_input.trim_end_matches('/'); + if repo_input.starts_with("http") || repo_input.starts_with("git@") { + if repo_input.contains("github.com/") || repo_input.contains("github.com:") { + let parts: Vec<&str> = repo_input.split('/').collect(); + if parts.len() < 2 { + return Err("Repository URL missing repository name".to_string()); + } + let user = parts[parts.len() - 2].split(':').next_back().unwrap_or(""); + let repo = parts[parts.len() - 1].trim_end_matches(".git"); + Ok(format!("{user}/{repo}")) + } else { + Err("Could not parse repository URL".to_string()) + } + } else if repo_input.contains('/') { + Ok(repo_input.to_string()) + } else { + Err("Repository must be in format 'username/repo' for mono-repo mode".to_string()) + } +} + /// Clones and configures a mono-repo ecosystem from a profile or explicit repository list. /// # Errors /// Returns an error if no repository is specified, directory creation fails, or any git or `CMake` command fails. diff --git a/src/commands/single.rs b/src/commands/single.rs new file mode 100644 index 0000000..0417e74 --- /dev/null +++ b/src/commands/single.rs @@ -0,0 +1,81 @@ +use crate::cli::ResolvedArgs; +use crate::commands::build::cmake_build; +use crate::commands::header::{print_mode_header, ModeHeader}; +use crate::repository::{repo_dir_name, resolve_repo_url}; +use crate::utils::{confirm, run_command}; +use std::fs; +use std::io::{BufRead, Write}; +use std::path::{Path, PathBuf}; + +/// Clones and configures a single repository. +/// # Errors +/// Returns an error if no repository is specified, or if any git or `CMake` command fails. +pub fn single_repo_mode( + args: &ResolvedArgs, + input: &mut impl BufRead, + output: &mut impl Write, +) -> Result<(), String> { + let repo = args.repo.as_deref().ok_or("No repository specified")?; + let repo_url = resolve_repo_url(repo, args.connection.ssh); + let dir_name = repo_dir_name(repo); + + print_mode_header( + &ModeHeader { + mode: "Single Repository Mode", + test_repo: None, + repo_name: Some(&dir_name), + use_ssh: args.connection.ssh, + mono_dir: None, + profile: None, + lib_count: None, + }, + output, + ); + + let repo_path = Path::new(&dir_name); + if repo_path.exists() { + writeln!(output, "Repository {dir_name} already exists").ok(); + if confirm("Update existing repository?", args.yes, input, output)? { + writeln!(output, "Updating {dir_name}\n").ok(); + run_command( + &["git", "pull"], + Some(Path::new(&dir_name)), + args.connection.verbose, + output, + )?; + } + } else { + writeln!(output, "Cloning {dir_name}\n").ok(); + run_command( + &["git", "clone", &repo_url, &dir_name], + None, + args.connection.verbose, + output, + )?; + } + + let build_path = PathBuf::from(&dir_name).join(&args.build.build_dir); + if args.build.clean && build_path.exists() { + writeln!(output, "Cleaning build directory\n").ok(); + fs::remove_dir_all(&build_path).map_err(|e| e.to_string())?; + } + + writeln!( + output, + "Creating build directory: {}\n", + args.build.build_dir + ) + .ok(); + fs::create_dir_all(&build_path).map_err(|e| e.to_string())?; + + writeln!(output, "Configuring with CMake\n").ok(); + cmake_build(args, build_path.as_path(), false, output)?; + + writeln!( + output, + "Project finished in {dir_name}/{}", + args.build.build_dir + ) + .ok(); + Ok(()) +} diff --git a/src/interactive.rs b/src/interactive.rs index c921946..ed67a4b 100644 --- a/src/interactive.rs +++ b/src/interactive.rs @@ -1,8 +1,8 @@ //! Interactive CLI mode. use crate::cli::{BuildType, ResolvedArgs}; +use crate::prompts::{ask, ask_default, ask_yesno}; use std::io::{BufRead, Write}; -use crate::prompts::{ask, ask_yesno, ask_default}; /// Interactive CLI mode — prompts for any unset arguments. /// # Errors @@ -94,10 +94,10 @@ pub fn interactive_mode( args.build.build_type = build_type_str.parse::()?; args.build.build_dir = ask_default("Build directory", &args.build.build_dir, input, output)?; - if args.cmake_flags.is_empty() { + if args.build.cmake_flags.is_empty() { let cmake_extra = ask_default("Additional CMake args (space separated)", "", input, output)?; if !cmake_extra.is_empty() { - args.cmake_flags = cmake_extra.split_whitespace().map(String::from).collect(); + args.build.cmake_flags = cmake_extra.split_whitespace().map(String::from).collect(); } } diff --git a/src/lib.rs b/src/lib.rs index 6841e7a..5094faf 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,6 +5,6 @@ pub mod commands; pub mod config; pub mod interactive; pub mod profiles; +pub mod prompts; pub mod repository; pub mod utils; -pub mod prompts; diff --git a/src/main.rs b/src/main.rs index 6a1fe64..4e88564 100644 --- a/src/main.rs +++ b/src/main.rs @@ -67,7 +67,7 @@ fn main() { no_build: args.build.no_build, clean: args.build.clean, verbose: args.connection.verbose, - cmake_flags: args.cmake_flags.clone(), + cmake_flags: args.build.cmake_flags.clone(), }; if let Err(e) = add_config(&mut config, name, entry, args.yes, &mut stdin, &mut stdout) { eprintln!("Error: {e}"); diff --git a/src/prompts.rs b/src/prompts.rs index 094dc85..7263e02 100644 --- a/src/prompts.rs +++ b/src/prompts.rs @@ -5,7 +5,11 @@ use std::io::{BufRead, Write}; /// Prompts the user for a required string value. /// # Errors /// Returns an error if stdin reaches EOF unexpectedly. -pub fn ask(prompt: &str, input: &mut impl BufRead, output: &mut impl Write) -> Result { +pub fn ask( + prompt: &str, + input: &mut impl BufRead, + output: &mut impl Write, +) -> Result { write!(output, "{prompt}: ").ok(); output.flush().ok(); let mut line = String::new(); diff --git a/tests/cli.rs b/tests/cli.rs index 6ea5d6d..53c32b8 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -1,5 +1,6 @@ use star_setup::cli::{ - Args, BuildFlags, BuildType, ConfigFlags, ConnectionFlags, MonoRepoFlags, ProfileFlags, resolve_bool, resolve_with_config, + resolve_bool, resolve_with_config, Args, BuildFlags, BuildType, ConfigFlags, ConnectionFlags, + MonoRepoFlags, ProfileFlags, }; use star_setup::config::{ConfigEntry, SetupConfig}; @@ -71,7 +72,6 @@ fn test_resolve_bool() { fn default_args() -> Args { Args { repo: None, - cmake_flags: vec![], yes: false, connection: ConnectionFlags { ssh: false, @@ -86,6 +86,7 @@ fn default_args() -> Args { build: false, clean: false, no_clean: false, + cmake_flags: vec![], }, mono: MonoRepoFlags { mono_repo: false, @@ -145,7 +146,7 @@ fn test_resolve_with_config_applies_config_defaults() { assert_eq!(resolved.build.build_dir, "out"); assert!(resolved.build.no_build); assert!(resolved.build.clean); - assert_eq!(resolved.cmake_flags, vec!["-DTEST=ON"]); + assert_eq!(resolved.build.cmake_flags, vec!["-DTEST=ON"]); } #[test] @@ -241,9 +242,9 @@ fn test_resolve_with_config_cli_cmake_flags_not_overwritten_by_config() { }, ); let mut args = default_args(); - args.cmake_flags = vec!["-DCLI_FLAG=ON".to_string()]; + args.build.cmake_flags = vec!["-DCLI_FLAG=ON".to_string()]; let resolved = resolve_with_config(args, &config).unwrap(); - assert_eq!(resolved.cmake_flags, vec!["-DCLI_FLAG=ON"]); + assert_eq!(resolved.build.cmake_flags, vec!["-DCLI_FLAG=ON"]); } #[test] diff --git a/tests/commands.rs b/tests/commands.rs index e79ca21..57fc85c 100644 --- a/tests/commands.rs +++ b/tests/commands.rs @@ -76,7 +76,6 @@ fn test_create_mono_repo_cmakelists_empty_repos() { fn default_resolved() -> star_setup::cli::ResolvedArgs { let args = Args { repo: Some("user/repo".to_string()), - cmake_flags: vec![], yes: false, connection: ConnectionFlags { ssh: false, @@ -91,6 +90,7 @@ fn default_resolved() -> star_setup::cli::ResolvedArgs { build: false, clean: false, no_clean: false, + cmake_flags: vec![], }, mono: MonoRepoFlags { mono_repo: false, diff --git a/tests/config.rs b/tests/config.rs index f382cfd..97939ba 100644 --- a/tests/config.rs +++ b/tests/config.rs @@ -1,6 +1,10 @@ -use star_setup::{cli::BuildType, config::{ - ConfigEntry, SetupConfig, format_entry, has_config, insert_config, load_config, remove_config_entry, save_config, -}}; +use star_setup::{ + cli::BuildType, + config::{ + format_entry, has_config, insert_config, load_config, remove_config_entry, save_config, + ConfigEntry, SetupConfig, + }, +}; use std::path::PathBuf; mod common; use common::{empty_input, sink}; diff --git a/tests/interactive.rs b/tests/interactive.rs index 60a56ff..e847c22 100644 --- a/tests/interactive.rs +++ b/tests/interactive.rs @@ -7,7 +7,6 @@ use star_setup::interactive::interactive_mode; fn default_resolved() -> star_setup::cli::ResolvedArgs { let args = Args { repo: None, - cmake_flags: vec![], yes: false, connection: ConnectionFlags { ssh: false, @@ -22,6 +21,7 @@ fn default_resolved() -> star_setup::cli::ResolvedArgs { build: false, clean: false, no_clean: false, + cmake_flags: vec![], }, mono: MonoRepoFlags { mono_repo: false, From 9d650bae92972539e80ceaddc617f9466c93f42f Mon Sep 17 00:00:00 2001 From: Masonlet Date: Tue, 23 Jun 2026 00:01:57 -0400 Subject: [PATCH 05/34] refactor(cli): split cli/mod.rs into submodules (args, resolve) --- src/cli/args.rs | 45 +++++++++++++++ src/cli/mod.rs | 138 ++------------------------------------------- src/cli/resolve.rs | 98 ++++++++++++++++++++++++++++++++ 3 files changed, 147 insertions(+), 134 deletions(-) create mode 100644 src/cli/args.rs create mode 100644 src/cli/resolve.rs diff --git a/src/cli/args.rs b/src/cli/args.rs new file mode 100644 index 0000000..fbf50be --- /dev/null +++ b/src/cli/args.rs @@ -0,0 +1,45 @@ +use crate::cli::flags::{BuildFlags, ConfigFlags, ConnectionFlags, MonoRepoFlags, ProfileFlags}; +use crate::cli::resolve::resolve_with_config; +use crate::cli::resolved::ResolvedArgs; +use crate::config::SetupConfig; +use clap::Parser; + +/// Top-level CLI arguments for star-setup. +#[derive(Parser)] +#[command( + name = "star-setup", + about = "Lightweight CLI to clone, configure, and wire single or multi-repo ecosystems", + long_about = None, +)] +pub struct Args { + /// Repository name (username/repo) or full GitHub URL + pub repo: Option, + + /// Skip confirmation prompts (non-interactive mode) + #[arg(short = 'y', long)] + pub yes: bool, + + #[command(flatten)] + pub connection: ConnectionFlags, + + #[command(flatten)] + pub build: BuildFlags, + + #[command(flatten)] + pub mono: MonoRepoFlags, + + #[command(flatten)] + pub config: ConfigFlags, + + #[command(flatten)] + pub profile: ProfileFlags, +} + +impl Args { + /// Parses CLI arguments and resolves them against the provided `SetupConfig`. + /// # Errors + /// Returns an error if the named config does not exist in the loaded `SetupConfig`. + pub fn parse_with_config(config: &SetupConfig) -> Result { + resolve_with_config(Args::parse(), config) + } +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 679f69f..e3e4396 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1,140 +1,10 @@ -use clap::Parser; +pub mod args; pub mod build; pub mod flags; +pub mod resolve; pub mod resolved; -use crate::config::SetupConfig; pub use build::{detect_build_system, BuildSystem, BuildType}; pub use flags::{BuildFlags, ConfigFlags, ConnectionFlags, MonoRepoFlags, ProfileFlags}; pub use resolved::{ResolvedArgs, ResolvedBuildFlags, ResolvedConnectionFlags, ResolvedMonoFlags}; - -/// Top-level CLI arguments for star-setup. -#[derive(Parser)] -#[command( - name = "star-setup", - about = "Lightweight CLI to clone, configure, and wire single or multi-repo ecosystems", - long_about = None, -)] -pub struct Args { - /// Repository name (username/repo) or full GitHub URL - pub repo: Option, - - /// Skip confirmation prompts (non-interactive mode) - #[arg(short = 'y', long)] - pub yes: bool, - - #[command(flatten)] - pub connection: ConnectionFlags, - - #[command(flatten)] - pub build: BuildFlags, - - #[command(flatten)] - pub mono: MonoRepoFlags, - - #[command(flatten)] - pub config: ConfigFlags, - - #[command(flatten)] - pub profile: ProfileFlags, -} - -/// Resolves a boolean flag from CLI positive/negative flags, config value, and a default. -/// Negative flag takes highest priority, then positive, then config, then default. -#[must_use] -pub fn resolve_bool(positive: bool, negative: bool, config: Option, default: bool) -> bool { - if negative { - return false; - } - if positive { - return true; - } - config.unwrap_or(default) -} - -/// Resolves raw `Args` into `ResolvedArgs` by applying config defaults and CLI overrides. -/// # Errors -/// 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.config_name.as_deref().unwrap_or("default"); - if let Some(name) = &args.config.config_name { - if !config.configs.contains_key(name.as_str()) { - return Err(format!("Configuration '{name}' not found")); - } - } - - let default = config.configs.get(config_name); - - let ssh = resolve_bool( - args.connection.ssh, - args.connection.https, - default.map(|e| e.ssh), - false, - ); - let verbose = resolve_bool( - args.connection.verbose, - args.connection.no_verbose, - default.map(|e| e.verbose), - false, - ); - let no_build = resolve_bool( - args.build.no_build, - args.build.build, - default.map(|e| e.no_build), - false, - ); - let clean = resolve_bool( - args.build.clean, - args.build.no_clean, - default.map(|e| e.clean), - false, - ); - if args.build.cmake_flags.is_empty() { - args.build.cmake_flags = default.map_or_else(Vec::new, |e| e.cmake_flags.clone()); - } - - let repos = args.mono.repos.take(); - let profile = args.mono.profile.take(); - let mono_repo = args.mono.mono_repo || repos.is_some() || profile.is_some(); - - Ok(ResolvedArgs { - repo: args.repo, - yes: args.yes, - connection: ResolvedConnectionFlags { ssh, verbose }, - build: ResolvedBuildFlags { - build_type: if let Some(s) = args.build.build_type { - s.parse::()? - } else { - default.map(|e| e.build_type.clone()).unwrap_or_default() - }, - build_dir: args - .build - .build_dir - .or_else(|| default.map(|e| e.build_dir.clone())) - .unwrap_or_else(|| "build".to_string()), - no_build, - clean, - cmake_flags: args.build.cmake_flags, - }, - mono: ResolvedMonoFlags { - mono_repo, - mono_dir: args - .mono - .mono_dir - .or_else(|| default.map(|e| e.mono_dir.clone())) - .unwrap_or_else(|| "build-mono".to_string()), - repos, - profile, - }, - config: args.config, - profile: args.profile, - }) -} - -impl Args { - /// Parses CLI arguments and resolves them against the provided `SetupConfig`. - /// # Errors - /// Returns an error if the named config does not exist in the loaded `SetupConfig`. - pub fn parse_with_config(config: &SetupConfig) -> Result { - resolve_with_config(Args::parse(), config) - } -} +pub use args::Args; +pub use resolve::{resolve_bool, resolve_with_config}; diff --git a/src/cli/resolve.rs b/src/cli/resolve.rs new file mode 100644 index 0000000..39166ba --- /dev/null +++ b/src/cli/resolve.rs @@ -0,0 +1,98 @@ +use crate::cli::args::Args; +use crate::cli::build::BuildType; +use crate::cli::resolved::{ + ResolvedArgs, ResolvedBuildFlags, ResolvedConnectionFlags, ResolvedMonoFlags, +}; +use crate::config::SetupConfig; + +/// Resolves a boolean flag from CLI positive/negative flags, config value, and a default. +/// Negative flag takes highest priority, then positive, then config, then default. +#[must_use] +pub fn resolve_bool(positive: bool, negative: bool, config: Option, default: bool) -> bool { + if negative { + return false; + } + if positive { + return true; + } + config.unwrap_or(default) +} + +/// Resolves raw `Args` into `ResolvedArgs` by applying config defaults and CLI overrides. +/// # Errors +/// 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.config_name.as_deref().unwrap_or("default"); + if let Some(name) = &args.config.config_name { + if !config.configs.contains_key(name.as_str()) { + return Err(format!("Configuration '{name}' not found")); + } + } + + let default = config.configs.get(config_name); + + let ssh = resolve_bool( + args.connection.ssh, + args.connection.https, + default.map(|e| e.ssh), + false, + ); + let verbose = resolve_bool( + args.connection.verbose, + args.connection.no_verbose, + default.map(|e| e.verbose), + false, + ); + let no_build = resolve_bool( + args.build.no_build, + args.build.build, + default.map(|e| e.no_build), + false, + ); + let clean = resolve_bool( + args.build.clean, + args.build.no_clean, + default.map(|e| e.clean), + false, + ); + if args.build.cmake_flags.is_empty() { + args.build.cmake_flags = default.map_or_else(Vec::new, |e| e.cmake_flags.clone()); + } + + let repos = args.mono.repos.take(); + let profile = args.mono.profile.take(); + let mono_repo = args.mono.mono_repo || repos.is_some() || profile.is_some(); + + Ok(ResolvedArgs { + repo: args.repo, + yes: args.yes, + connection: ResolvedConnectionFlags { ssh, verbose }, + build: ResolvedBuildFlags { + build_type: if let Some(s) = args.build.build_type { + s.parse::()? + } else { + default.map(|e| e.build_type.clone()).unwrap_or_default() + }, + build_dir: args + .build + .build_dir + .or_else(|| default.map(|e| e.build_dir.clone())) + .unwrap_or_else(|| "build".to_string()), + no_build, + clean, + cmake_flags: args.build.cmake_flags, + }, + mono: ResolvedMonoFlags { + mono_repo, + mono_dir: args + .mono + .mono_dir + .or_else(|| default.map(|e| e.mono_dir.clone())) + .unwrap_or_else(|| "build-mono".to_string()), + repos, + profile, + }, + config: args.config, + profile: args.profile, + }) +} From 08d644f9971821760a033ce795e885405fcc256a Mon Sep 17 00:00:00 2001 From: Masonlet Date: Tue, 23 Jun 2026 00:38:06 -0400 Subject: [PATCH 06/34] refactor(commands): split mono.rs into submodules (config, resolve, mode) --- src/commands/mono.rs | 223 ----------------------------------- src/commands/mono/config.rs | 58 +++++++++ src/commands/mono/mod.rs | 6 + src/commands/mono/mode.rs | 96 +++++++++++++++ src/commands/mono/resolve.rs | 78 ++++++++++++ 5 files changed, 238 insertions(+), 223 deletions(-) delete mode 100644 src/commands/mono.rs create mode 100644 src/commands/mono/config.rs create mode 100644 src/commands/mono/mod.rs create mode 100644 src/commands/mono/mode.rs create mode 100644 src/commands/mono/resolve.rs diff --git a/src/commands/mono.rs b/src/commands/mono.rs deleted file mode 100644 index 3fc2c36..0000000 --- a/src/commands/mono.rs +++ /dev/null @@ -1,223 +0,0 @@ -use crate::cli::ResolvedArgs; -use crate::commands::build::cmake_build; -use crate::commands::header::{print_mode_header, ModeHeader}; -use crate::config::SetupConfig; -use crate::profiles::list_profiles; -use crate::repository::{clone_repository, repo_dir_name}; -use std::fs; -use std::io::Write; -use std::path::{Path, PathBuf}; - -/// Generates a root `CMakeLists.txt` wiring all repositories as subdirectories. -/// # Errors -/// Returns an error if the `CMakeLists.txt` file cannot be written to `mono_dir` -pub fn create_mono_repo_cmakelists( - mono_dir: &Path, - test_repo: &str, - repos: &[String], - output: &mut impl Write, -) -> Result<(), String> { - let module_names: Vec = repos.iter().map(|r| repo_dir_name(r)).collect(); - let modules_cmake = module_names.join("\n "); - - let cmake_content = format!( - " -cmake_minimum_required(VERSION 3.23) - -project(star_setup LANGUAGES CXX) -set(CMAKE_CXX_STANDARD 20) - -if(NOT EXISTS \"${{CMAKE_CURRENT_SOURCE_DIR}}/{test_repo}/CMakeLists.txt\") - message(FATAL_ERROR \"Test repository '{test_repo}' not found\") -endif() - -set(MONO_REPO_MODULES - {modules_cmake} -) - -foreach(module IN LISTS MONO_REPO_MODULES) - if(EXISTS \"${{CMAKE_CURRENT_SOURCE_DIR}}/${{module}}/CMakeLists.txt\") - add_subdirectory(${{module}}) - else() - message(WARNING \"Module ${{module}} not found or missing CMakeLists.txt\") - endif() -endforeach() - -set_property(GLOBAL PROPERTY USE_FOLDERS ON) -set_property(GLOBAL PROPERTY PREDEFINED_TARGETS_FOLDER \"External\") - -string(REPLACE \"-\" \"_\" target \"{test_repo}\") -set_property(DIRECTORY ${{CMAKE_CURRENT_SOURCE_DIR}} PROPERTY VS_STARTUP_PROJECT ${{target}}) -" - ); - - let cmake_file = mono_dir.join("CMakeLists.txt"); - fs::write(&cmake_file, cmake_content).map_err(|e| e.to_string())?; - writeln!( - output, - "Created root CMakeLists.txt at {}\n", - mono_dir.display() - ) - .ok(); - Ok(()) -} - -/// Resolves the list of repositories for mono-repo mode from a profile or explicit repo list. -/// # Errors -/// Returns an error if the specified profile does not exist, or has no repositories. -pub fn resolve_repos_for_mono( - args: &ResolvedArgs, - config: &SetupConfig, - test_repo: &str, - output: &mut impl Write, -) -> Result, String> { - if let Some(profile_name) = &args.mono.profile { - let profile_repos = config.profiles.get(profile_name).ok_or_else(|| { - list_profiles(config, output); - format!("Profile '{profile_name}' not found") - })?; - if profile_repos.is_empty() { - return Err(format!("Profile '{profile_name}' has no repositories")); - } - print_mode_header( - &ModeHeader { - mode: "Profile", - test_repo: Some(test_repo), - repo_name: None, - use_ssh: args.connection.ssh, - mono_dir: Some(&args.mono.mono_dir), - profile: Some(profile_name), - lib_count: Some(profile_repos.len()), - }, - output, - ); - Ok(profile_repos.clone()) - } else if let Some(r) = &args.mono.repos { - print_mode_header( - &ModeHeader { - mode: "Mono-repository", - test_repo: Some(test_repo), - repo_name: None, - use_ssh: args.connection.ssh, - mono_dir: Some(&args.mono.mono_dir), - profile: None, - lib_count: Some(r.len()), - }, - output, - ); - Ok(r.clone()) - } else { - Err("No repos or profile specified for mono-repo mode".to_string()) - } -} - -/// Normalizes a repository input to `username/repo` format. -/// # Errors -/// Returns an error if the input is not a recognizable GitHub URL or `username/repo` format. -pub fn resolve_test_repo(repo_input: &str) -> Result { - let repo_input = repo_input.trim_end_matches('/'); - if repo_input.starts_with("http") || repo_input.starts_with("git@") { - if repo_input.contains("github.com/") || repo_input.contains("github.com:") { - let parts: Vec<&str> = repo_input.split('/').collect(); - if parts.len() < 2 { - return Err("Repository URL missing repository name".to_string()); - } - let user = parts[parts.len() - 2].split(':').next_back().unwrap_or(""); - let repo = parts[parts.len() - 1].trim_end_matches(".git"); - Ok(format!("{user}/{repo}")) - } else { - Err("Could not parse repository URL".to_string()) - } - } else if repo_input.contains('/') { - Ok(repo_input.to_string()) - } else { - Err("Repository must be in format 'username/repo' for mono-repo mode".to_string()) - } -} - -/// Clones and configures a mono-repo ecosystem from a profile or explicit repository list. -/// # Errors -/// Returns an error if no repository is specified, directory creation fails, or any git or `CMake` command fails. -pub fn mono_repo_mode( - args: &ResolvedArgs, - config: &SetupConfig, - output: &mut impl Write, -) -> Result<(), String> { - let repo_input = args.repo.as_deref().ok_or("No repository specified")?; - let repo_input = repo_input.trim_end_matches('/'); - - let test_repo = resolve_test_repo(repo_input)?; - let test_repo_name = repo_dir_name(&test_repo); - - let mut repos: Vec = resolve_repos_for_mono(args, config, &test_repo, output)?; - - if !repos - .iter() - .any(|r| repo_dir_name(r) == repo_dir_name(&test_repo)) - { - repos.push(test_repo.clone()); - } - - writeln!(output, "Total repositories: {}\n", repos.len()).ok(); - - let mono_repo_path = PathBuf::from(&args.mono.mono_dir); - writeln!(output, "Creating directory: {}\n", mono_repo_path.display()).ok(); - fs::create_dir_all(&mono_repo_path).map_err(|e| e.to_string())?; - - writeln!(output, "Cloning repositories").ok(); - for repo in &repos { - clone_repository( - repo, - &mono_repo_path, - args.connection.ssh, - args.connection.verbose, - output, - )?; - } - writeln!( - output, - "\n Finished cloning ({} repositories)\n", - repos.len() - ) - .ok(); - - writeln!(output, "Creating mono-repo configuration").ok(); - create_mono_repo_cmakelists(&mono_repo_path, &test_repo_name, &repos, output)?; - - writeln!(output, "Creating build directory\n").ok(); - let build_path = mono_repo_path.join(&args.build.build_dir); - - if args.build.clean && build_path.exists() { - writeln!(output, "Cleaning build directory\n").ok(); - fs::remove_dir_all(&build_path).map_err(|e| e.to_string())?; - } - - fs::create_dir_all(&build_path).map_err(|e| e.to_string())?; - - writeln!( - output, - "Configuring with CMake in {}\n", - build_path.display() - ) - .ok(); - cmake_build(args, build_path.as_path(), true, output)?; - - writeln!(output, "Setup complete").ok(); - writeln!( - output, - "Repositories in: {}", - dunce::canonicalize(&mono_repo_path) - .unwrap_or(mono_repo_path) - .display() - ) - .ok(); - writeln!( - output, - "Build output in: {}", - dunce::canonicalize(&build_path) - .unwrap_or(build_path) - .display() - ) - .ok(); - Ok(()) -} diff --git a/src/commands/mono/config.rs b/src/commands/mono/config.rs new file mode 100644 index 0000000..db70009 --- /dev/null +++ b/src/commands/mono/config.rs @@ -0,0 +1,58 @@ +use crate::repository::repo_dir_name; +use std::fs; +use std::io::Write; +use std::path::Path; + +/// Generates a root `CMakeLists.txt` wiring all repositories as subdirectories. +/// # Errors +/// Returns an error if the `CMakeLists.txt` file cannot be written to `mono_dir` +pub fn create_mono_repo_cmakelists( + mono_dir: &Path, + test_repo: &str, + repos: &[String], + output: &mut impl Write, +) -> Result<(), String> { + let module_names: Vec = repos.iter().map(|r| repo_dir_name(r)).collect(); + let modules_cmake = module_names.join("\n "); + + let cmake_content = format!( + " +cmake_minimum_required(VERSION 3.23) + +project(star_setup LANGUAGES CXX) +set(CMAKE_CXX_STANDARD 20) + +if(NOT EXISTS \"${{CMAKE_CURRENT_SOURCE_DIR}}/{test_repo}/CMakeLists.txt\") + message(FATAL_ERROR \"Test repository '{test_repo}' not found\") +endif() + +set(MONO_REPO_MODULES + {modules_cmake} +) + +foreach(module IN LISTS MONO_REPO_MODULES) + if(EXISTS \"${{CMAKE_CURRENT_SOURCE_DIR}}/${{module}}/CMakeLists.txt\") + add_subdirectory(${{module}}) + else() + message(WARNING \"Module ${{module}} not found or missing CMakeLists.txt\") + endif() +endforeach() + +set_property(GLOBAL PROPERTY USE_FOLDERS ON) +set_property(GLOBAL PROPERTY PREDEFINED_TARGETS_FOLDER \"External\") + +string(REPLACE \"-\" \"_\" target \"{test_repo}\") +set_property(DIRECTORY ${{CMAKE_CURRENT_SOURCE_DIR}} PROPERTY VS_STARTUP_PROJECT ${{target}}) +" + ); + + let cmake_file = mono_dir.join("CMakeLists.txt"); + fs::write(&cmake_file, cmake_content).map_err(|e| e.to_string())?; + writeln!( + output, + "Created root CMakeLists.txt at {}\n", + mono_dir.display() + ) + .ok(); + Ok(()) +} diff --git a/src/commands/mono/mod.rs b/src/commands/mono/mod.rs new file mode 100644 index 0000000..90a3e1c --- /dev/null +++ b/src/commands/mono/mod.rs @@ -0,0 +1,6 @@ +pub mod config; +pub mod mode; +pub mod resolve; +pub use config::create_mono_repo_cmakelists; +pub use mode::mono_repo_mode; +pub use resolve::{resolve_repos_for_mono, resolve_test_repo}; diff --git a/src/commands/mono/mode.rs b/src/commands/mono/mode.rs new file mode 100644 index 0000000..5def089 --- /dev/null +++ b/src/commands/mono/mode.rs @@ -0,0 +1,96 @@ +use crate::cli::ResolvedArgs; +use crate::commands::build::cmake_build; +use crate::commands::mono::config::create_mono_repo_cmakelists; +use crate::commands::mono::resolve::{resolve_repos_for_mono, resolve_test_repo}; +use crate::config::SetupConfig; +use crate::repository::{clone_repository, repo_dir_name}; +use std::fs; +use std::io::Write; +use std::path::PathBuf; + +/// Clones and configures a mono-repo ecosystem from a profile or explicit repository list. +/// # Errors +/// Returns an error if no repository is specified, directory creation fails, or any git or `CMake` command fails. +pub fn mono_repo_mode( + args: &ResolvedArgs, + config: &SetupConfig, + output: &mut impl Write, +) -> Result<(), String> { + let repo_input = args.repo.as_deref().ok_or("No repository specified")?; + let repo_input = repo_input.trim_end_matches('/'); + + let test_repo = resolve_test_repo(repo_input)?; + let test_repo_name = repo_dir_name(&test_repo); + + let mut repos: Vec = resolve_repos_for_mono(args, config, &test_repo, output)?; + + if !repos + .iter() + .any(|r| repo_dir_name(r) == repo_dir_name(&test_repo)) + { + repos.push(test_repo.clone()); + } + + writeln!(output, "Total repositories: {}\n", repos.len()).ok(); + + let mono_repo_path = PathBuf::from(&args.mono.mono_dir); + writeln!(output, "Creating directory: {}\n", mono_repo_path.display()).ok(); + fs::create_dir_all(&mono_repo_path).map_err(|e| e.to_string())?; + + writeln!(output, "Cloning repositories").ok(); + for repo in &repos { + clone_repository( + repo, + &mono_repo_path, + args.connection.ssh, + args.connection.verbose, + output, + )?; + } + writeln!( + output, + "\n Finished cloning ({} repositories)\n", + repos.len() + ) + .ok(); + + writeln!(output, "Creating mono-repo configuration").ok(); + create_mono_repo_cmakelists(&mono_repo_path, &test_repo_name, &repos, output)?; + + writeln!(output, "Creating build directory\n").ok(); + let build_path = mono_repo_path.join(&args.build.build_dir); + + if args.build.clean && build_path.exists() { + writeln!(output, "Cleaning build directory\n").ok(); + fs::remove_dir_all(&build_path).map_err(|e| e.to_string())?; + } + + fs::create_dir_all(&build_path).map_err(|e| e.to_string())?; + + writeln!( + output, + "Configuring with CMake in {}\n", + build_path.display() + ) + .ok(); + cmake_build(args, build_path.as_path(), true, output)?; + + writeln!(output, "Setup complete").ok(); + writeln!( + output, + "Repositories in: {}", + dunce::canonicalize(&mono_repo_path) + .unwrap_or(mono_repo_path) + .display() + ) + .ok(); + writeln!( + output, + "Build output in: {}", + dunce::canonicalize(&build_path) + .unwrap_or(build_path) + .display() + ) + .ok(); + Ok(()) +} diff --git a/src/commands/mono/resolve.rs b/src/commands/mono/resolve.rs new file mode 100644 index 0000000..f216d2a --- /dev/null +++ b/src/commands/mono/resolve.rs @@ -0,0 +1,78 @@ +use crate::cli::ResolvedArgs; +use crate::commands::header::{ModeHeader, print_mode_header}; +use crate::config::SetupConfig; +use crate::profiles::list_profiles; +use std::io::Write; + +/// Resolves the list of repositories for mono-repo mode from a profile or explicit repo list. +/// # Errors +/// Returns an error if the specified profile does not exist, or has no repositories. +pub fn resolve_repos_for_mono( + args: &ResolvedArgs, + config: &SetupConfig, + test_repo: &str, + output: &mut impl Write, +) -> Result, String> { + if let Some(profile_name) = &args.mono.profile { + let profile_repos = config.profiles.get(profile_name).ok_or_else(|| { + list_profiles(config, output); + format!("Profile '{profile_name}' not found") + })?; + if profile_repos.is_empty() { + return Err(format!("Profile '{profile_name}' has no repositories")); + } + print_mode_header( + &ModeHeader { + mode: "Profile", + test_repo: Some(test_repo), + repo_name: None, + use_ssh: args.connection.ssh, + mono_dir: Some(&args.mono.mono_dir), + profile: Some(profile_name), + lib_count: Some(profile_repos.len()), + }, + output, + ); + Ok(profile_repos.clone()) + } else if let Some(r) = &args.mono.repos { + print_mode_header( + &ModeHeader { + mode: "Mono-repository", + test_repo: Some(test_repo), + repo_name: None, + use_ssh: args.connection.ssh, + mono_dir: Some(&args.mono.mono_dir), + profile: None, + lib_count: Some(r.len()), + }, + output, + ); + Ok(r.clone()) + } else { + Err("No repos or profile specified for mono-repo mode".to_string()) + } +} + +/// Normalizes a repository input to `username/repo` format. +/// # Errors +/// Returns an error if the input is not a recognizable GitHub URL or `username/repo` format. +pub fn resolve_test_repo(repo_input: &str) -> Result { + let repo_input = repo_input.trim_end_matches('/'); + if repo_input.starts_with("http") || repo_input.starts_with("git@") { + if repo_input.contains("github.com/") || repo_input.contains("github.com:") { + let parts: Vec<&str> = repo_input.split('/').collect(); + if parts.len() < 2 { + return Err("Repository URL missing repository name".to_string()); + } + let user = parts[parts.len() - 2].split(':').next_back().unwrap_or(""); + let repo = parts[parts.len() - 1].trim_end_matches(".git"); + Ok(format!("{user}/{repo}")) + } else { + Err("Could not parse repository URL".to_string()) + } + } else if repo_input.contains('/') { + Ok(repo_input.to_string()) + } else { + Err("Repository must be in format 'username/repo' for mono-repo mode".to_string()) + } +} From 177116ec123e91116cea5113f5de390292d3e152 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Wed, 24 Jun 2026 00:17:27 -0400 Subject: [PATCH 07/34] feat(commands): add Meson build system support with unified repos layout and wrap hoisting --- src/cli/build.rs | 67 ++++++++++++-- src/cli/flags.rs | 4 + src/cli/mod.rs | 6 +- src/cli/resolve.rs | 1 + src/cli/resolved.rs | 1 + src/commands/build.rs | 54 +++++++++++- src/commands/mono/config.rs | 57 +++++++++--- src/commands/mono/mod.rs | 4 +- src/commands/mono/mode.rs | 165 ++++++++++++++++++++++++++--------- src/commands/mono/resolve.rs | 2 +- src/commands/mono/wraps.rs | 121 +++++++++++++++++++++++++ src/commands/single.rs | 15 +++- src/interactive.rs | 7 ++ src/main.rs | 2 +- src/prompts.rs | 30 +++++++ src/utils.rs | 63 ++++++++++++- tests/cli.rs | 1 + tests/commands.rs | 12 ++- tests/interactive.rs | 21 +++-- 19 files changed, 548 insertions(+), 85 deletions(-) create mode 100644 src/commands/mono/wraps.rs diff --git a/src/cli/build.rs b/src/cli/build.rs index 33abd96..075af4c 100644 --- a/src/cli/build.rs +++ b/src/cli/build.rs @@ -1,9 +1,13 @@ +use crate::prompts::ask_choice; use serde::{Deserialize, Serialize}; -use std::path::Path; +use std::io::{BufRead, Write}; +use std::path::{Path, PathBuf}; pub enum BuildSystem { /// `CMake` build system (`CMakeLists.txt`). Cmake, + /// Meson build system (`meson.build`). + Meson, } #[derive(Default, Clone, Serialize, Deserialize, PartialEq, Debug)] @@ -30,6 +34,16 @@ impl BuildType { Self::MinSizeRel => "MinSizeRel", } } + + #[must_use] + pub fn to_meson(&self) -> &'static str { + match self { + Self::Debug => "debug", + Self::Release => "release", + Self::RelWithDebInfo => "debugoptimized", + Self::MinSizeRel => "minsize", + } + } } impl std::str::FromStr for BuildType { @@ -54,10 +68,51 @@ impl std::str::FromStr for BuildType { /// 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) -> Result { - if dir.join("CMakeLists.txt").exists() { - Ok(BuildSystem::Cmake) - } else { - Err("No supported build system found".into()) +pub fn detect_build_system( + dir: &Path, + input: &mut impl BufRead, + output: &mut impl Write, +) -> Result { + let has_cmake = dir.join("CMakeLists.txt").exists(); + let has_meson = dir.join("meson.build").exists(); + match (has_cmake, has_meson) { + (true, false) => Ok(BuildSystem::Cmake), + (false, true) => Ok(BuildSystem::Meson), + (true, true) => match ask_choice( + "Multiple build systems detected:", + &["CMake", "Meson"], + input, + output, + )? { + 0 => Ok(BuildSystem::Cmake), + _ => Ok(BuildSystem::Meson), + }, + (false, false) => Err("No supported build system found".into()), + } +} + +/// Detects the build system consistently across all repo directories. +/// # Errors +/// Returns an error if systems are inconsistent or none found, or EOF during prompt. +pub fn detect_mono_build_system( + dirs: &[PathBuf], + input: &mut impl BufRead, + output: &mut impl Write, +) -> Result { + let all_cmake = dirs.iter().all(|d| d.join("CMakeLists.txt").exists()); + let all_meson = dirs.iter().all(|d| d.join("meson.build").exists()); + match (all_cmake, all_meson) { + (true, false) => Ok(BuildSystem::Cmake), + (false, true) => Ok(BuildSystem::Meson), + (true, true) => match ask_choice( + "Multiple build systems detected:", + &["CMake", "Meson"], + input, + output, + )? { + 0 => Ok(BuildSystem::Cmake), + _ => Ok(BuildSystem::Meson), + }, + (false, false) => Err("Repositories have inconsistent or missing build systems".into()), } } diff --git a/src/cli/flags.rs b/src/cli/flags.rs index 3e501bf..7bcd8d2 100644 --- a/src/cli/flags.rs +++ b/src/cli/flags.rs @@ -46,6 +46,10 @@ pub struct BuildFlags { /// Additional `CMake` arguments #[arg(long = "cmake-arg", action = clap::ArgAction::Append)] pub cmake_flags: Vec, + + /// Additional Meson arguments + #[arg(long = "meson-arg", action = clap::ArgAction::Append)] + pub meson_flags: Vec, } #[derive(ClapArgs)] diff --git a/src/cli/mod.rs b/src/cli/mod.rs index e3e4396..c86c821 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -3,8 +3,8 @@ pub mod build; pub mod flags; pub mod resolve; pub mod resolved; -pub use build::{detect_build_system, BuildSystem, BuildType}; -pub use flags::{BuildFlags, ConfigFlags, ConnectionFlags, MonoRepoFlags, ProfileFlags}; -pub use resolved::{ResolvedArgs, ResolvedBuildFlags, ResolvedConnectionFlags, ResolvedMonoFlags}; pub use args::Args; +pub use build::{detect_build_system, detect_mono_build_system, BuildSystem, BuildType}; +pub use flags::{BuildFlags, ConfigFlags, ConnectionFlags, MonoRepoFlags, ProfileFlags}; pub use resolve::{resolve_bool, resolve_with_config}; +pub use resolved::{ResolvedArgs, ResolvedBuildFlags, ResolvedConnectionFlags, ResolvedMonoFlags}; diff --git a/src/cli/resolve.rs b/src/cli/resolve.rs index 39166ba..53cf054 100644 --- a/src/cli/resolve.rs +++ b/src/cli/resolve.rs @@ -81,6 +81,7 @@ pub fn resolve_with_config(mut args: Args, config: &SetupConfig) -> Result, + pub meson_flags: Vec, } /// Resolved mono-repo flags after applying config and CLI overrides. diff --git a/src/commands/build.rs b/src/commands/build.rs index 44aee77..cd3dc6c 100644 --- a/src/commands/build.rs +++ b/src/commands/build.rs @@ -1,8 +1,10 @@ //! Build system dispatch and per-system build functions. +use crate::cli::detect_build_system; +use crate::cli::BuildSystem; use crate::cli::ResolvedArgs; use crate::utils::run_command; -use std::io::Write; +use std::io::{BufRead, Write}; use std::path::Path; /// Runs `CMake` configuration and optionally builds the project in `build_path`. @@ -44,3 +46,53 @@ pub fn cmake_build( } Ok(()) } + +/// Runs Meson configuration and optionally builds the project in `build_path`. +/// # Errors +/// Returns an error if any Meson command fails. +pub fn meson_build( + args: &ResolvedArgs, + build_path: &Path, + source_path: &Path, + output: &mut impl Write, +) -> Result<(), String> { + 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.extend(args.build.meson_flags.iter().map(String::as_str)); + run_command(&meson_cmd, None, args.connection.verbose, output)?; + if !args.build.no_build { + writeln!(output, "Building project\n").ok(); + run_command( + &[ + "meson", + "compile", + "-C", + build_path.to_str().ok_or("Invalid build path")?, + ], + None, + args.connection.verbose, + output, + )?; + } + Ok(()) +} + +/// Detects and dispatches to the appropriate build system. +/// # Errors +/// Returns an error if detection or the build system command fails. +pub fn build_project( + args: &ResolvedArgs, + build_path: &Path, + source_path: &Path, + mono: bool, + input: &mut impl BufRead, + output: &mut impl Write, +) -> Result<(), String> { + match detect_build_system(source_path, input, output)? { + BuildSystem::Cmake => cmake_build(args, build_path, mono, output), + BuildSystem::Meson => meson_build(args, build_path, source_path, output), + } +} diff --git a/src/commands/mono/config.rs b/src/commands/mono/config.rs index db70009..656e540 100644 --- a/src/commands/mono/config.rs +++ b/src/commands/mono/config.rs @@ -8,31 +8,24 @@ use std::path::Path; /// Returns an error if the `CMakeLists.txt` file cannot be written to `mono_dir` pub fn create_mono_repo_cmakelists( mono_dir: &Path, - test_repo: &str, repos: &[String], output: &mut impl Write, ) -> Result<(), String> { let module_names: Vec = repos.iter().map(|r| repo_dir_name(r)).collect(); let modules_cmake = module_names.join("\n "); - let cmake_content = format!( - " -cmake_minimum_required(VERSION 3.23) + "cmake_minimum_required(VERSION 3.23) project(star_setup LANGUAGES CXX) set(CMAKE_CXX_STANDARD 20) -if(NOT EXISTS \"${{CMAKE_CURRENT_SOURCE_DIR}}/{test_repo}/CMakeLists.txt\") - message(FATAL_ERROR \"Test repository '{test_repo}' not found\") -endif() - set(MONO_REPO_MODULES {modules_cmake} ) foreach(module IN LISTS MONO_REPO_MODULES) - if(EXISTS \"${{CMAKE_CURRENT_SOURCE_DIR}}/${{module}}/CMakeLists.txt\") - add_subdirectory(${{module}}) + if(EXISTS \"${{CMAKE_CURRENT_SOURCE_DIR}}/repos/${{module}}/CMakeLists.txt\") + add_subdirectory(repos/${{module}}) else() message(WARNING \"Module ${{module}} not found or missing CMakeLists.txt\") endif() @@ -40,12 +33,8 @@ endforeach() set_property(GLOBAL PROPERTY USE_FOLDERS ON) set_property(GLOBAL PROPERTY PREDEFINED_TARGETS_FOLDER \"External\") - -string(REPLACE \"-\" \"_\" target \"{test_repo}\") -set_property(DIRECTORY ${{CMAKE_CURRENT_SOURCE_DIR}} PROPERTY VS_STARTUP_PROJECT ${{target}}) " ); - let cmake_file = mono_dir.join("CMakeLists.txt"); fs::write(&cmake_file, cmake_content).map_err(|e| e.to_string())?; writeln!( @@ -56,3 +45,43 @@ set_property(DIRECTORY ${{CMAKE_CURRENT_SOURCE_DIR}} PROPERTY VS_STARTUP_PROJECT .ok(); Ok(()) } + +/// Generates a root `meson.build` wiring all repositories as subprojects. +/// # Errors +/// Returns an error if the `meson.build` file cannot be written to `mono_dir`. +pub fn create_mono_repo_mesonbuild( + mono_dir: &Path, + repos: &[String], + output: &mut impl Write, +) -> Result<(), String> { + let module_names: Vec = repos.iter().map(|r| repo_dir_name(r)).collect(); + let modules_meson = module_names + .iter() + .map(|m| format!(" '{m}'")) + .collect::>() + .join(",\n"); + let meson_content = format!( + "project('star_setup', 'cpp', + default_options: ['cpp_std=c++20'], + subproject_dir: 'repos' +) + +modules = [ +{modules_meson}, +] + +foreach module : modules + subproject(module) +endforeach +" + ); + let meson_file = mono_dir.join("meson.build"); + fs::write(&meson_file, meson_content).map_err(|e| e.to_string())?; + writeln!( + output, + "Created root meson.build at {}\n", + mono_dir.display() + ) + .ok(); + Ok(()) +} diff --git a/src/commands/mono/mod.rs b/src/commands/mono/mod.rs index 90a3e1c..f9aa307 100644 --- a/src/commands/mono/mod.rs +++ b/src/commands/mono/mod.rs @@ -1,6 +1,8 @@ pub mod config; pub mod mode; pub mod resolve; -pub use config::create_mono_repo_cmakelists; +pub mod wraps; +pub use config::{create_mono_repo_cmakelists, create_mono_repo_mesonbuild}; pub use mode::mono_repo_mode; pub use resolve::{resolve_repos_for_mono, resolve_test_repo}; +pub use wraps::hoist_wraps; diff --git a/src/commands/mono/mode.rs b/src/commands/mono/mode.rs index 5def089..efb6650 100644 --- a/src/commands/mono/mode.rs +++ b/src/commands/mono/mode.rs @@ -1,19 +1,72 @@ -use crate::cli::ResolvedArgs; -use crate::commands::build::cmake_build; -use crate::commands::mono::config::create_mono_repo_cmakelists; +use crate::cli::{detect_mono_build_system, BuildSystem, ResolvedArgs}; +use crate::commands::build::{cmake_build, meson_build}; +use crate::commands::mono::config::{create_mono_repo_cmakelists, create_mono_repo_mesonbuild}; use crate::commands::mono::resolve::{resolve_repos_for_mono, resolve_test_repo}; +use crate::commands::mono::wraps::hoist_wraps; use crate::config::SetupConfig; use crate::repository::{clone_repository, repo_dir_name}; use std::fs; -use std::io::Write; +use std::io::{BufRead, Write}; use std::path::PathBuf; +fn clone_mono_repos( + repos: &[String], + repos_path: &std::path::Path, + ssh: bool, + verbose: bool, + output: &mut impl Write, +) -> Result<(), String> { + for repo in repos { + clone_repository(repo, repos_path, ssh, verbose, output)?; + } + writeln!( + output, + "\n Finished cloning ({} repositories)\n", + repos.len() + ) + .ok(); + Ok(()) +} + +fn generate_mono_config( + build_system: &BuildSystem, + mono_repo_path: &std::path::Path, + repos_path: &std::path::Path, + repo_dirs: &[PathBuf], + repos: &[String], + output: &mut impl Write, +) -> Result>, String> { + match build_system { + BuildSystem::Cmake => { + create_mono_repo_cmakelists(mono_repo_path, repos, output)?; + Ok(None) + } + BuildSystem::Meson => { + let map = hoist_wraps(repos_path, repo_dirs, output)?; + let subproject_names: Vec = repos + .iter() + .map(|r| { + let dir = repo_dir_name(r); + map + .iter() + .find(|(_, v)| *v == &dir) + .map(|(k, _)| k.clone()) + .unwrap_or(dir) + }) + .collect(); + create_mono_repo_mesonbuild(mono_repo_path, &subproject_names, output)?; + Ok(Some(map)) + } + } +} + /// Clones and configures a mono-repo ecosystem from a profile or explicit repository list. /// # Errors -/// Returns an error if no repository is specified, directory creation fails, or any git or `CMake` command fails. +/// Returns an error if no repository is specified, directory creation fails, or any build system command fails. pub fn mono_repo_mode( args: &ResolvedArgs, config: &SetupConfig, + input: &mut impl BufRead, output: &mut impl Write, ) -> Result<(), String> { let repo_input = args.repo.as_deref().ok_or("No repository specified")?; @@ -22,40 +75,48 @@ pub fn mono_repo_mode( let test_repo = resolve_test_repo(repo_input)?; let test_repo_name = repo_dir_name(&test_repo); - let mut repos: Vec = resolve_repos_for_mono(args, config, &test_repo, output)?; - - if !repos + let mut repos: Vec = resolve_repos_for_mono(args, config, &test_repo, output)? .iter() - .any(|r| repo_dir_name(r) == repo_dir_name(&test_repo)) - { - repos.push(test_repo.clone()); - } - + .filter(|r| repo_dir_name(r) != test_repo_name) + .cloned() + .collect(); + repos.dedup_by(|a, b| repo_dir_name(a) == repo_dir_name(b)); + repos.insert(0, test_repo.clone()); writeln!(output, "Total repositories: {}\n", repos.len()).ok(); let mono_repo_path = PathBuf::from(&args.mono.mono_dir); writeln!(output, "Creating directory: {}\n", mono_repo_path.display()).ok(); fs::create_dir_all(&mono_repo_path).map_err(|e| e.to_string())?; + let repos_path = mono_repo_path.join("repos"); + fs::create_dir_all(&repos_path).map_err(|e| e.to_string())?; + writeln!(output, "Cloning repositories").ok(); - for repo in &repos { - clone_repository( - repo, - &mono_repo_path, - args.connection.ssh, - args.connection.verbose, - output, - )?; - } - writeln!( + clone_mono_repos( + &repos, + &repos_path, + args.connection.ssh, + args.connection.verbose, output, - "\n Finished cloning ({} repositories)\n", - repos.len() - ) - .ok(); + )?; + + let repo_dirs: Vec = repos + .iter() + .map(|r| repos_path.join(repo_dir_name(r))) + .collect(); + + writeln!(output, "Detecting build system\n").ok(); + let build_system = detect_mono_build_system(&repo_dirs, input, output)?; writeln!(output, "Creating mono-repo configuration").ok(); - create_mono_repo_cmakelists(&mono_repo_path, &test_repo_name, &repos, output)?; + let canonical_map = generate_mono_config( + &build_system, + &mono_repo_path, + &repos_path, + &repo_dirs, + &repos, + output, + )?; writeln!(output, "Creating build directory\n").ok(); let build_path = mono_repo_path.join(&args.build.build_dir); @@ -67,13 +128,11 @@ pub fn mono_repo_mode( fs::create_dir_all(&build_path).map_err(|e| e.to_string())?; - writeln!( - output, - "Configuring with CMake in {}\n", - build_path.display() - ) - .ok(); - cmake_build(args, build_path.as_path(), true, output)?; + writeln!(output, "Configuring project in {}\n", build_path.display()).ok(); + match &build_system { + BuildSystem::Cmake => cmake_build(args, build_path.as_path(), true, output)?, + BuildSystem::Meson => meson_build(args, build_path.as_path(), &mono_repo_path, output)?, + } writeln!(output, "Setup complete").ok(); writeln!( @@ -84,13 +143,33 @@ pub fn mono_repo_mode( .display() ) .ok(); - writeln!( - output, - "Build output in: {}", - dunce::canonicalize(&build_path) - .unwrap_or(build_path) - .display() - ) - .ok(); + if let Some(map) = canonical_map { + if let Some((canonical, _)) = map.iter().find(|(_, v)| *v == &test_repo_name) { + let exe_name = if cfg!(windows) { + format!("{canonical}.exe") + } else { + canonical.clone() + }; + let exe_path = build_path + .join("repos") + .join(&test_repo_name) + .join(&exe_name); + writeln!( + output, + "Executable: {}", + dunce::canonicalize(&exe_path).unwrap_or(exe_path).display() + ) + .ok(); + } + } else { + writeln!( + output, + "Build output in: {}", + dunce::canonicalize(&build_path) + .unwrap_or(build_path) + .display() + ) + .ok(); + } Ok(()) } diff --git a/src/commands/mono/resolve.rs b/src/commands/mono/resolve.rs index f216d2a..f0acc5a 100644 --- a/src/commands/mono/resolve.rs +++ b/src/commands/mono/resolve.rs @@ -1,5 +1,5 @@ use crate::cli::ResolvedArgs; -use crate::commands::header::{ModeHeader, print_mode_header}; +use crate::commands::header::{print_mode_header, ModeHeader}; use crate::config::SetupConfig; use crate::profiles::list_profiles; use std::io::Write; diff --git a/src/commands/mono/wraps.rs b/src/commands/mono/wraps.rs new file mode 100644 index 0000000..6cb4da0 --- /dev/null +++ b/src/commands/mono/wraps.rs @@ -0,0 +1,121 @@ +use std::collections::HashMap; +use std::fs; +use std::io::Write; +use std::path::{Path, PathBuf}; + +/// Parses the `project()` name from `meson.build` content. +/// Returns the name with hyphens replaced by underscores, or `None` if not found. +pub(crate) fn parse_project_name(content: &str) -> Option { + let needle = b"project("; + let bytes = content.as_bytes(); + let mut i = 0; + while i + needle.len() <= bytes.len() { + if bytes[i..].starts_with(needle) { + let preceding_ok = i == 0 || { + let p = bytes[i - 1] as char; + !p.is_alphanumeric() && p != '_' + }; + if preceding_ok { + let rest = &content[i + needle.len()..]; + let first_quote = rest.find(['"', '\''])?; + let quote = rest.as_bytes()[first_quote] as char; + let name_start = &rest[first_quote + 1..]; + let end = name_start.find(quote)?; + return Some(name_start[..end].replace('-', "_")); + } + } + i += 1; + } + None +} + +/// Parses `[provide]` key-value pairs from wrap file content. +pub(crate) fn parse_provide_pairs(content: &str) -> HashMap { + let mut in_provide = false; + let mut pairs = HashMap::new(); + for line in content.lines() { + let line = line.trim(); + if line.starts_with('[') { + in_provide = line == "[provide]"; + continue; + } + if in_provide { + if let Some((key, val)) = line.split_once('=') { + let k = key.trim().to_string(); + let v = val.trim().to_string(); + if !k.is_empty() && !v.is_empty() { + pairs.insert(k, v); + } + } + } + } + pairs +} + +/// Generates local-only wrap files in `repos_dir` bridging canonical dependency +/// names to owner-prefixed clone directories. +/// +/// Reads each cloned repo's `project()` name as the join key, scans each repo's +/// `subprojects/*.wrap` for verbatim `[provide]` pairs, and emits +/// `repos/.wrap` with `directory = ` and the provide line. +/// # Errors +/// Returns an error if any wrap file cannot be written or a directory cannot be read. +pub fn hoist_wraps( + repos_dir: &Path, + repo_dirs: &[PathBuf], + output: &mut impl Write, +) -> Result, String> { + // normalized project name -> owner-prefixed dir name + let mut project_to_dir: HashMap = HashMap::new(); + for dir in repo_dirs { + let meson_build = dir.join("meson.build"); + if !meson_build.exists() { + continue; + } + let content = fs::read_to_string(&meson_build).map_err(|e| e.to_string())?; + if let Some(name) = parse_project_name(&content) { + if let Some(dir_name) = dir.file_name().and_then(|n| n.to_str()) { + project_to_dir.insert(name, dir_name.to_string()); + } + } + } + + // collect [provide] pairs from each repo's subprojects/*.wrap + let mut provides: HashMap = HashMap::new(); + for dir in repo_dirs { + let subprojects_dir = dir.join("subprojects"); + if !subprojects_dir.exists() { + continue; + } + for entry in fs::read_dir(&subprojects_dir).map_err(|e| e.to_string())? { + let path = entry.map_err(|e| e.to_string())?.path(); + if path.extension().and_then(|e| e.to_str()) != Some("wrap") { + continue; + } + let content = fs::read_to_string(&path).map_err(|e| e.to_string())?; + for (key, val) in parse_provide_pairs(&content) { + if project_to_dir.contains_key(&key) { + provides.entry(key).or_insert(val); + } + } + } + } + + // emit hoisted wraps + for (canonical_name, dir_name) in &project_to_dir { + let wrap_content = if let Some(dep_var) = provides.get(canonical_name) { + format!("[wrap-file]\ndirectory = {dir_name}\n\n[provide]\n{canonical_name} = {dep_var}\n") + } else { + format!("[wrap-file]\ndirectory = {dir_name}\n") + }; + let wrap_path = repos_dir.join(format!("{canonical_name}.wrap")); + fs::write(&wrap_path, &wrap_content).map_err(|e| e.to_string())?; + writeln!( + output, + " Generated wrap: {canonical_name}.wrap -> {dir_name}" + ) + .ok(); + } + + Ok(project_to_dir) +} diff --git a/src/commands/single.rs b/src/commands/single.rs index 0417e74..57a04f3 100644 --- a/src/commands/single.rs +++ b/src/commands/single.rs @@ -1,5 +1,5 @@ use crate::cli::ResolvedArgs; -use crate::commands::build::cmake_build; +use crate::commands::build::build_project; use crate::commands::header::{print_mode_header, ModeHeader}; use crate::repository::{repo_dir_name, resolve_repo_url}; use crate::utils::{confirm, run_command}; @@ -9,7 +9,7 @@ use std::path::{Path, PathBuf}; /// Clones and configures a single repository. /// # Errors -/// Returns an error if no repository is specified, or if any git or `CMake` command fails. +/// Returns an error if no repository is specified, or if any git or build system fails. pub fn single_repo_mode( args: &ResolvedArgs, input: &mut impl BufRead, @@ -68,8 +68,15 @@ pub fn single_repo_mode( .ok(); fs::create_dir_all(&build_path).map_err(|e| e.to_string())?; - writeln!(output, "Configuring with CMake\n").ok(); - cmake_build(args, build_path.as_path(), false, output)?; + writeln!(output, "Configuring project\n").ok(); + build_project( + args, + build_path.as_path(), + Path::new(&dir_name), + false, + input, + output, + )?; writeln!( output, diff --git a/src/interactive.rs b/src/interactive.rs index ed67a4b..e7eb95b 100644 --- a/src/interactive.rs +++ b/src/interactive.rs @@ -101,6 +101,13 @@ pub fn interactive_mode( } } + if args.build.meson_flags.is_empty() { + let meson_extra = ask_default("Additional Meson args (space separated)", "", input, output)?; + if !meson_extra.is_empty() { + args.build.meson_flags = meson_extra.split_whitespace().map(String::from).collect(); + } + } + if !args.build.no_build { args.build.no_build = ask_yesno("Configure only (skip build)?", false, input, output)?; } diff --git a/src/main.rs b/src/main.rs index 4e88564..c89641f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -108,7 +108,7 @@ fn main() { } let result = if args.mono.mono_repo { - mono_repo_mode(&args, &config, &mut stdout) + mono_repo_mode(&args, &config, &mut stdin, &mut stdout) } else { single_repo_mode(&args, &mut stdin, &mut stdout) }; diff --git a/src/prompts.rs b/src/prompts.rs index 7263e02..096593c 100644 --- a/src/prompts.rs +++ b/src/prompts.rs @@ -61,3 +61,33 @@ pub fn ask_yesno( let val = line.trim().to_lowercase(); Ok(if val.is_empty() { default } else { val.eq("y") }) } + +/// Prompts the user to select from a numbered list of options. +/// Returns the zero-based index of the selected option. +/// # Errors +/// Returns an error on EOF or if the selection is out of range. +pub fn ask_choice( + prompt: &str, + options: &[&str], + input: &mut impl BufRead, + output: &mut impl Write, +) -> Result { + writeln!(output, "{prompt}").ok(); + for (i, opt) in options.iter().enumerate() { + writeln!(output, " {}) {opt}", i + 1).ok(); + } + loop { + write!(output, "Select: ").ok(); + output.flush().ok(); + let mut line = String::new(); + if 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::() { + if n >= 1 && n <= options.len() { + return Ok(n - 1); + } + } + } +} diff --git a/src/utils.rs b/src/utils.rs index 6995fcb..2d7dec8 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -29,13 +29,66 @@ pub fn confirm( Ok(line.trim().eq_ignore_ascii_case("y")) } +/// 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"); + + if !vswhere.exists() { + return None; + } + + let output = Command::new(&vswhere) + .args(["-latest", "-property", "installationPath"]) + .output() + .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"); + + vcvars.exists().then_some(vcvars) +} + +/// Runs vcvars64.bat and captures the resulting environment variables. +/// # Errors +/// Returns an error if vcvars64.bat cannot be found or run. +#[cfg(target_os = "windows")] +fn get_msvc_env() -> Result, String> { + let vcvars = find_vcvars().ok_or("Could not find vcvars64.bat via vswhere")?; + let output = Command::new("cmd") + .args([ + "/c", + vcvars.to_str().ok_or("Invalid vcvars path")?, + "&&", + "set", + ]) + .output() + .map_err(|e| format!("Failed to run vcvars64.bat: {e}"))?; + + let stdout = String::from_utf8_lossy(&output.stdout); + Ok( + stdout + .lines() + .filter_map(|line| { + let mut parts = line.splitn(2, '='); + Some((parts.next()?.to_string(), parts.next()?.to_string())) + }) + .collect(), + ) +} + /// Checks if required tools are available on PATH. /// Returns Result. /// # Errors /// Returns an error if any required tool is missing from PATH. pub fn check_prerequisites(verbose: bool, output: &mut impl Write) -> Result<(), String> { let mut missing: Vec<&str> = Vec::new(); - for tool in &["git", "cmake"] { + for tool in &["git", "cmake", "meson"] { if Command::new(tool) .arg("--version") .output() @@ -81,6 +134,14 @@ pub fn run_command( command.env("GIT_SSH_COMMAND", "ssh -o BatchMode=yes"); } } + #[cfg(target_os = "windows")] + if cmd[0] == "meson" && std::env::var("VSINSTALLDIR").is_err() { + if let Ok(env) = get_msvc_env() { + for (k, v) in env { + command.env(k, v); + } + } + } if verbose { command.stdout(Stdio::inherit()); diff --git a/tests/cli.rs b/tests/cli.rs index 53c32b8..c23683a 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -87,6 +87,7 @@ fn default_args() -> Args { clean: false, no_clean: false, cmake_flags: vec![], + meson_flags: vec![], }, mono: MonoRepoFlags { mono_repo: false, diff --git a/tests/commands.rs b/tests/commands.rs index 57fc85c..b1e5053 100644 --- a/tests/commands.rs +++ b/tests/commands.rs @@ -54,8 +54,12 @@ fn test_resolve_test_repo_errors() { fn test_create_mono_repo_cmakelists_creates_file() { let tmp = tempfile::TempDir::new().unwrap(); - let repos = vec!["user/lib1".to_string(), "user/lib2".to_string()]; - create_mono_repo_cmakelists(tmp.path(), "user-testrepo", &repos, &mut sink()).unwrap(); + let repos = vec![ + "user-testrepo".to_string(), + "user/lib1".to_string(), + "user/lib2".to_string(), + ]; + create_mono_repo_cmakelists(tmp.path(), &repos, &mut sink()).unwrap(); let cmake_file = tmp.path().join("CMakeLists.txt"); assert!(cmake_file.exists()); @@ -69,7 +73,8 @@ fn test_create_mono_repo_cmakelists_creates_file() { #[test] fn test_create_mono_repo_cmakelists_empty_repos() { let tmp = tempfile::TempDir::new().unwrap(); - create_mono_repo_cmakelists(tmp.path(), "user-testrepo", &[], &mut sink()).unwrap(); + let repos = vec!["user-testrepo".to_string()]; + create_mono_repo_cmakelists(tmp.path(), &repos, &mut sink()).unwrap(); assert!(tmp.path().join("CMakeLists.txt").exists()); } @@ -91,6 +96,7 @@ fn default_resolved() -> star_setup::cli::ResolvedArgs { clean: false, no_clean: false, cmake_flags: vec![], + meson_flags: vec![], }, mono: MonoRepoFlags { mono_repo: false, diff --git a/tests/interactive.rs b/tests/interactive.rs index e847c22..1fa0a20 100644 --- a/tests/interactive.rs +++ b/tests/interactive.rs @@ -22,6 +22,7 @@ fn default_resolved() -> star_setup::cli::ResolvedArgs { clean: false, no_clean: false, cmake_flags: vec![], + meson_flags: vec![], }, mono: MonoRepoFlags { mono_repo: false, @@ -45,9 +46,15 @@ fn default_resolved() -> star_setup::cli::ResolvedArgs { resolve_with_config(args, &SetupConfig::new()).unwrap() } +fn input_with_suffix(prefix: &[u8]) -> Vec { + let mut v = prefix.to_vec(); + v.extend_from_slice(b"\n\n\n\n\nn\n"); + v +} + #[test] fn test_interactive_mode_single_repo() { - let input = b"user/repo\nn\nn\nn\n1\n\n\n\nn\n"; + let input = input_with_suffix(b"user/repo\nn\nn\nn\n1"); let mut output = Vec::new(); let mut args = default_resolved(); interactive_mode(&mut args, &mut input.as_ref(), &mut output).unwrap(); @@ -58,7 +65,7 @@ fn test_interactive_mode_single_repo() { #[test] fn test_interactive_mode_ssh_enabled() { - let input = b"user/repo\ny\nn\nn\n1\n\n\n\nn\n"; + let input = input_with_suffix(b"user/repo\ny\nn\nn\n1"); let mut output = Vec::new(); let mut args = default_resolved(); interactive_mode(&mut args, &mut input.as_ref(), &mut output).unwrap(); @@ -67,7 +74,7 @@ fn test_interactive_mode_ssh_enabled() { #[test] fn test_interactive_mode_mono_repo_with_profile() { - let input = b"user/repo\nn\nn\nn\n2\n1\nmyprofile\n\n\n\nn\n"; + let input = input_with_suffix(b"user/repo\nn\nn\nn\n2\n1\nmyprofile"); let mut output = Vec::new(); let mut args = default_resolved(); interactive_mode(&mut args, &mut input.as_ref(), &mut output).unwrap(); @@ -77,7 +84,7 @@ fn test_interactive_mode_mono_repo_with_profile() { #[test] fn test_interactive_mode_mono_repo_with_manual_repos() { - let input = b"user/repo\nn\nn\nn\n2\n2\nuser/lib1 user/lib2\n\n\n\nn\n"; + let input = input_with_suffix(b"user/repo\nn\nn\nn\n2\n2\nuser/lib1 user/lib2"); let mut output = Vec::new(); let mut args = default_resolved(); interactive_mode(&mut args, &mut input.as_ref(), &mut output).unwrap(); @@ -90,7 +97,7 @@ fn test_interactive_mode_mono_repo_with_manual_repos() { #[test] fn test_interactive_mode_skips_repo_prompt_when_set() { - let input = b"n\nn\nn\n1\n\n\n\nn\n"; + let input = input_with_suffix(b"n\nn\nn\n1"); let mut output = Vec::new(); let mut args = default_resolved(); args.repo = Some("already/set".to_string()); @@ -100,7 +107,7 @@ fn test_interactive_mode_skips_repo_prompt_when_set() { #[test] fn test_interactive_mode_output_contains_header() { - let input = b"user/repo\nn\nn\nn\n1\n\n\n\nn\n"; + let input = input_with_suffix(b"user/repo\nn\nn\nn\n1"); let mut output = Vec::new(); let mut args = default_resolved(); interactive_mode(&mut args, &mut input.as_ref(), &mut output).unwrap(); @@ -122,7 +129,7 @@ fn test_interactive_mode_errors_on_eof() { #[test] fn test_interactive_mode_yes_word_not_accepted_for_ssh() { - let input = b"user/repo\nyes\nn\nn\n1\n\n\n\nn\n"; + let input = input_with_suffix(b"user/repo\nyes\nn\nn\n1"); let mut output = Vec::new(); let mut args = default_resolved(); interactive_mode(&mut args, &mut input.as_ref(), &mut output).unwrap(); From 327f0659cf71cf82b38eab0159b907a49d6468e7 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Wed, 24 Jun 2026 12:08:51 -0400 Subject: [PATCH 08/34] refactoe(tests): match src structure --- tests/cli.rs | 293 +-------------------------------- tests/cli/resolve.rs | 291 ++++++++++++++++++++++++++++++++ tests/commands.rs | 179 +------------------- tests/commands/mono.rs | 4 + tests/commands/mono/config.rs | 33 ++++ tests/commands/mono/resolve.rs | 147 +++++++++++++++++ 6 files changed, 479 insertions(+), 468 deletions(-) create mode 100644 tests/cli/resolve.rs create mode 100644 tests/commands/mono.rs create mode 100644 tests/commands/mono/config.rs create mode 100644 tests/commands/mono/resolve.rs diff --git a/tests/cli.rs b/tests/cli.rs index c23683a..40d6910 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -1,291 +1,2 @@ -use star_setup::cli::{ - resolve_bool, resolve_with_config, Args, BuildFlags, BuildType, ConfigFlags, ConnectionFlags, - MonoRepoFlags, ProfileFlags, -}; -use star_setup::config::{ConfigEntry, SetupConfig}; - -#[test] -fn test_resolve_bool() { - #[allow(clippy::struct_excessive_bools)] - struct Case { - flag_pos: bool, - flag_neg: bool, - config: Option, - default: bool, - expected: bool, - name: &'static str, - } - - let cases = [ - Case { - flag_pos: true, - flag_neg: true, - config: Some(true), - default: true, - expected: false, - name: "negative override all", - }, - Case { - flag_pos: true, - flag_neg: false, - config: Some(false), - default: false, - expected: true, - name: "positive override config and default", - }, - Case { - flag_pos: false, - flag_neg: false, - config: Some(true), - default: false, - expected: true, - name: "use config when no flags", - }, - Case { - flag_pos: false, - flag_neg: false, - config: None, - default: true, - expected: true, - name: "use default when no flags/config", - }, - Case { - flag_pos: false, - flag_neg: false, - config: None, - default: false, - expected: false, - name: "default false when nothing set", - }, - ]; - - for c in cases { - assert_eq!( - resolve_bool(c.flag_pos, c.flag_neg, c.config, c.default), - c.expected, - "Failed test: {}", - c.name - ); - } -} - -fn default_args() -> Args { - Args { - repo: None, - yes: false, - connection: ConnectionFlags { - ssh: false, - https: false, - verbose: false, - no_verbose: false, - }, - build: BuildFlags { - build_type: None, - build_dir: None, - no_build: false, - build: false, - clean: false, - no_clean: false, - cmake_flags: vec![], - meson_flags: vec![], - }, - mono: MonoRepoFlags { - mono_repo: false, - mono_dir: None, - repos: None, - profile: None, - }, - config: ConfigFlags { - init_config: false, - config_name: None, - config_add: None, - config_remove: None, - list_configs: false, - }, - profile: ProfileFlags { - profile_add: None, - profile_remove: None, - list_profiles: false, - }, - } -} - -// resolve_with_config tests -#[test] -fn test_resolve_with_config_defaults_when_no_config() { - let config = SetupConfig::new(); - let resolved = resolve_with_config(default_args(), &config).unwrap(); - assert!(!resolved.connection.ssh); - assert!(!resolved.connection.verbose); - assert_eq!(resolved.build.build_type, BuildType::Debug); - assert_eq!(resolved.build.build_dir, "build"); - assert_eq!(resolved.mono.mono_dir, "build-mono"); - assert!(!resolved.build.no_build); - assert!(!resolved.build.clean); -} - -#[test] -fn test_resolve_with_config_applies_config_defaults() { - let mut config = SetupConfig::new(); - config.configs.insert( - "default".to_string(), - ConfigEntry { - ssh: true, - verbose: true, - build_type: BuildType::Release, - build_dir: "out".to_string(), - mono_dir: "mono".to_string(), - no_build: true, - clean: true, - cmake_flags: vec!["-DTEST=ON".to_string()], - }, - ); - let resolved = resolve_with_config(default_args(), &config).unwrap(); - assert!(resolved.connection.ssh); - assert!(resolved.connection.verbose); - assert_eq!(resolved.build.build_type, BuildType::Release); - assert_eq!(resolved.build.build_dir, "out"); - assert!(resolved.build.no_build); - assert!(resolved.build.clean); - assert_eq!(resolved.build.cmake_flags, vec!["-DTEST=ON"]); -} - -#[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, - cmake_flags: vec![], - }, - ); - 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); -} - -#[test] -fn test_resolve_with_config_errors_on_missing_config_name() { - let config = SetupConfig::new(); - let mut args = default_args(); - args.config.config_name = Some("nonexistent".to_string()); - let result = resolve_with_config(args, &config); - assert!(result.is_err()); -} - -#[test] -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); -} - -#[test] -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); -} - -#[test] -fn test_resolve_with_config_named_config_pulls_correct_values() { - let mut config = SetupConfig::new(); - config.configs.insert( - "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, - cmake_flags: vec![], - }, - ); - let mut args = default_args(); - args.config.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); - assert_eq!(resolved.build.build_dir, "out"); - assert!(resolved.build.clean); -} - -#[test] -fn test_resolve_with_config_cli_cmake_flags_not_overwritten_by_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, - cmake_flags: vec!["-DCONFIG_FLAG=ON".to_string()], - }, - ); - 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"]); -} - -#[test] -fn test_resolve_with_config_negative_flags_override_config() { - let mut config = SetupConfig::new(); - config.configs.insert( - "default".to_string(), - 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, - cmake_flags: vec![], - }, - ); - - 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 - - let resolved = resolve_with_config(args, &config).unwrap(); - assert!( - !resolved.connection.ssh, - "https should override config ssh:true" - ); - assert!( - !resolved.connection.verbose, - "no_verbose should override config verbose:true" - ); - assert!( - !resolved.build.no_build, - "build should override config no_build:true" - ); - assert!( - !resolved.build.clean, - "no_clean should override config clean:true" - ); -} +#[path = "cli/resolve.rs"] +mod resolve; diff --git a/tests/cli/resolve.rs b/tests/cli/resolve.rs new file mode 100644 index 0000000..c23683a --- /dev/null +++ b/tests/cli/resolve.rs @@ -0,0 +1,291 @@ +use star_setup::cli::{ + resolve_bool, resolve_with_config, Args, BuildFlags, BuildType, ConfigFlags, ConnectionFlags, + MonoRepoFlags, ProfileFlags, +}; +use star_setup::config::{ConfigEntry, SetupConfig}; + +#[test] +fn test_resolve_bool() { + #[allow(clippy::struct_excessive_bools)] + struct Case { + flag_pos: bool, + flag_neg: bool, + config: Option, + default: bool, + expected: bool, + name: &'static str, + } + + let cases = [ + Case { + flag_pos: true, + flag_neg: true, + config: Some(true), + default: true, + expected: false, + name: "negative override all", + }, + Case { + flag_pos: true, + flag_neg: false, + config: Some(false), + default: false, + expected: true, + name: "positive override config and default", + }, + Case { + flag_pos: false, + flag_neg: false, + config: Some(true), + default: false, + expected: true, + name: "use config when no flags", + }, + Case { + flag_pos: false, + flag_neg: false, + config: None, + default: true, + expected: true, + name: "use default when no flags/config", + }, + Case { + flag_pos: false, + flag_neg: false, + config: None, + default: false, + expected: false, + name: "default false when nothing set", + }, + ]; + + for c in cases { + assert_eq!( + resolve_bool(c.flag_pos, c.flag_neg, c.config, c.default), + c.expected, + "Failed test: {}", + c.name + ); + } +} + +fn default_args() -> Args { + Args { + repo: None, + yes: false, + connection: ConnectionFlags { + ssh: false, + https: false, + verbose: false, + no_verbose: false, + }, + build: BuildFlags { + build_type: None, + build_dir: None, + no_build: false, + build: false, + clean: false, + no_clean: false, + cmake_flags: vec![], + meson_flags: vec![], + }, + mono: MonoRepoFlags { + mono_repo: false, + mono_dir: None, + repos: None, + profile: None, + }, + config: ConfigFlags { + init_config: false, + config_name: None, + config_add: None, + config_remove: None, + list_configs: false, + }, + profile: ProfileFlags { + profile_add: None, + profile_remove: None, + list_profiles: false, + }, + } +} + +// resolve_with_config tests +#[test] +fn test_resolve_with_config_defaults_when_no_config() { + let config = SetupConfig::new(); + let resolved = resolve_with_config(default_args(), &config).unwrap(); + assert!(!resolved.connection.ssh); + assert!(!resolved.connection.verbose); + assert_eq!(resolved.build.build_type, BuildType::Debug); + assert_eq!(resolved.build.build_dir, "build"); + assert_eq!(resolved.mono.mono_dir, "build-mono"); + assert!(!resolved.build.no_build); + assert!(!resolved.build.clean); +} + +#[test] +fn test_resolve_with_config_applies_config_defaults() { + let mut config = SetupConfig::new(); + config.configs.insert( + "default".to_string(), + ConfigEntry { + ssh: true, + verbose: true, + build_type: BuildType::Release, + build_dir: "out".to_string(), + mono_dir: "mono".to_string(), + no_build: true, + clean: true, + cmake_flags: vec!["-DTEST=ON".to_string()], + }, + ); + let resolved = resolve_with_config(default_args(), &config).unwrap(); + assert!(resolved.connection.ssh); + assert!(resolved.connection.verbose); + assert_eq!(resolved.build.build_type, BuildType::Release); + assert_eq!(resolved.build.build_dir, "out"); + assert!(resolved.build.no_build); + assert!(resolved.build.clean); + assert_eq!(resolved.build.cmake_flags, vec!["-DTEST=ON"]); +} + +#[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, + cmake_flags: vec![], + }, + ); + 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); +} + +#[test] +fn test_resolve_with_config_errors_on_missing_config_name() { + let config = SetupConfig::new(); + let mut args = default_args(); + args.config.config_name = Some("nonexistent".to_string()); + let result = resolve_with_config(args, &config); + assert!(result.is_err()); +} + +#[test] +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); +} + +#[test] +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); +} + +#[test] +fn test_resolve_with_config_named_config_pulls_correct_values() { + let mut config = SetupConfig::new(); + config.configs.insert( + "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, + cmake_flags: vec![], + }, + ); + let mut args = default_args(); + args.config.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); + assert_eq!(resolved.build.build_dir, "out"); + assert!(resolved.build.clean); +} + +#[test] +fn test_resolve_with_config_cli_cmake_flags_not_overwritten_by_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, + cmake_flags: vec!["-DCONFIG_FLAG=ON".to_string()], + }, + ); + 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"]); +} + +#[test] +fn test_resolve_with_config_negative_flags_override_config() { + let mut config = SetupConfig::new(); + config.configs.insert( + "default".to_string(), + 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, + cmake_flags: vec![], + }, + ); + + 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 + + let resolved = resolve_with_config(args, &config).unwrap(); + assert!( + !resolved.connection.ssh, + "https should override config ssh:true" + ); + assert!( + !resolved.connection.verbose, + "no_verbose should override config verbose:true" + ); + assert!( + !resolved.build.no_build, + "build should override config no_build:true" + ); + assert!( + !resolved.build.clean, + "no_clean should override config clean:true" + ); +} diff --git a/tests/commands.rs b/tests/commands.rs index b1e5053..d83415f 100644 --- a/tests/commands.rs +++ b/tests/commands.rs @@ -1,177 +1,2 @@ -use star_setup::cli::{ - resolve_with_config, Args, BuildFlags, ConfigFlags, ConnectionFlags, MonoRepoFlags, ProfileFlags, -}; -use star_setup::commands::{ - create_mono_repo_cmakelists, resolve_repos_for_mono, resolve_test_repo, -}; -use star_setup::config::SetupConfig; -mod common; -use common::sink; - -// resolve_test_repo tests -#[test] -fn test_resolve_test_repo() { - let cases = [ - "user/repo", - "user/repo/", - "https://github.com/user/repo", - "https://github.com/user/repo.git", - "git@github.com:user/repo.git", - "git@github.com:user/repo", - ]; - for input in cases { - assert_eq!( - resolve_test_repo(input), - Ok("user/repo".to_string()), - "Failed for input: {input}" - ); - } -} - -#[test] -fn test_resolve_test_repo_errors() { - let cases = vec![ - ( - "repo", - "Repository must be in format 'username/repo' for mono-repo mode", - ), - ( - "https://gitlab.com/user/repo", - "Could not parse repository URL", - ), - ( - "git@github.com:owner", - "Repository URL missing repository name", - ), - ]; - for (input, error) in cases { - assert_eq!(resolve_test_repo(input), Err(error.to_string())); - } -} - -// create_mono_repo_cmakelists tests -#[test] -fn test_create_mono_repo_cmakelists_creates_file() { - let tmp = tempfile::TempDir::new().unwrap(); - - let repos = vec![ - "user-testrepo".to_string(), - "user/lib1".to_string(), - "user/lib2".to_string(), - ]; - create_mono_repo_cmakelists(tmp.path(), &repos, &mut sink()).unwrap(); - - 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()]; - create_mono_repo_cmakelists(tmp.path(), &repos, &mut sink()).unwrap(); - assert!(tmp.path().join("CMakeLists.txt").exists()); -} - -fn default_resolved() -> star_setup::cli::ResolvedArgs { - let args = Args { - repo: Some("user/repo".to_string()), - yes: false, - connection: ConnectionFlags { - ssh: false, - https: false, - verbose: false, - no_verbose: false, - }, - build: BuildFlags { - build_type: None, - build_dir: None, - no_build: false, - build: false, - clean: false, - no_clean: false, - cmake_flags: vec![], - meson_flags: vec![], - }, - mono: MonoRepoFlags { - mono_repo: false, - mono_dir: None, - repos: None, - profile: None, - }, - config: ConfigFlags { - init_config: false, - config_name: None, - config_add: None, - config_remove: None, - list_configs: false, - }, - profile: ProfileFlags { - profile_add: None, - profile_remove: None, - list_profiles: false, - }, - }; - resolve_with_config(args, &SetupConfig::new()).unwrap() -} - -#[test] -fn test_resolve_repos_for_mono_empty_profile_errors() { - let mut config = SetupConfig::new(); - config.profiles.insert("emptyprofile".to_string(), vec![]); - let mut args = default_resolved(); - args.mono.profile = Some("emptyprofile".to_string()); - let result = resolve_repos_for_mono(&args, &config, "user/repo", &mut sink()); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("has no repositories")); -} - -#[test] -fn test_resolve_repos_for_mono_with_profile() { - let mut config = SetupConfig::new(); - config.profiles.insert( - "myprofile".to_string(), - vec!["user/lib1".to_string(), "user/lib2".to_string()], - ); - let mut args = default_resolved(); - args.mono.profile = Some("myprofile".to_string()); - let result = resolve_repos_for_mono(&args, &config, "user/repo", &mut sink()); - assert!(result.is_ok()); - assert_eq!(result.unwrap(), vec!["user/lib1", "user/lib2"]); -} - -#[test] -fn test_resolve_repos_for_mono_with_explicit_repos() { - let config = SetupConfig::new(); - let mut args = default_resolved(); - args.mono.repos = Some(vec!["user/lib1".to_string(), "user/lib2".to_string()]); - let result = resolve_repos_for_mono(&args, &config, "user/repo", &mut sink()); - assert!(result.is_ok()); - assert_eq!(result.unwrap(), vec!["user/lib1", "user/lib2"]); -} - -#[test] -fn test_resolve_repos_for_mono_no_repos_or_profile_errors() { - let config = SetupConfig::new(); - let args = default_resolved(); - let result = resolve_repos_for_mono(&args, &config, "user/repo", &mut sink()); - assert!(result.is_err()); - assert!(result - .unwrap_err() - .contains("No repos or profile specified")); -} - -#[test] -fn test_resolve_repos_for_mono_profile_not_found_errors() { - let config = SetupConfig::new(); - let mut args = default_resolved(); - args.mono.profile = Some("nonexistent".to_string()); - let result = resolve_repos_for_mono(&args, &config, "user/repo", &mut sink()); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("not found")); -} +#[path = "commands/mono.rs"] +mod mono; diff --git a/tests/commands/mono.rs b/tests/commands/mono.rs new file mode 100644 index 0000000..a96221d --- /dev/null +++ b/tests/commands/mono.rs @@ -0,0 +1,4 @@ +#[path = "mono/config.rs"] +mod config; +#[path = "mono/resolve.rs"] +mod resolve; diff --git a/tests/commands/mono/config.rs b/tests/commands/mono/config.rs new file mode 100644 index 0000000..6f1478b --- /dev/null +++ b/tests/commands/mono/config.rs @@ -0,0 +1,33 @@ +use star_setup::commands::create_mono_repo_cmakelists; +#[path = "../../common/mod.rs"] +mod common; +use common::sink; + +// create_mono_repo_cmakelists tests +#[test] +fn test_create_mono_repo_cmakelists_creates_file() { + let tmp = tempfile::TempDir::new().unwrap(); + + let repos = vec![ + "user-testrepo".to_string(), + "user/lib1".to_string(), + "user/lib2".to_string(), + ]; + create_mono_repo_cmakelists(tmp.path(), &repos, &mut sink()).unwrap(); + + 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()]; + create_mono_repo_cmakelists(tmp.path(), &repos, &mut sink()).unwrap(); + assert!(tmp.path().join("CMakeLists.txt").exists()); +} diff --git a/tests/commands/mono/resolve.rs b/tests/commands/mono/resolve.rs new file mode 100644 index 0000000..447e37a --- /dev/null +++ b/tests/commands/mono/resolve.rs @@ -0,0 +1,147 @@ +use star_setup::cli::{ + resolve_with_config, Args, BuildFlags, ConfigFlags, ConnectionFlags, MonoRepoFlags, ProfileFlags, +}; +use star_setup::commands::{resolve_repos_for_mono, resolve_test_repo}; +use star_setup::config::SetupConfig; +#[path = "../../common/mod.rs"] +mod common; +use common::sink; + +// resolve_test_repo tests +#[test] +fn test_resolve_test_repo() { + let cases = [ + "user/repo", + "user/repo/", + "https://github.com/user/repo", + "https://github.com/user/repo.git", + "git@github.com:user/repo.git", + "git@github.com:user/repo", + ]; + for input in cases { + assert_eq!( + resolve_test_repo(input), + Ok("user/repo".to_string()), + "Failed for input: {input}" + ); + } +} + +#[test] +fn test_resolve_test_repo_errors() { + let cases = vec![ + ( + "repo", + "Repository must be in format 'username/repo' for mono-repo mode", + ), + ( + "https://gitlab.com/user/repo", + "Could not parse repository URL", + ), + ( + "git@github.com:owner", + "Repository URL missing repository name", + ), + ]; + for (input, error) in cases { + assert_eq!(resolve_test_repo(input), Err(error.to_string())); + } +} + +fn default_resolved() -> star_setup::cli::ResolvedArgs { + let args = Args { + repo: Some("user/repo".to_string()), + yes: false, + connection: ConnectionFlags { + ssh: false, + https: false, + verbose: false, + no_verbose: false, + }, + build: BuildFlags { + build_type: None, + build_dir: None, + no_build: false, + build: false, + clean: false, + no_clean: false, + cmake_flags: vec![], + meson_flags: vec![], + }, + mono: MonoRepoFlags { + mono_repo: false, + mono_dir: None, + repos: None, + profile: None, + }, + config: ConfigFlags { + init_config: false, + config_name: None, + config_add: None, + config_remove: None, + list_configs: false, + }, + profile: ProfileFlags { + profile_add: None, + profile_remove: None, + list_profiles: false, + }, + }; + resolve_with_config(args, &SetupConfig::new()).unwrap() +} + +#[test] +fn test_resolve_repos_for_mono_empty_profile_errors() { + let mut config = SetupConfig::new(); + config.profiles.insert("emptyprofile".to_string(), vec![]); + let mut args = default_resolved(); + args.mono.profile = Some("emptyprofile".to_string()); + let result = resolve_repos_for_mono(&args, &config, "user/repo", &mut sink()); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("has no repositories")); +} + +#[test] +fn test_resolve_repos_for_mono_with_profile() { + let mut config = SetupConfig::new(); + config.profiles.insert( + "myprofile".to_string(), + vec!["user/lib1".to_string(), "user/lib2".to_string()], + ); + let mut args = default_resolved(); + args.mono.profile = Some("myprofile".to_string()); + let result = resolve_repos_for_mono(&args, &config, "user/repo", &mut sink()); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), vec!["user/lib1", "user/lib2"]); +} + +#[test] +fn test_resolve_repos_for_mono_with_explicit_repos() { + let config = SetupConfig::new(); + let mut args = default_resolved(); + args.mono.repos = Some(vec!["user/lib1".to_string(), "user/lib2".to_string()]); + let result = resolve_repos_for_mono(&args, &config, "user/repo", &mut sink()); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), vec!["user/lib1", "user/lib2"]); +} + +#[test] +fn test_resolve_repos_for_mono_no_repos_or_profile_errors() { + let config = SetupConfig::new(); + let args = default_resolved(); + let result = resolve_repos_for_mono(&args, &config, "user/repo", &mut sink()); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .contains("No repos or profile specified")); +} + +#[test] +fn test_resolve_repos_for_mono_profile_not_found_errors() { + let config = SetupConfig::new(); + let mut args = default_resolved(); + args.mono.profile = Some("nonexistent".to_string()); + let result = resolve_repos_for_mono(&args, &config, "user/repo", &mut sink()); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("not found")); +} From ee37f4533c37c31554e61dc7053e64a5e010a8a8 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Wed, 24 Jun 2026 12:32:32 -0400 Subject: [PATCH 09/34] refactor(config): split config.rs into types, io, crud, and display submodules --- src/cli/args.rs | 2 +- src/cli/resolve.rs | 2 +- src/commands/mono/mode.rs | 2 +- src/commands/mono/resolve.rs | 2 +- src/config.rs | 317 --------------------------------- src/config/crud.rs | 170 ++++++++++++++++++ src/config/display.rs | 26 +++ src/config/io.rs | 80 +++++++++ src/config/mod.rs | 4 + src/config/types.rs | 48 +++++ src/main.rs | 6 +- src/profiles.rs | 3 +- tests/cli/resolve.rs | 2 +- tests/commands/mono.rs | 2 + tests/commands/mono/config.rs | 4 +- tests/commands/mono/resolve.rs | 6 +- tests/config.rs | 29 +-- tests/interactive.rs | 2 +- tests/profiles.rs | 5 +- 19 files changed, 363 insertions(+), 349 deletions(-) delete mode 100644 src/config.rs create mode 100644 src/config/crud.rs create mode 100644 src/config/display.rs create mode 100644 src/config/io.rs create mode 100644 src/config/mod.rs create mode 100644 src/config/types.rs diff --git a/src/cli/args.rs b/src/cli/args.rs index fbf50be..d9cce7a 100644 --- a/src/cli/args.rs +++ b/src/cli/args.rs @@ -1,7 +1,7 @@ use crate::cli::flags::{BuildFlags, ConfigFlags, ConnectionFlags, MonoRepoFlags, ProfileFlags}; use crate::cli::resolve::resolve_with_config; use crate::cli::resolved::ResolvedArgs; -use crate::config::SetupConfig; +use crate::config::types::SetupConfig; use clap::Parser; /// Top-level CLI arguments for star-setup. diff --git a/src/cli/resolve.rs b/src/cli/resolve.rs index 53cf054..ac621da 100644 --- a/src/cli/resolve.rs +++ b/src/cli/resolve.rs @@ -3,7 +3,7 @@ use crate::cli::build::BuildType; use crate::cli::resolved::{ ResolvedArgs, ResolvedBuildFlags, ResolvedConnectionFlags, ResolvedMonoFlags, }; -use crate::config::SetupConfig; +use crate::config::types::SetupConfig; /// Resolves a boolean flag from CLI positive/negative flags, config value, and a default. /// Negative flag takes highest priority, then positive, then config, then default. diff --git a/src/commands/mono/mode.rs b/src/commands/mono/mode.rs index efb6650..553aae9 100644 --- a/src/commands/mono/mode.rs +++ b/src/commands/mono/mode.rs @@ -3,7 +3,7 @@ use crate::commands::build::{cmake_build, meson_build}; use crate::commands::mono::config::{create_mono_repo_cmakelists, create_mono_repo_mesonbuild}; use crate::commands::mono::resolve::{resolve_repos_for_mono, resolve_test_repo}; use crate::commands::mono::wraps::hoist_wraps; -use crate::config::SetupConfig; +use crate::config::types::SetupConfig; use crate::repository::{clone_repository, repo_dir_name}; use std::fs; use std::io::{BufRead, Write}; diff --git a/src/commands/mono/resolve.rs b/src/commands/mono/resolve.rs index f0acc5a..925f3b5 100644 --- a/src/commands/mono/resolve.rs +++ b/src/commands/mono/resolve.rs @@ -1,6 +1,6 @@ use crate::cli::ResolvedArgs; use crate::commands::header::{print_mode_header, ModeHeader}; -use crate::config::SetupConfig; +use crate::config::types::SetupConfig; use crate::profiles::list_profiles; use std::io::Write; diff --git a/src/config.rs b/src/config.rs deleted file mode 100644 index b0c55a8..0000000 --- a/src/config.rs +++ /dev/null @@ -1,317 +0,0 @@ -//! Configuration file management. - -use crate::cli::BuildType; -use crate::utils::confirm; -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; -use std::fmt::Write as FmtWrite; -use std::fs; -use std::io; -use std::io::{BufRead, Write}; -use std::path::PathBuf; - -/// Represents a single named configuration entry. -#[allow(clippy::struct_excessive_bools)] -#[derive(Serialize, Deserialize, Default)] -pub struct ConfigEntry { - /// Use SSH instead of HTTPS for cloning. - pub ssh: bool, - /// Build type (e.g. `Debug`, `Release`). - pub build_type: BuildType, - /// Build directory name. - pub build_dir: String, - /// Mono-repo build directory name. - pub mono_dir: String, - /// Skip the build step, only configure. - pub no_build: bool, - /// Clean the build directory before configuring. - pub clean: bool, - /// Show detailed command output. - pub verbose: bool, - /// Additional `CMake` arguments. - pub cmake_flags: Vec, -} - -/// Top-level configuration structure. -#[derive(Serialize, Deserialize, Default)] -pub struct SetupConfig { - /// Named configuration entries. - #[serde(default)] - pub configs: HashMap, - /// Named profile entries mapping profile names to repository lists. - #[serde(default)] - pub profiles: HashMap>, - /// Path to the config file this was loaded from, if any. - #[serde(skip)] - pub path: Option, -} - -impl SetupConfig { - /// Creates a new empty `SetupConfig`. - #[must_use] - pub fn new() -> Self { - Self::default() - } -} - -/// Inserts or overwrites a named configuration entry. -pub fn insert_config(config: &mut SetupConfig, name: &str, entry: ConfigEntry) { - config.configs.insert(name.to_string(), entry); -} - -/// Removes a named configuration entry. Returns `true` if it existed. -pub fn remove_config_entry(config: &mut SetupConfig, name: &str) -> bool { - config.configs.remove(name).is_some() -} - -/// Returns `true` if a configuration with the given name exists. -#[must_use] -pub fn has_config(config: &SetupConfig, name: &str) -> bool { - config.configs.contains_key(name) -} - -/// Formats a `ConfigEntry` as a human-readable string. -#[must_use] -pub fn format_entry(e: &ConfigEntry) -> String { - let mut out = String::new(); - writeln!(out, " SSH: {}", e.ssh).ok(); - writeln!(out, " Build Type: {}", e.build_type.to_cmake()).ok(); - writeln!(out, " Build Directory: {}", e.build_dir).ok(); - writeln!(out, " Mono-build Directory: {}", e.mono_dir).ok(); - writeln!(out, " No-build flag: {}", e.no_build).ok(); - writeln!(out, " Clean flag: {}", e.clean).ok(); - writeln!(out, " Verbose flag: {}", e.verbose).ok(); - if e.cmake_flags.is_empty() { - out.push('\n'); - } else if e.cmake_flags.len() == 1 { - writeln!(out, " CMake argument: {}", e.cmake_flags[0]).ok(); - } else { - out.push_str(" CMake arguments:\n"); - for arg in &e.cmake_flags { - writeln!(out, " {arg}").ok(); - } - } - out -} - -/// Loads configuration from the first valid JSON file in `locations`. -pub fn load_config(locations: &[PathBuf], output: &mut impl Write) -> SetupConfig { - let mut invalid_count = 0; - - for path in locations { - if !path.exists() { - continue; - } - match fs::read_to_string(path) { - Ok(contents) => match serde_json::from_str::(&contents) { - Ok(mut config) => { - config.path = Some(path.clone()); - return config; - } - Err(e) => { - writeln!(output, "Warning: Invalid JSON in {}: {e}", path.display()).ok(); - invalid_count += 1; - } - }, - Err(e) if e.kind() == io::ErrorKind::PermissionDenied => { - writeln!(output, "Error: No permission to read {}", path.display()).ok(); - invalid_count += 1; - } - Err(e) => { - writeln!( - output, - "An unexpected error occurred reading {}: {e}", - path.display() - ) - .ok(); - invalid_count += 1; - } - } - } - - if invalid_count != 0 { - writeln!( - output, - "Found {invalid_count} config file{} that had errors", - if invalid_count == 1 { "" } else { "s" } - ) - .ok(); - } - SetupConfig::new() -} - -/// Serializes the configuration and writes it to the path stored in `config.path`. -/// # Errors -/// Returns an error if serialization fails or if the file cannot be written. -pub fn save_config(config: &mut SetupConfig) -> Result { - let path = config - .path - .get_or_insert_with(|| { - dirs::home_dir().map_or_else( - || PathBuf::from(".star-setup.json"), - |h| h.join(".star-setup.json"), - ) - }) - .clone(); - let json = - serde_json::to_string_pretty(config).map_err(|e| format!("Failed to serialize config: {e}"))?; - - fs::write(&path, json).map_err(|e| match e.kind() { - io::ErrorKind::PermissionDenied => { - format!("Error: No permission to write to {}", path.display()) - } - _ => format!( - "An unexpected error occurred writing {}: {}", - path.display(), - e - ), - })?; - Ok(path) -} - -/// Creates a default configuration file in the current directory. -/// # Errors -/// Returns an error if the config file cannot be written. -pub fn create_default_config( - path: PathBuf, - yes: bool, - input: &mut impl BufRead, - output: &mut impl Write, -) -> Result<(), String> { - if path.exists() - && !confirm( - &format!("{} already exists. Overwrite?", path.display()), - yes, - input, - output, - )? - { - writeln!(output, "Aborted.").ok(); - return Ok(()); - } - - let mut config = SetupConfig::new(); - config.path = Some(path.clone()); - config.configs.insert( - "default".to_string(), - ConfigEntry { - ssh: false, - build_type: BuildType::Debug, - build_dir: "build".to_string(), - mono_dir: "build-mono".to_string(), - no_build: false, - clean: false, - verbose: false, - cmake_flags: vec![], - }, - ); - - save_config(&mut config)?; - - writeln!( - output, - "Created config file: {}", - dunce::canonicalize(&path).unwrap_or(path).display() - ) - .ok(); - writeln!(output, "Edit this file to customize your defaults.").ok(); - writeln!(output, "\nConfig files are checked in this order:").ok(); - writeln!(output, " 1. ./.star-setup.json (current directory)").ok(); - writeln!(output, " 2. ~/.star-setup.json (home directory)").ok(); - - Ok(()) -} - -/// Adds a new named configuration entry. -/// # Errors -/// Returns an error if saving the config file fails. -pub fn add_config( - config: &mut SetupConfig, - name: &str, - entry: ConfigEntry, - yes: bool, - input: &mut impl BufRead, - output: &mut impl Write, -) -> Result<(), String> { - if has_config(config, name) - && !confirm( - &format!("Warning: Configuration '{name}' already exists. Overwrite?"), - yes, - input, - output, - )? - { - writeln!(output, "Aborted.").ok(); - return Ok(()); - } - - insert_config(config, name, entry); - let path = save_config(config)?; - - let e = &config.configs[name]; - writeln!( - output, - "Configuration '{name}' added successfully to {}", - path.display() - ) - .ok(); - writeln!(output, "Configuration details:").ok(); - write!(output, "{}", format_entry(e)).ok(); - - Ok(()) -} - -/// Removes a named configuration entry. -/// # Errors -/// Returns an error if saving the config file fails. -pub fn remove_config( - config: &mut SetupConfig, - name: &str, - yes: bool, - input: &mut impl BufRead, - output: &mut impl Write, -) -> Result<(), String> { - let Some(e) = config.configs.get(name) else { - writeln!(output, "\nWarning: Config '{name}' not found.\n").ok(); - return Ok(()); - }; - - writeln!(output, "Config {name}").ok(); - writeln!(output, "Configuration details:").ok(); - write!(output, "{}", format_entry(e)).ok(); - - if !confirm( - "\nAre you sure you want to remove this config?", - yes, - input, - output, - )? { - writeln!(output, "Aborted.").ok(); - return Ok(()); - } - - remove_config_entry(config, name); - let path = save_config(config)?; - writeln!(output, "\nConfig '{name}' was successfully removed").ok(); - writeln!(output, "Configuration saved to: {}\n", path.display()).ok(); - Ok(()) -} - -/// Lists all saved configuration entries. -pub fn list_configs(config: &SetupConfig, output: &mut impl Write) { - if config.configs.is_empty() { - writeln!(output, " No configurations created.").ok(); - writeln!( - output, - " Run with --init-config to create a default configuration." - ) - .ok(); - return; - } - - writeln!(output, "Configurations:").ok(); - for (name, e) in &config.configs { - writeln!(output, "\n{name}:").ok(); - write!(output, "{}", format_entry(e)).ok(); - } -} diff --git a/src/config/crud.rs b/src/config/crud.rs new file mode 100644 index 0000000..a819b1f --- /dev/null +++ b/src/config/crud.rs @@ -0,0 +1,170 @@ +use crate::cli::BuildType; +use crate::config::display::format_entry; +use crate::config::io::save_config; +use crate::config::types::{ConfigEntry, SetupConfig}; +use crate::utils::confirm; +use std::io::{BufRead, Write}; +use std::path::PathBuf; + +/// Inserts or overwrites a named configuration entry. +pub fn insert_config(config: &mut SetupConfig, name: &str, entry: ConfigEntry) { + config.configs.insert(name.to_string(), entry); +} + +/// Removes a named configuration entry. Returns `true` if it existed. +pub fn remove_config_entry(config: &mut SetupConfig, name: &str) -> bool { + config.configs.remove(name).is_some() +} + +/// Returns `true` if a configuration with the given name exists. +#[must_use] +pub fn has_config(config: &SetupConfig, name: &str) -> bool { + config.configs.contains_key(name) +} + +/// Creates a default configuration file in the current directory. +/// # Errors +/// Returns an error if the config file cannot be written. +pub fn create_default_config( + path: PathBuf, + yes: bool, + input: &mut impl BufRead, + output: &mut impl Write, +) -> Result<(), String> { + if path.exists() + && !confirm( + &format!("{} already exists. Overwrite?", path.display()), + yes, + input, + output, + )? + { + writeln!(output, "Aborted.").ok(); + return Ok(()); + } + + let mut config = SetupConfig::new(); + config.path = Some(path.clone()); + config.configs.insert( + "default".to_string(), + ConfigEntry { + ssh: false, + build_type: BuildType::Debug, + build_dir: "build".to_string(), + mono_dir: "build-mono".to_string(), + no_build: false, + clean: false, + verbose: false, + cmake_flags: vec![], + }, + ); + + save_config(&mut config)?; + + writeln!( + output, + "Created config file: {}", + dunce::canonicalize(&path).unwrap_or(path).display() + ) + .ok(); + writeln!(output, "Edit this file to customize your defaults.").ok(); + writeln!(output, "\nConfig files are checked in this order:").ok(); + writeln!(output, " 1. ./.star-setup.json (current directory)").ok(); + writeln!(output, " 2. ~/.star-setup.json (home directory)").ok(); + + Ok(()) +} + +/// Adds a new named configuration entry. +/// # Errors +/// Returns an error if saving the config file fails. +pub fn add_config( + config: &mut SetupConfig, + name: &str, + entry: ConfigEntry, + yes: bool, + input: &mut impl BufRead, + output: &mut impl Write, +) -> Result<(), String> { + if has_config(config, name) + && !confirm( + &format!("Warning: Configuration '{name}' already exists. Overwrite?"), + yes, + input, + output, + )? + { + writeln!(output, "Aborted.").ok(); + return Ok(()); + } + + insert_config(config, name, entry); + let path = save_config(config)?; + + let e = &config.configs[name]; + writeln!( + output, + "Configuration '{name}' added successfully to {}", + path.display() + ) + .ok(); + writeln!(output, "Configuration details:").ok(); + write!(output, "{}", format_entry(e)).ok(); + + Ok(()) +} + +/// Removes a named configuration entry. +/// # Errors +/// Returns an error if saving the config file fails. +pub fn remove_config( + config: &mut SetupConfig, + name: &str, + yes: bool, + input: &mut impl BufRead, + output: &mut impl Write, +) -> Result<(), String> { + let Some(e) = config.configs.get(name) else { + writeln!(output, "\nWarning: Config '{name}' not found.\n").ok(); + return Ok(()); + }; + + writeln!(output, "Config {name}").ok(); + writeln!(output, "Configuration details:").ok(); + write!(output, "{}", format_entry(e)).ok(); + + if !confirm( + "\nAre you sure you want to remove this config?", + yes, + input, + output, + )? { + writeln!(output, "Aborted.").ok(); + return Ok(()); + } + + remove_config_entry(config, name); + let path = save_config(config)?; + writeln!(output, "\nConfig '{name}' was successfully removed").ok(); + writeln!(output, "Configuration saved to: {}\n", path.display()).ok(); + Ok(()) +} + +/// Lists all saved configuration entries. +pub fn list_configs(config: &SetupConfig, output: &mut impl Write) { + if config.configs.is_empty() { + writeln!(output, " No configurations created.").ok(); + writeln!( + output, + " Run with --init-config to create a default configuration." + ) + .ok(); + return; + } + + writeln!(output, "Configurations:").ok(); + for (name, e) in &config.configs { + writeln!(output, "\n{name}:").ok(); + write!(output, "{}", format_entry(e)).ok(); + } +} diff --git a/src/config/display.rs b/src/config/display.rs new file mode 100644 index 0000000..475cd7b --- /dev/null +++ b/src/config/display.rs @@ -0,0 +1,26 @@ +use crate::config::types::ConfigEntry; +use std::fmt::Write as FmtWrite; + +/// Formats a `ConfigEntry` as a human-readable string. +#[must_use] +pub fn format_entry(e: &ConfigEntry) -> String { + let mut out = String::new(); + writeln!(out, " SSH: {}", e.ssh).ok(); + writeln!(out, " Build Type: {}", e.build_type.to_cmake()).ok(); + writeln!(out, " Build Directory: {}", e.build_dir).ok(); + writeln!(out, " Mono-build Directory: {}", e.mono_dir).ok(); + writeln!(out, " No-build flag: {}", e.no_build).ok(); + writeln!(out, " Clean flag: {}", e.clean).ok(); + writeln!(out, " Verbose flag: {}", e.verbose).ok(); + if e.cmake_flags.is_empty() { + out.push('\n'); + } else if e.cmake_flags.len() == 1 { + writeln!(out, " CMake argument: {}", e.cmake_flags[0]).ok(); + } else { + out.push_str(" CMake arguments:\n"); + for arg in &e.cmake_flags { + writeln!(out, " {arg}").ok(); + } + } + out +} diff --git a/src/config/io.rs b/src/config/io.rs new file mode 100644 index 0000000..0bd0e66 --- /dev/null +++ b/src/config/io.rs @@ -0,0 +1,80 @@ +use crate::config::types::SetupConfig; +use std::fs; +use std::io; +use std::io::Write; +use std::path::PathBuf; + +/// Loads configuration from the first valid JSON file in `locations`. +pub fn load_config(locations: &[PathBuf], output: &mut impl Write) -> SetupConfig { + let mut invalid_count = 0; + + for path in locations { + if !path.exists() { + continue; + } + match fs::read_to_string(path) { + Ok(contents) => match serde_json::from_str::(&contents) { + Ok(mut config) => { + config.path = Some(path.clone()); + return config; + } + Err(e) => { + writeln!(output, "Warning: Invalid JSON in {}: {e}", path.display()).ok(); + invalid_count += 1; + } + }, + Err(e) if e.kind() == io::ErrorKind::PermissionDenied => { + writeln!(output, "Error: No permission to read {}", path.display()).ok(); + invalid_count += 1; + } + Err(e) => { + writeln!( + output, + "An unexpected error occurred reading {}: {e}", + path.display() + ) + .ok(); + invalid_count += 1; + } + } + } + + if invalid_count != 0 { + writeln!( + output, + "Found {invalid_count} config file{} that had errors", + if invalid_count == 1 { "" } else { "s" } + ) + .ok(); + } + SetupConfig::new() +} + +/// Serializes the configuration and writes it to the path stored in `config.path`. +/// # Errors +/// Returns an error if serialization fails or if the file cannot be written. +pub fn save_config(config: &mut SetupConfig) -> Result { + let path = config + .path + .get_or_insert_with(|| { + dirs::home_dir().map_or_else( + || PathBuf::from(".star-setup.json"), + |h| h.join(".star-setup.json"), + ) + }) + .clone(); + let json = + serde_json::to_string_pretty(config).map_err(|e| format!("Failed to serialize config: {e}"))?; + + fs::write(&path, json).map_err(|e| match e.kind() { + io::ErrorKind::PermissionDenied => { + format!("Error: No permission to write to {}", path.display()) + } + _ => format!( + "An unexpected error occurred writing {}: {}", + path.display(), + e + ), + })?; + Ok(path) +} diff --git a/src/config/mod.rs b/src/config/mod.rs new file mode 100644 index 0000000..832ad44 --- /dev/null +++ b/src/config/mod.rs @@ -0,0 +1,4 @@ +pub mod crud; +pub mod display; +pub mod io; +pub mod types; diff --git a/src/config/types.rs b/src/config/types.rs new file mode 100644 index 0000000..01bf4a1 --- /dev/null +++ b/src/config/types.rs @@ -0,0 +1,48 @@ +use crate::cli::BuildType; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::path::PathBuf; + +/// Represents a single named configuration entry. +#[allow(clippy::struct_excessive_bools)] +#[derive(Serialize, Deserialize, Default)] +pub struct ConfigEntry { + /// Use SSH instead of HTTPS for cloning. + pub ssh: bool, + /// Build type (e.g. `Debug`, `Release`). + pub build_type: BuildType, + /// Build directory name. + pub build_dir: String, + /// Mono-repo build directory name. + pub mono_dir: String, + /// Skip the build step, only configure. + pub no_build: bool, + /// Clean the build directory before configuring. + pub clean: bool, + /// Show detailed command output. + pub verbose: bool, + /// Additional `CMake` arguments. + pub cmake_flags: Vec, +} + +/// Top-level configuration structure. +#[derive(Serialize, Deserialize, Default)] +pub struct SetupConfig { + /// Named configuration entries. + #[serde(default)] + pub configs: HashMap, + /// Named profile entries mapping profile names to repository lists. + #[serde(default)] + pub profiles: HashMap>, + /// Path to the config file this was loaded from, if any. + #[serde(skip)] + pub path: Option, +} + +impl SetupConfig { + /// Creates a new empty `SetupConfig`. + #[must_use] + pub fn new() -> Self { + Self::default() + } +} diff --git a/src/main.rs b/src/main.rs index c89641f..cfe699e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,9 +2,9 @@ use star_setup::cli::Args; use star_setup::commands::{mono_repo_mode, single_repo_mode}; -use star_setup::config::{ - add_config, create_default_config, list_configs, load_config, remove_config, ConfigEntry, -}; +use star_setup::config::crud::{add_config, create_default_config, list_configs, remove_config}; +use star_setup::config::io::load_config; +use star_setup::config::types::ConfigEntry; use star_setup::interactive::interactive_mode; use star_setup::profiles::{add_profile, list_profiles, remove_profile}; use star_setup::utils::check_prerequisites; diff --git a/src/profiles.rs b/src/profiles.rs index e278b5f..a853e5a 100644 --- a/src/profiles.rs +++ b/src/profiles.rs @@ -1,6 +1,7 @@ //! Profile management. -use crate::config::{save_config, SetupConfig}; +use crate::config::io::save_config; +use crate::config::types::SetupConfig; use crate::utils::confirm; use std::io::{BufRead, Write}; diff --git a/tests/cli/resolve.rs b/tests/cli/resolve.rs index c23683a..e5d5473 100644 --- a/tests/cli/resolve.rs +++ b/tests/cli/resolve.rs @@ -2,7 +2,7 @@ use star_setup::cli::{ resolve_bool, resolve_with_config, Args, BuildFlags, BuildType, ConfigFlags, ConnectionFlags, MonoRepoFlags, ProfileFlags, }; -use star_setup::config::{ConfigEntry, SetupConfig}; +use star_setup::config::types::{ConfigEntry, SetupConfig}; #[test] fn test_resolve_bool() { diff --git a/tests/commands/mono.rs b/tests/commands/mono.rs index a96221d..4534215 100644 --- a/tests/commands/mono.rs +++ b/tests/commands/mono.rs @@ -2,3 +2,5 @@ mod config; #[path = "mono/resolve.rs"] mod resolve; +#[path = "../common/mod.rs"] +mod common; diff --git a/tests/commands/mono/config.rs b/tests/commands/mono/config.rs index 6f1478b..79fd0d6 100644 --- a/tests/commands/mono/config.rs +++ b/tests/commands/mono/config.rs @@ -1,7 +1,5 @@ use star_setup::commands::create_mono_repo_cmakelists; -#[path = "../../common/mod.rs"] -mod common; -use common::sink; +use super::common::sink; // create_mono_repo_cmakelists tests #[test] diff --git a/tests/commands/mono/resolve.rs b/tests/commands/mono/resolve.rs index 447e37a..5f47ab6 100644 --- a/tests/commands/mono/resolve.rs +++ b/tests/commands/mono/resolve.rs @@ -2,10 +2,8 @@ use star_setup::cli::{ resolve_with_config, Args, BuildFlags, ConfigFlags, ConnectionFlags, MonoRepoFlags, ProfileFlags, }; use star_setup::commands::{resolve_repos_for_mono, resolve_test_repo}; -use star_setup::config::SetupConfig; -#[path = "../../common/mod.rs"] -mod common; -use common::sink; +use star_setup::config::types::SetupConfig; +use super::common::sink; // resolve_test_repo tests #[test] diff --git a/tests/config.rs b/tests/config.rs index 97939ba..be8c19d 100644 --- a/tests/config.rs +++ b/tests/config.rs @@ -1,8 +1,13 @@ use star_setup::{ cli::BuildType, config::{ - format_entry, has_config, insert_config, load_config, remove_config_entry, save_config, - ConfigEntry, SetupConfig, + crud::{ + add_config, create_default_config, has_config, insert_config, list_configs, remove_config, + remove_config_entry, + }, + display::format_entry, + io::{load_config, save_config}, + types::{ConfigEntry, SetupConfig}, }, }; use std::path::PathBuf; @@ -143,8 +148,7 @@ fn test_create_default_config_creates_file() { let tmp = tempfile::TempDir::new().unwrap(); let path = tmp.path().join(".star-setup.json"); - star_setup::config::create_default_config(path.clone(), true, &mut empty_input(), &mut sink()) - .unwrap(); + create_default_config(path.clone(), true, &mut empty_input(), &mut sink()).unwrap(); assert!(path.exists()); } @@ -155,7 +159,7 @@ fn test_add_config_inserts_and_saves() { let mut config = SetupConfig::new(); config.path = Some(path.clone()); - star_setup::config::add_config( + add_config( &mut config, "myconfig", sample_entry(), @@ -177,7 +181,7 @@ fn test_remove_config_removes_and_saves() { insert_config(&mut config, "myconfig", sample_entry()); save_config(&mut config).unwrap(); - star_setup::config::remove_config( + remove_config( &mut config, "myconfig", true, @@ -191,7 +195,7 @@ fn test_remove_config_removes_and_saves() { #[test] fn test_remove_config_not_found() { let mut config = SetupConfig::new(); - star_setup::config::remove_config( + remove_config( &mut config, "nonexistent", true, @@ -209,7 +213,7 @@ fn test_add_config_aborts_when_exists_and_not_confirmed() { insert_config(&mut config, "myconfig", sample_entry()); let input = b"n\n"; - star_setup::config::add_config( + add_config( &mut config, "myconfig", ConfigEntry { @@ -238,7 +242,7 @@ fn test_remove_config_aborts_when_not_confirmed() { insert_config(&mut config, "myconfig", sample_entry()); let input = b"n\n"; - star_setup::config::remove_config( + remove_config( &mut config, "myconfig", false, @@ -256,8 +260,7 @@ fn test_create_default_config_aborts_when_exists_and_not_confirmed() { std::fs::write(&path, "original").unwrap(); let input = b"n\n"; - star_setup::config::create_default_config(path.clone(), false, &mut input.as_ref(), &mut sink()) - .unwrap(); + create_default_config(path.clone(), false, &mut input.as_ref(), &mut sink()).unwrap(); assert_eq!(std::fs::read_to_string(&path).unwrap(), "original"); } @@ -265,7 +268,7 @@ fn test_create_default_config_aborts_when_exists_and_not_confirmed() { fn test_list_configs_empty() { let config = SetupConfig::new(); let mut output = sink(); - star_setup::config::list_configs(&config, &mut output); + list_configs(&config, &mut output); let out = String::from_utf8(output).unwrap(); assert!(out.contains("No configurations created")); } @@ -275,7 +278,7 @@ fn test_list_configs_with_entries() { let mut config = SetupConfig::new(); insert_config(&mut config, "myconfig", sample_entry()); let mut output = sink(); - star_setup::config::list_configs(&config, &mut output); + list_configs(&config, &mut output); let out = String::from_utf8(output).unwrap(); assert!(out.contains("myconfig")); assert!(out.contains("Configurations:")); diff --git a/tests/interactive.rs b/tests/interactive.rs index 1fa0a20..1ed95ec 100644 --- a/tests/interactive.rs +++ b/tests/interactive.rs @@ -1,7 +1,7 @@ use star_setup::cli::{ resolve_with_config, Args, BuildFlags, ConfigFlags, ConnectionFlags, MonoRepoFlags, ProfileFlags, }; -use star_setup::config::SetupConfig; +use star_setup::config::types::SetupConfig; use star_setup::interactive::interactive_mode; fn default_resolved() -> star_setup::cli::ResolvedArgs { diff --git a/tests/profiles.rs b/tests/profiles.rs index ceaebfe..3d1c727 100644 --- a/tests/profiles.rs +++ b/tests/profiles.rs @@ -1,4 +1,5 @@ -use star_setup::config::{save_config, SetupConfig}; +use star_setup::config::io::{load_config, save_config}; +use star_setup::config::types::SetupConfig; use star_setup::profiles::{has_profile, insert_profile, remove_profile_entry}; mod common; use common::{empty_input, sink}; @@ -195,7 +196,7 @@ fn test_save_and_load_profile_roundtrip() { ); save_config(&mut config).unwrap(); - let loaded = star_setup::config::load_config(&[path], &mut sink()); + let loaded = load_config(&[path], &mut sink()); assert!(loaded.profiles.contains_key("myprofile")); assert_eq!( loaded.profiles["myprofile"], From e2390d629ded7d6c20d1386eb2526f29d040917e Mon Sep 17 00:00:00 2001 From: Masonlet Date: Wed, 24 Jun 2026 12:38:58 -0400 Subject: [PATCH 10/34] refactor(utils): split utils.rs into confirm, process, and prerequisites submodules --- src/commands/build.rs | 2 +- src/commands/single.rs | 3 +- src/config/crud.rs | 2 +- src/main.rs | 2 +- src/profiles.rs | 2 +- src/repository.rs | 2 +- src/utils/confirm.rs | 24 +++++++++++++++ src/utils/mod.rs | 3 ++ src/utils/prerequisites.rs | 25 ++++++++++++++++ src/{utils.rs => utils/process.rs} | 48 ------------------------------ tests/utils.rs | 46 ++-------------------------- tests/utils/confirm.rs | 44 +++++++++++++++++++++++++++ 12 files changed, 106 insertions(+), 97 deletions(-) create mode 100644 src/utils/confirm.rs create mode 100644 src/utils/mod.rs create mode 100644 src/utils/prerequisites.rs rename src/{utils.rs => utils/process.rs} (75%) create mode 100644 tests/utils/confirm.rs diff --git a/src/commands/build.rs b/src/commands/build.rs index cd3dc6c..b11de6f 100644 --- a/src/commands/build.rs +++ b/src/commands/build.rs @@ -3,7 +3,7 @@ use crate::cli::detect_build_system; use crate::cli::BuildSystem; use crate::cli::ResolvedArgs; -use crate::utils::run_command; +use crate::utils::process::run_command; use std::io::{BufRead, Write}; use std::path::Path; diff --git a/src/commands/single.rs b/src/commands/single.rs index 57a04f3..2d2544b 100644 --- a/src/commands/single.rs +++ b/src/commands/single.rs @@ -2,7 +2,8 @@ use crate::cli::ResolvedArgs; use crate::commands::build::build_project; use crate::commands::header::{print_mode_header, ModeHeader}; use crate::repository::{repo_dir_name, resolve_repo_url}; -use crate::utils::{confirm, run_command}; +use crate::utils::confirm::confirm; +use crate::utils::process::run_command; use std::fs; use std::io::{BufRead, Write}; use std::path::{Path, PathBuf}; diff --git a/src/config/crud.rs b/src/config/crud.rs index a819b1f..2561000 100644 --- a/src/config/crud.rs +++ b/src/config/crud.rs @@ -2,7 +2,7 @@ use crate::cli::BuildType; use crate::config::display::format_entry; use crate::config::io::save_config; use crate::config::types::{ConfigEntry, SetupConfig}; -use crate::utils::confirm; +use crate::utils::confirm::confirm; use std::io::{BufRead, Write}; use std::path::PathBuf; diff --git a/src/main.rs b/src/main.rs index cfe699e..b931fc7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,7 +7,7 @@ use star_setup::config::io::load_config; use star_setup::config::types::ConfigEntry; use star_setup::interactive::interactive_mode; use star_setup::profiles::{add_profile, list_profiles, remove_profile}; -use star_setup::utils::check_prerequisites; +use star_setup::utils::prerequisites::check_prerequisites; use std::io; use std::io::IsTerminal; use std::path::PathBuf; diff --git a/src/profiles.rs b/src/profiles.rs index a853e5a..835fd03 100644 --- a/src/profiles.rs +++ b/src/profiles.rs @@ -2,7 +2,7 @@ use crate::config::io::save_config; use crate::config::types::SetupConfig; -use crate::utils::confirm; +use crate::utils::confirm::confirm; use std::io::{BufRead, Write}; /// Inserts or overwrites a named profile. diff --git a/src/repository.rs b/src/repository.rs index 02da3d2..f9481dd 100644 --- a/src/repository.rs +++ b/src/repository.rs @@ -1,6 +1,6 @@ //! Repository functions including cloning and URL resolution. -use crate::utils::run_command; +use crate::utils::process::run_command; use std::io::Write; use std::path::Path; diff --git a/src/utils/confirm.rs b/src/utils/confirm.rs new file mode 100644 index 0000000..a4381c8 --- /dev/null +++ b/src/utils/confirm.rs @@ -0,0 +1,24 @@ +use std::io::BufRead; +use std::io::Write; + +/// Returns `true` if `yes` is set or the user enters `y`/`Y`. +/// # Errors +/// Returns an error if stdin reaches EOF unexpectedly. +pub fn confirm( + prompt: &str, + yes: bool, + input: &mut impl BufRead, + output: &mut impl Write, +) -> Result { + if yes { + return Ok(true); + } + + write!(output, "{prompt} (y/n): ").ok(); + output.flush().ok(); + let mut line = String::new(); + if input.read_line(&mut line).unwrap_or(0) == 0 { + return Err("unexpected end of input".to_string()); + } + Ok(line.trim().eq_ignore_ascii_case("y")) +} diff --git a/src/utils/mod.rs b/src/utils/mod.rs new file mode 100644 index 0000000..99f3316 --- /dev/null +++ b/src/utils/mod.rs @@ -0,0 +1,3 @@ +pub mod confirm; +pub mod prerequisites; +pub mod process; diff --git a/src/utils/prerequisites.rs b/src/utils/prerequisites.rs new file mode 100644 index 0000000..81b03c8 --- /dev/null +++ b/src/utils/prerequisites.rs @@ -0,0 +1,25 @@ +use std::io::Write; +use std::process::Command; + +/// Checks if required tools are available on PATH. +/// Returns Result. +/// # Errors +/// Returns an error if any required tool is missing from PATH. +pub fn check_prerequisites(verbose: bool, output: &mut impl Write) -> Result<(), String> { + let mut missing: Vec<&str> = Vec::new(); + 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 verbose { + writeln!(output, "Found {tool}").ok(); + } + } + if !missing.is_empty() { + return Err(format!("Missing required tools: {}", missing.join(", "))); + } + Ok(()) +} diff --git a/src/utils.rs b/src/utils/process.rs similarity index 75% rename from src/utils.rs rename to src/utils/process.rs index 2d7dec8..091c731 100644 --- a/src/utils.rs +++ b/src/utils/process.rs @@ -1,34 +1,9 @@ -//! Utility functions. - -use std::io::BufRead; use std::io::Read; use std::io::Write; use std::path::Path; use std::process::{Command, Stdio}; use std::thread; -/// Returns `true` if `yes` is set or the user enters `y`/`Y`. -/// # Errors -/// Returns an error if stdin reaches EOF unexpectedly. -pub fn confirm( - prompt: &str, - yes: bool, - input: &mut impl BufRead, - output: &mut impl Write, -) -> Result { - if yes { - return Ok(true); - } - - write!(output, "{prompt} (y/n): ").ok(); - output.flush().ok(); - let mut line = String::new(); - if input.read_line(&mut line).unwrap_or(0) == 0 { - return Err("unexpected end of input".to_string()); - } - Ok(line.trim().eq_ignore_ascii_case("y")) -} - /// Finds vcvars64.bat using vswhere.exe. /// Returns None if vswhere is not found or no VS installation exists. #[cfg(target_os = "windows")] @@ -82,29 +57,6 @@ fn get_msvc_env() -> Result, String> { ) } -/// Checks if required tools are available on PATH. -/// Returns Result. -/// # Errors -/// Returns an error if any required tool is missing from PATH. -pub fn check_prerequisites(verbose: bool, output: &mut impl Write) -> Result<(), String> { - let mut missing: Vec<&str> = Vec::new(); - 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 verbose { - writeln!(output, "Found {tool}").ok(); - } - } - if !missing.is_empty() { - return Err(format!("Missing required tools: {}", missing.join(", "))); - } - Ok(()) -} - /// Runs a shell command with optional working directory. /// Returns Result. /// # Errors diff --git a/tests/utils.rs b/tests/utils.rs index b33a6db..6c5f352 100644 --- a/tests/utils.rs +++ b/tests/utils.rs @@ -1,44 +1,4 @@ +#[path = "utils/confirm.rs"] +mod confirm; +#[path = "common/mod.rs"] mod common; -use common::sink; -use star_setup::utils::{confirm, run_command}; - -#[test] -fn test_confirm_input_cases() { - let cases = [ - (b"y\n" as &[u8], true, "y accepts"), - (b"Y\n", true, "Y accepts"), - (b" y \n", true, "padded y accepts"), - (b"n\n", false, "n rejects"), - (b"yes\n", false, "yes rejects"), - ]; - for (mut input, expected, name) in cases { - let mut output = sink(); - assert_eq!( - confirm("prompt", false, &mut input, &mut output).unwrap(), - expected, - "Failed: {name}" - ); - } -} - -#[test] -fn test_confirm_yes_flag_returns_true() { - let mut input = b"".as_ref(); - let mut output = sink(); - assert!(confirm("prompt", true, &mut input, &mut output).unwrap()); -} - -#[test] -fn test_confirm_errors_on_eof() { - let mut input = b"".as_ref(); - let mut output = sink(); - let result = star_setup::utils::confirm("prompt", false, &mut input, &mut output); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("unexpected end of input")); -} - -#[test] -fn test_run_command_errors_on_empty() { - let mut output = sink(); - assert!(run_command(&[], None, false, &mut output).is_err()); -} diff --git a/tests/utils/confirm.rs b/tests/utils/confirm.rs new file mode 100644 index 0000000..bc5e5a4 --- /dev/null +++ b/tests/utils/confirm.rs @@ -0,0 +1,44 @@ +use super::common::sink; +use star_setup::utils::confirm::confirm; +use star_setup::utils::process::run_command; + +#[test] +fn test_confirm_input_cases() { + let cases = [ + (b"y\n" as &[u8], true, "y accepts"), + (b"Y\n", true, "Y accepts"), + (b" y \n", true, "padded y accepts"), + (b"n\n", false, "n rejects"), + (b"yes\n", false, "yes rejects"), + ]; + for (mut input, expected, name) in cases { + let mut output = sink(); + assert_eq!( + confirm("prompt", false, &mut input, &mut output).unwrap(), + expected, + "Failed: {name}" + ); + } +} + +#[test] +fn test_confirm_yes_flag_returns_true() { + let mut input = b"".as_ref(); + let mut output = sink(); + assert!(confirm("prompt", true, &mut input, &mut output).unwrap()); +} + +#[test] +fn test_confirm_errors_on_eof() { + let mut input = b"".as_ref(); + let mut output = sink(); + let result = confirm("prompt", false, &mut input, &mut output); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("unexpected end of input")); +} + +#[test] +fn test_run_command_errors_on_empty() { + let mut output = sink(); + assert!(run_command(&[], None, false, &mut output).is_err()); +} From a47f47096ef92ef6bf2dd03e6c4112eb609fe0c5 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Wed, 24 Jun 2026 12:52:57 -0400 Subject: [PATCH 11/34] refactr(tests): split tests/config.rs into submodule structure mirroring src --- tests/config.rs | 333 ++------------------------------------- tests/config/crud.rs | 191 ++++++++++++++++++++++ tests/config/display.rs | 29 ++++ tests/config/fixtures.rs | 14 ++ tests/config/io.rs | 109 +++++++++++++ 5 files changed, 352 insertions(+), 324 deletions(-) create mode 100644 tests/config/crud.rs create mode 100644 tests/config/display.rs create mode 100644 tests/config/fixtures.rs create mode 100644 tests/config/io.rs diff --git a/tests/config.rs b/tests/config.rs index be8c19d..5fbd1fe 100644 --- a/tests/config.rs +++ b/tests/config.rs @@ -1,325 +1,10 @@ -use star_setup::{ - cli::BuildType, - config::{ - crud::{ - add_config, create_default_config, has_config, insert_config, list_configs, remove_config, - remove_config_entry, - }, - display::format_entry, - io::{load_config, save_config}, - types::{ConfigEntry, SetupConfig}, - }, -}; -use std::path::PathBuf; +#[path = "config/crud.rs"] +mod crud; +#[path = "config/display.rs"] +mod display; +#[path = "config/io.rs"] +mod io; +#[path = "config/fixtures.rs"] +mod fixtures; +#[path = "common/mod.rs"] mod common; -use common::{empty_input, sink}; - -fn sample_entry() -> ConfigEntry { - ConfigEntry { - ssh: true, - build_type: BuildType::Release, - build_dir: "build".to_string(), - mono_dir: "mono".to_string(), - no_build: false, - clean: true, - verbose: false, - cmake_flags: vec![], - } -} - -#[test] -fn test_insert_config() { - let mut config = SetupConfig::new(); - insert_config(&mut config, "myconfig", sample_entry()); - assert!(config.configs.contains_key("myconfig")); - assert!(config.configs["myconfig"].ssh); -} - -#[test] -fn test_has_config_true() { - let mut config = SetupConfig::new(); - insert_config(&mut config, "myconfig", sample_entry()); - assert!(has_config(&config, "myconfig")); -} - -#[test] -fn test_has_config_false() { - let config = SetupConfig::new(); - assert!(!has_config(&config, "nonexistent")); -} - -#[test] -fn test_remove_config_entry_exists() { - let mut config = SetupConfig::new(); - insert_config(&mut config, "myconfig", sample_entry()); - assert!(remove_config_entry(&mut config, "myconfig")); - assert!(!config.configs.contains_key("myconfig")); -} - -#[test] -fn test_remove_config_entry_missing() { - let mut config = SetupConfig::new(); - assert!(!remove_config_entry(&mut config, "nonexistent")); -} - -#[test] -fn test_format_entry_contains_fields() { - let entry = sample_entry(); - let output = format_entry(&entry); - assert!(output.contains("SSH: true")); - assert!(output.contains("Build Type: Release")); - assert!(output.contains("Clean flag: true")); -} - -#[test] -fn test_format_entry_single_cmake_flag() { - let mut entry = sample_entry(); - entry.cmake_flags = vec!["-DTEST=ON".to_string()]; - let output = format_entry(&entry); - assert!(output.contains("CMake argument: -DTEST=ON")); -} - -#[test] -fn test_format_entry_multiple_cmake_flags() { - let mut entry = sample_entry(); - entry.cmake_flags = vec!["-DTEST=ON".to_string(), "-DDEBUG=OFF".to_string()]; - let output = format_entry(&entry); - assert!(output.contains("CMake arguments:")); - assert!(output.contains("-DTEST=ON")); - assert!(output.contains("-DDEBUG=OFF")); -} -#[test] -fn test_save_and_load_roundtrip() { - let tmp = tempfile::TempDir::new().unwrap(); - let path = tmp.path().join(".star-setup.json"); - - let mut config = SetupConfig::new(); - config.path = Some(path.clone()); - config.configs.insert( - "default".to_string(), - ConfigEntry { - ssh: true, - build_type: BuildType::Release, - build_dir: "build".to_string(), - mono_dir: "mono".to_string(), - no_build: false, - clean: false, - verbose: false, - cmake_flags: vec![], - }, - ); - 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()); -} - -#[test] -fn test_load_config_skips_missing_local_file() { - let config = load_config(&[], &mut sink()); - assert!(config.configs.is_empty()); -} - -#[test] -fn test_load_config_handles_invalid_json() { - let tmp = tempfile::TempDir::new().unwrap(); - 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()); -} - -#[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()); -} - -#[test] -fn test_create_default_config_creates_file() { - let tmp = tempfile::TempDir::new().unwrap(); - let path = tmp.path().join(".star-setup.json"); - - create_default_config(path.clone(), true, &mut empty_input(), &mut sink()).unwrap(); - assert!(path.exists()); -} - -#[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()); - - add_config( - &mut config, - "myconfig", - sample_entry(), - true, - &mut empty_input(), - &mut sink(), - ) - .unwrap(); - assert!(has_config(&config, "myconfig")); - assert!(path.exists()); -} - -#[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(); - - remove_config( - &mut config, - "myconfig", - true, - &mut empty_input(), - &mut sink(), - ) - .unwrap(); - assert!(!has_config(&config, "myconfig")); -} - -#[test] -fn test_remove_config_not_found() { - let mut config = SetupConfig::new(); - remove_config( - &mut config, - "nonexistent", - true, - &mut empty_input(), - &mut sink(), - ) - .unwrap(); -} - -#[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 input = b"n\n"; - 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, - cmake_flags: vec![], - }, - false, - &mut input.as_ref(), - &mut sink(), - ) - .unwrap(); - assert!(config.configs["myconfig"].ssh); -} - -#[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 input = b"n\n"; - remove_config( - &mut config, - "myconfig", - false, - &mut input.as_ref(), - &mut sink(), - ) - .unwrap(); - assert!(has_config(&config, "myconfig")); -} - -#[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 input = b"n\n"; - create_default_config(path.clone(), false, &mut input.as_ref(), &mut sink()).unwrap(); - assert_eq!(std::fs::read_to_string(&path).unwrap(), "original"); -} - -#[test] -fn test_list_configs_empty() { - let config = SetupConfig::new(); - let mut output = sink(); - list_configs(&config, &mut output); - let out = String::from_utf8(output).unwrap(); - 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 output = sink(); - list_configs(&config, &mut output); - let out = String::from_utf8(output).unwrap(); - assert!(out.contains("myconfig")); - assert!(out.contains("Configurations:")); -} - -#[test] -fn test_load_config_first_valid_wins() { - let tmp1 = tempfile::TempDir::new().unwrap(); - let tmp2 = tempfile::TempDir::new().unwrap(); - let path1 = tmp1.path().join(".star-setup.json"); - let path2 = tmp2.path().join(".star-setup.json"); - - let mut config1 = SetupConfig::new(); - config1.path = Some(path1.clone()); - insert_config(&mut config1, "first", sample_entry()); - save_config(&mut config1).unwrap(); - - let mut config2 = SetupConfig::new(); - config2.path = Some(path2.clone()); - 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")); -} - -#[test] -fn test_load_config_falls_through_invalid_to_valid() { - let tmp1 = tempfile::TempDir::new().unwrap(); - let tmp2 = tempfile::TempDir::new().unwrap(); - let path1 = tmp1.path().join(".star-setup.json"); - let path2 = tmp2.path().join(".star-setup.json"); - - std::fs::write(&path1, "{invalid json").unwrap(); - - let mut config2 = SetupConfig::new(); - config2.path = Some(path2.clone()); - 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")); -} diff --git a/tests/config/crud.rs b/tests/config/crud.rs new file mode 100644 index 0000000..3e4b77d --- /dev/null +++ b/tests/config/crud.rs @@ -0,0 +1,191 @@ +use star_setup::{ + cli::BuildType, + config::{ + crud::{ + add_config, create_default_config, has_config, insert_config, list_configs, remove_config, + remove_config_entry, + }, + io::{save_config}, + types::{ConfigEntry, SetupConfig}, + }, +}; +use super::{ + common::{empty_input, sink}, + fixtures::sample_entry +}; + +#[test] +fn test_has_config_true() { + let mut config = SetupConfig::new(); + insert_config(&mut config, "myconfig", sample_entry()); + assert!(has_config(&config, "myconfig")); +} + +#[test] +fn test_has_config_false() { + let config = SetupConfig::new(); + assert!(!has_config(&config, "nonexistent")); +} + +#[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()); + + add_config( + &mut config, + "myconfig", + sample_entry(), + true, + &mut empty_input(), + &mut sink(), + ) + .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 input = b"n\n"; + 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, + cmake_flags: vec![], + }, + false, + &mut input.as_ref(), + &mut sink(), + ) + .unwrap(); + assert!(config.configs["myconfig"].ssh); +} + +#[test] +fn test_insert_config() { + let mut config = SetupConfig::new(); + insert_config(&mut config, "myconfig", sample_entry()); + assert!(config.configs.contains_key("myconfig")); + assert!(config.configs["myconfig"].ssh); +} + +#[test] +fn test_remove_config_entry_exists() { + let mut config = SetupConfig::new(); + insert_config(&mut config, "myconfig", sample_entry()); + assert!(remove_config_entry(&mut config, "myconfig")); + assert!(!config.configs.contains_key("myconfig")); +} + +#[test] +fn test_create_default_config_creates_file() { + let tmp = tempfile::TempDir::new().unwrap(); + let path = tmp.path().join(".star-setup.json"); + + create_default_config(path.clone(), true, &mut empty_input(), &mut sink()).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 input = b"n\n"; + create_default_config(path.clone(), false, &mut input.as_ref(), &mut sink()).unwrap(); + assert_eq!(std::fs::read_to_string(&path).unwrap(), "original"); +} + +#[test] +fn test_list_configs_empty() { + let config = SetupConfig::new(); + let mut output = sink(); + list_configs(&config, &mut output); + let out = String::from_utf8(output).unwrap(); + 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 output = sink(); + list_configs(&config, &mut output); + let out = String::from_utf8(output).unwrap(); + assert!(out.contains("myconfig")); + assert!(out.contains("Configurations:")); +} + +#[test] +fn test_remove_config_entry_missing() { + let mut config = SetupConfig::new(); + assert!(!remove_config_entry(&mut config, "nonexistent")); +} + +#[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(); + + remove_config( + &mut config, + "myconfig", + true, + &mut empty_input(), + &mut sink(), + ) + .unwrap(); + assert!(!has_config(&config, "myconfig")); +} + +#[test] +fn test_remove_config_not_found() { + let mut config = SetupConfig::new(); + remove_config( + &mut config, + "nonexistent", + true, + &mut empty_input(), + &mut sink(), + ) + .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 input = b"n\n"; + remove_config( + &mut config, + "myconfig", + false, + &mut input.as_ref(), + &mut sink(), + ) + .unwrap(); + assert!(has_config(&config, "myconfig")); +} diff --git a/tests/config/display.rs b/tests/config/display.rs new file mode 100644 index 0000000..1539bb2 --- /dev/null +++ b/tests/config/display.rs @@ -0,0 +1,29 @@ +use star_setup::config::display::format_entry; +use super::fixtures::sample_entry; + +#[test] +fn test_format_entry_contains_fields() { + let entry = sample_entry(); + let output = format_entry(&entry); + assert!(output.contains("SSH: true")); + assert!(output.contains("Build Type: Release")); + assert!(output.contains("Clean flag: true")); +} + +#[test] +fn test_format_entry_single_cmake_flag() { + let mut entry = sample_entry(); + entry.cmake_flags = vec!["-DTEST=ON".to_string()]; + let output = format_entry(&entry); + assert!(output.contains("CMake argument: -DTEST=ON")); +} + +#[test] +fn test_format_entry_multiple_cmake_flags() { + let mut entry = sample_entry(); + entry.cmake_flags = vec!["-DTEST=ON".to_string(), "-DDEBUG=OFF".to_string()]; + let output = format_entry(&entry); + assert!(output.contains("CMake arguments:")); + assert!(output.contains("-DTEST=ON")); + assert!(output.contains("-DDEBUG=OFF")); +} diff --git a/tests/config/fixtures.rs b/tests/config/fixtures.rs new file mode 100644 index 0000000..adf8fca --- /dev/null +++ b/tests/config/fixtures.rs @@ -0,0 +1,14 @@ +use star_setup::{cli::BuildType, config::types::ConfigEntry}; + +pub fn sample_entry() -> ConfigEntry { + ConfigEntry { + ssh: true, + build_type: BuildType::Release, + build_dir: "build".to_string(), + mono_dir: "mono".to_string(), + no_build: false, + clean: true, + verbose: false, + cmake_flags: vec![], + } +} diff --git a/tests/config/io.rs b/tests/config/io.rs new file mode 100644 index 0000000..216cc58 --- /dev/null +++ b/tests/config/io.rs @@ -0,0 +1,109 @@ +use star_setup::{ + cli::BuildType, + config::{ + crud::insert_config, + io::{load_config, save_config}, + types::{ConfigEntry, SetupConfig}, + }, +}; +use std::path::PathBuf; +use super::{ + common::{sink}, + fixtures::sample_entry +}; + +#[test] +fn test_save_and_load_roundtrip() { + let tmp = tempfile::TempDir::new().unwrap(); + let path = tmp.path().join(".star-setup.json"); + + let mut config = SetupConfig::new(); + config.path = Some(path.clone()); + config.configs.insert( + "default".to_string(), + ConfigEntry { + ssh: true, + build_type: BuildType::Release, + build_dir: "build".to_string(), + mono_dir: "mono".to_string(), + no_build: false, + clean: false, + verbose: false, + cmake_flags: vec![], + }, + ); + 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()); +} + +#[test] +fn test_load_config_skips_missing_local_file() { + let config = load_config(&[], &mut sink()); + assert!(config.configs.is_empty()); +} + +#[test] +fn test_load_config_handles_invalid_json() { + let tmp = tempfile::TempDir::new().unwrap(); + 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()); +} + +#[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()); +} + + +#[test] +fn test_load_config_first_valid_wins() { + let tmp1 = tempfile::TempDir::new().unwrap(); + let tmp2 = tempfile::TempDir::new().unwrap(); + let path1 = tmp1.path().join(".star-setup.json"); + let path2 = tmp2.path().join(".star-setup.json"); + + let mut config1 = SetupConfig::new(); + config1.path = Some(path1.clone()); + insert_config(&mut config1, "first", sample_entry()); + save_config(&mut config1).unwrap(); + + let mut config2 = SetupConfig::new(); + config2.path = Some(path2.clone()); + 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")); +} + +#[test] +fn test_load_config_falls_through_invalid_to_valid() { + let tmp1 = tempfile::TempDir::new().unwrap(); + let tmp2 = tempfile::TempDir::new().unwrap(); + let path1 = tmp1.path().join(".star-setup.json"); + let path2 = tmp2.path().join(".star-setup.json"); + + std::fs::write(&path1, "{invalid json").unwrap(); + + let mut config2 = SetupConfig::new(); + config2.path = Some(path2.clone()); + 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")); +} From 0faf19ff75f581fbf6fadaa3c97dec9e5ea4cde8 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Wed, 24 Jun 2026 14:10:43 -0400 Subject: [PATCH 12/34] test(commands): add tests for wraps parse_project_name, parse_provide_pairs, and hoist_wraps --- src/commands/mono/wraps.rs | 4 +- tests/commands/mono.rs | 6 +- tests/commands/mono/config.rs | 2 +- tests/commands/mono/resolve.rs | 2 +- tests/commands/mono/wraps.rs | 144 +++++++++++++++++++++++++++++++++ tests/config.rs | 8 +- tests/config/crud.rs | 10 +-- tests/config/display.rs | 2 +- tests/config/io.rs | 6 +- tests/utils.rs | 4 +- 10 files changed, 165 insertions(+), 23 deletions(-) create mode 100644 tests/commands/mono/wraps.rs diff --git a/src/commands/mono/wraps.rs b/src/commands/mono/wraps.rs index 6cb4da0..1ce8869 100644 --- a/src/commands/mono/wraps.rs +++ b/src/commands/mono/wraps.rs @@ -5,7 +5,7 @@ use std::path::{Path, PathBuf}; /// Parses the `project()` name from `meson.build` content. /// Returns the name with hyphens replaced by underscores, or `None` if not found. -pub(crate) fn parse_project_name(content: &str) -> Option { +pub fn parse_project_name(content: &str) -> Option { let needle = b"project("; let bytes = content.as_bytes(); let mut i = 0; @@ -30,7 +30,7 @@ pub(crate) fn parse_project_name(content: &str) -> Option { } /// Parses `[provide]` key-value pairs from wrap file content. -pub(crate) fn parse_provide_pairs(content: &str) -> HashMap { +pub fn parse_provide_pairs(content: &str) -> HashMap { let mut in_provide = false; let mut pairs = HashMap::new(); for line in content.lines() { diff --git a/tests/commands/mono.rs b/tests/commands/mono.rs index 4534215..ddb099b 100644 --- a/tests/commands/mono.rs +++ b/tests/commands/mono.rs @@ -1,6 +1,8 @@ +#[path = "../common/mod.rs"] +mod common; #[path = "mono/config.rs"] mod config; #[path = "mono/resolve.rs"] mod resolve; -#[path = "../common/mod.rs"] -mod common; +#[path = "mono/wraps.rs"] +mod wraps; diff --git a/tests/commands/mono/config.rs b/tests/commands/mono/config.rs index 79fd0d6..b783043 100644 --- a/tests/commands/mono/config.rs +++ b/tests/commands/mono/config.rs @@ -1,5 +1,5 @@ -use star_setup::commands::create_mono_repo_cmakelists; use super::common::sink; +use star_setup::commands::create_mono_repo_cmakelists; // create_mono_repo_cmakelists tests #[test] diff --git a/tests/commands/mono/resolve.rs b/tests/commands/mono/resolve.rs index 5f47ab6..105b1c8 100644 --- a/tests/commands/mono/resolve.rs +++ b/tests/commands/mono/resolve.rs @@ -1,9 +1,9 @@ +use super::common::sink; use star_setup::cli::{ resolve_with_config, Args, BuildFlags, ConfigFlags, ConnectionFlags, MonoRepoFlags, ProfileFlags, }; use star_setup::commands::{resolve_repos_for_mono, resolve_test_repo}; use star_setup::config::types::SetupConfig; -use super::common::sink; // resolve_test_repo tests #[test] diff --git a/tests/commands/mono/wraps.rs b/tests/commands/mono/wraps.rs new file mode 100644 index 0000000..dce62cf --- /dev/null +++ b/tests/commands/mono/wraps.rs @@ -0,0 +1,144 @@ +use star_setup::commands::mono::{ + hoist_wraps, + wraps::{parse_project_name, parse_provide_pairs}, +}; +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); +} + +#[test] +fn test_parse_provide_pairs_basic() { + let content = "[provide]\nmy_lib = my_lib_dep\n"; + let pairs = parse_provide_pairs(content); + assert_eq!(pairs.get("my_lib"), Some(&"my_lib_dep".to_string())); +} + +#[test] +fn test_parse_provide_pairs_multiple() { + let content = "[provide]\nfoo = foo_dep\nbar = bar_dep\n"; + let pairs = parse_provide_pairs(content); + assert_eq!(pairs.len(), 2); +} + +#[test] +fn test_parse_provide_pairs_ignores_other_sections() { + let content = "[wrap-file]\nurl = http://example.com\n\n[provide]\nmy_lib = my_lib_dep\n"; + let pairs = parse_provide_pairs(content); + assert_eq!(pairs.len(), 1); + assert!(pairs.contains_key("my_lib")); +} + +#[test] +fn test_parse_provide_pairs_empty() { + let pairs = parse_provide_pairs(""); + assert!(pairs.is_empty()); +} + +#[test] +fn test_parse_provide_pairs_no_provide_section() { + let content = "[wrap-file]\nurl = http://example.com\n"; + let pairs = parse_provide_pairs(content); + assert!(pairs.is_empty()); +} + +fn make_repo(project_name: &str) -> TempDir { + let tmp = TempDir::new().unwrap(); + std::fs::write( + tmp.path().join("meson.build"), + format!("project('{project_name}', 'cpp')"), + ) + .unwrap(); + tmp +} + +#[test] +fn test_hoist_wraps_empty_repos() { + let repos_dir = TempDir::new().unwrap(); + let mut output = Vec::new(); + let result = hoist_wraps(repos_dir.path(), &[], &mut output).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 output = Vec::new(); + let result = hoist_wraps(repos_dir.path(), &[repo.path().to_path_buf()], &mut output).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 output = Vec::new(); + let result = hoist_wraps(repos_dir.path(), &[repo.path().to_path_buf()], &mut output).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]")); +} + +#[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 output = Vec::new(); + let result = hoist_wraps(repos_dir.path(), &[repo.path().to_path_buf()], &mut output).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")); +} diff --git a/tests/config.rs b/tests/config.rs index 5fbd1fe..ef1106c 100644 --- a/tests/config.rs +++ b/tests/config.rs @@ -1,10 +1,10 @@ +#[path = "common/mod.rs"] +mod common; #[path = "config/crud.rs"] mod crud; #[path = "config/display.rs"] mod display; -#[path = "config/io.rs"] -mod io; #[path = "config/fixtures.rs"] mod fixtures; -#[path = "common/mod.rs"] -mod common; +#[path = "config/io.rs"] +mod io; diff --git a/tests/config/crud.rs b/tests/config/crud.rs index 3e4b77d..5abdbd0 100644 --- a/tests/config/crud.rs +++ b/tests/config/crud.rs @@ -1,3 +1,7 @@ +use super::{ + common::{empty_input, sink}, + fixtures::sample_entry, +}; use star_setup::{ cli::BuildType, config::{ @@ -5,14 +9,10 @@ use star_setup::{ add_config, create_default_config, has_config, insert_config, list_configs, remove_config, remove_config_entry, }, - io::{save_config}, + io::save_config, types::{ConfigEntry, SetupConfig}, }, }; -use super::{ - common::{empty_input, sink}, - fixtures::sample_entry -}; #[test] fn test_has_config_true() { diff --git a/tests/config/display.rs b/tests/config/display.rs index 1539bb2..b8662cf 100644 --- a/tests/config/display.rs +++ b/tests/config/display.rs @@ -1,5 +1,5 @@ -use star_setup::config::display::format_entry; use super::fixtures::sample_entry; +use star_setup::config::display::format_entry; #[test] fn test_format_entry_contains_fields() { diff --git a/tests/config/io.rs b/tests/config/io.rs index 216cc58..584e0b4 100644 --- a/tests/config/io.rs +++ b/tests/config/io.rs @@ -1,3 +1,4 @@ +use super::{common::sink, fixtures::sample_entry}; use star_setup::{ cli::BuildType, config::{ @@ -7,10 +8,6 @@ use star_setup::{ }, }; use std::path::PathBuf; -use super::{ - common::{sink}, - fixtures::sample_entry -}; #[test] fn test_save_and_load_roundtrip() { @@ -67,7 +64,6 @@ fn test_load_config_skips_nonexistent_path() { assert!(config.configs.is_empty()); } - #[test] fn test_load_config_first_valid_wins() { let tmp1 = tempfile::TempDir::new().unwrap(); diff --git a/tests/utils.rs b/tests/utils.rs index 6c5f352..b593c94 100644 --- a/tests/utils.rs +++ b/tests/utils.rs @@ -1,4 +1,4 @@ -#[path = "utils/confirm.rs"] -mod confirm; #[path = "common/mod.rs"] mod common; +#[path = "utils/confirm.rs"] +mod confirm; From 8cac5f465c0dc0e6d7ec432cde93cf3e4bbb4647 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Wed, 24 Jun 2026 14:25:46 -0400 Subject: [PATCH 13/34] refactor(cli): split build.rs into types and detect submodules --- src/cli/{build.rs => build/detect.rs} | 64 +-------------------------- src/cli/build/mod.rs | 4 ++ src/cli/build/types.rs | 63 ++++++++++++++++++++++++++ src/cli/mod.rs | 7 ++- src/commands/build.rs | 3 +- src/commands/mono/mode.rs | 5 ++- src/config/crud.rs | 2 +- src/config/types.rs | 2 +- src/interactive.rs | 2 +- tests/cli.rs | 2 + tests/cli/build.rs | 2 + tests/cli/build/types.rs | 54 ++++++++++++++++++++++ tests/cli/resolve.rs | 4 +- tests/config/crud.rs | 2 +- tests/config/fixtures.rs | 2 +- tests/config/io.rs | 2 +- 16 files changed, 142 insertions(+), 78 deletions(-) rename src/cli/{build.rs => build/detect.rs} (51%) create mode 100644 src/cli/build/mod.rs create mode 100644 src/cli/build/types.rs create mode 100644 tests/cli/build.rs create mode 100644 tests/cli/build/types.rs diff --git a/src/cli/build.rs b/src/cli/build/detect.rs similarity index 51% rename from src/cli/build.rs rename to src/cli/build/detect.rs index 075af4c..39aa057 100644 --- a/src/cli/build.rs +++ b/src/cli/build/detect.rs @@ -1,70 +1,8 @@ +use crate::cli::build::BuildSystem; use crate::prompts::ask_choice; -use serde::{Deserialize, Serialize}; use std::io::{BufRead, Write}; use std::path::{Path, PathBuf}; -pub enum BuildSystem { - /// `CMake` build system (`CMakeLists.txt`). - Cmake, - /// Meson build system (`meson.build`). - Meson, -} - -#[derive(Default, Clone, Serialize, Deserialize, PartialEq, Debug)] -#[serde(rename_all = "kebab-case")] -pub enum BuildType { - /// Debug build with no optimizations. - #[default] - Debug, - /// Optimized release build. - Release, - /// Release build with debug info. - RelWithDebInfo, - /// Minimized binary size release build. - MinSizeRel, -} - -impl BuildType { - #[must_use] - pub fn to_cmake(&self) -> &'static str { - match self { - Self::Debug => "Debug", - Self::Release => "Release", - Self::RelWithDebInfo => "RelWithDebInfo", - Self::MinSizeRel => "MinSizeRel", - } - } - - #[must_use] - pub fn to_meson(&self) -> &'static str { - match self { - Self::Debug => "debug", - Self::Release => "release", - Self::RelWithDebInfo => "debugoptimized", - Self::MinSizeRel => "minsize", - } - } -} - -impl std::str::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" - )), - } - } -} - /// 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. diff --git a/src/cli/build/mod.rs b/src/cli/build/mod.rs new file mode 100644 index 0000000..f5a51ff --- /dev/null +++ b/src/cli/build/mod.rs @@ -0,0 +1,4 @@ +pub mod types; +pub use types::{BuildSystem, BuildType}; +pub mod detect; +pub use detect::{detect_build_system, detect_mono_build_system}; diff --git a/src/cli/build/types.rs b/src/cli/build/types.rs new file mode 100644 index 0000000..5bd81ac --- /dev/null +++ b/src/cli/build/types.rs @@ -0,0 +1,63 @@ +use serde::{Deserialize, Serialize}; + +pub enum BuildSystem { + /// `CMake` build system (`CMakeLists.txt`). + Cmake, + /// Meson build system (`meson.build`). + Meson, +} + +#[derive(Default, Clone, Serialize, Deserialize, PartialEq, Debug)] +#[serde(rename_all = "kebab-case")] +pub enum BuildType { + /// Debug build with no optimizations. + #[default] + Debug, + /// Optimized release build. + Release, + /// Release build with debug info. + RelWithDebInfo, + /// Minimized binary size release build. + MinSizeRel, +} + +impl BuildType { + #[must_use] + pub fn to_cmake(&self) -> &'static str { + match self { + Self::Debug => "Debug", + Self::Release => "Release", + Self::RelWithDebInfo => "RelWithDebInfo", + Self::MinSizeRel => "MinSizeRel", + } + } + + #[must_use] + pub fn to_meson(&self) -> &'static str { + match self { + Self::Debug => "debug", + Self::Release => "release", + Self::RelWithDebInfo => "debugoptimized", + Self::MinSizeRel => "minsize", + } + } +} + +impl std::str::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" + )), + } + } +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs index c86c821..fc7c2e5 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1,10 +1,9 @@ pub mod args; +pub use args::Args; pub mod build; pub mod flags; -pub mod resolve; -pub mod resolved; -pub use args::Args; -pub use build::{detect_build_system, detect_mono_build_system, BuildSystem, BuildType}; pub use flags::{BuildFlags, ConfigFlags, ConnectionFlags, MonoRepoFlags, ProfileFlags}; +pub mod resolve; pub use resolve::{resolve_bool, resolve_with_config}; +pub mod resolved; pub use resolved::{ResolvedArgs, ResolvedBuildFlags, ResolvedConnectionFlags, ResolvedMonoFlags}; diff --git a/src/commands/build.rs b/src/commands/build.rs index b11de6f..8306fbf 100644 --- a/src/commands/build.rs +++ b/src/commands/build.rs @@ -1,7 +1,6 @@ //! Build system dispatch and per-system build functions. -use crate::cli::detect_build_system; -use crate::cli::BuildSystem; +use crate::cli::build::{detect_build_system, BuildSystem}; use crate::cli::ResolvedArgs; use crate::utils::process::run_command; use std::io::{BufRead, Write}; diff --git a/src/commands/mono/mode.rs b/src/commands/mono/mode.rs index 553aae9..91c894e 100644 --- a/src/commands/mono/mode.rs +++ b/src/commands/mono/mode.rs @@ -1,4 +1,7 @@ -use crate::cli::{detect_mono_build_system, BuildSystem, ResolvedArgs}; +use crate::cli::{ + build::{detect_mono_build_system, BuildSystem}, + ResolvedArgs, +}; use crate::commands::build::{cmake_build, meson_build}; use crate::commands::mono::config::{create_mono_repo_cmakelists, create_mono_repo_mesonbuild}; use crate::commands::mono::resolve::{resolve_repos_for_mono, resolve_test_repo}; diff --git a/src/config/crud.rs b/src/config/crud.rs index 2561000..1851c29 100644 --- a/src/config/crud.rs +++ b/src/config/crud.rs @@ -1,4 +1,4 @@ -use crate::cli::BuildType; +use crate::cli::build::BuildType; use crate::config::display::format_entry; use crate::config::io::save_config; use crate::config::types::{ConfigEntry, SetupConfig}; diff --git a/src/config/types.rs b/src/config/types.rs index 01bf4a1..cc06636 100644 --- a/src/config/types.rs +++ b/src/config/types.rs @@ -1,4 +1,4 @@ -use crate::cli::BuildType; +use crate::cli::build::BuildType; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::path::PathBuf; diff --git a/src/interactive.rs b/src/interactive.rs index e7eb95b..829e418 100644 --- a/src/interactive.rs +++ b/src/interactive.rs @@ -1,6 +1,6 @@ //! Interactive CLI mode. -use crate::cli::{BuildType, ResolvedArgs}; +use crate::cli::{build::BuildType, ResolvedArgs}; use crate::prompts::{ask, ask_default, ask_yesno}; use std::io::{BufRead, Write}; diff --git a/tests/cli.rs b/tests/cli.rs index 40d6910..ff28415 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -1,2 +1,4 @@ +#[path = "cli/build.rs"] +mod build; #[path = "cli/resolve.rs"] mod resolve; diff --git a/tests/cli/build.rs b/tests/cli/build.rs new file mode 100644 index 0000000..75661ff --- /dev/null +++ b/tests/cli/build.rs @@ -0,0 +1,2 @@ +#[path = "build/types.rs"] +mod types; diff --git a/tests/cli/build/types.rs b/tests/cli/build/types.rs new file mode 100644 index 0000000..aacf122 --- /dev/null +++ b/tests/cli/build/types.rs @@ -0,0 +1,54 @@ +use star_setup::cli::build::BuildType; + +#[test] +fn test_to_cmake_all_variants() { + assert_eq!(BuildType::Debug.to_cmake(), "Debug"); + assert_eq!(BuildType::Release.to_cmake(), "Release"); + assert_eq!(BuildType::RelWithDebInfo.to_cmake(), "RelWithDebInfo"); + assert_eq!(BuildType::MinSizeRel.to_cmake(), "MinSizeRel"); +} + +#[test] +fn test_to_meson_all_variants() { + assert_eq!(BuildType::Debug.to_meson(), "debug"); + assert_eq!(BuildType::Release.to_meson(), "release"); + assert_eq!(BuildType::RelWithDebInfo.to_meson(), "debugoptimized"); + assert_eq!(BuildType::MinSizeRel.to_meson(), "minsize"); +} + +#[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 + ); +} + +#[test] +fn test_from_str_error() { + use std::str::FromStr; + assert!(BuildType::from_str("unknown").is_err()); +} diff --git a/tests/cli/resolve.rs b/tests/cli/resolve.rs index e5d5473..3a7bdbc 100644 --- a/tests/cli/resolve.rs +++ b/tests/cli/resolve.rs @@ -1,6 +1,6 @@ use star_setup::cli::{ - resolve_bool, resolve_with_config, Args, BuildFlags, BuildType, ConfigFlags, ConnectionFlags, - MonoRepoFlags, ProfileFlags, + build::BuildType, resolve_bool, resolve_with_config, Args, BuildFlags, ConfigFlags, + ConnectionFlags, MonoRepoFlags, ProfileFlags, }; use star_setup::config::types::{ConfigEntry, SetupConfig}; diff --git a/tests/config/crud.rs b/tests/config/crud.rs index 5abdbd0..ea87300 100644 --- a/tests/config/crud.rs +++ b/tests/config/crud.rs @@ -3,7 +3,7 @@ use super::{ fixtures::sample_entry, }; use star_setup::{ - cli::BuildType, + cli::build::BuildType, config::{ crud::{ add_config, create_default_config, has_config, insert_config, list_configs, remove_config, diff --git a/tests/config/fixtures.rs b/tests/config/fixtures.rs index adf8fca..abf1432 100644 --- a/tests/config/fixtures.rs +++ b/tests/config/fixtures.rs @@ -1,4 +1,4 @@ -use star_setup::{cli::BuildType, config::types::ConfigEntry}; +use star_setup::{cli::build::BuildType, config::types::ConfigEntry}; pub fn sample_entry() -> ConfigEntry { ConfigEntry { diff --git a/tests/config/io.rs b/tests/config/io.rs index 584e0b4..350026a 100644 --- a/tests/config/io.rs +++ b/tests/config/io.rs @@ -1,6 +1,6 @@ use super::{common::sink, fixtures::sample_entry}; use star_setup::{ - cli::BuildType, + cli::build::BuildType, config::{ crud::insert_config, io::{load_config, save_config}, From cf4bc2100ae3439ef55dc0e88febbe1ce0fde796 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Wed, 24 Jun 2026 14:27:39 -0400 Subject: [PATCH 14/34] test(cli): add tests for detect_build_system/detect_mono_build_system --- tests/cli/build.rs | 2 + tests/cli/build/detect.rs | 112 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 114 insertions(+) create mode 100644 tests/cli/build/detect.rs diff --git a/tests/cli/build.rs b/tests/cli/build.rs index 75661ff..c1b7b60 100644 --- a/tests/cli/build.rs +++ b/tests/cli/build.rs @@ -1,2 +1,4 @@ +#[path = "build/detect.rs"] +mod detect; #[path = "build/types.rs"] mod types; diff --git a/tests/cli/build/detect.rs b/tests/cli/build/detect.rs new file mode 100644 index 0000000..ad3e838 --- /dev/null +++ b/tests/cli/build/detect.rs @@ -0,0 +1,112 @@ +use star_setup::cli::build::{detect_build_system, detect_mono_build_system, BuildSystem}; +use tempfile::TempDir; + +fn cmake_dir() -> TempDir { + let tmp = TempDir::new().unwrap(); + std::fs::write(tmp.path().join("CMakeLists.txt"), "").unwrap(); + tmp +} + +fn meson_dir() -> TempDir { + let tmp = TempDir::new().unwrap(); + std::fs::write(tmp.path().join("meson.build"), "").unwrap(); + tmp +} + +#[test] +fn test_detect_build_system_cmake() { + let dir = cmake_dir(); + let result = detect_build_system(dir.path(), &mut b"".as_ref(), &mut Vec::new()).unwrap(); + assert!(matches!(result, BuildSystem::Cmake)); +} + +#[test] +fn test_detect_build_system_meson() { + let dir = meson_dir(); + let result = detect_build_system(dir.path(), &mut b"".as_ref(), &mut Vec::new()).unwrap(); + assert!(matches!(result, BuildSystem::Meson)); +} + +#[test] +fn test_detect_build_system_none() { + let dir = TempDir::new().unwrap(); + let result = detect_build_system(dir.path(), &mut b"".as_ref(), &mut Vec::new()); + assert!(result.is_err()); +} + +#[test] +fn test_detect_build_system_both_picks_cmake() { + let dir = cmake_dir(); + std::fs::write(dir.path().join("meson.build"), "").unwrap(); + let result = detect_build_system(dir.path(), &mut b"1\n".as_ref(), &mut Vec::new()).unwrap(); + assert!(matches!(result, 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 = detect_build_system(dir.path(), &mut b"2\n".as_ref(), &mut Vec::new()).unwrap(); + assert!(matches!(result, BuildSystem::Meson)); +} + +#[test] +fn test_detect_mono_build_system_cmake() { + let dir = cmake_dir(); + let result = detect_mono_build_system( + &[dir.path().to_path_buf()], + &mut b"".as_ref(), + &mut Vec::new(), + ) + .unwrap(); + assert!(matches!(result, BuildSystem::Cmake)); +} + +#[test] +fn test_detect_mono_build_system_meson() { + let dir = meson_dir(); + let result = detect_mono_build_system( + &[dir.path().to_path_buf()], + &mut b"".as_ref(), + &mut Vec::new(), + ) + .unwrap(); + assert!(matches!(result, BuildSystem::Meson)); +} + +#[test] +fn test_detect_mono_build_system_none() { + let dir = TempDir::new().unwrap(); + let result = detect_mono_build_system( + &[dir.path().to_path_buf()], + &mut b"".as_ref(), + &mut Vec::new(), + ); + 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 result = detect_mono_build_system( + &[dir.path().to_path_buf()], + &mut b"1\n".as_ref(), + &mut Vec::new(), + ) + .unwrap(); + assert!(matches!(result, 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 = detect_mono_build_system( + &[dir.path().to_path_buf()], + &mut b"2\n".as_ref(), + &mut Vec::new(), + ) + .unwrap(); + assert!(matches!(result, BuildSystem::Meson)); +} From 8b91b9d6ad97fdeae86a245c406e2fb0a188e3c9 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Wed, 24 Jun 2026 14:30:57 -0400 Subject: [PATCH 15/34] tests(commands): add tests for create_mono_repo_mesonbuild --- src/commands/mod.rs | 3 ++- tests/commands/mono/config.rs | 28 +++++++++++++++++++++++++++- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/commands/mod.rs b/src/commands/mod.rs index a4a5b55..fd0c748 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -3,6 +3,7 @@ pub mod header; pub mod mono; pub mod single; pub use mono::{ - create_mono_repo_cmakelists, mono_repo_mode, resolve_repos_for_mono, resolve_test_repo, + create_mono_repo_cmakelists, create_mono_repo_mesonbuild, mono_repo_mode, resolve_repos_for_mono, + resolve_test_repo, }; pub use single::single_repo_mode; diff --git a/tests/commands/mono/config.rs b/tests/commands/mono/config.rs index b783043..c82c63e 100644 --- a/tests/commands/mono/config.rs +++ b/tests/commands/mono/config.rs @@ -1,5 +1,5 @@ use super::common::sink; -use star_setup::commands::create_mono_repo_cmakelists; +use star_setup::commands::{create_mono_repo_cmakelists, create_mono_repo_mesonbuild}; // create_mono_repo_cmakelists tests #[test] @@ -29,3 +29,29 @@ fn test_create_mono_repo_cmakelists_empty_repos() { create_mono_repo_cmakelists(tmp.path(), &repos, &mut sink()).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(), + ]; + create_mono_repo_mesonbuild(tmp.path(), &repos, &mut sink()).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()]; + create_mono_repo_mesonbuild(tmp.path(), &repos, &mut sink()).unwrap(); + assert!(tmp.path().join("meson.build").exists()); +} From d3015095fe0b96149a00c4b5bfa14a09a2846b0c Mon Sep 17 00:00:00 2001 From: Masonlet Date: Wed, 24 Jun 2026 14:37:17 -0400 Subject: [PATCH 16/34] refactor(commands): simplify mono repo list construction --- src/commands/mono/mode.rs | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/src/commands/mono/mode.rs b/src/commands/mono/mode.rs index 91c894e..55a596c 100644 --- a/src/commands/mono/mode.rs +++ b/src/commands/mono/mode.rs @@ -76,15 +76,10 @@ pub fn mono_repo_mode( let repo_input = repo_input.trim_end_matches('/'); let test_repo = resolve_test_repo(repo_input)?; - let test_repo_name = repo_dir_name(&test_repo); - - let mut repos: Vec = resolve_repos_for_mono(args, config, &test_repo, output)? - .iter() - .filter(|r| repo_dir_name(r) != test_repo_name) - .cloned() + let mut repos: Vec = std::iter::once(test_repo.clone()) + .chain(resolve_repos_for_mono(args, config, &test_repo, output)?) .collect(); repos.dedup_by(|a, b| repo_dir_name(a) == repo_dir_name(b)); - repos.insert(0, test_repo.clone()); writeln!(output, "Total repositories: {}\n", repos.len()).ok(); let mono_repo_path = PathBuf::from(&args.mono.mono_dir); @@ -147,6 +142,7 @@ pub fn mono_repo_mode( ) .ok(); if let Some(map) = canonical_map { + let test_repo_name = repo_dir_name(&test_repo); if let Some((canonical, _)) = map.iter().find(|(_, v)| *v == &test_repo_name) { let exe_name = if cfg!(windows) { format!("{canonical}.exe") From c5fa2b3db527b400ce6a849c742d94a5ecee2b26 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Wed, 24 Jun 2026 14:45:03 -0400 Subject: [PATCH 17/34] refactor(commands): extract build_repo_list --- src/commands/mono/mod.rs | 2 +- src/commands/mono/mode.rs | 14 ++++++++++---- tests/commands/mono.rs | 2 ++ tests/commands/mono/mode.rs | 36 ++++++++++++++++++++++++++++++++++++ 4 files changed, 49 insertions(+), 5 deletions(-) create mode 100644 tests/commands/mono/mode.rs diff --git a/src/commands/mono/mod.rs b/src/commands/mono/mod.rs index f9aa307..4b601ed 100644 --- a/src/commands/mono/mod.rs +++ b/src/commands/mono/mod.rs @@ -3,6 +3,6 @@ pub mod mode; pub mod resolve; pub mod wraps; pub use config::{create_mono_repo_cmakelists, create_mono_repo_mesonbuild}; -pub use mode::mono_repo_mode; +pub use mode::{build_repo_list, mono_repo_mode}; pub use resolve::{resolve_repos_for_mono, resolve_test_repo}; pub use wraps::hoist_wraps; diff --git a/src/commands/mono/mode.rs b/src/commands/mono/mode.rs index 55a596c..5455ce5 100644 --- a/src/commands/mono/mode.rs +++ b/src/commands/mono/mode.rs @@ -63,6 +63,14 @@ fn generate_mono_config( } } +pub fn build_repo_list(test_repo: &str, deps: &[String]) -> Vec { + let mut seen = std::collections::HashSet::new(); + std::iter::once(test_repo.to_string()) + .chain(deps.iter().cloned()) + .filter(|r| seen.insert(repo_dir_name(r))) + .collect() +} + /// Clones and configures a mono-repo ecosystem from a profile or explicit repository list. /// # Errors /// Returns an error if no repository is specified, directory creation fails, or any build system command fails. @@ -76,10 +84,8 @@ pub fn mono_repo_mode( let repo_input = repo_input.trim_end_matches('/'); let test_repo = resolve_test_repo(repo_input)?; - let mut repos: Vec = std::iter::once(test_repo.clone()) - .chain(resolve_repos_for_mono(args, config, &test_repo, output)?) - .collect(); - repos.dedup_by(|a, b| repo_dir_name(a) == repo_dir_name(b)); + let deps = resolve_repos_for_mono(args, config, &test_repo, output)?; + let repos = build_repo_list(&test_repo, &deps); writeln!(output, "Total repositories: {}\n", repos.len()).ok(); let mono_repo_path = PathBuf::from(&args.mono.mono_dir); diff --git a/tests/commands/mono.rs b/tests/commands/mono.rs index ddb099b..0c64178 100644 --- a/tests/commands/mono.rs +++ b/tests/commands/mono.rs @@ -2,6 +2,8 @@ mod common; #[path = "mono/config.rs"] mod config; +#[path = "mono/mode.rs"] +mod mode; #[path = "mono/resolve.rs"] mod resolve; #[path = "mono/wraps.rs"] diff --git a/tests/commands/mono/mode.rs b/tests/commands/mono/mode.rs new file mode 100644 index 0000000..1ba6cdc --- /dev/null +++ b/tests/commands/mono/mode.rs @@ -0,0 +1,36 @@ +use star_setup::commands::mono::build_repo_list; + +#[test] +fn test_build_repo_list_test_repo_first() { + let deps = vec!["user/lib1".to_string(), "user/lib2".to_string()]; + 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 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 result = build_repo_list("user/testrepo", &deps); + assert_eq!(result.len(), 2); + assert_eq!(result[0], "user/testrepo"); +} + +#[test] +fn test_build_repo_list_dedupes_duplicate_deps() { + let deps = vec!["user/lib1".to_string(), "user/lib1".to_string()]; + let result = build_repo_list("user/testrepo", &deps); + assert_eq!(result.len(), 2); +} + +#[test] +fn test_build_repo_list_no_deps() { + let result = build_repo_list("user/testrepo", &[]); + assert_eq!(result, vec!["user/testrepo"]); +} From 45dff4848b3db9ad254bf461698a66602f8ce609 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Wed, 24 Jun 2026 14:49:54 -0400 Subject: [PATCH 18/34] test(commands): add test for print_mode_header repo_name branch --- tests/commands.rs | 2 ++ tests/commands/header.rs | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+) create mode 100644 tests/commands/header.rs diff --git a/tests/commands.rs b/tests/commands.rs index d83415f..4c007c5 100644 --- a/tests/commands.rs +++ b/tests/commands.rs @@ -1,2 +1,4 @@ +#[path = "commands/header.rs"] +mod header; #[path = "commands/mono.rs"] mod mono; diff --git a/tests/commands/header.rs b/tests/commands/header.rs new file mode 100644 index 0000000..45ab5c0 --- /dev/null +++ b/tests/commands/header.rs @@ -0,0 +1,19 @@ +#[test] +fn test_print_mode_header_repo_name_without_test_repo() { + use star_setup::commands::header::{print_mode_header, ModeHeader}; + let mut output = Vec::new(); + 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 output, + ); + let out = String::from_utf8(output).unwrap(); + assert!(out.contains("Repository: myrepo")); +} From 4c01a7b2fcf1e61ebbce3db9d05a1da846e15f2b Mon Sep 17 00:00:00 2001 From: Masonlet Date: Wed, 24 Jun 2026 14:54:31 -0400 Subject: [PATCH 19/34] tests(prompts): add tests for ask, ask_default, and ask_yesno EOF and input branches --- tests/prompts.rs | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 tests/prompts.rs diff --git a/tests/prompts.rs b/tests/prompts.rs new file mode 100644 index 0000000..b4c2496 --- /dev/null +++ b/tests/prompts.rs @@ -0,0 +1,31 @@ +use star_setup::prompts::{ask, ask_default, ask_yesno}; + +#[test] +fn test_ask_errors_on_eof() { + let result = ask("prompt", &mut b"".as_ref(), &mut Vec::new()); + assert!(result.is_err()); +} + +#[test] +fn test_ask_default_errors_on_eof() { + let result = ask_default("prompt", "default", &mut b"".as_ref(), &mut Vec::new()); + assert!(result.is_err()); +} + +#[test] +fn test_ask_yesno_errors_on_eof() { + let result = ask_yesno("prompt", true, &mut b"".as_ref(), &mut Vec::new()); + assert!(result.is_err()); +} + +#[test] +fn test_ask_default_returns_input_when_not_empty() { + let result = ask_default( + "prompt", + "default", + &mut b"custom\n".as_ref(), + &mut Vec::new(), + ) + .unwrap(); + assert_eq!(result, "custom"); +} From bd83296478682289e631be5ee3f4312b09e3a617 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Wed, 24 Jun 2026 14:56:25 -0400 Subject: [PATCH 20/34] test(utils): add testss for check_prerequisites --- tests/utils.rs | 2 ++ tests/utils/prerequisites.rs | 17 +++++++++++++++++ 2 files changed, 19 insertions(+) create mode 100644 tests/utils/prerequisites.rs diff --git a/tests/utils.rs b/tests/utils.rs index b593c94..cf3b0eb 100644 --- a/tests/utils.rs +++ b/tests/utils.rs @@ -2,3 +2,5 @@ mod common; #[path = "utils/confirm.rs"] mod confirm; +#[path = "utils/prerequisites.rs"] +mod prerequisites; diff --git a/tests/utils/prerequisites.rs b/tests/utils/prerequisites.rs new file mode 100644 index 0000000..3daff91 --- /dev/null +++ b/tests/utils/prerequisites.rs @@ -0,0 +1,17 @@ +use star_setup::utils::prerequisites::check_prerequisites; + +#[test] +fn test_check_prerequisites_succeeds_with_tools_present() { + let result = check_prerequisites(false, &mut Vec::new()); + assert!(result.is_ok()); +} + +#[test] +fn test_check_prerequisites_verbose_outputs_found() { + let mut output = Vec::new(); + check_prerequisites(true, &mut output).unwrap(); + let out = String::from_utf8(output).unwrap(); + assert!(out.contains("Found git")); + assert!(out.contains("Found cmake")); + assert!(out.contains("Found meson")); +} From 752700825b1ad5bd1490a00c73f0b1a90f6a27cb Mon Sep 17 00:00:00 2001 From: Masonlet Date: Wed, 24 Jun 2026 15:03:38 -0400 Subject: [PATCH 21/34] test(interactive): add tests for cmake/meson flag and fix must_use on pub functions --- src/commands/mono/mode.rs | 1 + src/commands/mono/wraps.rs | 2 ++ tests/interactive.rs | 37 +++++++++++++++++++++++++++++++++++++ 3 files changed, 40 insertions(+) diff --git a/src/commands/mono/mode.rs b/src/commands/mono/mode.rs index 5455ce5..38f75c9 100644 --- a/src/commands/mono/mode.rs +++ b/src/commands/mono/mode.rs @@ -63,6 +63,7 @@ fn generate_mono_config( } } +#[must_use] pub fn build_repo_list(test_repo: &str, deps: &[String]) -> Vec { let mut seen = std::collections::HashSet::new(); std::iter::once(test_repo.to_string()) diff --git a/src/commands/mono/wraps.rs b/src/commands/mono/wraps.rs index 1ce8869..ce819ab 100644 --- a/src/commands/mono/wraps.rs +++ b/src/commands/mono/wraps.rs @@ -5,6 +5,7 @@ use std::path::{Path, PathBuf}; /// Parses the `project()` name from `meson.build` content. /// Returns the name with hyphens replaced by underscores, or `None` if not found. +#[must_use] pub fn parse_project_name(content: &str) -> Option { let needle = b"project("; let bytes = content.as_bytes(); @@ -30,6 +31,7 @@ pub fn parse_project_name(content: &str) -> Option { } /// Parses `[provide]` key-value pairs from wrap file content. +#[must_use] pub fn parse_provide_pairs(content: &str) -> HashMap { let mut in_provide = false; let mut pairs = HashMap::new(); diff --git a/tests/interactive.rs b/tests/interactive.rs index 1ed95ec..19d46d2 100644 --- a/tests/interactive.rs +++ b/tests/interactive.rs @@ -135,3 +135,40 @@ fn test_interactive_mode_yes_word_not_accepted_for_ssh() { interactive_mode(&mut args, &mut input.as_ref(), &mut output).unwrap(); assert!(!args.connection.ssh); } + +#[test] +fn test_interactive_mode_cmake_flags_set() { + let input = b"user/repo\nn\nn\nn\n1\n\n\n-DFOO=ON\n\nn\n".to_vec(); + let mut output = Vec::new(); + let mut args = default_resolved(); + interactive_mode(&mut args, &mut input.as_ref(), &mut output).unwrap(); + assert_eq!(args.build.cmake_flags, vec!["-DFOO=ON"]); +} + +#[test] +fn test_interactive_mode_meson_flags_set() { + let input = b"user/repo\nn\nn\nn\n1\n\n\n\n-Dfoo=true\nn\n".to_vec(); + let mut output = Vec::new(); + let mut args = default_resolved(); + interactive_mode(&mut args, &mut input.as_ref(), &mut output).unwrap(); + assert_eq!(args.build.meson_flags, vec!["-Dfoo=true"]); +} + +#[test] +fn test_interactive_mode_invalid_mode_then_valid() { + let input = input_with_suffix(b"user/repo\nn\nn\nn\nfoo\n1"); + let mut output = Vec::new(); + let mut args = default_resolved(); + interactive_mode(&mut args, &mut input.as_ref(), &mut output).unwrap(); + 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\n2\nfoo\n1\nmyprofile"); + let mut output = Vec::new(); + let mut args = default_resolved(); + interactive_mode(&mut args, &mut input.as_ref(), &mut output).unwrap(); + assert!(args.mono.mono_repo); + assert_eq!(args.mono.profile, Some("myprofile".to_string())); +} From da4f12e1e85537faf7e046dcb3d657dc9740bd93 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Wed, 24 Jun 2026 15:16:41 -0400 Subject: [PATCH 22/34] docs: update README for Meson support --- README.md | 48 ++++++++++++++++++++++++++++++++++-------------- 1 file changed, 34 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 5a46469..ca6de3b 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Star Setup -A lightweight CLI to clone, configure, and wire single or multi-repo CMake ecosystems. +A lightweight CLI to clone, configure, and wire single or multi-repo ecosystems. [![CI](https://github.com/star-setup/core/actions/workflows/ci.yml/badge.svg)](https://github.com/star-setup/core/actions/workflows/ci.yml) [![License: MIT](https://img.shields.io/github/license/star-setup/core)](./LICENSE) @@ -20,7 +20,9 @@ star-setup username/repo --repos user/lib1 user/lib2 ## Prerequisites - Git -- CMake +- At least one supported build system: + - CMake + - Meson ## Installation @@ -74,6 +76,7 @@ Select mode: (1) Single Repo (2) Mono-Repo: 1 Build type [Debug]: Build directory [build]: Additional CMake args (space separated): +Additional Meson args (space separated): Configure only (skip build)? (y/n) [N]: Interactive mode complete @@ -94,10 +97,12 @@ star-setup username/repo --no-build star-setup username/repo --clean star-setup username/repo --verbose star-setup username/repo --cmake-arg=-DCMAKE_CXX_COMPILER=clang++ +star-setup username/repo --meson-arg=-Db_lto=true ``` ### Mono-Repo Mode -Clones multiple repositories into a single workspace and generates a root `CMakeLists.txt` that wires them together as subdirectories. +Clones multiple repositories into a single workspace and auto-detects the build system. For CMake projects, generates a root `CMakeLists.txt` wiring all repositories as subdirectories. For Meson projects, generates a root `meson.build` and auto-generates local `.wrap` files bridging canonical dependency names to cloned directories. + ```bash # Manual repo list star-setup username/repo --repos user/lib1 user/lib2 @@ -109,29 +114,43 @@ star-setup username/repo --profile myprofile star-setup username/repo --repos user/lib1 user/lib2 --ssh --mono-dir my-workspace ``` -#### Workspace Structure +#### Workspace Structure (CMake) ``` build-mono/ -├── CMakeLists.txt # Auto-generated root project -├── lib1/ -├── lib2/ -├── my-repo/ # Test repository -└── build/ # Build output +├── CMakeLists.txt # Auto-generated root project +├── repos/ +│ ├── user-my-repo/ # Test repository +│ ├── user-lib1/ +│ └── user-lib2/ +└── build/ # Build output ``` -#### BUILD_LOCAL +##### BUILD_LOCAL Mono-repo mode sets `-DBUILD_LOCAL=ON` when configuring CMake. This flag tells your test repository to link against local module directories instead of fetching them remotely via FetchContent: ```cmake # In your test repo's CMakeLists.txt if(NOT BUILD_LOCAL) - FetchContent_Declare(mylib - GIT_REPOSITORY https://github.com/user/mylib.git - GIT_TAG main - ) + FetchContent_Declare(mylib + GIT_REPOSITORY https://github.com/user/mylib.git + GIT_TAG main + ) endif() ``` This allows the same repository to work both standalone (fetching dependencies automatically) and inside a mono-repo workspace (linking locally for full cross-module debugging). +#### Workspace Structure (Meson) +``` +build-mono/ +├── meson.build # Auto-generated root project +├── repos/ +│ ├── user-my-repo/ # Test repository +│ ├── user-lib1/ +│ ├── user-lib2/ +│ ├── lib1.wrap # Auto-generated local wrap +│ └── lib2.wrap # Auto-generated local wrap +└── build/ # Build output +``` + ### Profile Mode Profiles represent a saved ecosystem of libraries commonly used together. ```bash @@ -152,6 +171,7 @@ star-setup username/repo --profile myprofile Config files are checked in this order: - `./.star-setup.json` (current directory) - `~/.star-setup.json` (home directory) + ```bash # Initialize a default config file star-setup --init-config From 96868378ea26ddc4d91a4c2b2fc3070ff29eeca0 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Wed, 24 Jun 2026 15:21:20 -0400 Subject: [PATCH 23/34] refactor(interactive): remove cmake/meson flag prompts from interactive mode --- README.md | 2 -- src/interactive.rs | 14 -------------- tests/interactive.rs | 18 ------------------ 3 files changed, 34 deletions(-) diff --git a/README.md b/README.md index ca6de3b..4ea4f96 100644 --- a/README.md +++ b/README.md @@ -75,8 +75,6 @@ Clean build directory if exists? (y/n) [N]: Select mode: (1) Single Repo (2) Mono-Repo: 1 Build type [Debug]: Build directory [build]: -Additional CMake args (space separated): -Additional Meson args (space separated): Configure only (skip build)? (y/n) [N]: Interactive mode complete diff --git a/src/interactive.rs b/src/interactive.rs index 829e418..9dd8565 100644 --- a/src/interactive.rs +++ b/src/interactive.rs @@ -94,20 +94,6 @@ pub fn interactive_mode( args.build.build_type = build_type_str.parse::()?; args.build.build_dir = ask_default("Build directory", &args.build.build_dir, input, output)?; - if args.build.cmake_flags.is_empty() { - let cmake_extra = ask_default("Additional CMake args (space separated)", "", input, output)?; - if !cmake_extra.is_empty() { - args.build.cmake_flags = cmake_extra.split_whitespace().map(String::from).collect(); - } - } - - if args.build.meson_flags.is_empty() { - let meson_extra = ask_default("Additional Meson args (space separated)", "", input, output)?; - if !meson_extra.is_empty() { - args.build.meson_flags = meson_extra.split_whitespace().map(String::from).collect(); - } - } - if !args.build.no_build { args.build.no_build = ask_yesno("Configure only (skip build)?", false, input, output)?; } diff --git a/tests/interactive.rs b/tests/interactive.rs index 19d46d2..5463cfc 100644 --- a/tests/interactive.rs +++ b/tests/interactive.rs @@ -136,24 +136,6 @@ fn test_interactive_mode_yes_word_not_accepted_for_ssh() { assert!(!args.connection.ssh); } -#[test] -fn test_interactive_mode_cmake_flags_set() { - let input = b"user/repo\nn\nn\nn\n1\n\n\n-DFOO=ON\n\nn\n".to_vec(); - let mut output = Vec::new(); - let mut args = default_resolved(); - interactive_mode(&mut args, &mut input.as_ref(), &mut output).unwrap(); - assert_eq!(args.build.cmake_flags, vec!["-DFOO=ON"]); -} - -#[test] -fn test_interactive_mode_meson_flags_set() { - let input = b"user/repo\nn\nn\nn\n1\n\n\n\n-Dfoo=true\nn\n".to_vec(); - let mut output = Vec::new(); - let mut args = default_resolved(); - interactive_mode(&mut args, &mut input.as_ref(), &mut output).unwrap(); - assert_eq!(args.build.meson_flags, vec!["-Dfoo=true"]); -} - #[test] fn test_interactive_mode_invalid_mode_then_valid() { let input = input_with_suffix(b"user/repo\nn\nn\nn\nfoo\n1"); From 45d2ab3c730a06448839db5932a74502594f8909 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Wed, 24 Jun 2026 15:24:21 -0400 Subject: [PATCH 24/34] chore: bump v0.2.0 --- Cargo.toml | 2 +- README.md | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 0c8e2b5..40aa054 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "star-setup" -version = "0.1.0" +version = "0.2.0" edition = "2021" repository = "https://github.com/star-setup/core" description = "Lightweight CLI to clone, configure, and wire single or multi-repo CMake ecosystems" diff --git a/README.md b/README.md index 4ea4f96..c468de0 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,7 @@ A lightweight CLI to clone, configure, and wire single or multi-repo ecosystems. +[![GitHub Release](https://img.shields.io/github/v/release/star-setup/core?include_prereleases&sort=semver)](https://github.com/star-setup/core/releases) [![CI](https://github.com/star-setup/core/actions/workflows/ci.yml/badge.svg)](https://github.com/star-setup/core/actions/workflows/ci.yml) [![License: MIT](https://img.shields.io/github/license/star-setup/core)](./LICENSE) From 944d04f9f19df864c25159df5e59abe6df181206 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Wed, 24 Jun 2026 15:30:32 -0400 Subject: [PATCH 25/34] refactor: centralize CLI error handling and cleanup exit boilerplate --- src/lib.rs | 1 + src/main.rs | 114 +--------------------------------------------------- src/run.rs | 96 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 99 insertions(+), 112 deletions(-) create mode 100644 src/run.rs diff --git a/src/lib.rs b/src/lib.rs index 5094faf..d5135bd 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,3 +8,4 @@ pub mod profiles; pub mod prompts; pub mod repository; pub mod utils; +pub mod run; diff --git a/src/main.rs b/src/main.rs index b931fc7..4e4d1ca 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,119 +1,9 @@ //! Entry point. Parses arguments, loads config, and dispatches to the appropriate command handler. -use star_setup::cli::Args; -use star_setup::commands::{mono_repo_mode, single_repo_mode}; -use star_setup::config::crud::{add_config, create_default_config, list_configs, remove_config}; -use star_setup::config::io::load_config; -use star_setup::config::types::ConfigEntry; -use star_setup::interactive::interactive_mode; -use star_setup::profiles::{add_profile, list_profiles, remove_profile}; -use star_setup::utils::prerequisites::check_prerequisites; -use std::io; -use std::io::IsTerminal; -use std::path::PathBuf; +use star_setup::run::run; fn main() { - let mut stdin = io::stdin().lock(); - let mut stdout = io::stdout(); - - let mut locations = vec![PathBuf::from(".star-setup.json")]; - if let Some(home) = dirs::home_dir() { - locations.push(home.join(".star-setup.json")); - } - - let mut config = load_config(&locations, &mut stdout); - let mut args = match Args::parse_with_config(&config) { - Ok(args) => args, - Err(e) => { - eprintln!("Error: {e}"); - std::process::exit(1); - } - }; - - if args.config.init_config { - if let Err(e) = create_default_config( - PathBuf::from(".star-setup.json"), - args.yes, - &mut stdin, - &mut stdout, - ) { - eprintln!("Error: {e}"); - std::process::exit(1); - } - return; - } - if args.config.list_configs { - list_configs(&config, &mut stdout); - return; - } - if args.profile.list_profiles { - list_profiles(&config, &mut stdout); - return; - } - - if let Some(name) = args.config.config_remove.as_deref() { - if let Err(e) = remove_config(&mut config, name, args.yes, &mut stdin, &mut stdout) { - eprintln!("Error: {e}"); - std::process::exit(1); - } - return; - } - if let Some(name) = args.config.config_add.as_deref() { - let entry = ConfigEntry { - ssh: args.connection.ssh, - build_type: args.build.build_type.clone(), - build_dir: args.build.build_dir.clone(), - mono_dir: args.mono.mono_dir.clone(), - no_build: args.build.no_build, - clean: args.build.clean, - verbose: args.connection.verbose, - cmake_flags: args.build.cmake_flags.clone(), - }; - if let Err(e) = add_config(&mut config, name, entry, args.yes, &mut stdin, &mut stdout) { - eprintln!("Error: {e}"); - std::process::exit(1); - } - return; - } - if let Some(name) = args.profile.profile_remove.as_deref() { - if let Err(e) = remove_profile(&mut config, name, args.yes, &mut stdin, &mut stdout) { - eprintln!("Error: {e}"); - std::process::exit(1); - } - return; - } - if let Some(vals) = args.profile.profile_add.as_ref() { - if let Err(e) = add_profile(&mut config, vals, args.yes, &mut stdin, &mut stdout) { - eprintln!("Error: {e}"); - std::process::exit(1); - } - return; - } - - if args.repo.is_none() { - if std::io::stdin().is_terminal() { - if let Err(e) = interactive_mode(&mut args, &mut stdin, &mut io::stdout()) { - eprintln!("Error: {e}"); - std::process::exit(1); - } - } else { - eprintln!("Error: no repository specified"); - std::process::exit(1); - } - } - - if let Err(e) = check_prerequisites(args.connection.verbose, &mut stdout) { - eprintln!("Error: {e}"); - std::process::exit(1); - } - - let result = if args.mono.mono_repo { - mono_repo_mode(&args, &config, &mut stdin, &mut stdout) - } else { - single_repo_mode(&args, &mut stdin, &mut stdout) - }; - - if let Err(e) = result { + if let Err(e) = run() { eprintln!("Error: {e}"); std::process::exit(1); } diff --git a/src/run.rs b/src/run.rs new file mode 100644 index 0000000..d3c103a --- /dev/null +++ b/src/run.rs @@ -0,0 +1,96 @@ +use crate::cli::Args; +use crate::commands::{mono_repo_mode, single_repo_mode}; +use crate::config::crud::{add_config, create_default_config, list_configs, remove_config}; +use crate::config::io::load_config; +use crate::config::types::ConfigEntry; +use crate::interactive::interactive_mode; +use crate::profiles::{add_profile, list_profiles, remove_profile}; +use crate::utils::prerequisites::check_prerequisites; +use std::error::Error; +use std::io; +use std::io::IsTerminal; +use std::path::PathBuf; + +/// Runs the setup process. +/// # Errors +/// Returns an error if the configuration file is missing or corrupted. +pub fn run() -> Result<(), Box> { + let mut stdin = io::stdin().lock(); + let mut stdout = io::stdout(); + + let mut locations = vec![PathBuf::from(".star-setup.json")]; + if let Some(home) = dirs::home_dir() { + locations.push(home.join(".star-setup.json")); + } + + let mut config = load_config(&locations, &mut stdout); + let mut args = Args::parse_with_config(&config)?; + + if args.config.init_config { + create_default_config( + PathBuf::from(".star-setup.json"), + args.yes, + &mut stdin, + &mut stdout, + )?; + return Ok(()); + } + + if args.config.list_configs { + list_configs(&config, &mut stdout); + return Ok(()); + } + + if args.profile.list_profiles { + list_profiles(&config, &mut stdout); + return Ok(()); + } + + if let Some(name) = args.config.config_remove.as_deref() { + remove_config(&mut config, name, args.yes, &mut stdin, &mut stdout)?; + return Ok(()); + } + + if let Some(name) = args.config.config_add.as_deref() { + let entry = ConfigEntry { + ssh: args.connection.ssh, + build_type: args.build.build_type.clone(), + build_dir: args.build.build_dir.clone(), + mono_dir: args.mono.mono_dir.clone(), + no_build: args.build.no_build, + clean: args.build.clean, + verbose: args.connection.verbose, + cmake_flags: args.build.cmake_flags.clone(), + }; + add_config(&mut config, name, entry, args.yes, &mut stdin, &mut stdout)?; + return Ok(()); + } + + if let Some(name) = args.profile.profile_remove.as_deref() { + remove_profile(&mut config, name, args.yes, &mut stdin, &mut stdout)?; + return Ok(()); + } + + if let Some(vals) = args.profile.profile_add.as_ref() { + add_profile(&mut config, vals, args.yes, &mut stdin, &mut stdout)?; + return Ok(()); + } + + if args.repo.is_none() { + if std::io::stdin().is_terminal() { + interactive_mode(&mut args, &mut stdin, &mut io::stdout())?; + } else { + return Err("no repository specified".into()); + } + } + + check_prerequisites(args.connection.verbose, &mut stdout)?; + + if args.mono.mono_repo { + mono_repo_mode(&args, &config, &mut stdin, &mut stdout)?; + } else { + single_repo_mode(&args, &mut stdin, &mut stdout)?; + } + + Ok(()) +} From e0ab75f34f786855a65db7c350666f012a28c4fd Mon Sep 17 00:00:00 2001 From: Masonlet Date: Wed, 24 Jun 2026 15:53:29 -0400 Subject: [PATCH 26/34] refactor(mono): deduplicate config logic --- src/commands/mono/config.rs | 87 ++++++++++++++++++++++++------------- src/lib.rs | 2 +- 2 files changed, 58 insertions(+), 31 deletions(-) diff --git a/src/commands/mono/config.rs b/src/commands/mono/config.rs index 656e540..40f2b39 100644 --- a/src/commands/mono/config.rs +++ b/src/commands/mono/config.rs @@ -3,6 +3,35 @@ use std::fs; use std::io::Write; use std::path::Path; +/// Shared helper to generate, write, and log monorepo build configuration files. +fn write_mono_repo_config( + mono_dir: &Path, + repos: &[String], + output: &mut impl Write, + filename: &str, + format_modules: impl Fn(&[String]) -> String, + render_template: impl Fn(&str) -> String, +) -> Result<(), String> { + let module_names: Vec = repos.iter().map(|r| repo_dir_name(r)).collect(); + let modules_str = format_modules(&module_names); + + let content = render_template(&modules_str); + let file_path = mono_dir.join(filename); + fs::write(&file_path, content).map_err(|e| e.to_string())?; + + // .to_string() is required to force an allocation and satisfy line coverage tracking + #[allow(clippy::to_string_in_format_args)] + writeln!( + output, + "Created root {} at {}\n", + filename.to_string(), + mono_dir.display() + ) + .ok(); + + Ok(()) +} + /// Generates a root `CMakeLists.txt` wiring all repositories as subdirectories. /// # Errors /// Returns an error if the `CMakeLists.txt` file cannot be written to `mono_dir` @@ -11,10 +40,15 @@ pub fn create_mono_repo_cmakelists( repos: &[String], output: &mut impl Write, ) -> Result<(), String> { - let module_names: Vec = repos.iter().map(|r| repo_dir_name(r)).collect(); - let modules_cmake = module_names.join("\n "); - let cmake_content = format!( - "cmake_minimum_required(VERSION 3.23) + write_mono_repo_config( + mono_dir, + repos, + output, + "CMakeLists.txt", + |modules| modules.join("\n "), + |modules_cmake| { + format!( + "cmake_minimum_required(VERSION 3.23) project(star_setup LANGUAGES CXX) set(CMAKE_CXX_STANDARD 20) @@ -34,16 +68,9 @@ endforeach() set_property(GLOBAL PROPERTY USE_FOLDERS ON) set_property(GLOBAL PROPERTY PREDEFINED_TARGETS_FOLDER \"External\") " - ); - let cmake_file = mono_dir.join("CMakeLists.txt"); - fs::write(&cmake_file, cmake_content).map_err(|e| e.to_string())?; - writeln!( - output, - "Created root CMakeLists.txt at {}\n", - mono_dir.display() + ) + }, ) - .ok(); - Ok(()) } /// Generates a root `meson.build` wiring all repositories as subprojects. @@ -54,14 +81,21 @@ pub fn create_mono_repo_mesonbuild( repos: &[String], output: &mut impl Write, ) -> Result<(), String> { - let module_names: Vec = repos.iter().map(|r| repo_dir_name(r)).collect(); - let modules_meson = module_names - .iter() - .map(|m| format!(" '{m}'")) - .collect::>() - .join(",\n"); - let meson_content = format!( - "project('star_setup', 'cpp', + write_mono_repo_config( + mono_dir, + repos, + output, + "meson.build", + |modules| { + modules + .iter() + .map(|m| format!(" '{m}'")) + .collect::>() + .join(",\n") + }, + |modules_meson| { + format!( + "project('star_setup', 'cpp', default_options: ['cpp_std=c++20'], subproject_dir: 'repos' ) @@ -74,14 +108,7 @@ foreach module : modules subproject(module) endforeach " - ); - let meson_file = mono_dir.join("meson.build"); - fs::write(&meson_file, meson_content).map_err(|e| e.to_string())?; - writeln!( - output, - "Created root meson.build at {}\n", - mono_dir.display() + ) + }, ) - .ok(); - Ok(()) } diff --git a/src/lib.rs b/src/lib.rs index d5135bd..2de6d82 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -7,5 +7,5 @@ pub mod interactive; pub mod profiles; pub mod prompts; pub mod repository; -pub mod utils; pub mod run; +pub mod utils; From cf2aa8eabc4f02983d51af02cb0d9870653aea75 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Wed, 24 Jun 2026 16:33:00 -0400 Subject: [PATCH 27/34] refactor: move status messages into helpers and fix stdout variable reuse --- src/cli/build/detect.rs | 1 + src/commands/mono/config.rs | 2 ++ src/commands/mono/mode.rs | 8 +++----- src/profiles.rs | 41 +++++++++++++++---------------------- src/run.rs | 2 +- 5 files changed, 24 insertions(+), 30 deletions(-) diff --git a/src/cli/build/detect.rs b/src/cli/build/detect.rs index 39aa057..bf7bfa8 100644 --- a/src/cli/build/detect.rs +++ b/src/cli/build/detect.rs @@ -37,6 +37,7 @@ pub fn detect_mono_build_system( input: &mut impl BufRead, output: &mut impl Write, ) -> Result { + writeln!(output, "Detecting build system\n").ok(); let all_cmake = dirs.iter().all(|d| d.join("CMakeLists.txt").exists()); let all_meson = dirs.iter().all(|d| d.join("meson.build").exists()); match (all_cmake, all_meson) { diff --git a/src/commands/mono/config.rs b/src/commands/mono/config.rs index 40f2b39..2621ad1 100644 --- a/src/commands/mono/config.rs +++ b/src/commands/mono/config.rs @@ -40,6 +40,7 @@ pub fn create_mono_repo_cmakelists( repos: &[String], output: &mut impl Write, ) -> Result<(), String> { + writeln!(output, " Creating CMake configuration").ok(); write_mono_repo_config( mono_dir, repos, @@ -81,6 +82,7 @@ pub fn create_mono_repo_mesonbuild( repos: &[String], output: &mut impl Write, ) -> Result<(), String> { + writeln!(output, " Creating Meson configuration").ok(); write_mono_repo_config( mono_dir, repos, diff --git a/src/commands/mono/mode.rs b/src/commands/mono/mode.rs index 38f75c9..2768536 100644 --- a/src/commands/mono/mode.rs +++ b/src/commands/mono/mode.rs @@ -19,6 +19,7 @@ fn clone_mono_repos( verbose: bool, output: &mut impl Write, ) -> Result<(), String> { + writeln!(output, "Cloning repositories").ok(); for repo in repos { clone_repository(repo, repos_path, ssh, verbose, output)?; } @@ -39,6 +40,7 @@ fn generate_mono_config( repos: &[String], output: &mut impl Write, ) -> Result>, String> { + writeln!(output, "Creating mono-repo configuration").ok(); match build_system { BuildSystem::Cmake => { create_mono_repo_cmakelists(mono_repo_path, repos, output)?; @@ -96,7 +98,6 @@ pub fn mono_repo_mode( let repos_path = mono_repo_path.join("repos"); fs::create_dir_all(&repos_path).map_err(|e| e.to_string())?; - writeln!(output, "Cloning repositories").ok(); clone_mono_repos( &repos, &repos_path, @@ -110,10 +111,8 @@ pub fn mono_repo_mode( .map(|r| repos_path.join(repo_dir_name(r))) .collect(); - writeln!(output, "Detecting build system\n").ok(); let build_system = detect_mono_build_system(&repo_dirs, input, output)?; - writeln!(output, "Creating mono-repo configuration").ok(); let canonical_map = generate_mono_config( &build_system, &mono_repo_path, @@ -123,14 +122,13 @@ pub fn mono_repo_mode( output, )?; - writeln!(output, "Creating build directory\n").ok(); let build_path = mono_repo_path.join(&args.build.build_dir); - if args.build.clean && build_path.exists() { writeln!(output, "Cleaning build directory\n").ok(); fs::remove_dir_all(&build_path).map_err(|e| e.to_string())?; } + writeln!(output, "Creating build directory\n").ok(); fs::create_dir_all(&build_path).map_err(|e| e.to_string())?; writeln!(output, "Configuring project in {}\n", build_path.display()).ok(); diff --git a/src/profiles.rs b/src/profiles.rs index 835fd03..032f00f 100644 --- a/src/profiles.rs +++ b/src/profiles.rs @@ -21,6 +21,14 @@ pub fn has_profile(config: &SetupConfig, name: &str) -> bool { config.profiles.contains_key(name) } +fn print_profile_details(output: &mut impl Write, title: &str, label: &str, repos: &[String]) { + writeln!(output, " {title}").ok(); + writeln!(output, " {label}: {}", repos.len()).ok(); + for repo in repos { + writeln!(output, " - {repo}").ok(); + } +} + /// Adds a new profile to the configuration. /// args: [name, repo1, repo2, ...] /// # Errors @@ -56,11 +64,7 @@ pub fn add_profile( writeln!(output, "Profile '{name}' added successfully").ok(); writeln!(output, "Configuration saved to: {}", path.display()).ok(); - writeln!(output, "Profile details:").ok(); - writeln!(output, " Repositories ({}):", repos.len()).ok(); - for repo in repos { - writeln!(output, " - {repo}").ok(); - } + print_profile_details(output, "Profile details:", "Repositories", &repos); writeln!( output, "\nUsage: star-setup username/test-repo --profile {name}" @@ -80,18 +84,14 @@ pub fn remove_profile( output: &mut impl Write, ) -> Result<(), String> { let repos = match config.profiles.get(name) { - None => { - writeln!(output, "Warning: Profile '{name}' not found.").ok(); - return Ok(()); - } - Some(r) => r.clone(), + None => { + writeln!(output, "Warning: Profile '{name}' not found.").ok(); + return Ok(()); + } + Some(r) => r.clone(), }; - writeln!(output, "Profile '{name}'").ok(); - writeln!(output, " Libraries: {}", repos.len()).ok(); - for repo in &repos { - writeln!(output, " - {repo}").ok(); - } + print_profile_details(output, &format!("Profile '{name}'"), "Repositories", &repos); if !confirm( &format!("Are you sure you want to remove profile '{name}'?"), @@ -112,13 +112,10 @@ pub fn remove_profile( /// Lists all configured profiles. pub fn list_profiles(config: &SetupConfig, output: &mut impl Write) { - writeln!(output, "Available profiles:").ok(); - if config.profiles.is_empty() { - writeln!(output, " No profiles configured.").ok(); writeln!( output, - " Run with --init-config to create a default configuration." + "No profiles configured. Run with --init-config to create a default configuration." ) .ok(); return; @@ -126,11 +123,7 @@ pub fn list_profiles(config: &SetupConfig, output: &mut impl Write) { writeln!(output, "Configured profiles:\n").ok(); for (name, repos) in &config.profiles { - writeln!(output, " {name}").ok(); - writeln!(output, " Repositories ({}):", repos.len()).ok(); - for repo in repos { - writeln!(output, " - {repo}").ok(); - } + print_profile_details(output, name, "Repositories", repos); writeln!(output).ok(); } } diff --git a/src/run.rs b/src/run.rs index d3c103a..007bd14 100644 --- a/src/run.rs +++ b/src/run.rs @@ -78,7 +78,7 @@ pub fn run() -> Result<(), Box> { if args.repo.is_none() { if std::io::stdin().is_terminal() { - interactive_mode(&mut args, &mut stdin, &mut io::stdout())?; + interactive_mode(&mut args, &mut stdin, &mut stdout)?; } else { return Err("no repository specified".into()); } From 25c70490ff5d20b5238fd511064aebc3968fe010 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Wed, 24 Jun 2026 17:14:25 -0400 Subject: [PATCH 28/34] refactor: consolidate imports, move confirm to prompts, remove utils/confirm --- src/cli/args.rs | 12 ++++++---- src/cli/build/detect.rs | 9 ++++---- src/cli/resolve.rs | 12 ++++++---- src/cli/resolved.rs | 6 +++-- src/commands/build.rs | 14 ++++++++---- src/commands/mono/config.rs | 4 +--- src/commands/mono/mod.rs | 6 ++--- src/commands/mono/mode.rs | 30 ++++++++++++++---------- src/commands/mono/resolve.rs | 10 ++++---- src/commands/mono/wraps.rs | 10 ++++---- src/commands/single.rs | 24 ++++++++++++-------- src/config/crud.rs | 20 ++++++++++------ src/config/io.rs | 5 +--- src/config/types.rs | 3 +-- src/interactive.rs | 6 +++-- src/profiles.rs | 17 +++++++------- src/prompts.rs | 22 ++++++++++++++++++ src/repository.rs | 3 +-- src/run.rs | 25 ++++++++++---------- src/utils/confirm.rs | 24 -------------------- src/utils/mod.rs | 1 - tests/prompts.rs | 38 +++++++++++++++++++++++++++++++ tests/utils.rs | 4 ++-- tests/utils/confirm.rs | 44 ------------------------------------ tests/utils/process.rs | 8 +++++++ 25 files changed, 194 insertions(+), 163 deletions(-) delete mode 100644 src/utils/confirm.rs delete mode 100644 tests/utils/confirm.rs create mode 100644 tests/utils/process.rs diff --git a/src/cli/args.rs b/src/cli/args.rs index d9cce7a..ebec689 100644 --- a/src/cli/args.rs +++ b/src/cli/args.rs @@ -1,7 +1,11 @@ -use crate::cli::flags::{BuildFlags, ConfigFlags, ConnectionFlags, MonoRepoFlags, ProfileFlags}; -use crate::cli::resolve::resolve_with_config; -use crate::cli::resolved::ResolvedArgs; -use crate::config::types::SetupConfig; +use crate::{ + cli::{ + flags::{BuildFlags, ConfigFlags, ConnectionFlags, MonoRepoFlags, ProfileFlags}, + resolve::resolve_with_config, + resolved::ResolvedArgs, + }, + config::types::SetupConfig, +}; use clap::Parser; /// Top-level CLI arguments for star-setup. diff --git a/src/cli/build/detect.rs b/src/cli/build/detect.rs index bf7bfa8..2928bc8 100644 --- a/src/cli/build/detect.rs +++ b/src/cli/build/detect.rs @@ -1,7 +1,8 @@ -use crate::cli::build::BuildSystem; -use crate::prompts::ask_choice; -use std::io::{BufRead, Write}; -use std::path::{Path, PathBuf}; +use crate::{cli::build::BuildSystem, prompts::ask_choice}; +use std::{ + io::{BufRead, Write}, + path::{Path, PathBuf}, +}; /// Detects the build system in use by inspecting the given directory. /// # Errors diff --git a/src/cli/resolve.rs b/src/cli/resolve.rs index ac621da..9baa896 100644 --- a/src/cli/resolve.rs +++ b/src/cli/resolve.rs @@ -1,9 +1,11 @@ -use crate::cli::args::Args; -use crate::cli::build::BuildType; -use crate::cli::resolved::{ - ResolvedArgs, ResolvedBuildFlags, ResolvedConnectionFlags, ResolvedMonoFlags, +use crate::{ + cli::{ + args::Args, + build::BuildType, + resolved::{ResolvedArgs, ResolvedBuildFlags, ResolvedConnectionFlags, ResolvedMonoFlags}, + }, + config::types::SetupConfig, }; -use crate::config::types::SetupConfig; /// Resolves a boolean flag from CLI positive/negative flags, config value, and a default. /// Negative flag takes highest priority, then positive, then config, then default. diff --git a/src/cli/resolved.rs b/src/cli/resolved.rs index 44237a8..814c29d 100644 --- a/src/cli/resolved.rs +++ b/src/cli/resolved.rs @@ -1,5 +1,7 @@ -use crate::cli::build::BuildType; -use crate::cli::flags::{ConfigFlags, ProfileFlags}; +use crate::cli::{ + build::BuildType, + flags::{ConfigFlags, ProfileFlags}, +}; /// Resolved connection flags after applying config and CLI overrides. pub struct ResolvedConnectionFlags { diff --git a/src/commands/build.rs b/src/commands/build.rs index 8306fbf..57c2103 100644 --- a/src/commands/build.rs +++ b/src/commands/build.rs @@ -1,10 +1,14 @@ //! Build system dispatch and per-system build functions. -use crate::cli::build::{detect_build_system, BuildSystem}; -use crate::cli::ResolvedArgs; -use crate::utils::process::run_command; -use std::io::{BufRead, Write}; -use std::path::Path; +use crate::{ + cli::build::{detect_build_system, BuildSystem}, + cli::ResolvedArgs, + utils::process::run_command, +}; +use std::{ + io::{BufRead, Write}, + path::Path, +}; /// Runs `CMake` configuration and optionally builds the project in `build_path`. /// # Errors diff --git a/src/commands/mono/config.rs b/src/commands/mono/config.rs index 2621ad1..af6c85d 100644 --- a/src/commands/mono/config.rs +++ b/src/commands/mono/config.rs @@ -1,7 +1,5 @@ use crate::repository::repo_dir_name; -use std::fs; -use std::io::Write; -use std::path::Path; +use std::{fs, io::Write, path::Path}; /// Shared helper to generate, write, and log monorepo build configuration files. fn write_mono_repo_config( diff --git a/src/commands/mono/mod.rs b/src/commands/mono/mod.rs index 4b601ed..e12ed36 100644 --- a/src/commands/mono/mod.rs +++ b/src/commands/mono/mod.rs @@ -1,8 +1,8 @@ pub mod config; -pub mod mode; -pub mod resolve; -pub mod wraps; pub use config::{create_mono_repo_cmakelists, create_mono_repo_mesonbuild}; +pub mod mode; pub use mode::{build_repo_list, mono_repo_mode}; +pub mod resolve; pub use resolve::{resolve_repos_for_mono, resolve_test_repo}; +pub mod wraps; pub use wraps::hoist_wraps; diff --git a/src/commands/mono/mode.rs b/src/commands/mono/mode.rs index 2768536..e2670ba 100644 --- a/src/commands/mono/mode.rs +++ b/src/commands/mono/mode.rs @@ -1,16 +1,22 @@ -use crate::cli::{ - build::{detect_mono_build_system, BuildSystem}, - ResolvedArgs, +use crate::{ + cli::{ + build::{detect_mono_build_system, BuildSystem}, + ResolvedArgs, + }, + commands::{ + build::{cmake_build, meson_build}, + mono::config::{create_mono_repo_cmakelists, create_mono_repo_mesonbuild}, + mono::resolve::{resolve_repos_for_mono, resolve_test_repo}, + mono::wraps::hoist_wraps, + }, + config::types::SetupConfig, + repository::{clone_repository, repo_dir_name}, +}; +use std::{ + fs, + io::{BufRead, Write}, + path::PathBuf, }; -use crate::commands::build::{cmake_build, meson_build}; -use crate::commands::mono::config::{create_mono_repo_cmakelists, create_mono_repo_mesonbuild}; -use crate::commands::mono::resolve::{resolve_repos_for_mono, resolve_test_repo}; -use crate::commands::mono::wraps::hoist_wraps; -use crate::config::types::SetupConfig; -use crate::repository::{clone_repository, repo_dir_name}; -use std::fs; -use std::io::{BufRead, Write}; -use std::path::PathBuf; fn clone_mono_repos( repos: &[String], diff --git a/src/commands/mono/resolve.rs b/src/commands/mono/resolve.rs index 925f3b5..6800ffa 100644 --- a/src/commands/mono/resolve.rs +++ b/src/commands/mono/resolve.rs @@ -1,7 +1,9 @@ -use crate::cli::ResolvedArgs; -use crate::commands::header::{print_mode_header, ModeHeader}; -use crate::config::types::SetupConfig; -use crate::profiles::list_profiles; +use crate::{ + cli::ResolvedArgs, + commands::header::{print_mode_header, ModeHeader}, + config::types::SetupConfig, + profiles::list_profiles, +}; use std::io::Write; /// Resolves the list of repositories for mono-repo mode from a profile or explicit repo list. diff --git a/src/commands/mono/wraps.rs b/src/commands/mono/wraps.rs index ce819ab..d45662e 100644 --- a/src/commands/mono/wraps.rs +++ b/src/commands/mono/wraps.rs @@ -1,7 +1,9 @@ -use std::collections::HashMap; -use std::fs; -use std::io::Write; -use std::path::{Path, PathBuf}; +use std::{ + collections::HashMap, + fs, + io::Write, + path::{Path, PathBuf}, +}; /// Parses the `project()` name from `meson.build` content. /// Returns the name with hyphens replaced by underscores, or `None` if not found. diff --git a/src/commands/single.rs b/src/commands/single.rs index 2d2544b..63de4b1 100644 --- a/src/commands/single.rs +++ b/src/commands/single.rs @@ -1,12 +1,18 @@ -use crate::cli::ResolvedArgs; -use crate::commands::build::build_project; -use crate::commands::header::{print_mode_header, ModeHeader}; -use crate::repository::{repo_dir_name, resolve_repo_url}; -use crate::utils::confirm::confirm; -use crate::utils::process::run_command; -use std::fs; -use std::io::{BufRead, Write}; -use std::path::{Path, PathBuf}; +use crate::{ + cli::ResolvedArgs, + commands::{ + build::build_project, + header::{print_mode_header, ModeHeader}, + }, + prompts::confirm, + repository::{repo_dir_name, resolve_repo_url}, + utils::process::run_command, +}; +use std::{ + fs, + io::{BufRead, Write}, + path::{Path, PathBuf}, +}; /// Clones and configures a single repository. /// # Errors diff --git a/src/config/crud.rs b/src/config/crud.rs index 1851c29..1a8ad49 100644 --- a/src/config/crud.rs +++ b/src/config/crud.rs @@ -1,10 +1,16 @@ -use crate::cli::build::BuildType; -use crate::config::display::format_entry; -use crate::config::io::save_config; -use crate::config::types::{ConfigEntry, SetupConfig}; -use crate::utils::confirm::confirm; -use std::io::{BufRead, Write}; -use std::path::PathBuf; +use crate::{ + cli::build::BuildType, + config::{ + display::format_entry, + io::save_config, + types::{ConfigEntry, SetupConfig}, + }, + prompts::confirm, +}; +use std::{ + io::{BufRead, Write}, + path::PathBuf, +}; /// Inserts or overwrites a named configuration entry. pub fn insert_config(config: &mut SetupConfig, name: &str, entry: ConfigEntry) { diff --git a/src/config/io.rs b/src/config/io.rs index 0bd0e66..68457d2 100644 --- a/src/config/io.rs +++ b/src/config/io.rs @@ -1,8 +1,5 @@ use crate::config::types::SetupConfig; -use std::fs; -use std::io; -use std::io::Write; -use std::path::PathBuf; +use std::{fs, io, io::Write, path::PathBuf}; /// Loads configuration from the first valid JSON file in `locations`. pub fn load_config(locations: &[PathBuf], output: &mut impl Write) -> SetupConfig { diff --git a/src/config/types.rs b/src/config/types.rs index cc06636..2bb4b95 100644 --- a/src/config/types.rs +++ b/src/config/types.rs @@ -1,7 +1,6 @@ use crate::cli::build::BuildType; use serde::{Deserialize, Serialize}; -use std::collections::HashMap; -use std::path::PathBuf; +use std::{collections::HashMap, path::PathBuf}; /// Represents a single named configuration entry. #[allow(clippy::struct_excessive_bools)] diff --git a/src/interactive.rs b/src/interactive.rs index 9dd8565..083e1bc 100644 --- a/src/interactive.rs +++ b/src/interactive.rs @@ -1,7 +1,9 @@ //! Interactive CLI mode. -use crate::cli::{build::BuildType, ResolvedArgs}; -use crate::prompts::{ask, ask_default, ask_yesno}; +use crate::{ + cli::{build::BuildType, ResolvedArgs}, + prompts::{ask, ask_default, ask_yesno}, +}; use std::io::{BufRead, Write}; /// Interactive CLI mode — prompts for any unset arguments. diff --git a/src/profiles.rs b/src/profiles.rs index 032f00f..ba1484e 100644 --- a/src/profiles.rs +++ b/src/profiles.rs @@ -1,8 +1,9 @@ //! Profile management. -use crate::config::io::save_config; -use crate::config::types::SetupConfig; -use crate::utils::confirm::confirm; +use crate::{ + config::{io::save_config, types::SetupConfig}, + prompts::confirm, +}; use std::io::{BufRead, Write}; /// Inserts or overwrites a named profile. @@ -84,11 +85,11 @@ pub fn remove_profile( output: &mut impl Write, ) -> Result<(), String> { let repos = match config.profiles.get(name) { - None => { - writeln!(output, "Warning: Profile '{name}' not found.").ok(); - return Ok(()); - } - Some(r) => r.clone(), + None => { + writeln!(output, "Warning: Profile '{name}' not found.").ok(); + return Ok(()); + } + Some(r) => r.clone(), }; print_profile_details(output, &format!("Profile '{name}'"), "Repositories", &repos); diff --git a/src/prompts.rs b/src/prompts.rs index 096593c..4a7f4d4 100644 --- a/src/prompts.rs +++ b/src/prompts.rs @@ -91,3 +91,25 @@ pub fn ask_choice( } } } + +/// Returns `true` if `yes` is set or the user enters `y`/`Y`. +/// # Errors +/// Returns an error if stdin reaches EOF unexpectedly. +pub fn confirm( + prompt: &str, + yes: bool, + input: &mut impl BufRead, + output: &mut impl Write, +) -> Result { + if yes { + return Ok(true); + } + + write!(output, "{prompt} (y/n): ").ok(); + output.flush().ok(); + let mut line = String::new(); + if input.read_line(&mut line).unwrap_or(0) == 0 { + return Err("unexpected end of input".to_string()); + } + Ok(line.trim().eq_ignore_ascii_case("y")) +} diff --git a/src/repository.rs b/src/repository.rs index f9481dd..e0450a5 100644 --- a/src/repository.rs +++ b/src/repository.rs @@ -1,8 +1,7 @@ //! Repository functions including cloning and URL resolution. use crate::utils::process::run_command; -use std::io::Write; -use std::path::Path; +use std::{io::Write, path::Path}; /// Converts a repository path or URL to a local directory name (`owner-repo`). #[must_use] diff --git a/src/run.rs b/src/run.rs index 007bd14..c1fd88d 100644 --- a/src/run.rs +++ b/src/run.rs @@ -1,15 +1,16 @@ -use crate::cli::Args; -use crate::commands::{mono_repo_mode, single_repo_mode}; -use crate::config::crud::{add_config, create_default_config, list_configs, remove_config}; -use crate::config::io::load_config; -use crate::config::types::ConfigEntry; -use crate::interactive::interactive_mode; -use crate::profiles::{add_profile, list_profiles, remove_profile}; -use crate::utils::prerequisites::check_prerequisites; -use std::error::Error; -use std::io; -use std::io::IsTerminal; -use std::path::PathBuf; +use crate::{ + cli::Args, + commands::{mono_repo_mode, single_repo_mode}, + config::{ + crud::{add_config, create_default_config, list_configs, remove_config}, + io::load_config, + types::ConfigEntry, + }, + interactive::interactive_mode, + profiles::{add_profile, list_profiles, remove_profile}, + utils::prerequisites::check_prerequisites, +}; +use std::{error::Error, io, io::IsTerminal, path::PathBuf}; /// Runs the setup process. /// # Errors diff --git a/src/utils/confirm.rs b/src/utils/confirm.rs deleted file mode 100644 index a4381c8..0000000 --- a/src/utils/confirm.rs +++ /dev/null @@ -1,24 +0,0 @@ -use std::io::BufRead; -use std::io::Write; - -/// Returns `true` if `yes` is set or the user enters `y`/`Y`. -/// # Errors -/// Returns an error if stdin reaches EOF unexpectedly. -pub fn confirm( - prompt: &str, - yes: bool, - input: &mut impl BufRead, - output: &mut impl Write, -) -> Result { - if yes { - return Ok(true); - } - - write!(output, "{prompt} (y/n): ").ok(); - output.flush().ok(); - let mut line = String::new(); - if input.read_line(&mut line).unwrap_or(0) == 0 { - return Err("unexpected end of input".to_string()); - } - Ok(line.trim().eq_ignore_ascii_case("y")) -} diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 99f3316..246925a 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -1,3 +1,2 @@ -pub mod confirm; pub mod prerequisites; pub mod process; diff --git a/tests/prompts.rs b/tests/prompts.rs index b4c2496..5d17ad9 100644 --- a/tests/prompts.rs +++ b/tests/prompts.rs @@ -1,4 +1,7 @@ +use star_setup::prompts::confirm; use star_setup::prompts::{ask, ask_default, ask_yesno}; +mod common; +use common::sink; #[test] fn test_ask_errors_on_eof() { @@ -29,3 +32,38 @@ fn test_ask_default_returns_input_when_not_empty() { .unwrap(); assert_eq!(result, "custom"); } + +#[test] +fn test_confirm_input_cases() { + let cases = [ + (b"y\n" as &[u8], true, "y accepts"), + (b"Y\n", true, "Y accepts"), + (b" y \n", true, "padded y accepts"), + (b"n\n", false, "n rejects"), + (b"yes\n", false, "yes rejects"), + ]; + for (mut input, expected, name) in cases { + let mut output = sink(); + assert_eq!( + confirm("prompt", false, &mut input, &mut output).unwrap(), + expected, + "Failed: {name}" + ); + } +} + +#[test] +fn test_confirm_yes_flag_returns_true() { + let mut input = b"".as_ref(); + let mut output = sink(); + assert!(confirm("prompt", true, &mut input, &mut output).unwrap()); +} + +#[test] +fn test_confirm_errors_on_eof() { + let mut input = b"".as_ref(); + let mut output = sink(); + let result = confirm("prompt", false, &mut input, &mut output); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("unexpected end of input")); +} diff --git a/tests/utils.rs b/tests/utils.rs index cf3b0eb..724e05b 100644 --- a/tests/utils.rs +++ b/tests/utils.rs @@ -1,6 +1,6 @@ #[path = "common/mod.rs"] mod common; -#[path = "utils/confirm.rs"] -mod confirm; #[path = "utils/prerequisites.rs"] mod prerequisites; +#[path = "utils/process.rs"] +mod process; diff --git a/tests/utils/confirm.rs b/tests/utils/confirm.rs deleted file mode 100644 index bc5e5a4..0000000 --- a/tests/utils/confirm.rs +++ /dev/null @@ -1,44 +0,0 @@ -use super::common::sink; -use star_setup::utils::confirm::confirm; -use star_setup::utils::process::run_command; - -#[test] -fn test_confirm_input_cases() { - let cases = [ - (b"y\n" as &[u8], true, "y accepts"), - (b"Y\n", true, "Y accepts"), - (b" y \n", true, "padded y accepts"), - (b"n\n", false, "n rejects"), - (b"yes\n", false, "yes rejects"), - ]; - for (mut input, expected, name) in cases { - let mut output = sink(); - assert_eq!( - confirm("prompt", false, &mut input, &mut output).unwrap(), - expected, - "Failed: {name}" - ); - } -} - -#[test] -fn test_confirm_yes_flag_returns_true() { - let mut input = b"".as_ref(); - let mut output = sink(); - assert!(confirm("prompt", true, &mut input, &mut output).unwrap()); -} - -#[test] -fn test_confirm_errors_on_eof() { - let mut input = b"".as_ref(); - let mut output = sink(); - let result = confirm("prompt", false, &mut input, &mut output); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("unexpected end of input")); -} - -#[test] -fn test_run_command_errors_on_empty() { - let mut output = sink(); - assert!(run_command(&[], None, false, &mut output).is_err()); -} diff --git a/tests/utils/process.rs b/tests/utils/process.rs new file mode 100644 index 0000000..4e9c60b --- /dev/null +++ b/tests/utils/process.rs @@ -0,0 +1,8 @@ +use super::common::sink; +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()); +} From 78fa9d2982cd5dd7599369c40cd66ec0e38ca419 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Wed, 24 Jun 2026 17:18:48 -0400 Subject: [PATCH 29/34] fix: add meson_flags to ConfigEntry and integrate through config add/save --- src/cli/resolve.rs | 3 +++ src/config/crud.rs | 1 + src/config/display.rs | 10 ++++++++++ src/config/types.rs | 2 ++ src/run.rs | 3 ++- src/utils/process.rs | 11 ++++++----- tests/cli/resolve.rs | 5 +++++ tests/config/crud.rs | 1 + tests/config/fixtures.rs | 1 + tests/config/io.rs | 1 + 10 files changed, 32 insertions(+), 6 deletions(-) diff --git a/src/cli/resolve.rs b/src/cli/resolve.rs index 9baa896..c2c8dcd 100644 --- a/src/cli/resolve.rs +++ b/src/cli/resolve.rs @@ -60,6 +60,9 @@ pub fn resolve_with_config(mut args: Args, config: &SetupConfig) -> Result String { writeln!(out, " {arg}").ok(); } } + if e.meson_flags.is_empty() { + out.push('\n'); + } else if e.meson_flags.len() == 1 { + writeln!(out, " Meson argument: {}", e.meson_flags[0]).ok(); + } else { + out.push_str(" Meson arguments:\n"); + for arg in &e.meson_flags { + writeln!(out, " {arg}").ok(); + } + } out } diff --git a/src/config/types.rs b/src/config/types.rs index 2bb4b95..c9e3223 100644 --- a/src/config/types.rs +++ b/src/config/types.rs @@ -22,6 +22,8 @@ pub struct ConfigEntry { pub verbose: bool, /// Additional `CMake` arguments. pub cmake_flags: Vec, + /// Additional `Meson` arguments. + pub meson_flags: Vec, } /// Top-level configuration structure. diff --git a/src/run.rs b/src/run.rs index c1fd88d..bf40843 100644 --- a/src/run.rs +++ b/src/run.rs @@ -62,6 +62,7 @@ pub fn run() -> Result<(), Box> { clean: args.build.clean, verbose: args.connection.verbose, cmake_flags: args.build.cmake_flags.clone(), + meson_flags: args.build.meson_flags.clone(), }; add_config(&mut config, name, entry, args.yes, &mut stdin, &mut stdout)?; return Ok(()); @@ -78,7 +79,7 @@ pub fn run() -> Result<(), Box> { } if args.repo.is_none() { - if std::io::stdin().is_terminal() { + if io::stdin().is_terminal() { interactive_mode(&mut args, &mut stdin, &mut stdout)?; } else { return Err("no repository specified".into()); diff --git a/src/utils/process.rs b/src/utils/process.rs index 091c731..997eaa9 100644 --- a/src/utils/process.rs +++ b/src/utils/process.rs @@ -1,8 +1,9 @@ -use std::io::Read; -use std::io::Write; -use std::path::Path; -use std::process::{Command, Stdio}; -use std::thread; +use std::{ + io::{Read, Write}, + path::Path, + process::{Command, Stdio}, + thread +}; /// Finds vcvars64.bat using vswhere.exe. /// Returns None if vswhere is not found or no VS installation exists. diff --git a/tests/cli/resolve.rs b/tests/cli/resolve.rs index 3a7bdbc..e67220a 100644 --- a/tests/cli/resolve.rs +++ b/tests/cli/resolve.rs @@ -138,6 +138,7 @@ fn test_resolve_with_config_applies_config_defaults() { no_build: true, clean: true, cmake_flags: vec!["-DTEST=ON".to_string()], + meson_flags: vec![], }, ); let resolved = resolve_with_config(default_args(), &config).unwrap(); @@ -164,6 +165,7 @@ fn test_resolve_with_config_cli_overrides_config() { no_build: false, clean: false, cmake_flags: vec![], + meson_flags: vec![], }, ); let mut args = default_args(); @@ -215,6 +217,7 @@ fn test_resolve_with_config_named_config_pulls_correct_values() { no_build: false, clean: true, cmake_flags: vec![], + meson_flags: vec![], }, ); let mut args = default_args(); @@ -240,6 +243,7 @@ fn test_resolve_with_config_cli_cmake_flags_not_overwritten_by_config() { no_build: false, clean: false, cmake_flags: vec!["-DCONFIG_FLAG=ON".to_string()], + meson_flags: vec![], }, ); let mut args = default_args(); @@ -262,6 +266,7 @@ fn test_resolve_with_config_negative_flags_override_config() { no_build: true, clean: true, cmake_flags: vec![], + meson_flags: vec![], }, ); diff --git a/tests/config/crud.rs b/tests/config/crud.rs index ea87300..34ebae3 100644 --- a/tests/config/crud.rs +++ b/tests/config/crud.rs @@ -67,6 +67,7 @@ fn test_add_config_aborts_when_exists_and_not_confirmed() { clean: false, verbose: false, cmake_flags: vec![], + meson_flags: vec![], }, false, &mut input.as_ref(), diff --git a/tests/config/fixtures.rs b/tests/config/fixtures.rs index abf1432..20d4f40 100644 --- a/tests/config/fixtures.rs +++ b/tests/config/fixtures.rs @@ -10,5 +10,6 @@ pub fn sample_entry() -> ConfigEntry { clean: true, verbose: false, cmake_flags: vec![], + meson_flags: vec![], } } diff --git a/tests/config/io.rs b/tests/config/io.rs index 350026a..2dda436 100644 --- a/tests/config/io.rs +++ b/tests/config/io.rs @@ -27,6 +27,7 @@ fn test_save_and_load_roundtrip() { clean: false, verbose: false, cmake_flags: vec![], + meson_flags: vec![], }, ); save_config(&mut config).unwrap(); From 43d7f5dc86b740defcc821c7c4d9e302e4cf837f Mon Sep 17 00:00:00 2001 From: Masonlet Date: Wed, 24 Jun 2026 17:30:48 -0400 Subject: [PATCH 30/34] refactor(commands): re-export build_repo_list, hoist_wraps, and wrap parsing functions from commands root --- src/commands/mod.rs | 5 +++-- src/utils/process.rs | 2 +- tests/commands/mono/mode.rs | 2 +- tests/commands/mono/wraps.rs | 5 +---- 4 files changed, 6 insertions(+), 8 deletions(-) diff --git a/src/commands/mod.rs b/src/commands/mod.rs index fd0c748..ad4de2c 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -3,7 +3,8 @@ pub mod header; pub mod mono; pub mod single; pub use mono::{ - create_mono_repo_cmakelists, create_mono_repo_mesonbuild, mono_repo_mode, resolve_repos_for_mono, - resolve_test_repo, + build_repo_list, create_mono_repo_cmakelists, create_mono_repo_mesonbuild, hoist_wraps, + mono_repo_mode, resolve_repos_for_mono, resolve_test_repo, + wraps::{parse_project_name, parse_provide_pairs}, }; pub use single::single_repo_mode; diff --git a/src/utils/process.rs b/src/utils/process.rs index 997eaa9..b810700 100644 --- a/src/utils/process.rs +++ b/src/utils/process.rs @@ -2,7 +2,7 @@ use std::{ io::{Read, Write}, path::Path, process::{Command, Stdio}, - thread + thread, }; /// Finds vcvars64.bat using vswhere.exe. diff --git a/tests/commands/mono/mode.rs b/tests/commands/mono/mode.rs index 1ba6cdc..502aff4 100644 --- a/tests/commands/mono/mode.rs +++ b/tests/commands/mono/mode.rs @@ -1,4 +1,4 @@ -use star_setup::commands::mono::build_repo_list; +use star_setup::commands::build_repo_list; #[test] fn test_build_repo_list_test_repo_first() { diff --git a/tests/commands/mono/wraps.rs b/tests/commands/mono/wraps.rs index dce62cf..23618b9 100644 --- a/tests/commands/mono/wraps.rs +++ b/tests/commands/mono/wraps.rs @@ -1,7 +1,4 @@ -use star_setup::commands::mono::{ - hoist_wraps, - wraps::{parse_project_name, parse_provide_pairs}, -}; +use star_setup::commands::{hoist_wraps, parse_project_name, parse_provide_pairs}; use tempfile::TempDir; #[test] From c40c7ef2852f64f2875a32e6e55ed6aff1156979 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Wed, 24 Jun 2026 17:40:25 -0400 Subject: [PATCH 31/34] fix(utils): normalize meson command check on Windows to handle path variants --- src/utils/process.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/utils/process.rs b/src/utils/process.rs index b810700..233d26f 100644 --- a/src/utils/process.rs +++ b/src/utils/process.rs @@ -87,8 +87,13 @@ pub fn run_command( command.env("GIT_SSH_COMMAND", "ssh -o BatchMode=yes"); } } + #[cfg(target_os = "windows")] - if cmd[0] == "meson" && std::env::var("VSINSTALLDIR").is_err() { + if std::path::Path::new(&cmd[0]) + .file_stem() + .map_or(false, |s| s.to_string_lossy().eq_ignore_ascii_case("meson")) + && std::env::var("VSINSTALLDIR").is_err() + { if let Ok(env) = get_msvc_env() { for (k, v) in env { command.env(k, v); From 07e1726cbe91557024c8c430e1e7c3510e887615 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Wed, 24 Jun 2026 17:45:24 -0400 Subject: [PATCH 32/34] chore: regenerate release.yml --- .github/workflows/release.yml | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e62a76f..77da8f2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -370,15 +370,3 @@ jobs: with: persist-credentials: false submodules: recursive - - - name: Trigger py-wrapper release - if: ${{ !github.event.pull_request }} - run: | - VERSION="${{ needs.plan.outputs.tag }}" - VERSION="${VERSION#v}" - gh api repos/star-setup/py-wrapper/dispatches \ - --method POST \ - --field event_type=core-release \ - --field "client_payload[version]=${VERSION}" - env: - GH_TOKEN: ${{ secrets.PY_WRAPPER_DISPATCH_TOKEN }} From 257ab57ad8a3f2f85f353f7379a91d813a87b603 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Wed, 24 Jun 2026 17:48:59 -0400 Subject: [PATCH 33/34] fix(ci): install meson --- .github/workflows/ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 99836f7..0654d00 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,5 +25,8 @@ jobs: - name: Clippy run: cargo clippy --all-targets -- -D warnings + - name: Install Meson + run: pip install meson + - name: Test run: cargo test From c4d0626348fb903ac4d821006dbc7ee00ee3bb0d Mon Sep 17 00:00:00 2001 From: Masonlet Date: Wed, 24 Jun 2026 17:51:32 -0400 Subject: [PATCH 34/34] fix(utils): use is_some_and instead of map_or --- src/utils/process.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/process.rs b/src/utils/process.rs index 233d26f..004778e 100644 --- a/src/utils/process.rs +++ b/src/utils/process.rs @@ -91,7 +91,7 @@ pub fn run_command( #[cfg(target_os = "windows")] if std::path::Path::new(&cmd[0]) .file_stem() - .map_or(false, |s| s.to_string_lossy().eq_ignore_ascii_case("meson")) + .is_some_and(|s| s.to_string_lossy().eq_ignore_ascii_case("meson")) && std::env::var("VSINSTALLDIR").is_err() { if let Ok(env) = get_msvc_env() {