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"));
+}