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 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 }} 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 5a46469..c468de0 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,8 @@ # 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. +[![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) @@ -20,7 +21,9 @@ star-setup username/repo --repos user/lib1 user/lib2 ## Prerequisites - Git -- CMake +- At least one supported build system: + - CMake + - Meson ## Installation @@ -73,7 +76,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): Configure only (skip build)? (y/n) [N]: Interactive mode complete @@ -94,10 +96,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 +113,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 +170,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 diff --git a/src/cli.rs b/src/cli.rs deleted file mode 100644 index 113d027..0000000 --- a/src/cli.rs +++ /dev/null @@ -1,281 +0,0 @@ -//! Command-line argument parsing. - -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, -} - -/// 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, - - /// 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, - - #[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, -} - -/// 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] -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.cmake_flags.is_empty() { - args.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, - cmake_flags: args.cmake_flags, - yes: args.yes, - connection: ResolvedConnectionFlags { ssh, verbose }, - build: ResolvedBuildFlags { - build_type: args - .build - .build_type - .or_else(|| default.map(|e| e.build_type.clone())) - .unwrap_or_else(|| "Debug".to_string()), - build_dir: args - .build - .build_dir - .or_else(|| default.map(|e| e.build_dir.clone())) - .unwrap_or_else(|| "build".to_string()), - no_build, - clean, - }, - 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) - } -} diff --git a/src/cli/args.rs b/src/cli/args.rs new file mode 100644 index 0000000..ebec689 --- /dev/null +++ b/src/cli/args.rs @@ -0,0 +1,49 @@ +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. +#[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/build/detect.rs b/src/cli/build/detect.rs new file mode 100644 index 0000000..2928bc8 --- /dev/null +++ b/src/cli/build/detect.rs @@ -0,0 +1,58 @@ +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 +/// Returns an error on EOF during prompt, or if no supported build system is found. +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 { + 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) { + (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/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/flags.rs b/src/cli/flags.rs new file mode 100644 index 0000000..7bcd8d2 --- /dev/null +++ b/src/cli/flags.rs @@ -0,0 +1,111 @@ +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, + + /// 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)] +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/mod.rs b/src/cli/mod.rs new file mode 100644 index 0000000..fc7c2e5 --- /dev/null +++ b/src/cli/mod.rs @@ -0,0 +1,9 @@ +pub mod args; +pub use args::Args; +pub mod build; +pub mod flags; +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/cli/resolve.rs b/src/cli/resolve.rs new file mode 100644 index 0000000..c2c8dcd --- /dev/null +++ b/src/cli/resolve.rs @@ -0,0 +1,104 @@ +use crate::{ + cli::{ + args::Args, + build::BuildType, + resolved::{ResolvedArgs, ResolvedBuildFlags, ResolvedConnectionFlags, ResolvedMonoFlags}, + }, + 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. +#[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()); + } + if args.build.meson_flags.is_empty() { + args.build.meson_flags = default.map_or_else(Vec::new, |e| e.meson_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, + meson_flags: args.build.meson_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, + }) +} diff --git a/src/cli/resolved.rs b/src/cli/resolved.rs new file mode 100644 index 0000000..814c29d --- /dev/null +++ b/src/cli/resolved.rs @@ -0,0 +1,39 @@ +use crate::cli::{ + build::BuildType, + 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: BuildType, + pub build_dir: String, + pub no_build: bool, + pub clean: bool, + pub cmake_flags: Vec, + pub meson_flags: Vec, +} + +/// 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 yes: bool, + pub connection: ResolvedConnectionFlags, + pub build: ResolvedBuildFlags, + pub mono: ResolvedMonoFlags, + pub config: ConfigFlags, + pub profile: ProfileFlags, +} diff --git a/src/commands.rs b/src/commands.rs deleted file mode 100644 index 705be16..0000000 --- a/src/commands.rs +++ /dev/null @@ -1,368 +0,0 @@ -//! Command handlers for single and mono-repo modes. - -use crate::cli::ResolvedArgs; -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 std::fs; -use std::io::{BufRead, 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); - 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], - 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` -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()) - } -} - -/// 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/build.rs b/src/commands/build.rs new file mode 100644 index 0000000..57c2103 --- /dev/null +++ b/src/commands/build.rs @@ -0,0 +1,101 @@ +//! Build system dispatch and per-system build functions. + +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 +/// 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(()) +} + +/// 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/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..ad4de2c --- /dev/null +++ b/src/commands/mod.rs @@ -0,0 +1,10 @@ +pub mod build; +pub mod header; +pub mod mono; +pub mod single; +pub use mono::{ + 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/commands/mono/config.rs b/src/commands/mono/config.rs new file mode 100644 index 0000000..af6c85d --- /dev/null +++ b/src/commands/mono/config.rs @@ -0,0 +1,114 @@ +use crate::repository::repo_dir_name; +use std::{fs, io::Write, 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` +pub fn create_mono_repo_cmakelists( + mono_dir: &Path, + repos: &[String], + output: &mut impl Write, +) -> Result<(), String> { + writeln!(output, " Creating CMake configuration").ok(); + 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) + +set(MONO_REPO_MODULES + {modules_cmake} +) + +foreach(module IN LISTS MONO_REPO_MODULES) + 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() +endforeach() + +set_property(GLOBAL PROPERTY USE_FOLDERS ON) +set_property(GLOBAL PROPERTY PREDEFINED_TARGETS_FOLDER \"External\") +" + ) + }, + ) +} + +/// 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> { + writeln!(output, " Creating Meson configuration").ok(); + 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' +) + +modules = [ +{modules_meson}, +] + +foreach module : modules + subproject(module) +endforeach +" + ) + }, + ) +} diff --git a/src/commands/mono/mod.rs b/src/commands/mono/mod.rs new file mode 100644 index 0000000..e12ed36 --- /dev/null +++ b/src/commands/mono/mod.rs @@ -0,0 +1,8 @@ +pub mod config; +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 new file mode 100644 index 0000000..e2670ba --- /dev/null +++ b/src/commands/mono/mode.rs @@ -0,0 +1,185 @@ +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, +}; + +fn clone_mono_repos( + repos: &[String], + repos_path: &std::path::Path, + ssh: bool, + 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)?; + } + 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> { + writeln!(output, "Creating mono-repo configuration").ok(); + 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)) + } + } +} + +#[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()) + .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. +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")?; + let repo_input = repo_input.trim_end_matches('/'); + + let test_repo = resolve_test_repo(repo_input)?; + 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); + 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())?; + + clone_mono_repos( + &repos, + &repos_path, + args.connection.ssh, + args.connection.verbose, + output, + )?; + + let repo_dirs: Vec = repos + .iter() + .map(|r| repos_path.join(repo_dir_name(r))) + .collect(); + + let build_system = detect_mono_build_system(&repo_dirs, input, output)?; + + let canonical_map = generate_mono_config( + &build_system, + &mono_repo_path, + &repos_path, + &repo_dirs, + &repos, + output, + )?; + + 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(); + 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!( + output, + "Repositories in: {}", + dunce::canonicalize(&mono_repo_path) + .unwrap_or(mono_repo_path) + .display() + ) + .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") + } 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 new file mode 100644 index 0000000..6800ffa --- /dev/null +++ b/src/commands/mono/resolve.rs @@ -0,0 +1,80 @@ +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. +/// # 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()) + } +} diff --git a/src/commands/mono/wraps.rs b/src/commands/mono/wraps.rs new file mode 100644 index 0000000..d45662e --- /dev/null +++ b/src/commands/mono/wraps.rs @@ -0,0 +1,125 @@ +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. +#[must_use] +pub 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. +#[must_use] +pub 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 new file mode 100644 index 0000000..63de4b1 --- /dev/null +++ b/src/commands/single.rs @@ -0,0 +1,95 @@ +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 +/// 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, + 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 project\n").ok(); + build_project( + args, + build_path.as_path(), + Path::new(&dir_name), + false, + input, + output, + )?; + + writeln!( + output, + "Project finished in {dir_name}/{}", + args.build.build_dir + ) + .ok(); + Ok(()) +} diff --git a/src/config.rs b/src/config.rs deleted file mode 100644 index b3ca373..0000000 --- a/src/config.rs +++ /dev/null @@ -1,316 +0,0 @@ -//! Configuration file management. - -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, - /// `CMake` build type (e.g. `Debug`, `Release`). - pub build_type: String, - /// 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).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: "Debug".to_string(), - 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..b2ce294 --- /dev/null +++ b/src/config/crud.rs @@ -0,0 +1,177 @@ +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) { + 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![], + meson_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..2beb792 --- /dev/null +++ b/src/config/display.rs @@ -0,0 +1,36 @@ +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(); + } + } + 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/io.rs b/src/config/io.rs new file mode 100644 index 0000000..68457d2 --- /dev/null +++ b/src/config/io.rs @@ -0,0 +1,77 @@ +use crate::config::types::SetupConfig; +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 { + 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..c9e3223 --- /dev/null +++ b/src/config/types.rs @@ -0,0 +1,49 @@ +use crate::cli::build::BuildType; +use serde::{Deserialize, Serialize}; +use std::{collections::HashMap, 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, + /// Additional `Meson` arguments. + pub meson_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/interactive.rs b/src/interactive.rs index f9b4ece..083e1bc 100644 --- a/src/interactive.rs +++ b/src/interactive.rs @@ -1,58 +1,11 @@ //! Interactive CLI mode. -use crate::cli::ResolvedArgs; +use crate::{ + cli::{build::BuildType, ResolvedArgs}, + prompts::{ask, ask_default, ask_yesno}, +}; 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") }) -} - /// Interactive CLI mode — prompts for any unset arguments. /// # Errors /// Returns an error if stdin reaches EOF unexpectedly. @@ -134,16 +87,15 @@ 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() { - 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(); - } - } - if !args.build.no_build { args.build.no_build = ask_yesno("Configure only (skip build)?", false, input, output)?; } diff --git a/src/lib.rs b/src/lib.rs index c71ebea..2de6d82 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,5 +5,7 @@ pub mod commands; pub mod config; pub mod interactive; pub mod profiles; +pub mod prompts; pub mod repository; +pub mod run; pub mod utils; diff --git a/src/main.rs b/src/main.rs index 6a1fe64..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::{ - add_config, create_default_config, list_configs, load_config, remove_config, ConfigEntry, -}; -use star_setup::interactive::interactive_mode; -use star_setup::profiles::{add_profile, list_profiles, remove_profile}; -use star_setup::utils::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.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 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/profiles.rs b/src/profiles.rs index e278b5f..ba1484e 100644 --- a/src/profiles.rs +++ b/src/profiles.rs @@ -1,7 +1,9 @@ //! Profile management. -use crate::config::{save_config, SetupConfig}; -use crate::utils::confirm; +use crate::{ + config::{io::save_config, types::SetupConfig}, + prompts::confirm, +}; use std::io::{BufRead, Write}; /// Inserts or overwrites a named profile. @@ -20,6 +22,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 @@ -55,11 +65,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}" @@ -86,11 +92,7 @@ pub fn remove_profile( 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}'?"), @@ -111,13 +113,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; @@ -125,11 +124,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/prompts.rs b/src/prompts.rs new file mode 100644 index 0000000..4a7f4d4 --- /dev/null +++ b/src/prompts.rs @@ -0,0 +1,115 @@ +//! 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") }) +} + +/// 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); + } + } + } +} + +/// 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 02da3d2..e0450a5 100644 --- a/src/repository.rs +++ b/src/repository.rs @@ -1,8 +1,7 @@ //! Repository functions including cloning and URL resolution. -use crate::utils::run_command; -use std::io::Write; -use std::path::Path; +use crate::utils::process::run_command; +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 new file mode 100644 index 0000000..bf40843 --- /dev/null +++ b/src/run.rs @@ -0,0 +1,98 @@ +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 +/// 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(), + meson_flags: args.build.meson_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 io::stdin().is_terminal() { + interactive_mode(&mut args, &mut stdin, &mut 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(()) +} diff --git a/src/utils/mod.rs b/src/utils/mod.rs new file mode 100644 index 0000000..246925a --- /dev/null +++ b/src/utils/mod.rs @@ -0,0 +1,2 @@ +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 51% rename from src/utils.rs rename to src/utils/process.rs index 6995fcb..004778e 100644 --- a/src/utils.rs +++ b/src/utils/process.rs @@ -1,55 +1,61 @@ -//! Utility functions. +use std::{ + io::{Read, Write}, + path::Path, + process::{Command, Stdio}, + thread, +}; -use std::io::BufRead; -use std::io::Read; -use std::io::Write; -use std::path::Path; -use std::process::{Command, Stdio}; -use std::thread; +/// Finds vcvars64.bat using vswhere.exe. +/// Returns None if vswhere is not found or no VS installation exists. +#[cfg(target_os = "windows")] +fn find_vcvars() -> Option { + let vswhere = std::path::PathBuf::from( + std::env::var("ProgramFiles(x86)").unwrap_or_else(|_| r"C:\Program Files (x86)".to_string()), + ) + .join(r"Microsoft Visual Studio\Installer\vswhere.exe"); -/// 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); + if !vswhere.exists() { + return None; } - 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")) + 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) } -/// Checks if required tools are available on PATH. -/// Returns Result. +/// Runs vcvars64.bat and captures the resulting environment variables. /// # 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"] { - 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(()) +/// 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(), + ) } /// Runs a shell command with optional working directory. @@ -82,6 +88,19 @@ pub fn run_command( } } + #[cfg(target_os = "windows")] + if std::path::Path::new(&cmd[0]) + .file_stem() + .is_some_and(|s| s.to_string_lossy().eq_ignore_ascii_case("meson")) + && std::env::var("VSINSTALLDIR").is_err() + { + if let Ok(env) = get_msvc_env() { + for (k, v) in env { + command.env(k, v); + } + } + } + if verbose { command.stdout(Stdio::inherit()); command.stderr(Stdio::inherit()); diff --git a/tests/cli.rs b/tests/cli.rs index 4b7d675..ff28415 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -1,289 +1,4 @@ -use star_setup::cli::{ - resolve_bool, resolve_with_config, Args, BuildFlags, ConfigFlags, ConnectionFlags, MonoRepoFlags, - ProfileFlags, -}; -use star_setup::config::{ConfigEntry, SetupConfig}; - -#[test] -fn test_resolve_bool() { - 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, - cmake_flags: vec![], - 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, - }, - 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, "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: "Release".to_string(), - 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, "Release"); - assert_eq!(resolved.build.build_dir, "out"); - assert!(resolved.build.no_build); - assert!(resolved.build.clean); - assert_eq!(resolved.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: "Debug".to_string(), - 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, "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: "RelWithDebInfo".to_string(), - 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, "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: "Debug".to_string(), - 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.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"]); -} - -#[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: "Debug".to_string(), - 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/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..c1b7b60 --- /dev/null +++ b/tests/cli/build.rs @@ -0,0 +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)); +} 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 new file mode 100644 index 0000000..e67220a --- /dev/null +++ b/tests/cli/resolve.rs @@ -0,0 +1,296 @@ +use star_setup::cli::{ + build::BuildType, resolve_bool, resolve_with_config, Args, BuildFlags, ConfigFlags, + ConnectionFlags, MonoRepoFlags, ProfileFlags, +}; +use star_setup::config::types::{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()], + meson_flags: vec![], + }, + ); + 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![], + meson_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![], + meson_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()], + meson_flags: vec![], + }, + ); + 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![], + meson_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 8ec7bfe..4c007c5 100644 --- a/tests/commands.rs +++ b/tests/commands.rs @@ -1,171 +1,4 @@ -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/lib1".to_string(), "user/lib2".to_string()]; - create_mono_repo_cmakelists(tmp.path(), "user-testrepo", &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(); - create_mono_repo_cmakelists(tmp.path(), "user-testrepo", &[], &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()), - cmake_flags: vec![], - 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, - }, - 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/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")); +} diff --git a/tests/commands/mono.rs b/tests/commands/mono.rs new file mode 100644 index 0000000..0c64178 --- /dev/null +++ b/tests/commands/mono.rs @@ -0,0 +1,10 @@ +#[path = "../common/mod.rs"] +mod common; +#[path = "mono/config.rs"] +mod config; +#[path = "mono/mode.rs"] +mod mode; +#[path = "mono/resolve.rs"] +mod resolve; +#[path = "mono/wraps.rs"] +mod wraps; diff --git a/tests/commands/mono/config.rs b/tests/commands/mono/config.rs new file mode 100644 index 0000000..c82c63e --- /dev/null +++ b/tests/commands/mono/config.rs @@ -0,0 +1,57 @@ +use super::common::sink; +use star_setup::commands::{create_mono_repo_cmakelists, create_mono_repo_mesonbuild}; + +// create_mono_repo_cmakelists tests +#[test] +fn test_create_mono_repo_cmakelists_creates_file() { + 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()); +} + +// 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()); +} diff --git a/tests/commands/mono/mode.rs b/tests/commands/mono/mode.rs new file mode 100644 index 0000000..502aff4 --- /dev/null +++ b/tests/commands/mono/mode.rs @@ -0,0 +1,36 @@ +use star_setup::commands::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"]); +} diff --git a/tests/commands/mono/resolve.rs b/tests/commands/mono/resolve.rs new file mode 100644 index 0000000..105b1c8 --- /dev/null +++ b/tests/commands/mono/resolve.rs @@ -0,0 +1,145 @@ +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; + +// 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")); +} diff --git a/tests/commands/mono/wraps.rs b/tests/commands/mono/wraps.rs new file mode 100644 index 0000000..23618b9 --- /dev/null +++ b/tests/commands/mono/wraps.rs @@ -0,0 +1,141 @@ +use star_setup::commands::{hoist_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 1922de5..ef1106c 100644 --- a/tests/config.rs +++ b/tests/config.rs @@ -1,319 +1,10 @@ -use star_setup::config::{ - format_entry, has_config, insert_config, load_config, remove_config_entry, save_config, - ConfigEntry, SetupConfig, -}; -use std::path::PathBuf; +#[path = "common/mod.rs"] mod common; -use common::{empty_input, sink}; - -fn sample_entry() -> ConfigEntry { - ConfigEntry { - ssh: true, - build_type: "Release".to_string(), - 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: "Release".to_string(), - 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, "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"); - - star_setup::config::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()); - - star_setup::config::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(); - - star_setup::config::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(); - star_setup::config::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"; - star_setup::config::add_config( - &mut config, - "myconfig", - ConfigEntry { - ssh: false, // different from sample_entry's ssh: true - build_type: "Debug".to_string(), - 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"; - star_setup::config::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"; - star_setup::config::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(); - star_setup::config::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(); - star_setup::config::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")); -} +#[path = "config/crud.rs"] +mod crud; +#[path = "config/display.rs"] +mod display; +#[path = "config/fixtures.rs"] +mod fixtures; +#[path = "config/io.rs"] +mod io; diff --git a/tests/config/crud.rs b/tests/config/crud.rs new file mode 100644 index 0000000..34ebae3 --- /dev/null +++ b/tests/config/crud.rs @@ -0,0 +1,192 @@ +use super::{ + common::{empty_input, sink}, + fixtures::sample_entry, +}; +use star_setup::{ + cli::build::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}, + }, +}; + +#[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![], + meson_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..b8662cf --- /dev/null +++ b/tests/config/display.rs @@ -0,0 +1,29 @@ +use super::fixtures::sample_entry; +use star_setup::config::display::format_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..20d4f40 --- /dev/null +++ b/tests/config/fixtures.rs @@ -0,0 +1,15 @@ +use star_setup::{cli::build::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![], + meson_flags: vec![], + } +} diff --git a/tests/config/io.rs b/tests/config/io.rs new file mode 100644 index 0000000..2dda436 --- /dev/null +++ b/tests/config/io.rs @@ -0,0 +1,106 @@ +use super::{common::sink, fixtures::sample_entry}; +use star_setup::{ + cli::build::BuildType, + config::{ + crud::insert_config, + io::{load_config, save_config}, + types::{ConfigEntry, SetupConfig}, + }, +}; +use std::path::PathBuf; + +#[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![], + meson_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")); +} diff --git a/tests/interactive.rs b/tests/interactive.rs index 60a56ff..5463cfc 100644 --- a/tests/interactive.rs +++ b/tests/interactive.rs @@ -1,13 +1,12 @@ 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 { let args = Args { repo: None, - cmake_flags: vec![], yes: false, connection: ConnectionFlags { ssh: false, @@ -22,6 +21,8 @@ fn default_resolved() -> star_setup::cli::ResolvedArgs { build: false, 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,9 +129,28 @@ 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(); assert!(!args.connection.ssh); } + +#[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())); +} 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"], diff --git a/tests/prompts.rs b/tests/prompts.rs new file mode 100644 index 0000000..5d17ad9 --- /dev/null +++ b/tests/prompts.rs @@ -0,0 +1,69 @@ +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() { + 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"); +} + +#[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 b33a6db..724e05b 100644 --- a/tests/utils.rs +++ b/tests/utils.rs @@ -1,44 +1,6 @@ +#[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()); -} +#[path = "utils/prerequisites.rs"] +mod prerequisites; +#[path = "utils/process.rs"] +mod process; 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")); +} 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()); +}