diff --git a/Cargo.toml b/Cargo.toml index c30351e..9a0ca10 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "star-setup" -version = "0.4.1" +version = "0.4.2" edition = "2021" repository = "https://github.com/star-setup/core" description = "Lightweight CLI to clone, configure, and wire single or multi-repo ecosystems" diff --git a/src/cli/args.rs b/src/cli/args.rs index ef91b43..20208cb 100644 --- a/src/cli/args.rs +++ b/src/cli/args.rs @@ -21,6 +21,7 @@ pub enum Command { #[derive(Parser)] #[command( name = "star-setup", + version, about = "Lightweight CLI to clone, configure, and wire single or multi-repo ecosystems", long_about = None, )] @@ -28,14 +29,14 @@ 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, - /// Select a named configuration to use #[arg(long = "config")] pub config_name: Option, + /// Skip confirmation prompts (non-interactive mode) + #[arg(short = 'y', long)] + pub yes: bool, + #[command(subcommand)] pub command: Option, diff --git a/src/cli/build/detect.rs b/src/cli/build/detect.rs index 0318a84..9493c5b 100644 --- a/src/cli/build/detect.rs +++ b/src/cli/build/detect.rs @@ -28,15 +28,24 @@ fn pick_build_system( /// # Errors /// Returns an error on EOF during prompt, or if no supported build system is found. pub fn detect_build_system(dir: &Path, ctx: &mut RunCtx<'_, '_>) -> Result { - crate::time!(ctx.flags.timing, ctx.io.output, "Detect", { + crate::time!(ctx.flags.timing, ctx.io.output, "Scanned directory", { let mut detected = Vec::new(); if dir.join("CMakeLists.txt").exists() { + if ctx.flags.verbose { + writeln!(ctx.io.output, " Found CMakeLists.txt").ok(); + } detected.push(BuildSystem::Cmake); } if dir.join("meson.build").exists() { + if ctx.flags.verbose { + writeln!(ctx.io.output, " Found meson.build").ok(); + } detected.push(BuildSystem::Meson); } if dir.join("package.json").exists() { + if ctx.flags.verbose { + writeln!(ctx.io.output, " Found package.json").ok(); + } detected.push(BuildSystem::Npm); } pick_build_system(&detected, "No supported build system found", ctx) @@ -50,16 +59,24 @@ pub fn detect_mono_build_system( dirs: &[PathBuf], ctx: &mut RunCtx<'_, '_>, ) -> Result { - writeln!(ctx.io.output, "Detecting build system\n").ok(); - crate::time!(ctx.flags.timing, ctx.io.output, "Detect", { + crate::time!(ctx.flags.timing, ctx.io.output, "Scanned directories", { let mut detected = Vec::new(); if dirs.iter().all(|d| d.join("CMakeLists.txt").exists()) { + if ctx.flags.verbose { + writeln!(ctx.io.output, " Found CMakeLists.txt").ok(); + } detected.push(BuildSystem::Cmake); } if dirs.iter().all(|d| d.join("meson.build").exists()) { + if ctx.flags.verbose { + writeln!(ctx.io.output, " Found meson.build").ok(); + } detected.push(BuildSystem::Meson); } if dirs.iter().all(|d| d.join("package.json").exists()) { + if ctx.flags.verbose { + writeln!(ctx.io.output, " Found package.json").ok(); + } detected.push(BuildSystem::Npm); } pick_build_system( diff --git a/src/cli/commands.rs b/src/cli/commands.rs index 50ca16f..0036324 100644 --- a/src/cli/commands.rs +++ b/src/cli/commands.rs @@ -1,7 +1,7 @@ use std::path::PathBuf; use crate::cli::{BuildFlags, ConnectionFlags, DiagnosticFlags, MonoRepoFlags}; -use clap::{Parser, Subcommand}; +use clap::{Args as ClapArgs, Parser, Subcommand}; /// Config subcommand. #[derive(Parser)] @@ -70,38 +70,35 @@ pub struct WorkspaceCommand { pub action: WorkspaceAction, } -/// Workspace subcommand actions. +#[derive(ClapArgs)] +pub struct WorkspaceTarget { + /// Workspace root directory (default: current directory). + #[arg(long)] + pub path: Option, + /// Mono-repo workspace directory name (default: build-mono). + #[arg(long)] + pub mono_dir: Option, + #[arg(long)] + pub build_dir: Option, +} + #[derive(Subcommand)] pub enum WorkspaceAction { /// Pull latest changes for all repos in the workspace. Update { - /// Workspace root directory (default: current directory). - #[arg(long)] - path: Option, - /// Mono-repo workspace directory name (default: build-mono). - #[arg(long)] - mono_dir: Option, - #[arg(long)] - build_dir: Option, + #[command(flatten)] + target: WorkspaceTarget, }, /// Show status of all repos in the workspace. Status { - #[arg(long)] - path: Option, - #[arg(long)] - mono_dir: Option, - #[arg(long)] - build_dir: Option, + #[command(flatten)] + target: WorkspaceTarget, #[arg(long)] fetch: bool, }, /// Remove the build directory from the workspace. Clean { - #[arg(long)] - path: Option, - #[arg(long)] - mono_dir: Option, - #[arg(long)] - build_dir: Option, + #[command(flatten)] + target: WorkspaceTarget, }, } diff --git a/src/cli/flags.rs b/src/cli/flags.rs index caeeedd..a1c79ed 100644 --- a/src/cli/flags.rs +++ b/src/cli/flags.rs @@ -3,6 +3,7 @@ use clap::Args as ClapArgs; #[allow(clippy::struct_excessive_bools)] #[derive(ClapArgs)] +#[command(next_help_heading = "Connection")] pub struct ConnectionFlags { /// Use SSH instead of HTTPS for cloning #[arg(long, conflicts_with = "https")] @@ -14,9 +15,10 @@ pub struct ConnectionFlags { #[allow(clippy::struct_excessive_bools)] #[derive(ClapArgs)] +#[command(next_help_heading = "Build")] pub struct BuildFlags { /// Build type - #[arg(short = 'b', long)] + #[arg(long)] pub build_type: Option, /// Build directory name @@ -27,8 +29,8 @@ pub struct BuildFlags { #[arg(long, value_name = "BUILD_SYSTEM")] pub build_system: Option, - // Build after configuring (overrides config) - #[arg(long, conflicts_with = "no_build")] + /// Build after configuring (overrides config) + #[arg(short = 'b', long, conflicts_with = "no_build")] pub build: bool, /// Skip building, only configure #[arg(short = 'n', long, conflicts_with = "build")] @@ -58,6 +60,7 @@ pub struct BuildFlags { } #[derive(ClapArgs)] +#[command(next_help_heading = "Mono-repo")] pub struct MonoRepoFlags { /// Mono-repo mode #[arg(long)] @@ -72,12 +75,13 @@ pub struct MonoRepoFlags { pub repos: Option>, /// Use saved profile for library repositories - #[arg(long, conflicts_with = "repos")] + #[arg(short = 'p', long, conflicts_with = "repos")] pub profile: Option, } #[allow(clippy::struct_excessive_bools)] #[derive(ClapArgs)] +#[command(next_help_heading = "Diagnostics")] pub struct DiagnosticFlags { /// Show detailed command output #[arg(short = 'v', long, conflicts_with = "no_verbose")] @@ -97,6 +101,6 @@ pub struct DiagnosticFlags { #[arg(long)] pub dry_run: bool, /// Do not use dry-run mode (overrides config) - #[arg(long, conflicts_with = "no_dry_run")] + #[arg(long, conflicts_with = "dry_run")] pub no_dry_run: bool, } diff --git a/src/commands/build.rs b/src/commands/build.rs index 9578d71..bb596af 100644 --- a/src/commands/build.rs +++ b/src/commands/build.rs @@ -30,7 +30,7 @@ pub fn cmake_build( }); if !args.build.no_build { - writeln!(ctx.io.output, "Building project\n").ok(); + writeln!(ctx.io.output, "Building project").ok(); crate::time!(ctx.flags.timing, ctx.io.output, "CMake build", { ctx.runner.run( &[ @@ -75,7 +75,7 @@ pub fn meson_build( ctx.runner.run(&meson_cmd, None, ctx.flags, ctx.io.output)?; }); if !args.build.no_build { - writeln!(ctx.io.output, "Building project\n").ok(); + writeln!(ctx.io.output, "Building project").ok(); crate::time!(ctx.flags.timing, ctx.io.output, "Meson compile", { ctx.runner.run( &["meson", "compile", "-C", to_str(build_path)?], @@ -97,6 +97,7 @@ pub fn npm_build( is_mono: bool, ctx: &mut RunCtx<'_, '_>, ) -> Result<(), String> { + writeln!(ctx.io.output, "Installing dependencies").ok(); crate::time!(ctx.flags.timing, ctx.io.output, "npm install", { ctx.runner.run( &["npm", "install"], @@ -106,7 +107,7 @@ pub fn npm_build( )?; }); if !args.build.no_build && !is_mono { - writeln!(ctx.io.output, "Building project\n").ok(); + writeln!(ctx.io.output, "Building project").ok(); crate::time!(ctx.flags.timing, ctx.io.output, "npm build", { ctx.runner.run( &["npm", "run", "build"], @@ -130,9 +131,11 @@ pub fn build_project( mono: bool, ctx: &mut RunCtx<'_, '_>, ) -> Result<(), String> { - match build_system { + let result = match build_system { BuildSystem::Cmake => cmake_build(args, build_path, mono, ctx), BuildSystem::Meson => meson_build(args, build_path, source_path, ctx), BuildSystem::Npm => npm_build(args, source_path, mono, ctx), - } + }; + writeln!(ctx.io.output).ok(); + result } diff --git a/src/commands/handlers.rs b/src/commands/handlers.rs index a00753e..7422c72 100644 --- a/src/commands/handlers.rs +++ b/src/commands/handlers.rs @@ -63,36 +63,32 @@ pub fn handle_profile_cmd( /// # Errors /// Returns an error if resolving, updating, cleaning, or fetching status for the workspace fails. pub fn handle_workspace_cmd( - action: WorkspaceAction, - io: IoCtx, + action: &WorkspaceAction, + mut io: IoCtx, flags: RunFlags, ) -> Result<(), Box> { + let target = match &action { + WorkspaceAction::Update { target } + | WorkspaceAction::Clean { target } + | WorkspaceAction::Status { target, .. } => target, + }; + let ws = resolve_workspace( + target.path.as_deref(), + target.mono_dir.as_deref(), + target.build_dir.as_deref(), + &mut io, + flags.verbose, + )?; + match action { - WorkspaceAction::Update { - path, - mono_dir, - build_dir, - } => { - let ws = resolve_workspace(path.as_deref(), mono_dir.as_deref(), build_dir.as_deref())?; - with_runner(io, flags, |ctx| ws.update(ctx).map_err(Into::into))?; + WorkspaceAction::Update { .. } => { + with_runner(io, flags, |ctx| ws.update(ctx).map_err(Into::into)) } - WorkspaceAction::Status { - path, - mono_dir, - build_dir, - fetch, - } => { - let ws = resolve_workspace(path.as_deref(), mono_dir.as_deref(), build_dir.as_deref())?; - with_runner(io, flags, |ctx| ws.status(fetch, ctx).map_err(Into::into))?; + WorkspaceAction::Status { fetch, .. } => { + with_runner(io, flags, |ctx| ws.status(*fetch, ctx).map_err(Into::into)) } - WorkspaceAction::Clean { - path, - mono_dir, - build_dir, - } => { - let ws = resolve_workspace(path.as_deref(), mono_dir.as_deref(), build_dir.as_deref())?; - with_runner(io, flags, |ctx| ws.clean(ctx).map_err(Into::into))?; + WorkspaceAction::Clean { .. } => { + with_runner(io, flags, |ctx| ws.clean(ctx).map_err(Into::into)) } } - Ok(()) } diff --git a/src/commands/header.rs b/src/commands/header.rs index 5d285c5..3f8b4fd 100644 --- a/src/commands/header.rs +++ b/src/commands/header.rs @@ -11,6 +11,7 @@ pub struct ModeHeader<'a> { pub mono_dir: Option<&'a str>, pub profile: Option<&'a str>, pub lib_count: Option, + pub repo_count: Option, } /// Prints a formatted header summarizing the current mode and configuration. @@ -36,5 +37,8 @@ pub fn print_mode_header(header: &ModeHeader<'_>, io: &mut IoCtx<'_>) { if let Some(c) = header.lib_count { writeln!(io.output, " Libraries: {c}").ok(); } + if let Some(c) = header.repo_count { + writeln!(io.output, " Total repositories: {c}").ok(); + } writeln!(io.output).ok(); } diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 962e294..2e1b928 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -12,6 +12,6 @@ pub use mono::{ pub mod single; pub use single::single_repo_mode; pub mod setup; -pub use setup::{configure_and_build, extract_repo_input, prepare_build_dir}; +pub use setup::{extract_repo_input, prepare_build_dir}; pub mod handlers; pub use handlers::{handle_config_cmd, handle_profile_cmd, handle_workspace_cmd}; diff --git a/src/commands/mono/clone.rs b/src/commands/mono/clone.rs index 2c759f1..1a595fd 100644 --- a/src/commands/mono/clone.rs +++ b/src/commands/mono/clone.rs @@ -12,15 +12,26 @@ pub fn clone_mono_repos( writeln!(ctx.io.output, "Cloning repositories").ok(); crate::time!(ctx.flags.timing, ctx.io.output, "Clone", { for repo in repos { - clone_repository(repo, repos_path, ssh, ctx)?; + if ctx.flags.verbose { + writeln!( + ctx.io.output, + " Cloning {}", + crate::repository::repo_dir_name(repo) + ) + .ok(); + } + clone_repository(repo, repos_path, ssh, true, false, ctx)?; + } + if ctx.flags.verbose { + writeln!( + ctx.io.output, + " Finished cloning ({} repositories)", + repos.len() + ) + .ok(); } Ok::<(), String>(()) })?; - writeln!( - ctx.io.output, - "\n Finished cloning ({} repositories)\n", - repos.len() - ) - .ok(); + writeln!(ctx.io.output).ok(); Ok(()) } diff --git a/src/commands/mono/config.rs b/src/commands/mono/config.rs index 11e9b6a..52a90ac 100644 --- a/src/commands/mono/config.rs +++ b/src/commands/mono/config.rs @@ -1,6 +1,7 @@ use crate::{ ctx::{IoCtx, RunFlags}, repository::repo_dir_name, + utils::dry_run_or_do, }; use std::{fs, path::Path}; @@ -19,19 +20,38 @@ fn write_mono_repo_config( let content = render_template(&modules_str); let file_path = mono_dir.join(filename); - crate::time!(flags.timing, io.output, &format!("Generate {filename}"), { - fs::write(&file_path, content).map_err(|e| e.to_string())?; - }); + dry_run_or_do( + "create file", + "Creating", + &file_path, + io, + flags, + &format!("Generate {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!( - io.output, - "Created root {} at {}\n", - filename.to_string(), - mono_dir.display() - ) - .ok(); + if flags.dry_run { + if flags.verbose { + #[allow(clippy::to_string_in_format_args)] + writeln!( + io.output, + " Would create root {} at {}\n", + filename.to_string(), + mono_dir.display() + ) + .ok(); + } + } else { + #[allow(clippy::to_string_in_format_args)] + writeln!( + io.output, + " Created root {} at {}\n", + filename.to_string(), + mono_dir.display() + ) + .ok(); + } Ok(()) } @@ -187,18 +207,33 @@ pub fn create_mono_repo_package_json( ); let file_path = mono_dir.join("package.json"); - crate::time!(flags.timing, io.output, "Generate package.json", { - fs::write(&file_path, content).map_err(|e| e.to_string())?; - }); - - #[allow(clippy::to_string_in_format_args)] - writeln!( - io.output, - "Created root {} at {}\n", - "package.json".to_string(), - mono_dir.display() - ) - .ok(); + dry_run_or_do( + "create file", + "Creating", + &file_path, + io, + flags, + "Generate package.json", + || fs::write(&file_path, content).map_err(|e| e.to_string()), + )?; + + if flags.dry_run { + if flags.verbose { + writeln!( + io.output, + " Would create root package.json at {}\n", + mono_dir.display() + ) + .ok(); + } + } else { + writeln!( + io.output, + " Created root package.json at {}\n", + mono_dir.display() + ) + .ok(); + } Ok(()) } diff --git a/src/commands/mono/display.rs b/src/commands/mono/display.rs index a37304e..69aa62e 100644 --- a/src/commands/mono/display.rs +++ b/src/commands/mono/display.rs @@ -71,20 +71,20 @@ pub fn print_setup_complete( io: &mut IoCtx<'_>, flags: RunFlags, ) { - writeln!(io.output, "Setup complete").ok(); + writeln!(io.output, " Setup complete").ok(); writeln!( io.output, - "Repositories in: {}", + " Repositories in: {}", paths.mono_repo_disp.display() ) .ok(); if let Some(exe) = &paths.exe_path { - writeln!(io.output, "Executable: {}", exe.display()).ok(); + writeln!(io.output, " Executable: {}", exe.display()).ok(); } if let Some(build) = &paths.build_disp { - writeln!(io.output, "Build output in: {}", build.display()).ok(); + writeln!(io.output, " Build output in: {}", build.display()).ok(); } if flags.timing { - writeln!(io.output, "[timing] Total: {:.2?}", total.elapsed()).ok(); + writeln!(io.output, " [timing] Total: {:.2?}", total.elapsed()).ok(); } } diff --git a/src/commands/mono/mode.rs b/src/commands/mono/mode.rs index f9b0e36..b7265a7 100644 --- a/src/commands/mono/mode.rs +++ b/src/commands/mono/mode.rs @@ -1,17 +1,18 @@ use crate::{ cli::{detect_mono_build_system, BuildSystem, ResolvedArgs}, commands::{ - build_repo_list, configure_and_build, extract_repo_input, + build_project, build_repo_list, extract_repo_input, mono::{ clone_mono_repos, display::{resolve_setup_paths, SetupPaths}, generate_mono_config, generate_watch_scripts, open_watch_scripts, print_setup_complete, }, - prepare_build_dir, resolve_repos_for_mono, resolve_test_repo, + prepare_build_dir, print_mode_header, resolve_repos_for_mono, resolve_test_repo, ModeHeader, }, config::SetupConfig, ctx::RunCtx, repository::repo_dir_name, + utils::{dry_run::detect_or_dry_run, dry_run_or_do}, }; use std::{ fs, @@ -28,26 +29,45 @@ pub fn mono_repo_mode( ctx: &mut RunCtx<'_, '_>, ) -> Result<(), String> { let total = std::time::Instant::now(); - let repo_input = extract_repo_input(args)?; let test_repo = resolve_test_repo(repo_input)?; - let deps = resolve_repos_for_mono(args, config, &test_repo, &mut ctx.io)?; + let deps = resolve_repos_for_mono(args, config, &mut ctx.io)?; let repos = build_repo_list(&test_repo, &deps); - writeln!(ctx.io.output, "Total repositories: {}\n", repos.len()).ok(); + + print_mode_header( + &ModeHeader { + mode: if args.mono.profile.is_some() { + "Profile" + } else { + "Mono-repository" + }, + test_repo: Some(&test_repo), + repo_name: None, + use_ssh: args.connection.ssh, + mono_dir: Some(&args.mono.mono_dir), + profile: args.mono.profile.as_deref(), + lib_count: Some(deps.len()), + repo_count: Some(repos.len()), + }, + &mut ctx.io, + ); let mono_repo_path = base_dir.join(&args.mono.mono_dir); let repos_path = mono_repo_path.join("repos"); - if ctx.flags.dry_run { - writeln!( - ctx.io.output, - "Would create directory: {}", - repos_path.display() - ) - .ok(); - } else { - crate::time!(ctx.flags.timing, ctx.io.output, "Create directory", { - fs::create_dir_all(&repos_path).map_err(|e| e.to_string())?; - }); + if ctx.flags.verbose { + writeln!(ctx.io.output, "Creating directory").ok(); + } + dry_run_or_do( + "create directory", + "Creating", + &repos_path, + &mut ctx.io, + ctx.flags, + "Create directory", + || fs::create_dir_all(&repos_path).map_err(|e| e.to_string()), + )?; + if ctx.flags.verbose { + writeln!(ctx.io.output).ok(); } clone_mono_repos(&repos, &repos_path, args.connection.ssh, ctx)?; @@ -58,32 +78,29 @@ pub fn mono_repo_mode( .collect(); let build_path = mono_repo_path.join(&args.build.build_dir); - - let build_system = if let Some(bs) = args.build.build_system { - Some(bs) - } else if !ctx.flags.dry_run { - Some(detect_mono_build_system(&repo_dirs, ctx)?) - } else { - None - }; + let build_system = detect_or_dry_run(args.build.build_system, ctx, |ctx| { + detect_mono_build_system(&repo_dirs, ctx) + })?; let canonical_map = if let Some(bs) = build_system { let map = generate_mono_config(bs, &mono_repo_path, &repos_path, &repo_dirs, &repos, ctx)?; if bs != BuildSystem::Npm { prepare_build_dir(build_path.as_path(), args.build.clean, ctx)?; + } else if args.build.clean && ctx.flags.verbose { + writeln!(ctx.io.output, " --clean has no effect for npm projects").ok(); } - configure_and_build(args, &mono_repo_path, &build_path, bs, true, ctx)?; + build_project(args, &build_path, &mono_repo_path, bs, true, ctx)?; map } else { - prepare_build_dir(build_path.as_path(), args.build.clean, ctx)?; None }; - if build_system == Some(BuildSystem::Npm) && !args.build.no_watch && !ctx.flags.dry_run { - generate_watch_scripts(&mono_repo_path, &repos_path, &repos, &mut ctx.io, ctx.flags)?; - if args.build.watch { - open_watch_scripts(&mono_repo_path, &mut ctx.io)?; - } + if build_system == Some(BuildSystem::Npm) + && !args.build.no_watch + && generate_watch_scripts(&mono_repo_path, &repos_path, &repos, &mut ctx.io, ctx.flags)? + && args.build.watch + { + open_watch_scripts(&mono_repo_path, &mut ctx.io, ctx.flags)?; } let paths = if ctx.flags.dry_run { @@ -106,6 +123,15 @@ pub fn mono_repo_mode( ) }; - print_setup_complete(&paths, total, &mut ctx.io, ctx.flags); + if ctx.flags.dry_run || build_system.is_none() { + writeln!( + ctx.io.output, + "Would finish setup in {}", + paths.mono_repo_disp.display() + ) + .ok(); + } else { + print_setup_complete(&paths, total, &mut ctx.io, ctx.flags); + } Ok(()) } diff --git a/src/commands/mono/resolve.rs b/src/commands/mono/resolve.rs index 665b26b..17ae900 100644 --- a/src/commands/mono/resolve.rs +++ b/src/commands/mono/resolve.rs @@ -1,10 +1,4 @@ -use crate::{ - cli::ResolvedArgs, - commands::{print_mode_header, ModeHeader}, - config::SetupConfig, - ctx::IoCtx, - profile::list_profiles, -}; +use crate::{cli::ResolvedArgs, config::SetupConfig, ctx::IoCtx, profile::list_profiles}; /// Resolves the list of repositories for mono-repo mode from a profile or explicit repo list. /// # Errors @@ -12,7 +6,6 @@ use crate::{ pub fn resolve_repos_for_mono( args: &ResolvedArgs, config: &SetupConfig, - test_repo: &str, io: &mut IoCtx<'_>, ) -> Result, String> { if let Some(profile_name) = &args.mono.profile { @@ -23,32 +16,8 @@ pub fn resolve_repos_for_mono( 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()), - }, - io, - ); 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()), - }, - io, - ); Ok(r.clone()) } else { Err("No repos or profile specified for mono-repo mode".to_string()) diff --git a/src/commands/mono/setup.rs b/src/commands/mono/setup.rs index 52c8c31..e409549 100644 --- a/src/commands/mono/setup.rs +++ b/src/commands/mono/setup.rs @@ -20,7 +20,7 @@ pub fn generate_mono_config( repos: &[String], ctx: &mut RunCtx<'_, '_>, ) -> Result>, String> { - writeln!(ctx.io.output, "Creating mono-repo configuration").ok(); + writeln!(ctx.io.output, " Creating mono-repo configuration").ok(); match build_system { BuildSystem::Cmake => { create_mono_repo_cmakelists(mono_repo_path, repos, &mut ctx.io, ctx.flags)?; diff --git a/src/commands/mono/watch.rs b/src/commands/mono/watch.rs index e79b77a..1c4e312 100644 --- a/src/commands/mono/watch.rs +++ b/src/commands/mono/watch.rs @@ -1,6 +1,7 @@ use crate::{ ctx::{IoCtx, RunFlags}, repository::repo_dir_name, + utils::dry_run_or_do, }; use std::{fs, path::Path}; @@ -64,11 +65,11 @@ pub fn generate_watch_scripts( repos: &[String], io: &mut IoCtx<'_>, flags: RunFlags, -) -> Result<(), String> { +) -> Result { let lib_dirs: Vec = repos.iter().skip(1).map(|r| repo_dir_name(r)).collect(); if lib_dirs.is_empty() { - return Ok(()); + return Ok(false); } let ps1_lines: Vec = lib_dirs @@ -97,20 +98,42 @@ pub fn generate_watch_scripts( sh_lines.join("\n") ); - fs::write(mono_dir.join("watch.ps1"), ps1_content) - .map_err(|e| format!("Failed to write watch.ps1: {e}"))?; - fs::write(mono_dir.join("watch.sh"), sh_content) - .map_err(|e| format!("Failed to write watch.sh: {e}"))?; + dry_run_or_do( + "write watch scripts", + "Writing", + mono_dir, + io, + flags, + "Write scripts", + || { + fs::write(mono_dir.join("watch.ps1"), ps1_content) + .map_err(|e| format!("Failed to write watch.ps1: {e}"))?; + fs::write(mono_dir.join("watch.sh"), sh_content) + .map_err(|e| format!("Failed to write watch.sh: {e}"))?; + Ok(()) + }, + )?; - writeln!( - io.output, - "Generated watch scripts at {}", - mono_dir.display() - ) - .ok(); + if flags.dry_run { + if flags.verbose { + writeln!( + io.output, + " Would generate watch scripts at {}", + mono_dir.display() + ) + .ok(); + } + } else { + writeln!( + io.output, + " Generated watch scripts at {}", + mono_dir.display() + ) + .ok(); + } if flags.verbose { - writeln!(io.output, "Watching {} libraries:", lib_dirs.len()).ok(); + writeln!(io.output, " Watching {} libraries:", lib_dirs.len()).ok(); for d in &lib_dirs { let full_path = dunce::canonicalize(repos_path.join(d)).unwrap_or_else(|_| repos_path.join(d)); @@ -118,36 +141,49 @@ pub fn generate_watch_scripts( } } - Ok(()) + Ok(true) } /// Opens watch scripts in new terminals. /// # Errors /// Returns an error if the terminal cannot be opened. -pub fn open_watch_scripts(mono_dir: &Path, io: &mut IoCtx<'_>) -> Result<(), String> { - #[cfg(target_os = "windows")] - { - let ps1_path = mono_dir.join("watch.ps1"); - std::process::Command::new("powershell") - .args([ - "-ExecutionPolicy", - "Bypass", - "-File", - ps1_path.to_str().ok_or("Invalid path")?, - ]) - .spawn() - .map_err(|e| format!("Failed to open watch.ps1: {e}"))?; +pub fn open_watch_scripts( + mono_dir: &Path, + io: &mut IoCtx<'_>, + flags: RunFlags, +) -> Result<(), String> { + if flags.dry_run { + if flags.verbose { + writeln!(io.output, " Would open watch scripts").ok(); + } + return Ok(()); } - #[cfg(not(target_os = "windows"))] - { - let sh_path = mono_dir.join("watch.sh"); - std::process::Command::new("bash") - .arg(sh_path.to_str().ok_or("Invalid path")?) - .spawn() - .map_err(|e| format!("Failed to open watch.sh: {e}"))?; - } + crate::time!(flags.timing, io.output, "Open", { + #[cfg(target_os = "windows")] + { + let ps1_path = mono_dir.join("watch.ps1"); + std::process::Command::new("powershell") + .args([ + "-ExecutionPolicy", + "Bypass", + "-File", + ps1_path.to_str().ok_or("Invalid path")?, + ]) + .spawn() + .map_err(|e| format!("Failed to open watch.ps1: {e}"))?; + } - writeln!(io.output, "Opening watch scripts").ok(); + #[cfg(not(target_os = "windows"))] + { + let sh_path = mono_dir.join("watch.sh"); + std::process::Command::new("bash") + .arg(sh_path.to_str().ok_or("Invalid path")?) + .spawn() + .map_err(|e| format!("Failed to open watch.sh: {e}"))?; + } + Ok::<(), String>(()) + })?; + writeln!(io.output, " Opening watch scripts").ok(); Ok(()) } diff --git a/src/commands/mono/wraps.rs b/src/commands/mono/wraps.rs index bbd0552..9379fe7 100644 --- a/src/commands/mono/wraps.rs +++ b/src/commands/mono/wraps.rs @@ -4,7 +4,10 @@ use std::{ path::{Path, PathBuf}, }; -use crate::ctx::{IoCtx, RunFlags}; +use crate::{ + ctx::{IoCtx, RunFlags}, + utils::dry_run_or_do, +}; /// Parses the `project()` name from `meson.build` content. /// Returns the name with hyphens replaced by underscores, or `None` if not found. @@ -116,12 +119,30 @@ pub fn hoist_wraps( 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!( - io.output, - " Generated wrap: {canonical_name}.wrap -> {dir_name}" - ) - .ok(); + dry_run_or_do( + "create file", + "Creating", + &wrap_path, + io, + flags, + "Write wrap", + || fs::write(&wrap_path, &wrap_content).map_err(|e| e.to_string()), + )?; + if flags.dry_run { + if flags.verbose { + writeln!( + io.output, + " Would generate wrap: {canonical_name}.wrap -> {dir_name}" + ) + .ok(); + } + } else { + writeln!( + io.output, + " Generated wrap: {canonical_name}.wrap -> {dir_name}" + ) + .ok(); + } } Ok(project_to_dir) diff --git a/src/commands/setup.rs b/src/commands/setup.rs index e83ffca..f4115ea 100644 --- a/src/commands/setup.rs +++ b/src/commands/setup.rs @@ -1,9 +1,5 @@ -use crate::{ - cli::{BuildSystem, ResolvedArgs}, - commands::build_project, - ctx::RunCtx, -}; -use std::{fs, path::Path}; +use crate::{cli::ResolvedArgs, ctx::RunCtx, utils::dry_run_or_do}; +use std::fs; /// Prepares the build directory, optionally cleaning it first. /// # Errors @@ -13,52 +9,45 @@ pub fn prepare_build_dir( clean: bool, ctx: &mut RunCtx<'_, '_>, ) -> Result<(), String> { - if clean && ctx.flags.dry_run { - writeln!(ctx.io.output, "Cleaning build directory\n").ok(); - writeln!( - ctx.io.output, - "Would remove directory: {}", - build_path.display() - ) - .ok(); - } else if clean && build_path.exists() { - writeln!(ctx.io.output, "Cleaning build directory\n").ok(); - crate::time!(ctx.flags.timing, ctx.io.output, "Clean", { - fs::remove_dir_all(build_path).map_err(|e| e.to_string())?; - }); + if clean { + writeln!(ctx.io.output, "Cleaning build directory").ok(); + dry_run_or_do( + "remove directory", + "Removing", + build_path, + &mut ctx.io, + ctx.flags, + "Clean", + || { + if build_path.exists() { + fs::remove_dir_all(build_path).map_err(|e| e.to_string()) + } else { + Ok(()) + } + }, + )?; + if ctx.flags.verbose { + writeln!(ctx.io.output, " Finished cleaning\n").ok(); + } } - writeln!(ctx.io.output, "Creating build directory\n").ok(); - if ctx.flags.dry_run { - writeln!( - ctx.io.output, - "Would create directory: {}", - build_path.display() - ) - .ok(); - } else { - crate::time!(ctx.flags.timing, ctx.io.output, "Create build directory", { - fs::create_dir_all(build_path).map_err(|e| e.to_string())?; - }); + writeln!(ctx.io.output, "Creating build directory").ok(); + dry_run_or_do( + "create directory", + "Creating", + build_path, + &mut ctx.io, + ctx.flags, + "Create build directory", + || fs::create_dir_all(build_path).map_err(|e| e.to_string()), + )?; + + if ctx.flags.verbose { + writeln!(ctx.io.output, " Finished creating\n").ok(); } Ok(()) } -/// Detects the build system and runs configuration and optional build. -/// # Errors -/// Returns an error if detection or build fails. -pub fn configure_and_build( - args: &ResolvedArgs, - project_path: &Path, - build_path: &Path, - build_system: BuildSystem, - is_mono: bool, - ctx: &mut RunCtx<'_, '_>, -) -> Result<(), String> { - writeln!(ctx.io.output, "Configuring project\n").ok(); - build_project(args, build_path, project_path, build_system, is_mono, ctx) -} - /// Extracts and sanitizes the repository input from args. /// # Errors /// Returns an error if no repository is specified. diff --git a/src/commands/single.rs b/src/commands/single.rs index 1a28692..46f3a0e 100644 --- a/src/commands/single.rs +++ b/src/commands/single.rs @@ -1,11 +1,9 @@ use crate::{ cli::{detect_build_system, BuildSystem, ResolvedArgs}, - commands::{ - configure_and_build, extract_repo_input, prepare_build_dir, print_mode_header, ModeHeader, - }, + commands::{build_project, extract_repo_input, prepare_build_dir, print_mode_header, ModeHeader}, ctx::RunCtx, - prompts::confirm, - repository::{clone_repository, pull_repository, repo_dir_name}, + repository::{clone_repository, repo_dir_name}, + utils::dry_run::detect_or_dry_run, }; use std::path::Path; @@ -18,9 +16,9 @@ pub fn single_repo_mode( ctx: &mut RunCtx<'_, '_>, ) -> Result<(), String> { let total = std::time::Instant::now(); - let repo = extract_repo_input(args)?; let dir_name = repo_dir_name(repo); + let repo_path = base_dir.join(&dir_name); print_mode_header( &ModeHeader { @@ -31,42 +29,35 @@ pub fn single_repo_mode( mono_dir: None, profile: None, lib_count: None, + repo_count: None, }, &mut ctx.io, ); - let repo_path = base_dir.join(&dir_name); - if repo_path.exists() { - writeln!(ctx.io.output, "Repository {dir_name} already exists").ok(); - if confirm("Update existing repository?", args.yes, &mut ctx.io)? { - writeln!(ctx.io.output, "Updating {dir_name}\n").ok(); - crate::time!(ctx.flags.timing, ctx.io.output, "Update", { - pull_repository(&repo_path, ctx)?; - }); - } - } else { - clone_repository(repo, base_dir, args.connection.ssh, ctx)?; - } + writeln!(ctx.io.output, "Cloning repository").ok(); + clone_repository(repo, base_dir, args.connection.ssh, false, args.yes, ctx)?; + writeln!(ctx.io.output).ok(); let build_path = repo_path.join(&args.build.build_dir); - let build_system = if let Some(bs) = args.build.build_system { - Some(bs) - } else if !ctx.flags.dry_run { - Some(detect_build_system(&repo_path, ctx)?) - } else { - None - }; + let build_system = detect_or_dry_run(args.build.build_system, ctx, |ctx| { + detect_build_system(&repo_path, ctx) + })?; if let Some(build_system) = build_system { if build_system == BuildSystem::Npm { - configure_and_build(args, &repo_path, &repo_path, build_system, false, ctx)?; + if args.build.clean && ctx.flags.verbose { + writeln!(ctx.io.output, " --clean has no effect for npm projects").ok(); + } + build_project(args, &repo_path, &repo_path, build_system, false, ctx)?; } else { prepare_build_dir(&build_path, args.build.clean, ctx)?; - configure_and_build(args, &repo_path, &build_path, build_system, false, ctx)?; + build_project(args, &build_path, &repo_path, build_system, false, ctx)?; } } - if build_system == Some(BuildSystem::Npm) { + if ctx.flags.dry_run || build_system.is_none() { + writeln!(ctx.io.output, "Would finish in {dir_name}").ok(); + } else if build_system == Some(BuildSystem::Npm) { writeln!(ctx.io.output, "Project finished in {dir_name}").ok(); } else { writeln!( diff --git a/src/config/crud.rs b/src/config/crud.rs index 86115fb..4f385a9 100644 --- a/src/config/crud.rs +++ b/src/config/crud.rs @@ -31,17 +31,26 @@ pub fn create_default_config( io: &mut IoCtx<'_>, flags: RunFlags, ) -> Result<(), String> { - if path.exists() - && !confirm_abort( - &format!("{} already exists. Overwrite?", path.display()), - yes, - io, - )? - { + let prompt = if flags.dry_run { + format!( + " {} already exists. Overwrite? [DRY-RUN]: No changes will be made", + path.display() + ) + } else { + format!(" {} already exists. Overwrite?", path.display()) + }; + if path.exists() && !confirm_abort(&prompt, yes, io)? { return Ok(()); } - if !flags.dry_run { + if flags.dry_run { + writeln!( + io.output, + " Would create config file: {}", + dunce::canonicalize(&path).unwrap_or(path).display() + ) + .ok(); + } else { let mut config = SetupConfig::new(); config.path = Some(path.clone()); config.configs.insert( @@ -61,19 +70,20 @@ pub fn create_default_config( }, ); - save_config(&mut config)?; + let path = save_config(&mut config, flags.timing, &mut io.output)?; + + writeln!( + io.output, + " Created config file: {}", + dunce::canonicalize(&path).unwrap_or(path).display() + ) + .ok(); } - writeln!( - io.output, - "Created config file: {}", - dunce::canonicalize(&path).unwrap_or(path).display() - ) - .ok(); - writeln!(io.output, "Edit this file to customize your defaults.").ok(); - writeln!(io.output, "\nConfig files are checked in this order:").ok(); - writeln!(io.output, " 1. ./.star-setup.json (current directory)").ok(); - writeln!(io.output, " 2. ~/.star-setup.json (home directory)").ok(); + writeln!(io.output, " Edit this file to customize your defaults.\n").ok(); + writeln!(io.output, " Config files are checked in this order:").ok(); + writeln!(io.output, " 1. ./.star-setup.json (current directory)").ok(); + writeln!(io.output, " 2. ~/.star-setup.json (home directory)").ok(); Ok(()) } @@ -91,7 +101,7 @@ pub fn add_config( ) -> Result<(), String> { if has_config(config, name) && !confirm_abort( - &format!("Warning: Configuration '{name}' already exists. Overwrite?"), + &format!(" Warning: Configuration '{name}' already exists. Overwrite?"), yes, io, )? @@ -102,21 +112,21 @@ pub fn add_config( if flags.dry_run { writeln!( io.output, - "Would save configuration '{name}' to config file" + " Would save configuration '{name}' to config file" ) .ok(); } else { insert_config(config, name, entry); - let path = save_config(config)?; + let path: PathBuf = save_config(config, flags.timing, &mut io.output)?; writeln!( io.output, - "Configuration '{name}' added successfully to {}", + " Configuration '{name}' added successfully to {}", path.display() ) .ok(); let e: &ConfigEntry = &config.configs[name]; - writeln!(io.output, "Configuration details:").ok(); - write!(io.output, "{}", format_entry(e)).ok(); + writeln!(io.output, " Configuration details:").ok(); + write!(io.output, " {}", format_entry(e)).ok(); } Ok(()) } @@ -132,29 +142,29 @@ pub fn remove_config( flags: RunFlags, ) -> Result<(), String> { let Some(e) = config.configs.get(name) else { - writeln!(io.output, "\nWarning: Config '{name}' not found.\n").ok(); + writeln!(io.output, " Warning: Config '{name}' not found.\n").ok(); return Ok(()); }; - writeln!(io.output, "Config {name}").ok(); - writeln!(io.output, "Configuration details:").ok(); - write!(io.output, "{}", format_entry(e)).ok(); + writeln!(io.output, " Config {name}").ok(); + writeln!(io.output, " Configuration details:").ok(); + write!(io.output, " {}", format_entry(e)).ok(); - if !confirm_abort("\nAre you sure you want to remove this config?", yes, io)? { + if !confirm_abort(" Are you sure you want to remove this config?", yes, io)? { return Ok(()); } if flags.dry_run { writeln!( io.output, - "Would remove configuration '{name}' from config file" + " Would remove configuration '{name}' from config file" ) .ok(); } else { remove_config_entry(config, name); - let path = save_config(config)?; - writeln!(io.output, "\nConfig '{name}' was successfully removed").ok(); - writeln!(io.output, "Configuration saved to: {}\n", path.display()).ok(); + let path = save_config(config, flags.timing, &mut io.output)?; + writeln!(io.output, " Config '{name}' was successfully removed").ok(); + writeln!(io.output, " Configuration saved to: {}", path.display()).ok(); } Ok(()) } diff --git a/src/config/display.rs b/src/config/display.rs index 45d0ba1..b0e16f2 100644 --- a/src/config/display.rs +++ b/src/config/display.rs @@ -45,7 +45,7 @@ pub fn list_configs(config: &SetupConfig, io: &mut IoCtx<'_>) { writeln!(io.output, " No configurations created.").ok(); writeln!( io.output, - " Run with --init-config to create a default configuration." + " Run with 'star-setup config init' to create a default configuration." ) .ok(); return; @@ -53,7 +53,8 @@ pub fn list_configs(config: &SetupConfig, io: &mut IoCtx<'_>) { writeln!(io.output, "Configurations:").ok(); for (name, e) in &config.configs { - writeln!(io.output, "\n{name}:").ok(); + writeln!(io.output, "{name}:").ok(); write!(io.output, "{}", format_entry(e)).ok(); + writeln!(io.output).ok(); } } diff --git a/src/config/io.rs b/src/config/io.rs index e6c5eca..fd02471 100644 --- a/src/config/io.rs +++ b/src/config/io.rs @@ -1,5 +1,9 @@ use crate::config::SetupConfig; -use std::{fs, io, io::Write, path::PathBuf}; +use std::{ + fs, + io::{self, Write}, + path::{Path, PathBuf}, +}; /// Returns the list of paths to search for a config file. #[must_use] @@ -13,56 +17,89 @@ pub fn config_locations(path: &std::path::Path) -> Vec { .collect() } +fn io_error_msg(verb: &str, path: &Path, e: &io::Error) -> String { + match e.kind() { + io::ErrorKind::PermissionDenied => format!("Error: No permission to {verb} {}", path.display()), + _ => format!( + "An unexpected error occurred: {verb} {}: {e}", + path.display() + ), + } +} + /// Loads configuration from the first valid JSON file in `locations`. -pub fn load_config(locations: &[PathBuf], output: &mut impl Write) -> SetupConfig { +pub fn load_config( + locations: &[PathBuf], + verbose: bool, + timing: bool, + output: &mut impl Write, +) -> SetupConfig { + if verbose { + writeln!(output, "Loading config").ok(); + } + let mut invalid_count = 0; for path in locations { if !path.exists() { + if verbose { + writeln!(output, " Config path not found: {}", path.display()).ok(); + } 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; - } + let result = crate::time!(timing, output, "Read config", { + match fs::read_to_string(path) { + Ok(contents) => match serde_json::from_str::(&contents) { + Ok(mut config) => { + config.path = Some(path.clone()); + if verbose { + writeln!(output, " Loaded config from {}", path.display()).ok(); + } + Some(config) + } + Err(e) => { + writeln!(output, " Warning: Invalid JSON in {}: {e}", path.display()).ok(); + invalid_count += 1; + None + } + }, Err(e) => { - writeln!(output, "Warning: Invalid JSON in {}: {e}", path.display()).ok(); + writeln!(output, " {}", io_error_msg("read", path, &e)).ok(); invalid_count += 1; + None } - }, - 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 let Some(config) = result { + if verbose || timing { + writeln!(output).ok(); } + return config; } } if invalid_count != 0 { writeln!( output, - "Found {invalid_count} config file{} that had errors", + " Found {invalid_count} config file{} that had errors", if invalid_count == 1 { "" } else { "s" } ) .ok(); } + if verbose || timing { + writeln!(output).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 { +pub fn save_config( + config: &mut SetupConfig, + timing: bool, + output: &mut impl Write, +) -> Result { let path = config .path .get_or_insert_with(|| { @@ -75,15 +112,9 @@ pub fn save_config(config: &mut SetupConfig) -> Result { 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 - ), - })?; + crate::time!(timing, output, "Write config", { + fs::write(&path, json).map_err(|e| io_error_msg("write to", &path, &e))?; + }); + Ok(path) } diff --git a/src/ctx.rs b/src/ctx.rs index a8937ed..f50cd94 100644 --- a/src/ctx.rs +++ b/src/ctx.rs @@ -83,12 +83,14 @@ impl Runner for DryRunRunner { &mut self, cmd: &[&str], cwd: Option<&Path>, - _flags: RunFlags, + flags: RunFlags, output: &mut dyn Write, ) -> Result<(), String> { - writeln!(output, "Would run: {}", cmd.join(" ")).map_err(|e| e.to_string())?; - if let Some(dir) = cwd { - writeln!(output, " in directory: {}", dir.display()).map_err(|e| e.to_string())?; + if flags.verbose { + writeln!(output, " Would run: {}", cmd.join(" ")).map_err(|e| e.to_string())?; + if let Some(dir) = cwd { + writeln!(output, " in directory: {}", dir.display()).map_err(|e| e.to_string())?; + } } Ok(()) } diff --git a/src/interactive.rs b/src/interactive.rs index 0ed522b..54ddfa5 100644 --- a/src/interactive.rs +++ b/src/interactive.rs @@ -13,17 +13,17 @@ pub fn interactive_mode(args: &mut ResolvedArgs, io: &mut IoCtx<'_>) -> Result<( writeln!(io.output, "Star Setup Interactive Mode").ok(); if args.repo.is_none() { - args.repo = Some(ask_required("Enter repository (user/repo or URL)", io)?); + args.repo = Some(ask_required(" Enter repository (user/repo or URL)", io)?); } - args.connection.ssh = ask_bool_if("Use SSH?", args.connection.ssh, io)?; - args.diagnostic.verbose = ask_bool_if("Verbose?", args.diagnostic.verbose, io)?; - args.diagnostic.timing = ask_bool_if("Show timing?", args.diagnostic.timing, io)?; - args.build.clean = ask_bool_if("Clean build directory if exists?", args.build.clean, io)?; + args.connection.ssh = ask_bool_if(" Use SSH?", args.connection.ssh, io)?; + args.diagnostic.verbose = ask_bool_if(" Verbose?", args.diagnostic.verbose, io)?; + args.diagnostic.timing = ask_bool_if(" Show timing?", args.diagnostic.timing, io)?; + args.build.clean = ask_bool_if(" Clean build directory if exists?", args.build.clean, io)?; if !args.mono.mono_repo { loop { - match ask("Select mode: (1) Single Repo (2) Mono-Repo", io)?.as_str() { + match ask(" Select mode: (1) Single Repo (2) Mono-Repo", io)?.as_str() { "1" => break, "2" => { args.mono.mono_repo = true; @@ -36,14 +36,14 @@ pub fn interactive_mode(args: &mut ResolvedArgs, io: &mut IoCtx<'_>) -> Result<( if args.mono.mono_repo && args.mono.profile.is_none() && args.mono.repos.is_none() { loop { - match ask("Mono-repo: (1) Use profile (2) Manual repo list", io)?.as_str() { + match ask(" Mono-repo: (1) Use profile (2) Manual repo list", io)?.as_str() { "1" => { - args.mono.profile = Some(ask_required("Profile name", io)?); + args.mono.profile = Some(ask_required(" Profile name", io)?); break; } "2" => { let repo_list = ask_required( - "Enter repos (space separated 'username/lib1 username/lib2')", + " Enter repos (space separated 'username/lib1 username/lib2')", io, )?; args.mono.repos = Some(repo_list.split_whitespace().map(String::from).collect()); @@ -54,11 +54,11 @@ pub fn interactive_mode(args: &mut ResolvedArgs, io: &mut IoCtx<'_>) -> Result<( } } - let build_type_str = ask_default("Build type", args.build.build_type.to_cmake(), io)?; + let build_type_str = ask_default(" Build type", args.build.build_type.to_cmake(), io)?; args.build.build_type = build_type_str.parse::()?; - args.build.build_dir = ask_default("Build directory", &args.build.build_dir, io)?; - args.build.no_build = ask_bool_if("Configure only (skip build)?", args.build.no_build, io)?; + args.build.build_dir = ask_default(" Build directory", &args.build.build_dir, io)?; + args.build.no_build = ask_bool_if(" Configure only (skip build)?", args.build.no_build, io)?; - writeln!(io.output, "\nInteractive mode complete").ok(); + writeln!(io.output, " Interactive mode complete\n").ok(); Ok(()) } diff --git a/src/profile/crud.rs b/src/profile/crud.rs index cd2e21a..30ea6a1 100644 --- a/src/profile/crud.rs +++ b/src/profile/crud.rs @@ -33,7 +33,7 @@ pub fn add_profile( flags: RunFlags, ) -> Result<(), String> { if args.len() < 2 { - return Err("--profile-add requires NAME REPO1 [REPO2 ...]".to_string()); + return Err("profile add requires NAME REPO1 [REPO2 ...]".to_string()); } let name = args[0].clone(); @@ -41,7 +41,7 @@ pub fn add_profile( if has_profile(config, &name) && !confirm_abort( - &format!("Warning: Profile '{name}' already exists. Overwrite?"), + &format!(" Warning: Profile '{name}' already exists. Overwrite?"), yes, io, )? @@ -50,17 +50,17 @@ pub fn add_profile( } if flags.dry_run { - writeln!(io.output, "Would save profile '{name}' to config file").ok(); + writeln!(io.output, " Would save profile '{name}' to config file").ok(); } else { insert_profile(config, &name, repos.clone()); - let path = save_config(config)?; - writeln!(io.output, "Profile '{name}' added successfully").ok(); - writeln!(io.output, "Configuration saved to: {}", path.display()).ok(); + let path = save_config(config, flags.timing, &mut io.output)?; + writeln!(io.output, " Profile '{name}' added successfully").ok(); + writeln!(io.output, " Configuration saved to: {}", path.display()).ok(); } print_profile_details(io.output, "Profile details:", "Repositories", &repos); writeln!( io.output, - "\nUsage: star-setup username/test-repo --profile {name}" + " Usage: star-setup username/test-repo --profile {name}" ) .ok(); Ok(()) @@ -78,7 +78,7 @@ pub fn remove_profile( ) -> Result<(), String> { let repos = match config.profiles.get(name) { None => { - writeln!(io.output, "Warning: Profile '{name}' not found.").ok(); + writeln!(io.output, " Warning: Profile '{name}' not found.").ok(); return Ok(()); } Some(r) => r.clone(), @@ -92,7 +92,7 @@ pub fn remove_profile( ); if !confirm_abort( - &format!("Are you sure you want to remove profile '{name}'?"), + &format!(" Are you sure you want to remove profile '{name}'?"), yes, io, )? { @@ -100,12 +100,16 @@ pub fn remove_profile( } if flags.dry_run { - writeln!(io.output, "Would remove profile '{name}' from config file").ok(); + writeln!( + io.output, + " Would remove profile '{name}' from config file" + ) + .ok(); } else { remove_profile_entry(config, name); - let path = save_config(config)?; - writeln!(io.output, "\nProfile '{name}' removed successfully").ok(); - writeln!(io.output, "Configuration saved to: {}\n", path.display()).ok(); + let path = save_config(config, flags.timing, &mut io.output)?; + writeln!(io.output, " Profile '{name}' removed successfully").ok(); + writeln!(io.output, " Configuration saved to: {}", path.display()).ok(); } Ok(()) } diff --git a/src/profile/display.rs b/src/profile/display.rs index 3ba59b4..02231de 100644 --- a/src/profile/display.rs +++ b/src/profile/display.rs @@ -19,7 +19,7 @@ pub fn list_profiles(config: &SetupConfig, io: &mut IoCtx<'_>) { if config.profiles.is_empty() { writeln!( io.output, - "No profiles configured. Run with --init-config to create a default configuration." + "No profiles configured. Run with profile add to create a new profile." ) .ok(); return; diff --git a/src/prompts.rs b/src/prompts.rs index 524ce13..dd2f7f1 100644 --- a/src/prompts.rs +++ b/src/prompts.rs @@ -56,7 +56,7 @@ pub fn ask_choice(prompt: &str, options: &[&str], io: &mut IoCtx<'_>) -> Result< writeln!(io.output, " {}) {opt}", i + 1).ok(); } loop { - let input = read_input_line("Select: ", io)?; + let input = read_input_line(" Select: ", io)?; if let Ok(n) = input.parse::() { if n >= 1 && n <= options.len() { return Ok(n - 1); @@ -104,7 +104,7 @@ pub fn confirm(prompt: &str, yes: bool, io: &mut IoCtx<'_>) -> Result) -> Result { if !confirm(warning_msg, yes, io)? { - writeln!(io.output, "Aborted.").ok(); + writeln!(io.output, " Aborted.").ok(); return Ok(false); } Ok(true) diff --git a/src/repository.rs b/src/repository.rs index a509723..e50b8d8 100644 --- a/src/repository.rs +++ b/src/repository.rs @@ -1,6 +1,6 @@ //! Repository functions including cloning and URL resolution. -use crate::ctx::RunCtx; +use crate::{ctx::RunCtx, prompts::confirm}; use std::path::Path; /// Converts a repository path or URL to a local directory name (`owner-repo`). @@ -41,28 +41,38 @@ pub fn clone_repository( repo_path: &str, target_dir: &Path, use_ssh: bool, + on_exists_skip: bool, + yes: bool, ctx: &mut RunCtx<'_, '_>, ) -> Result<(), String> { let repo_name = repo_dir_name(repo_path); let repo_dir = target_dir.join(&repo_name); if repo_dir.exists() { - writeln!(ctx.io.output, "\n {repo_name} already exists").ok(); - return Ok(()); + writeln!(ctx.io.output, " Repository already exists").ok(); + if !on_exists_skip && confirm(" Update existing repository?", yes, &mut ctx.io)? { + crate::time!(ctx.flags.timing, ctx.io.output, "Update", { + pull_repository(&repo_dir, ctx)?; + }); + } + } else { + let repo_url = resolve_repo_url(repo_path, use_ssh); + crate::time!(ctx.flags.timing, ctx.io.output, "Clone", { + ctx.runner.run( + &["git", "clone", &repo_url, &repo_name], + Some(target_dir), + ctx.flags, + ctx.io.output, + )?; + if ctx.flags.verbose { + writeln!(ctx.io.output, " Finished cloning {repo_name}").ok(); + } + Ok::<(), String>(()) + }) + .map_err(|e| format!("Failed to clone {repo_path}: {e}"))?; } - writeln!(ctx.io.output, "\n Cloning {repo_name}").ok(); - let repo_url = resolve_repo_url(repo_path, use_ssh); - - ctx - .runner - .run( - &["git", "clone", &repo_url, &repo_name], - Some(target_dir), - ctx.flags, - ctx.io.output, - ) - .map_err(|e| format!("Failed to clone {repo_path}: {e}")) + Ok(()) } /// Pulls the latest changes for an existing repository. diff --git a/src/run.rs b/src/run.rs index 05bd691..08909e4 100644 --- a/src/run.rs +++ b/src/run.rs @@ -17,16 +17,23 @@ use std::{ /// Runs the setup process. /// # Errors -/// Returns an error if the configuration file is missing or corrupted. +/// Returns an error if arguments can't be resolved, +/// a required tool is missing, +/// or the selected mode fails. pub fn run(config_path: PathBuf) -> Result<(), Box> { let mut stdin = io::stdin().lock(); let mut stdout = io::stdout(); let is_terminal = stdin.is_terminal() && stdout.is_terminal(); - let mut config = load_config(&config_locations(config_path.as_path()), &mut stdout); let mut raw = Args::parse(); - let command = raw.command.take(); let yes = raw.yes; + let command = raw.command.take(); + let mut config = load_config( + &config_locations(config_path.as_path()), + raw.diagnostic.verbose, + raw.diagnostic.timing, + &mut stdout, + ); let mut args = resolve_with_config(raw, &config).map_err(Box::::from)?; let mut flags = args.diagnostic; @@ -44,7 +51,7 @@ pub fn run(config_path: PathBuf) -> Result<(), Box> { Command::Profile(p) => { handle_profile_cmd(p.action, &mut config, yes, &mut io, flags)?; } - Command::Workspace(w) => handle_workspace_cmd(w.action, io, flags)?, + Command::Workspace(w) => handle_workspace_cmd(&w.action, io, flags)?, } return Ok(()); } diff --git a/src/utils/dry_run.rs b/src/utils/dry_run.rs new file mode 100644 index 0000000..7afdbd8 --- /dev/null +++ b/src/utils/dry_run.rs @@ -0,0 +1,68 @@ +use crate::{ + cli::BuildSystem, + ctx::{IoCtx, RunCtx, RunFlags}, +}; +use std::path::Path; + +/// Prints a dry-run message or executes `op`, timing it if not a dry run. +/// # Errors +/// Returns an error if `op` fails. +pub fn dry_run_or_do( + verb: &str, + progressive: &str, + path: &Path, + io: &mut IoCtx<'_>, + flags: RunFlags, + timer_label: &str, + op: impl FnOnce() -> Result<(), String>, +) -> Result<(), String> { + if flags.dry_run { + if flags.verbose { + writeln!(io.output, " Would {verb}: {}", path.display()).ok(); + } + } else { + if flags.verbose { + writeln!(io.output, " {progressive}: {}", path.display()).ok(); + } + crate::time!(flags.timing, io.output, timer_label, { op() })?; + if flags.verbose { + writeln!(io.output, " Done").ok(); + } + } + Ok(()) +} + +/// Detects the build system or prints what would be detected in a dry run. +/// # Errors +/// Returns an error if `detect_fn` fails. +pub fn detect_or_dry_run( + bs_flag: Option, + ctx: &mut RunCtx, + detect_fn: F, +) -> Result, String> +where + F: FnOnce(&mut RunCtx) -> Result, +{ + writeln!(ctx.io.output, "Detecting build system").ok(); + let result = crate::time!(ctx.flags.timing, ctx.io.output, "Detect", { + let r = if let Some(bs) = bs_flag { + if ctx.flags.verbose { + writeln!(ctx.io.output, " Build system flag set: {bs:?}").ok(); + } + Some(bs) + } else if ctx.flags.dry_run { + if ctx.flags.verbose { + writeln!(ctx.io.output, " Would detect build system after cloning").ok(); + } + None + } else { + Some(detect_fn(ctx)?) + }; + if ctx.flags.verbose { + writeln!(ctx.io.output, " Finished detecting").ok(); + } + r + }); + writeln!(ctx.io.output).ok(); + Ok(result) +} diff --git a/src/utils/mod.rs b/src/utils/mod.rs index e6a147d..47c2f12 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -2,4 +2,6 @@ pub mod prerequisites; pub use prerequisites::check_prerequisites; pub mod process; pub use process::run_command; +pub mod dry_run; pub mod timing; +pub use dry_run::dry_run_or_do; diff --git a/src/utils/prerequisites.rs b/src/utils/prerequisites.rs index 911cd2e..70aaf24 100644 --- a/src/utils/prerequisites.rs +++ b/src/utils/prerequisites.rs @@ -6,6 +6,10 @@ use std::process::Command; /// # Errors /// Returns an error if any required tool is missing from PATH. pub fn check_prerequisites(io: &mut IoCtx<'_>, flags: RunFlags) -> Result<(), String> { + if flags.verbose { + writeln!(io.output, "Checking Prerequisites").ok(); + } + crate::time!(flags.timing, io.output, "Check prerequisites", { let missing: Vec<&str> = ["git", "cmake", "meson"] .into_iter() @@ -16,7 +20,7 @@ pub fn check_prerequisites(io: &mut IoCtx<'_>, flags: RunFlags) -> Result<(), St .map_or(true, |o| !o.status.success()); if !is_missing && flags.verbose { - let _ = writeln!(io.output, " Found {tool}"); + writeln!(io.output, " Found {tool}").ok(); } is_missing }) @@ -26,6 +30,13 @@ pub fn check_prerequisites(io: &mut IoCtx<'_>, flags: RunFlags) -> Result<(), St return Err(format!("Missing required tools: {}", missing.join(", "))); } - Ok(()) - }) + if flags.verbose { + writeln!(io.output, " Prerequisites complete").ok(); + } + }); + + if flags.verbose || flags.timing { + writeln!(io.output).ok(); + } + Ok(()) } diff --git a/src/utils/process.rs b/src/utils/process.rs index 181cb05..dc671a7 100644 --- a/src/utils/process.rs +++ b/src/utils/process.rs @@ -71,9 +71,9 @@ pub fn run_command( }; if verbose { - writeln!(output, "Running: {}", cmd.join(" ")).ok(); + writeln!(output, " Running: {}", cmd.join(" ")).ok(); if let Some(dir) = cwd { - writeln!(output, " in directory: {}", dir.display()).ok(); + writeln!(output, " in directory: {}", dir.display()).ok(); } } diff --git a/src/utils/timing.rs b/src/utils/timing.rs index fe1b32b..962ed03 100644 --- a/src/utils/timing.rs +++ b/src/utils/timing.rs @@ -4,10 +4,11 @@ macro_rules! time { let t = std::time::Instant::now(); let result = $block; if $timing { - let _ = std::io::Write::write_fmt( + std::io::Write::write_fmt( $output, format_args!(" [timing] {}: {:.2?}\n", $msg, t.elapsed()), - ); + ) + .ok(); } result }}; diff --git a/src/workspace/clean.rs b/src/workspace/clean.rs index cf85f54..d19b1eb 100644 --- a/src/workspace/clean.rs +++ b/src/workspace/clean.rs @@ -1,4 +1,4 @@ -use crate::{ctx::RunCtx, workspace::Workspace}; +use crate::{ctx::RunCtx, utils::dry_run_or_do, workspace::Workspace}; use std::fs; impl Workspace { @@ -6,36 +6,38 @@ impl Workspace { /// # Errors /// Returns an error if the build directory cannot be removed. pub fn clean(&self, ctx: &mut RunCtx<'_, '_>) -> Result<(), String> { - if !self.build_path.exists() { - writeln!( - ctx.io.output, - "Build directory does not exist: {}", - self.build_path.display() - ) - .ok(); + writeln!(ctx.io.output, "Cleaning workspace").ok(); + + if self.root.join("package.json").exists() { + if ctx.flags.verbose { + writeln!(ctx.io.output, " Clean has no effect for npm workspaces").ok(); + } return Ok(()); } - writeln!( - ctx.io.output, - "Removing build directory: {}", - self.build_path.display() - ) - .ok(); - - if ctx.flags.dry_run { + if !self.build_path.exists() { writeln!( ctx.io.output, - "Would remove directory: {}", + " Build directory does not exist: {}", self.build_path.display() ) .ok(); - } else { - fs::remove_dir_all(&self.build_path) - .map_err(|e| format!("Failed to remove build directory: {e}"))?; - writeln!(ctx.io.output, "Done").ok(); + return Ok(()); } + dry_run_or_do( + "remove directory", + "Removing", + &self.build_path, + &mut ctx.io, + ctx.flags, + "Clean", + || { + fs::remove_dir_all(&self.build_path) + .map_err(|e| format!("Failed to remove build directory: {e}")) + }, + )?; + Ok(()) } } diff --git a/src/workspace/resolve.rs b/src/workspace/resolve.rs index 44821d4..b2fe8ca 100644 --- a/src/workspace/resolve.rs +++ b/src/workspace/resolve.rs @@ -1,4 +1,4 @@ -use crate::workspace::Workspace; +use crate::{ctx::IoCtx, workspace::Workspace}; use std::{fs, path::Path}; /// Resolves a workspace from optional path overrides. @@ -8,6 +8,8 @@ pub fn resolve_workspace( path: Option<&Path>, mono_dir: Option<&str>, build_dir: Option<&str>, + io: &mut IoCtx<'_>, + verbose: bool, ) -> Result { let base = path.unwrap_or_else(|| Path::new(".")); let root = base.join(mono_dir.unwrap_or("build-mono")); @@ -31,6 +33,10 @@ pub fn resolve_workspace( .filter(|p| p.is_dir() && p.join(".git").exists()) .collect(); + if verbose { + writeln!(io.output, " Resolved workspace: {}", root.display()).ok(); + } + Ok(Workspace { root, repos_path, diff --git a/src/workspace/status.rs b/src/workspace/status.rs index 2ae84b2..1fad91d 100644 --- a/src/workspace/status.rs +++ b/src/workspace/status.rs @@ -7,64 +7,71 @@ impl Workspace { pub fn status(&self, fetch: bool, ctx: &mut RunCtx<'_, '_>) -> Result<(), String> { writeln!(ctx.io.output, "Workspace status:\n").ok(); - for repo_dir in &self.repo_dirs { - let name = repo_dir - .file_name() - .map(|n| n.to_string_lossy().into_owned()) - .unwrap_or_default(); + crate::time!(ctx.flags.timing, ctx.io.output, "Status", { + for repo_dir in &self.repo_dirs { + let name = repo_dir + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default(); - if ctx.flags.dry_run { - writeln!(ctx.io.output, "Would show status for {name}").ok(); - continue; - } + if ctx.flags.dry_run { + writeln!(ctx.io.output, " Would show status for {name}").ok(); + continue; + } - if fetch { - ctx - .runner - .run(&["git", "fetch"], Some(repo_dir), ctx.flags, ctx.io.output)?; - } + crate::time!(ctx.flags.timing, ctx.io.output, &name, { + if fetch { + ctx + .runner + .run(&["git", "fetch"], Some(repo_dir), ctx.flags, ctx.io.output)?; + } - let branch = ctx - .runner - .run_capture( - &["git", "rev-parse", "--abbrev-ref", "HEAD"], - Some(repo_dir), - ) - .unwrap_or_else(|_| "(unknown)".to_string()); + let branch = ctx + .runner + .run_capture( + &["git", "rev-parse", "--abbrev-ref", "HEAD"], + Some(repo_dir), + ) + .unwrap_or_else(|_| "(unknown)".to_string()); - let dirty = !ctx - .runner - .run_capture(&["git", "status", "--porcelain"], Some(repo_dir))? - .is_empty(); + let dirty = !ctx + .runner + .run_capture(&["git", "status", "--porcelain"], Some(repo_dir))? + .is_empty(); - let status_str = if dirty { "dirty" } else { "clean" }; + let status_str = if dirty { "dirty" } else { "clean" }; - let ahead_behind = if fetch { - let ahead = ctx - .runner - .run_capture( - &["git", "rev-list", "--count", "@{u}..HEAD"], - Some(repo_dir), - ) - .unwrap_or_else(|_| "?".to_string()); - let behind = ctx - .runner - .run_capture( - &["git", "rev-list", "--count", "HEAD..@{u}"], - Some(repo_dir), + let ahead_behind = if fetch { + let ahead = ctx + .runner + .run_capture( + &["git", "rev-list", "--count", "@{u}..HEAD"], + Some(repo_dir), + ) + .unwrap_or_else(|_| "?".to_string()); + let behind = ctx + .runner + .run_capture( + &["git", "rev-list", "--count", "HEAD..@{u}"], + Some(repo_dir), + ) + .unwrap_or_else(|_| "?".to_string()); + format!(" ↑{ahead} ↓{behind}") + } else { + String::new() + }; + + writeln!( + ctx.io.output, + " {name:<20} {branch:<12} {status_str}{ahead_behind}" ) - .unwrap_or_else(|_| "?".to_string()); - format!(" ↑{ahead} ↓{behind}") - } else { - String::new() - }; + .ok(); - writeln!( - ctx.io.output, - " {name:<20} {branch:<12} {status_str}{ahead_behind}" - ) - .ok(); - } + Ok::<(), String>(()) + })?; + } + Ok::<(), String>(()) + })?; Ok(()) } diff --git a/src/workspace/update.rs b/src/workspace/update.rs index ad420b0..8c3381a 100644 --- a/src/workspace/update.rs +++ b/src/workspace/update.rs @@ -14,26 +14,31 @@ impl Workspace { let mut errors: Vec = Vec::new(); - for repo_dir in &self.repo_dirs { - let name = repo_dir - .file_name() - .map(|n| n.to_string_lossy()) - .unwrap_or_default(); + crate::time!(ctx.flags.timing, ctx.io.output, "Update", { + for repo_dir in &self.repo_dirs { + let name = repo_dir + .file_name() + .map(|n| n.to_string_lossy()) + .unwrap_or_default(); - if ctx.flags.dry_run { - writeln!(ctx.io.output, "Would update {name}").ok(); - continue; - } + if ctx.flags.dry_run { + writeln!(ctx.io.output, " Would update {name}").ok(); + continue; + } - writeln!(ctx.io.output, " Updating {name}").ok(); - if let Err(e) = pull_repository(repo_dir, ctx) { - writeln!(ctx.io.output, " Failed to update {name}: {e}").ok(); - errors.push(format!("{name}: {e}")); + writeln!(ctx.io.output, " Updating {name}").ok(); + crate::time!(ctx.flags.timing, ctx.io.output, &name, { + if let Err(e) = pull_repository(repo_dir, ctx) { + writeln!(ctx.io.output, " Failed to update {name}: {e}").ok(); + errors.push(format!("{name}: {e}")); + } + }); } - } + Ok::<(), String>(()) + })?; if errors.is_empty() { - writeln!(ctx.io.output, "\nDone").ok(); + writeln!(ctx.io.output, " Done").ok(); Ok(()) } else { Err(format!( diff --git a/tests/cli/build/detect.rs b/tests/cli/build/detect.rs index 840d492..cba3642 100644 --- a/tests/cli/build/detect.rs +++ b/tests/cli/build/detect.rs @@ -78,7 +78,7 @@ fn test_detect_build_system_timing_output() { }); assert!(String::from_utf8(output) .unwrap() - .contains("[timing] Detect:")); + .contains("[timing] Scanned directory:")); } #[test] @@ -143,7 +143,7 @@ fn test_detect_mono_build_system_timing_output() { }); assert!(String::from_utf8(output) .unwrap() - .contains("[timing] Detect:")); + .contains("[timing] Scanned directories:")); } #[test] diff --git a/tests/commands/build.rs b/tests/commands/build.rs index 9edb2bb..e323ab6 100644 --- a/tests/commands/build.rs +++ b/tests/commands/build.rs @@ -1,4 +1,4 @@ -use super::common::{default_resolved_with_no_build, with_ctx_runner, MockRunner}; +use super::common::{default_resolved_with_no_build, with_ctx, with_ctx_runner, MockRunner}; use star_setup::{ cli::BuildSystem, commands::{build_project, cmake_build, meson_build}, @@ -92,6 +92,16 @@ fn test_npm_build_with_build_step() { assert!(runner.calls[1].0.contains(&"build".to_string())); } +#[test] +fn test_npm_build_install_only_prints_header() { + let args = default_resolved_with_no_build(true); + let (_, output) = with_ctx(MockRunner::new(), |tmp_path, ctx| { + star_setup::commands::npm_build(&args, tmp_path, false, ctx).unwrap(); + }); + let out = String::from_utf8(output).unwrap(); + assert!(out.contains("Installing dependencies")); +} + #[test] fn test_build_project_dispatches_npm() { let args = default_resolved_with_no_build(true); diff --git a/tests/commands/header.rs b/tests/commands/header.rs index 8250845..a27f962 100644 --- a/tests/commands/header.rs +++ b/tests/commands/header.rs @@ -13,6 +13,7 @@ fn test_print_mode_header_repo_name_without_test_repo() { mono_dir: None, profile: None, lib_count: None, + repo_count: None, }, io, ); diff --git a/tests/commands/mono/clone.rs b/tests/commands/mono/clone.rs index be4e54a..a7d7447 100644 --- a/tests/commands/mono/clone.rs +++ b/tests/commands/mono/clone.rs @@ -1,4 +1,4 @@ -use super::super::common::{with_ctx_runner, MockRunner}; +use super::super::common::{with_ctx, with_ctx_runner, MockRunner}; use star_setup::commands::mono::clone::clone_mono_repos; #[test] @@ -23,3 +23,24 @@ fn test_clone_mono_repos_empty() { assert!(runner.calls.is_empty()); } + +#[test] +fn test_clone_mono_repos_per_repo_lines_require_verbose() { + let repos = vec!["user/repo1".to_string(), "user/repo2".to_string()]; + + let (_, output) = with_ctx(MockRunner::new(), |tmp_path, ctx| { + clone_mono_repos(&repos, tmp_path, false, ctx).unwrap(); + }); + let out = String::from_utf8(output).unwrap(); + assert!(out.contains("Cloning repositories")); + assert!(!out.contains("Cloning user-repo1")); + assert!(!out.contains("Finished cloning (2 repositories)")); + + let (_, output) = with_ctx(MockRunner::new(), |tmp_path, ctx| { + ctx.flags.verbose = true; + clone_mono_repos(&repos, tmp_path, false, ctx).unwrap(); + }); + let out = String::from_utf8(output).unwrap(); + assert!(out.contains("Cloning user-repo1")); + assert!(out.contains("Finished cloning (2 repositories)")); +} diff --git a/tests/commands/mono/mode.rs b/tests/commands/mono/mode.rs index b2c9e9d..23a84cc 100644 --- a/tests/commands/mono/mode.rs +++ b/tests/commands/mono/mode.rs @@ -39,6 +39,31 @@ fn test_mono_repo_mode_dry_run_makes_no_fs_changes() { }); } +#[test] +fn test_mono_repo_mode_dry_run_with_build_system_makes_no_fs_changes() { + for bs in [ + star_setup::cli::BuildSystem::Npm, + star_setup::cli::BuildSystem::Cmake, + star_setup::cli::BuildSystem::Meson, + ] { + let mut args = default_resolved_mono(vec!["user/lib1".to_string()]); + args.diagnostic.dry_run = true; + args.build.build_system = Some(bs); + args.build.watch = true; + + with_ctx(DryRunRunner, |tmp_path, ctx| { + ctx.flags.dry_run = true; + + mono_repo_mode(&args, &SetupConfig::new(), tmp_path, ctx).unwrap(); + + assert!( + std::fs::read_dir(tmp_path).unwrap().next().is_none(), + "{bs:?} dry-run wrote to disk" + ); + }); + } +} + #[test] fn test_mono_repo_mode_with_build_system_flag() { let mut args = default_resolved_mono(vec!["user/lib1".to_string()]); diff --git a/tests/commands/mono/resolve.rs b/tests/commands/mono/resolve.rs index fbade92..4e7604b 100644 --- a/tests/commands/mono/resolve.rs +++ b/tests/commands/mono/resolve.rs @@ -53,7 +53,7 @@ fn test_resolve_repos_for_mono_empty_profile_errors() { args.mono.profile = Some("emptyprofile".to_string()); with_io(|io| { - let result = resolve_repos_for_mono(&args, &config, "user/repo", io); + let result = resolve_repos_for_mono(&args, &config, io); assert!(result.is_err()); assert!(result.unwrap_err().contains("has no repositories")); }); @@ -70,7 +70,7 @@ fn test_resolve_repos_for_mono_with_profile() { args.mono.profile = Some("myprofile".to_string()); with_io(|io| { - let result = resolve_repos_for_mono(&args, &config, "user/repo", io); + let result = resolve_repos_for_mono(&args, &config, io); assert!(result.is_ok()); assert_eq!(result.unwrap(), vec!["user/lib1", "user/lib2"]); }); @@ -83,7 +83,7 @@ fn test_resolve_repos_for_mono_with_explicit_repos() { args.mono.repos = Some(vec!["user/lib1".to_string(), "user/lib2".to_string()]); with_io(|io| { - let result = resolve_repos_for_mono(&args, &config, "user/repo", io); + let result = resolve_repos_for_mono(&args, &config, io); assert!(result.is_ok()); assert_eq!(result.unwrap(), vec!["user/lib1", "user/lib2"]); }); @@ -95,7 +95,7 @@ fn test_resolve_repos_for_mono_no_repos_or_profile_errors() { let args = default_resolved(); with_io(|io| { - let result = resolve_repos_for_mono(&args, &config, "user/repo", io); + let result = resolve_repos_for_mono(&args, &config, io); assert!(result.is_err()); assert!(result .unwrap_err() @@ -110,7 +110,7 @@ fn test_resolve_repos_for_mono_profile_not_found_errors() { args.mono.profile = Some("nonexistent".to_string()); with_io(|io| { - let result = resolve_repos_for_mono(&args, &config, "user/repo", io); + let result = resolve_repos_for_mono(&args, &config, io); assert!(result.is_err()); assert!(result.unwrap_err().contains("not found")); }); diff --git a/tests/commands/single.rs b/tests/commands/single.rs index 8d98c11..14d6770 100644 --- a/tests/commands/single.rs +++ b/tests/commands/single.rs @@ -74,12 +74,12 @@ fn test_single_repo_mode_dry_run_makes_no_fs_changes() { #[test] fn test_single_repo_mode_dry_run_clean_prints_would_remove() { let mut args = default_resolved(); - args.diagnostic.dry_run = true; args.build.clean = true; args.build.build_system = Some(BuildSystem::Cmake); let (_, output) = with_ctx_input(b"", DryRunRunner, |tmp_path, ctx| { ctx.flags.dry_run = true; + ctx.flags.verbose = true; single_repo_mode(&args, tmp_path, ctx).unwrap(); assert!(std::fs::read_dir(tmp_path).unwrap().next().is_none()); }); @@ -88,6 +88,25 @@ fn test_single_repo_mode_dry_run_clean_prints_would_remove() { .contains("Would remove directory:")); } +#[test] +fn test_single_repo_mode_dry_run_with_build_system_says_would_finish() { + for bs in [BuildSystem::Npm, BuildSystem::Cmake, BuildSystem::Meson] { + let mut args = default_resolved(); + args.diagnostic.dry_run = true; + args.build.build_system = Some(bs); + + let (_, output) = with_ctx_input(b"", DryRunRunner, |tmp_path, ctx| { + ctx.flags.dry_run = true; + single_repo_mode(&args, tmp_path, ctx).unwrap(); + assert!(std::fs::read_dir(tmp_path).unwrap().next().is_none()); + }); + + let out = String::from_utf8(output).unwrap(); + assert!(out.contains("Would finish in"), "{bs:?}: {out}"); + assert!(!out.contains("Project finished"), "{bs:?}: {out}"); + } +} + #[test] fn test_single_repo_mode_with_build_system_flag() { let mut args = default_resolved(); diff --git a/tests/config/crud.rs b/tests/config/crud.rs index 207f688..b06d068 100644 --- a/tests/config/crud.rs +++ b/tests/config/crud.rs @@ -145,7 +145,7 @@ fn test_remove_config_removes_and_saves() { let mut config = SetupConfig::new(); config.path = Some(path.clone()); insert_config(&mut config, "myconfig", sample_entry()); - save_config(&mut config).unwrap(); + save_config(&mut config, false, &mut io.output).unwrap(); remove_config(&mut config, "myconfig", true, io, make_flags()).unwrap(); assert!(!has_config(&config, "myconfig")); diff --git a/tests/config/io.rs b/tests/config/io.rs index 0baaccb..2a706ef 100644 --- a/tests/config/io.rs +++ b/tests/config/io.rs @@ -30,10 +30,10 @@ fn test_save_and_load_roundtrip() { meson_flags: vec![], }, ); - save_config(&mut config).unwrap(); with_io_output(|io| { - let loaded = load_config(&[path], &mut io.output); + save_config(&mut config, false, &mut io.output).unwrap(); + let loaded = load_config(&[path], false, false, &mut io.output); assert!(loaded.configs.contains_key("default")); assert!(loaded.configs["default"].ssh); assert_eq!(loaded.configs["default"].build_type, BuildType::Release); @@ -45,7 +45,7 @@ fn test_save_and_load_roundtrip() { #[test] fn test_load_config_skips_missing_local_file() { with_io_output(|io| { - let config = load_config(&[], &mut io.output); + let config = load_config(&[], false, false, &mut io.output); assert!(config.configs.is_empty()); }); } @@ -57,7 +57,7 @@ fn test_load_config_handles_invalid_json() { std::fs::write(&path, "{invalid json").unwrap(); with_io_output(|io| { - let config = load_config(&[path], &mut io.output); + let config = load_config(&[path], false, false, &mut io.output); assert!(config.configs.is_empty()); }); } @@ -67,6 +67,8 @@ fn test_load_config_skips_nonexistent_path() { with_io_output(|io| { let config = load_config( &[PathBuf::from("/nonexistent/path/.star-setup.json")], + false, + false, &mut io.output, ); assert!(config.configs.is_empty()); @@ -81,17 +83,18 @@ fn test_load_config_first_valid_wins() { 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(); with_io_output(|io| { - let loaded = load_config(&[path1, path2], &mut io.output); + config1.path = Some(path1.clone()); + insert_config(&mut config1, "first", sample_entry()); + save_config(&mut config1, false, &mut io.output).unwrap(); + + config2.path = Some(path2.clone()); + insert_config(&mut config2, "second", sample_entry()); + save_config(&mut config2, false, &mut io.output).unwrap(); + + let loaded = load_config(&[path1, path2], false, false, &mut io.output); assert!(loaded.configs.contains_key("first")); assert!(!loaded.configs.contains_key("second")); }); @@ -109,10 +112,10 @@ fn test_load_config_falls_through_invalid_to_valid() { let mut config2 = SetupConfig::new(); config2.path = Some(path2.clone()); insert_config(&mut config2, "second", sample_entry()); - save_config(&mut config2).unwrap(); with_io_output(|io| { - let loaded = load_config(&[path1, path2], &mut io.output); + save_config(&mut config2, false, &mut io.output).unwrap(); + let loaded = load_config(&[path1, path2], false, false, &mut io.output); assert!(loaded.configs.contains_key("second")); }); } diff --git a/tests/ctx.rs b/tests/ctx.rs index ebb3ac4..8b3cd03 100644 --- a/tests/ctx.rs +++ b/tests/ctx.rs @@ -1,10 +1,17 @@ -use star_setup::ctx::{DryRunRunner, ProcessRunner, Runner}; +use star_setup::ctx::{DryRunRunner, ProcessRunner, RunFlags, Runner}; use std::path::Path; mod common; use common::{with_io, with_io_output}; use crate::common::make_flags; +fn verbose_flags() -> RunFlags { + RunFlags { + verbose: true, + ..make_flags() + } +} + #[test] fn test_process_runner_runs_command() { with_io(|io| { @@ -18,10 +25,10 @@ fn test_process_runner_runs_command() { fn test_dry_run_runner_prints_command() { let ((), output) = with_io_output(|io| { DryRunRunner - .run(&["git", "clone", "foo"], None, make_flags(), io.output) + .run(&["git", "clone", "foo"], None, verbose_flags(), io.output) .unwrap(); }); - assert_eq!(output, "Would run: git clone foo\n"); + assert_eq!(output, " Would run: git clone foo\n"); } #[test] @@ -31,12 +38,12 @@ fn test_dry_run_runner_prints_cwd() { .run( &["cmake", ".."], Some(Path::new("/tmp/build")), - make_flags(), + verbose_flags(), io.output, ) .unwrap(); }); - assert!(output.contains("Would run: cmake ..")); + assert!(output.contains(" Would run: cmake ..")); assert!(output.contains(" in directory: /tmp/build")); } diff --git a/tests/profile/crud.rs b/tests/profile/crud.rs index 532c2a7..de97b93 100644 --- a/tests/profile/crud.rs +++ b/tests/profile/crud.rs @@ -122,7 +122,7 @@ fn test_remove_profile_removes_and_saves() { let mut config = SetupConfig::new(); config.path = Some(path.clone()); insert_profile(&mut config, "myprofile", vec!["user/repo1".to_string()]); - save_config(&mut config).unwrap(); + save_config(&mut config, false, &mut io.output).unwrap(); remove_profile(&mut config, "myprofile", true, io, make_flags()).unwrap(); assert!(!has_profile(&config, "myprofile")); @@ -159,10 +159,11 @@ fn test_save_and_load_profile_roundtrip() { "myprofile", vec!["user/repo1".to_string(), "user/repo2".to_string()], ); - save_config(&mut config).unwrap(); with_io_output(|io| { - let loaded = load_config(&[path], &mut io.output); + save_config(&mut config, false, &mut io.output).unwrap(); + + let loaded = load_config(&[path], false, false, &mut io.output); assert!(loaded.profiles.contains_key("myprofile")); assert_eq!( loaded.profiles["myprofile"], diff --git a/tests/repository.rs b/tests/repository.rs index db32eaa..7775c77 100644 --- a/tests/repository.rs +++ b/tests/repository.rs @@ -70,7 +70,7 @@ fn test_clone_skips_existing_directory() { let repo_dir = tmp_path.join("owner-repo"); std::fs::create_dir_all(&repo_dir).unwrap(); - let result = clone_repository("owner/repo", tmp_path, false, ctx); + let result = clone_repository("owner/repo", tmp_path, false, true, false, ctx); assert!(result.is_ok()); assert!(repo_dir.exists()); }); @@ -81,7 +81,7 @@ fn test_clone_repository_calls_git_clone() { let tmp = tempfile::TempDir::new().unwrap(); let runner = with_ctx_runner(MockRunner::new(), |_, ctx| { - clone_repository("user/repo", tmp.path(), false, ctx).unwrap(); + clone_repository("user/repo", tmp.path(), false, true, false, ctx).unwrap(); }); assert_eq!(runner.calls.len(), 1); diff --git a/tests/workspace/clean.rs b/tests/workspace/clean.rs index 7360a35..84dd0a0 100644 --- a/tests/workspace/clean.rs +++ b/tests/workspace/clean.rs @@ -29,6 +29,7 @@ fn test_workspace_clean_removes_build_dir() { fn test_workspace_clean_dry_run() { let (_, output) = with_ctx(MockRunner::new(), |tmp, ctx| { ctx.flags.dry_run = true; + ctx.flags.verbose = true; let ws = make_workspace(tmp, vec![]); fs::create_dir_all(&ws.build_path).unwrap(); diff --git a/tests/workspace/resolve.rs b/tests/workspace/resolve.rs index e234ee5..657ea26 100644 --- a/tests/workspace/resolve.rs +++ b/tests/workspace/resolve.rs @@ -5,8 +5,8 @@ use crate::common::with_io_dir; #[test] fn test_resolve_workspace_errors_when_missing() { - with_io_dir(|path, _| { - let result = resolve_workspace(Some(path), None, None); + with_io_dir(|path, io| { + let result = resolve_workspace(Some(path), None, None, io, false); assert!(result.is_err()); assert!(result.unwrap_err().contains("Workspace not found")); }); @@ -14,9 +14,9 @@ fn test_resolve_workspace_errors_when_missing() { #[test] fn test_resolve_workspace_errors_when_no_repos() { - with_io_dir(|path, _| { + with_io_dir(|path, io| { fs::create_dir_all(path.join("build-mono")).unwrap(); - let result = resolve_workspace(Some(path), None, None); + let result = resolve_workspace(Some(path), None, None, io, false); assert!(result.is_err()); assert!(result.unwrap_err().contains("Repos directory not found")); }); @@ -24,9 +24,9 @@ fn test_resolve_workspace_errors_when_no_repos() { #[test] fn test_resolve_workspace_succeeds() { - with_io_dir(|path, _| { + with_io_dir(|path, io| { fs::create_dir_all(path.join("build-mono").join("repos")).unwrap(); - let result = resolve_workspace(Some(path), None, None); + let result = resolve_workspace(Some(path), None, None, io, false); assert!(result.is_ok()); let ws = result.unwrap(); assert_eq!(ws.repo_dirs.len(), 0); @@ -35,40 +35,40 @@ fn test_resolve_workspace_succeeds() { #[test] fn test_resolve_workspace_finds_repos() { - with_io_dir(|path, _| { + with_io_dir(|path, io| { let repos = path.join("build-mono").join("repos"); fs::create_dir_all(repos.join("user-lib1").join(".git")).unwrap(); fs::create_dir_all(repos.join("user-lib2").join(".git")).unwrap(); - let ws = resolve_workspace(Some(path), None, None).unwrap(); + let ws = resolve_workspace(Some(path), None, None, io, false).unwrap(); assert_eq!(ws.repo_dirs.len(), 2); }); } #[test] fn test_resolve_workspace_custom_mono_dir() { - with_io_dir(|path, _| { + with_io_dir(|path, io| { fs::create_dir_all(path.join("my-workspace").join("repos")).unwrap(); - let result = resolve_workspace(Some(path), Some("my-workspace"), None); + let result = resolve_workspace(Some(path), Some("my-workspace"), None, io, false); assert!(result.is_ok()); }); } #[test] fn test_resolve_workspace_custom_build_dir() { - with_io_dir(|path, _| { + with_io_dir(|path, io| { fs::create_dir_all(path.join("build-mono").join("repos")).unwrap(); - let ws = resolve_workspace(Some(path), None, Some("out")).unwrap(); + let ws = resolve_workspace(Some(path), None, Some("out"), io, false).unwrap(); assert!(ws.build_path.ends_with("out")); }); } #[test] fn test_resolve_workspace_excludes_non_git_dirs() { - with_io_dir(|path, _| { + with_io_dir(|path, io| { let repos = path.join("build-mono").join("repos"); fs::create_dir_all(repos.join("user-lib1").join(".git")).unwrap(); fs::create_dir_all(repos.join("not-a-repo")).unwrap(); - let ws = resolve_workspace(Some(path), None, None).unwrap(); + let ws = resolve_workspace(Some(path), None, None, io, false).unwrap(); assert_eq!(ws.repo_dirs.len(), 1); }); }