diff --git a/Cargo.toml b/Cargo.toml index 6414520..956ac4a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "star-setup" -version = "0.3.3" +version = "0.3.4" 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/README.md b/README.md index ac8492a..67c94e1 100644 --- a/README.md +++ b/README.md @@ -167,6 +167,30 @@ build-mono/ └── build/ # Build output ``` +#### Workspace Mode +Manage an existing mono-repo workspace. + +```bash +# Pull latest changes for all repos +star-setup workspace update + +# Show status of all repos +star-setup workspace status + +# Show status with ahead/behind remote +star-setup workspace status --fetch + +# Remove build directory +star-setup workspace clean +``` + +Workspace flags: +| Flag | Description | +|------|-------------| +| `--path ` | Workspace root directory (default: current directory) | +| `--mono-dir ` | Workspace directory name (default: `build-mono`) | +| `--build-dir ` | Build directory name (default: `build`) | + ### Profile Mode Profiles represent a saved ecosystem of libraries commonly used together. ```bash diff --git a/src/cli/args.rs b/src/cli/args.rs index 9ef218b..ef91b43 100644 --- a/src/cli/args.rs +++ b/src/cli/args.rs @@ -1,7 +1,7 @@ use crate::{ cli::{ - commands::{ConfigCommand, ProfileCommand}, - resolve_with_config, BuildFlags, ConnectionFlags, DiagnosticFlags, MonoRepoFlags, ResolvedArgs, + resolve_with_config, BuildFlags, ConfigCommand, ConnectionFlags, DiagnosticFlags, + MonoRepoFlags, ProfileCommand, ResolvedArgs, WorkspaceCommand, }, config::SetupConfig, }; @@ -13,6 +13,8 @@ pub enum Command { Config(ConfigCommand), /// Manage saved profiles. Profile(ProfileCommand), + /// Manage an existing workspace. + Workspace(WorkspaceCommand), } /// Top-level CLI arguments for star-setup. diff --git a/src/cli/commands.rs b/src/cli/commands.rs index aa5dcf3..50ca16f 100644 --- a/src/cli/commands.rs +++ b/src/cli/commands.rs @@ -1,3 +1,5 @@ +use std::path::PathBuf; + use crate::cli::{BuildFlags, ConnectionFlags, DiagnosticFlags, MonoRepoFlags}; use clap::{Parser, Subcommand}; @@ -60,3 +62,46 @@ pub enum ProfileAction { /// List all saved profiles. List, } + +/// Workspace subcommand. +#[derive(Parser)] +pub struct WorkspaceCommand { + #[command(subcommand)] + pub action: WorkspaceAction, +} + +/// Workspace subcommand actions. +#[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, + }, + /// Show status of all repos in the workspace. + Status { + #[arg(long)] + path: Option, + #[arg(long)] + mono_dir: Option, + #[arg(long)] + build_dir: Option, + #[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, + }, +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 7bac394..6107b17 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -12,4 +12,6 @@ pub use resolved::{ ResolvedMonoFlags, }; pub mod commands; -pub use commands::{ConfigAction, ConfigCommand, ProfileAction, ProfileCommand}; +pub use commands::{ + ConfigAction, ConfigCommand, ProfileAction, ProfileCommand, WorkspaceAction, WorkspaceCommand, +}; diff --git a/src/config/types.rs b/src/config/types.rs index 94fc65c..e6e1615 100644 --- a/src/config/types.rs +++ b/src/config/types.rs @@ -1,4 +1,6 @@ -use crate::cli::{BuildFlags, BuildType, ConnectionFlags, DiagnosticFlags, MonoRepoFlags, ResolvedArgs}; +use crate::cli::{ + BuildFlags, BuildType, ConnectionFlags, DiagnosticFlags, MonoRepoFlags, ResolvedArgs, +}; use serde::{Deserialize, Serialize}; use std::{collections::HashMap, path::PathBuf}; @@ -43,9 +45,20 @@ impl ConfigEntry { ) -> Self { Self { ssh: connection.ssh, - build_type: build.build_type.as_deref().unwrap_or("debug").parse().unwrap_or_default(), - build_dir: build.build_dir.clone().unwrap_or_else(|| "build".to_string()), - mono_dir: mono.mono_dir.clone().unwrap_or_else(|| "build-mono".to_string()), + build_type: build + .build_type + .as_deref() + .unwrap_or("debug") + .parse() + .unwrap_or_default(), + build_dir: build + .build_dir + .clone() + .unwrap_or_else(|| "build".to_string()), + mono_dir: mono + .mono_dir + .clone() + .unwrap_or_else(|| "build-mono".to_string()), no_build: build.no_build, clean: build.clean, verbose: connection.verbose, diff --git a/src/ctx.rs b/src/ctx.rs index 8c910fb..520ff47 100644 --- a/src/ctx.rs +++ b/src/ctx.rs @@ -4,15 +4,6 @@ use std::{ path::Path, }; -/// IO context passed to functions that need input/output and behavioral flags. -pub struct IoCtx<'a> { - pub input: &'a mut dyn BufRead, - pub output: &'a mut dyn Write, - pub verbose: bool, - pub timing: bool, - pub dry_run: bool, -} - /// Trait for executing shell commands. /// # Errors /// Returns an error if the command fails to spawn or exits with a non-zero status. @@ -21,12 +12,11 @@ pub trait Runner { /// # Errors /// Returns an error if the command fails to spawn or exits with a non-zero status. fn run(&mut self, cmd: &[&str], cwd: Option<&Path>, io: &mut IoCtx<'_>) -> Result<(), String>; -} -/// Full execution context combining IO and a command runner. -pub struct RunCtx<'a> { - pub io: IoCtx<'a>, - pub runner: &'a mut dyn Runner, + /// Executes a shell command and captures stdout as a string. + /// # Errors + /// Returns an error if the command fails to spawn or exits with a non-zero status. + fn run_capture(&mut self, cmd: &[&str], cwd: Option<&Path>) -> Result; } /// Runner that executes commands. @@ -35,6 +25,28 @@ impl Runner for ProcessRunner { fn run(&mut self, cmd: &[&str], cwd: Option<&Path>, io: &mut IoCtx<'_>) -> Result<(), String> { run_command(cmd, cwd, io.verbose, io.output) } + + fn run_capture(&mut self, cmd: &[&str], cwd: Option<&Path>) -> Result { + if cmd.is_empty() { + return Err("No command provided".to_string()); + } + let mut command = std::process::Command::new(cmd[0]); + command.args(&cmd[1..]); + if let Some(dir) = cwd { + command.current_dir(dir); + } + let output = command + .output() + .map_err(|e| format!("Failed to run command: {e}"))?; + if output.status.success() { + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) + } else { + Err(format!( + "Command failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + )) + } + } } /// Runner that prints commands instead of executing them. @@ -47,4 +59,23 @@ impl Runner for DryRunRunner { } Ok(()) } + + fn run_capture(&mut self, _cmd: &[&str], _cwd: Option<&Path>) -> Result { + Ok(String::new()) + } +} + +/// IO context passed to functions that need input/output and behavioral flags. +pub struct IoCtx<'a> { + pub input: &'a mut dyn BufRead, + pub output: &'a mut dyn Write, + pub verbose: bool, + pub timing: bool, + pub dry_run: bool, +} + +/// Full execution context combining IO and a command runner. +pub struct RunCtx<'a> { + pub io: IoCtx<'a>, + pub runner: &'a mut dyn Runner, } diff --git a/src/lib.rs b/src/lib.rs index 59a4947..21f144a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,3 +10,4 @@ pub mod prompts; pub mod repository; pub mod run; pub mod utils; +pub mod workspace; diff --git a/src/run.rs b/src/run.rs index 99b44b7..5544120 100644 --- a/src/run.rs +++ b/src/run.rs @@ -1,20 +1,28 @@ use crate::{ - cli::{Args, ConfigAction, ProfileAction, args::Command, resolve_with_config}, commands::{mono_repo_mode, single_repo_mode}, config::{ - ConfigEntry, add_config, create_default_config, list_configs, load_config, remove_config, - }, ctx::{DryRunRunner, IoCtx, ProcessRunner, RunCtx, Runner}, interactive::interactive_mode, profile::{add_profile, list_profiles, remove_profile}, utils::check_prerequisites, + cli::{args::Command, resolve_with_config, Args, ConfigAction, ProfileAction, WorkspaceAction}, + commands::{mono_repo_mode, single_repo_mode}, + config::{ + add_config, create_default_config, list_configs, load_config, remove_config, ConfigEntry, + }, + ctx::{DryRunRunner, IoCtx, ProcessRunner, RunCtx, Runner}, + interactive::interactive_mode, + profile::{add_profile, list_profiles, remove_profile}, + utils::check_prerequisites, + workspace::{clean_workspace, resolve_workspace, status_workspace, update_workspace}, }; +use clap::Parser; use std::{ error::Error, io::{self, IsTerminal}, path::{Path, PathBuf}, }; -use clap::Parser; const CONFIG_FILE_NAME: &str = ".star-setup.json"; /// Runs the setup process. /// # Errors /// Returns an error if the configuration file is missing or corrupted. +#[allow(clippy::too_many_lines)] pub fn run() -> Result<(), Box> { let mut stdin = io::stdin().lock(); let mut stdout = io::stdout(); @@ -69,6 +77,57 @@ pub fn run() -> Result<(), Box> { add_profile(&mut config, &vals, raw.yes, &mut io)?; } }, + Command::Workspace(cmd) => match cmd.action { + WorkspaceAction::Update { + path, + mono_dir, + build_dir, + } => { + let workspace = + resolve_workspace(path.as_deref(), mono_dir.as_deref(), build_dir.as_deref())?; + let mut dry = DryRunRunner; + let mut real = ProcessRunner; + let runner: &mut dyn Runner = if raw.diagnostic.dry_run { + &mut dry + } else { + &mut real + }; + let mut ctx = RunCtx { io, runner }; + update_workspace(&workspace, &mut ctx)?; + } + WorkspaceAction::Status { + path, + mono_dir, + build_dir, + fetch, + } => { + let workspace = + resolve_workspace(path.as_deref(), mono_dir.as_deref(), build_dir.as_deref())?; + let mut real = ProcessRunner; + let mut ctx = RunCtx { + io, + runner: &mut real, + }; + status_workspace(&workspace, fetch, &mut ctx)?; + } + WorkspaceAction::Clean { + path, + mono_dir, + build_dir, + } => { + let workspace = + resolve_workspace(path.as_deref(), mono_dir.as_deref(), build_dir.as_deref())?; + let mut dry = DryRunRunner; + let mut real = ProcessRunner; + let runner: &mut dyn Runner = if raw.diagnostic.dry_run { + &mut dry + } else { + &mut real + }; + let mut ctx = RunCtx { io, runner }; + clean_workspace(&workspace, &mut ctx)?; + } + }, } return Ok(()); } diff --git a/src/workspace/clean.rs b/src/workspace/clean.rs new file mode 100644 index 0000000..cc1de27 --- /dev/null +++ b/src/workspace/clean.rs @@ -0,0 +1,39 @@ +use crate::{ctx::RunCtx, workspace::resolve::Workspace}; +use std::fs; + +/// Removes the build directory from the workspace. +/// # Errors +/// Returns an error if the build directory cannot be removed. +pub fn clean_workspace(workspace: &Workspace, ctx: &mut RunCtx<'_>) -> Result<(), String> { + if !workspace.build_path.exists() { + writeln!( + ctx.io.output, + "Build directory does not exist: {}", + workspace.build_path.display() + ) + .ok(); + return Ok(()); + } + + writeln!( + ctx.io.output, + "Removing build directory: {}", + workspace.build_path.display() + ) + .ok(); + + if ctx.io.dry_run { + writeln!( + ctx.io.output, + "Would remove directory: {}", + workspace.build_path.display() + ) + .ok(); + } else { + fs::remove_dir_all(&workspace.build_path) + .map_err(|e| format!("Failed to remove build directory: {e}"))?; + writeln!(ctx.io.output, "Done").ok(); + } + + Ok(()) +} diff --git a/src/workspace/mod.rs b/src/workspace/mod.rs new file mode 100644 index 0000000..1558217 --- /dev/null +++ b/src/workspace/mod.rs @@ -0,0 +1,8 @@ +pub mod clean; +pub mod resolve; +pub mod status; +pub mod update; +pub use clean::clean_workspace; +pub use resolve::{resolve_workspace, Workspace}; +pub use status::status_workspace; +pub use update::update_workspace; diff --git a/src/workspace/resolve.rs b/src/workspace/resolve.rs new file mode 100644 index 0000000..5836afc --- /dev/null +++ b/src/workspace/resolve.rs @@ -0,0 +1,55 @@ +use std::{ + fs, + path::{Path, PathBuf}, +}; + +/// A resolved workspace with paths to key directories and cloned repos. +#[derive(Debug)] +pub struct Workspace { + /// Workspace root directory (e.g. `./build-mono`). + pub root: PathBuf, + /// Path to the cloned repositories directory. + pub repos_path: PathBuf, + /// Path to the build output directory. + pub build_path: PathBuf, + /// Paths to each cloned repository directory. + pub repo_dirs: Vec, +} + +/// Resolves a workspace from optional path overrides. +/// # Errors +/// Returns an error if the workspace root or repos directory does not exist. +pub fn resolve_workspace( + path: Option<&Path>, + mono_dir: Option<&str>, + build_dir: Option<&str>, +) -> Result { + let base = path.unwrap_or_else(|| Path::new(".")); + let root = base.join(mono_dir.unwrap_or("build-mono")); + let repos_path = root.join("repos"); + let build_path = root.join(build_dir.unwrap_or("build")); + + if !root.exists() { + return Err(format!("Workspace not found: {}", root.display())); + } + if !repos_path.exists() { + return Err(format!( + "Repos directory not found: {}", + repos_path.display() + )); + } + + let repo_dirs = fs::read_dir(&repos_path) + .map_err(|e| format!("Failed to read repos directory: {e}"))? + .filter_map(Result::ok) + .map(|entry| entry.path()) + .filter(|p| p.is_dir() && p.join(".git").exists()) + .collect(); + + Ok(Workspace { + root, + repos_path, + build_path, + repo_dirs, + }) +} diff --git a/src/workspace/status.rs b/src/workspace/status.rs new file mode 100644 index 0000000..0b209aa --- /dev/null +++ b/src/workspace/status.rs @@ -0,0 +1,68 @@ +use crate::{ctx::RunCtx, workspace::resolve::Workspace}; + +/// Shows the status of all repositories in the workspace. +/// # Errors +/// Returns an error if any git command fails. +pub fn status_workspace( + workspace: &Workspace, + fetch: bool, + ctx: &mut RunCtx<'_>, +) -> Result<(), String> { + writeln!(ctx.io.output, "Workspace status:\n").ok(); + + for repo_dir in &workspace.repo_dirs { + let name = repo_dir + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default(); + + if fetch { + ctx + .runner + .run(&["git", "fetch"], Some(repo_dir), &mut ctx.io)?; + } + + let branch = ctx + .runner + .run_capture( + &["git", "rev-parse", "--abbrev-ref", "HEAD"], + Some(repo_dir), + ) + .unwrap_or_else(|_| "(unknown)".to_string()); + + let dirty = !ctx + .runner + .run_capture(&["git", "status", "--porcelain"], Some(repo_dir))? + .is_empty(); + + let status_str = if dirty { "dirty" } else { "clean" }; + + let ahead_behind = if fetch { + let ahead = ctx + .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}" + ) + .ok(); + } + + Ok(()) +} diff --git a/src/workspace/update.rs b/src/workspace/update.rs new file mode 100644 index 0000000..c71c575 --- /dev/null +++ b/src/workspace/update.rs @@ -0,0 +1,42 @@ +use crate::{ctx::RunCtx, workspace::resolve::Workspace}; + +/// Pulls latest changes for all repositories in the workspace. +/// # Errors +/// Returns an error if any `git pull` command fails. +pub fn update_workspace(workspace: &Workspace, ctx: &mut RunCtx<'_>) -> Result<(), String> { + writeln!( + ctx.io.output, + "Updating {} repositories\n", + workspace.repo_dirs.len() + ) + .ok(); + + let mut errors: Vec = Vec::new(); + + for repo_dir in &workspace.repo_dirs { + let name = repo_dir + .file_name() + .map(|n| n.to_string_lossy()) + .unwrap_or_default(); + + writeln!(ctx.io.output, " Updating {name}").ok(); + if let Err(e) = ctx + .runner + .run(&["git", "pull"], Some(repo_dir), &mut ctx.io) + { + writeln!(ctx.io.output, " Failed to update {name}: {e}").ok(); + errors.push(format!("{name}: {e}")); + } + } + + if errors.is_empty() { + writeln!(ctx.io.output, "\nDone").ok(); + Ok(()) + } else { + Err(format!( + "{} repository(s) failed to update:\n{}", + errors.len(), + errors.join("\n") + )) + } +} diff --git a/tests/common/args.rs b/tests/common/args.rs index e8237a7..d52b27e 100644 --- a/tests/common/args.rs +++ b/tests/common/args.rs @@ -1,9 +1,7 @@ #![allow(dead_code)] use star_setup::{ - cli::{ - resolve_with_config, Args, BuildFlags, ConnectionFlags, DiagnosticFlags, MonoRepoFlags, - }, + cli::{resolve_with_config, Args, BuildFlags, ConnectionFlags, DiagnosticFlags, MonoRepoFlags}, config::SetupConfig, }; diff --git a/tests/common/runner.rs b/tests/common/runner.rs index 7f6180e..a265562 100644 --- a/tests/common/runner.rs +++ b/tests/common/runner.rs @@ -1,10 +1,13 @@ use star_setup::ctx::{IoCtx, Runner}; +use std::collections::VecDeque; use std::path::Path; #[allow(dead_code)] pub struct MockRunner { pub calls: Vec<(Vec, Option)>, pub fail_on: Option, + pub capture_output: String, + pub capture_responses: VecDeque, } impl MockRunner { @@ -13,6 +16,8 @@ impl MockRunner { Self { calls: vec![], fail_on: None, + capture_output: String::new(), + capture_responses: VecDeque::new(), } } } @@ -20,12 +25,32 @@ impl MockRunner { impl Runner for MockRunner { fn run(&mut self, cmd: &[&str], cwd: Option<&Path>, _io: &mut IoCtx<'_>) -> Result<(), String> { let cmd_vec: Vec = cmd.iter().map(ToString::to_string).collect(); + self + .calls + .push((cmd_vec.clone(), cwd.map(Path::to_path_buf))); if let Some(fail) = &self.fail_on { if cmd_vec.contains(fail) { return Err(format!("MockRunner: forced failure on {fail}")); } } - self.calls.push((cmd_vec, cwd.map(Path::to_path_buf))); Ok(()) } + + fn run_capture(&mut self, cmd: &[&str], cwd: Option<&Path>) -> Result { + let cmd_vec: Vec = cmd.iter().map(ToString::to_string).collect(); + self + .calls + .push((cmd_vec.clone(), cwd.map(Path::to_path_buf))); + if let Some(fail) = &self.fail_on { + if cmd_vec.contains(fail) { + return Err(format!("MockRunner: forced failure on {fail}")); + } + } + Ok( + self + .capture_responses + .pop_front() + .unwrap_or_else(|| self.capture_output.clone()), + ) + } } diff --git a/tests/config/types.rs b/tests/config/types.rs index b23ce37..c59133f 100644 --- a/tests/config/types.rs +++ b/tests/config/types.rs @@ -1,12 +1,17 @@ +use crate::common::default_resolved; use star_setup::{ cli::{BuildFlags, BuildType, ConnectionFlags, DiagnosticFlags, MonoRepoFlags}, - config::{ConfigEntry}, + config::ConfigEntry, }; -use crate::common::default_resolved; #[test] fn test_from_flags_defaults() { - let connection = ConnectionFlags { ssh: false, https: false, verbose: false, no_verbose: false }; + let connection = ConnectionFlags { + ssh: false, + https: false, + verbose: false, + no_verbose: false, + }; let build = BuildFlags { build_type: None, build_dir: None, @@ -18,8 +23,16 @@ fn test_from_flags_defaults() { cmake_flags: vec![], meson_flags: vec![], }; - let mono = MonoRepoFlags { mono_repo: false, mono_dir: None, repos: None, profile: None }; - let diagnostic = DiagnosticFlags { timing: false, dry_run: false }; + let mono = MonoRepoFlags { + mono_repo: false, + mono_dir: None, + repos: None, + profile: None, + }; + let diagnostic = DiagnosticFlags { + timing: false, + dry_run: false, + }; let entry = ConfigEntry::from_flags(&connection, &build, &mono, &diagnostic); @@ -36,7 +49,12 @@ fn test_from_flags_defaults() { #[test] fn test_from_flags_with_values() { - let connection = ConnectionFlags { ssh: true, https: false, verbose: true, no_verbose: false }; + let connection = ConnectionFlags { + ssh: true, + https: false, + verbose: true, + no_verbose: false, + }; let build = BuildFlags { build_type: Some("release".to_string()), build_dir: Some("out".to_string()), @@ -48,8 +66,16 @@ fn test_from_flags_with_values() { cmake_flags: vec!["-DFOO=ON".to_string()], meson_flags: vec![], }; - let mono = MonoRepoFlags { mono_repo: false, mono_dir: Some("workspace".to_string()), repos: None, profile: None }; - let diagnostic = DiagnosticFlags { timing: true, dry_run: true }; + let mono = MonoRepoFlags { + mono_repo: false, + mono_dir: Some("workspace".to_string()), + repos: None, + profile: None, + }; + let diagnostic = DiagnosticFlags { + timing: true, + dry_run: true, + }; let entry = ConfigEntry::from_flags(&connection, &build, &mono, &diagnostic); diff --git a/tests/ctx.rs b/tests/ctx.rs index e12095c..db891dd 100644 --- a/tests/ctx.rs +++ b/tests/ctx.rs @@ -54,3 +54,39 @@ fn test_dry_run_runner_prints_cwd() { assert!(out.contains("Would run: cmake ..")); assert!(out.contains(" in directory: /tmp/build")); } + +#[test] +fn test_process_runner_captures_output() { + let mut runner = ProcessRunner; + let result = runner.run_capture(&["git", "--version"], None); + assert!(result.is_ok()); + assert!(result.unwrap().contains("git version")); +} + +#[test] +fn test_dry_run_runner_capture_returns_empty() { + let mut runner = DryRunRunner; + let result = runner.run_capture(&["git", "--version"], None); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), ""); +} + +#[test] +fn test_process_runner_capture_errors_on_empty() { + let mut runner = ProcessRunner; + assert!(runner.run_capture(&[], None).is_err()); +} + +#[test] +fn test_process_runner_capture_with_cwd() { + let mut runner = ProcessRunner; + let result = runner.run_capture(&["git", "--version"], Some(Path::new("."))); + assert!(result.is_ok()); +} + +#[test] +fn test_process_runner_capture_errors_on_failure() { + let mut runner = ProcessRunner; + let result = runner.run_capture(&["git", "invalid-command-xyz"], None); + assert!(result.is_err()); +} diff --git a/tests/workspace.rs b/tests/workspace.rs new file mode 100644 index 0000000..1d06a17 --- /dev/null +++ b/tests/workspace.rs @@ -0,0 +1,10 @@ +#[path = "workspace/clean.rs"] +mod clean; +#[path = "common/mod.rs"] +mod common; +#[path = "workspace/resolve.rs"] +mod resolve; +#[path = "workspace/status.rs"] +mod status; +#[path = "workspace/update.rs"] +mod update; diff --git a/tests/workspace/clean.rs b/tests/workspace/clean.rs new file mode 100644 index 0000000..a5e54cf --- /dev/null +++ b/tests/workspace/clean.rs @@ -0,0 +1,78 @@ +use crate::common::{empty_input, make_io, sink, MockRunner}; +use star_setup::{ctx::RunCtx, workspace::resolve::Workspace}; +use std::fs; +use tempfile::TempDir; + +#[test] +fn test_clean_workspace_no_build_dir() { + let tmp = TempDir::new().unwrap(); + let ws = Workspace { + root: tmp.path().to_path_buf(), + repos_path: tmp.path().join("repos"), + build_path: tmp.path().join("build"), + repo_dirs: vec![], + }; + let mut input = empty_input(); + let mut output = Vec::new(); + let mut runner = MockRunner::new(); + let mut ctx = RunCtx { + io: make_io(&mut input, &mut output), + runner: &mut runner, + }; + star_setup::workspace::clean_workspace(&ws, &mut ctx).unwrap(); + let out = String::from_utf8(output).unwrap(); + assert!(out.contains("does not exist")); +} + +#[test] +fn test_clean_workspace_removes_build_dir() { + let tmp = TempDir::new().unwrap(); + let build = tmp.path().join("build"); + fs::create_dir_all(&build).unwrap(); + fs::write(build.join("dummy.txt"), "").unwrap(); + let ws = Workspace { + root: tmp.path().to_path_buf(), + repos_path: tmp.path().join("repos"), + build_path: build.clone(), + repo_dirs: vec![], + }; + let mut input = empty_input(); + let mut output = sink(); + let mut runner = MockRunner::new(); + let mut ctx = RunCtx { + io: make_io(&mut input, &mut output), + runner: &mut runner, + }; + star_setup::workspace::clean_workspace(&ws, &mut ctx).unwrap(); + assert!(!build.exists()); +} + +#[test] +fn test_clean_workspace_dry_run() { + let tmp = TempDir::new().unwrap(); + let build = tmp.path().join("build"); + fs::create_dir_all(&build).unwrap(); + let ws = Workspace { + root: tmp.path().to_path_buf(), + repos_path: tmp.path().join("repos"), + build_path: build.clone(), + repo_dirs: vec![], + }; + let mut input = empty_input(); + let mut output = Vec::new(); + let mut runner = MockRunner::new(); + let mut ctx = RunCtx { + io: star_setup::ctx::IoCtx { + input: &mut input, + output: &mut output, + verbose: false, + timing: false, + dry_run: true, + }, + runner: &mut runner, + }; + star_setup::workspace::clean_workspace(&ws, &mut ctx).unwrap(); + assert!(build.exists()); + let out = String::from_utf8(output).unwrap(); + assert!(out.contains("Would remove directory:")); +} diff --git a/tests/workspace/resolve.rs b/tests/workspace/resolve.rs new file mode 100644 index 0000000..003fba6 --- /dev/null +++ b/tests/workspace/resolve.rs @@ -0,0 +1,66 @@ +use star_setup::workspace::resolve_workspace; +use std::fs; +use tempfile::TempDir; + +#[test] +fn test_resolve_workspace_errors_when_missing() { + let tmp = TempDir::new().unwrap(); + let result = resolve_workspace(Some(tmp.path()), None, None); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Workspace not found")); +} + +#[test] +fn test_resolve_workspace_errors_when_no_repos() { + let tmp = TempDir::new().unwrap(); + fs::create_dir_all(tmp.path().join("build-mono")).unwrap(); + let result = resolve_workspace(Some(tmp.path()), None, None); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Repos directory not found")); +} + +#[test] +fn test_resolve_workspace_succeeds() { + let tmp = TempDir::new().unwrap(); + fs::create_dir_all(tmp.path().join("build-mono").join("repos")).unwrap(); + let result = resolve_workspace(Some(tmp.path()), None, None); + assert!(result.is_ok()); + let ws = result.unwrap(); + assert_eq!(ws.repo_dirs.len(), 0); +} + +#[test] +fn test_resolve_workspace_finds_repos() { + let tmp = TempDir::new().unwrap(); + let repos = tmp.path().join("build-mono").join("repos"); + fs::create_dir_all(repos.join("user-lib1").join(".git")).unwrap(); + fs::create_dir_all(repos.join("user-lib2").join(".git")).unwrap(); + let ws = resolve_workspace(Some(tmp.path()), None, None).unwrap(); + assert_eq!(ws.repo_dirs.len(), 2); +} + +#[test] +fn test_resolve_workspace_custom_mono_dir() { + let tmp = TempDir::new().unwrap(); + fs::create_dir_all(tmp.path().join("my-workspace").join("repos")).unwrap(); + let result = resolve_workspace(Some(tmp.path()), Some("my-workspace"), None); + assert!(result.is_ok()); +} + +#[test] +fn test_resolve_workspace_custom_build_dir() { + let tmp = TempDir::new().unwrap(); + fs::create_dir_all(tmp.path().join("build-mono").join("repos")).unwrap(); + let ws = resolve_workspace(Some(tmp.path()), None, Some("out")).unwrap(); + assert!(ws.build_path.ends_with("out")); +} + +#[test] +fn test_resolve_workspace_excludes_non_git_dirs() { + let tmp = TempDir::new().unwrap(); + let repos = tmp.path().join("build-mono").join("repos"); + fs::create_dir_all(repos.join("user-lib1").join(".git")).unwrap(); + fs::create_dir_all(repos.join("not-a-repo")).unwrap(); + let ws = resolve_workspace(Some(tmp.path()), None, None).unwrap(); + assert_eq!(ws.repo_dirs.len(), 1); +} diff --git a/tests/workspace/status.rs b/tests/workspace/status.rs new file mode 100644 index 0000000..c027e3b --- /dev/null +++ b/tests/workspace/status.rs @@ -0,0 +1,68 @@ +use crate::common::{empty_input, make_io, MockRunner}; +use star_setup::{ctx::RunCtx, workspace::resolve::Workspace}; +use std::path::PathBuf; + +fn make_workspace(repo_dirs: Vec) -> Workspace { + Workspace { + root: PathBuf::from("build-mono"), + repos_path: PathBuf::from("build-mono/repos"), + build_path: PathBuf::from("build-mono/build"), + repo_dirs, + } +} + +#[test] +fn test_status_workspace_empty() { + let ws = make_workspace(vec![]); + let mut input = empty_input(); + let mut output = Vec::new(); + let mut runner = MockRunner::new(); + let mut ctx = RunCtx { + io: make_io(&mut input, &mut output), + runner: &mut runner, + }; + star_setup::workspace::status_workspace(&ws, false, &mut ctx).unwrap(); + let out = String::from_utf8(output).unwrap(); + assert!(out.contains("Workspace status:")); +} + +#[test] +fn test_status_workspace_shows_repos() { + let ws = make_workspace(vec![PathBuf::from("build-mono/repos/user-lib1")]); + let mut input = empty_input(); + let mut output = Vec::new(); + let mut runner = MockRunner::new(); + runner.capture_responses.push_back("main".to_string()); + runner.capture_responses.push_back(String::new()); + let mut ctx = RunCtx { + io: make_io(&mut input, &mut output), + runner: &mut runner, + }; + star_setup::workspace::status_workspace(&ws, false, &mut ctx).unwrap(); + let out = String::from_utf8(output).unwrap(); + assert!(out.contains("user-lib1")); + assert!(out.contains("clean")); +} + +#[test] +fn test_status_workspace_with_fetch() { + let ws = make_workspace(vec![PathBuf::from("build-mono/repos/user-lib1")]); + let mut input = empty_input(); + let mut output = Vec::new(); + let mut runner = MockRunner::new(); + runner.capture_responses.push_back("main".to_string()); + runner.capture_responses.push_back(String::new()); + runner.capture_responses.push_back("2".to_string()); + runner.capture_responses.push_back("1".to_string()); + let mut ctx = RunCtx { + io: make_io(&mut input, &mut output), + runner: &mut runner, + }; + star_setup::workspace::status_workspace(&ws, true, &mut ctx).unwrap(); + let out = String::from_utf8(output).unwrap(); + assert!(out.contains("↑2 ↓1")); + assert!(runner + .calls + .iter() + .any(|(cmd, _)| cmd[0] == "git" && cmd[1] == "fetch")); +} diff --git a/tests/workspace/update.rs b/tests/workspace/update.rs new file mode 100644 index 0000000..79ad1a0 --- /dev/null +++ b/tests/workspace/update.rs @@ -0,0 +1,68 @@ +use crate::common::{empty_input, make_io, sink, MockRunner}; +use star_setup::{ctx::RunCtx, workspace::resolve::Workspace}; +use std::path::PathBuf; + +fn make_workspace(repo_dirs: Vec) -> Workspace { + Workspace { + root: PathBuf::from("build-mono"), + repos_path: PathBuf::from("build-mono/repos"), + build_path: PathBuf::from("build-mono/build"), + repo_dirs, + } +} + +#[test] +fn test_update_workspace_empty() { + let ws = make_workspace(vec![]); + let mut input = empty_input(); + let mut output = sink(); + let mut runner = MockRunner::new(); + let mut ctx = RunCtx { + io: make_io(&mut input, &mut output), + runner: &mut runner, + }; + star_setup::workspace::update_workspace(&ws, &mut ctx).unwrap(); + assert!(runner.calls.is_empty()); +} + +#[test] +fn test_update_workspace_pulls_each_repo() { + let ws = make_workspace(vec![ + PathBuf::from("build-mono/repos/user-lib1"), + PathBuf::from("build-mono/repos/user-lib2"), + ]); + let mut input = empty_input(); + let mut output = sink(); + let mut runner = MockRunner::new(); + let mut ctx = RunCtx { + io: make_io(&mut input, &mut output), + runner: &mut runner, + }; + star_setup::workspace::update_workspace(&ws, &mut ctx).unwrap(); + assert_eq!(runner.calls.len(), 2); + assert!(runner + .calls + .iter() + .all(|(cmd, _)| cmd[0] == "git" && cmd[1] == "pull")); +} + +#[test] +fn test_update_workspace_continues_on_failure() { + let ws = make_workspace(vec![ + PathBuf::from("build-mono/repos/user-lib1"), + PathBuf::from("build-mono/repos/user-lib2"), + ]); + let mut input = empty_input(); + let mut output = Vec::new(); + let mut runner = MockRunner::new(); + runner.fail_on = Some("pull".to_string()); + let mut ctx = RunCtx { + io: make_io(&mut input, &mut output), + runner: &mut runner, + }; + let result = star_setup::workspace::update_workspace(&ws, &mut ctx); + assert!(result.is_err()); + assert_eq!(runner.calls.len(), 2); + let out = String::from_utf8(output).unwrap(); + assert!(out.contains("Failed to update")); +}