From bff3fd8523ae49381ff874ab6a32d429363b8d0b Mon Sep 17 00:00:00 2001 From: Masonlet Date: Fri, 26 Jun 2026 20:23:10 -0400 Subject: [PATCH 1/4] feat: replace flat config/profile flags with subcommands --- src/cli/args.rs | 27 ++++++++---- src/cli/commands.rs | 62 +++++++++++++++++++++++++++ src/cli/flags.rs | 39 ----------------- src/cli/mod.rs | 6 +-- src/cli/resolve.rs | 6 +-- src/cli/resolved.rs | 4 +- src/config/types.rs | 29 ++++++++++++- src/run.rs | 100 ++++++++++++++++++------------------------- tests/cli/resolve.rs | 4 +- tests/common/args.rs | 17 ++------ 10 files changed, 160 insertions(+), 134 deletions(-) create mode 100644 src/cli/commands.rs diff --git a/src/cli/args.rs b/src/cli/args.rs index 12890d1..9ef218b 100644 --- a/src/cli/args.rs +++ b/src/cli/args.rs @@ -1,11 +1,19 @@ use crate::{ cli::{ - resolve_with_config, BuildFlags, ConfigFlags, ConnectionFlags, DiagnosticFlags, MonoRepoFlags, - ProfileFlags, ResolvedArgs, + commands::{ConfigCommand, ProfileCommand}, + resolve_with_config, BuildFlags, ConnectionFlags, DiagnosticFlags, MonoRepoFlags, ResolvedArgs, }, config::SetupConfig, }; -use clap::Parser; +use clap::{Parser, Subcommand}; + +#[derive(Subcommand)] +pub enum Command { + /// Manage saved configurations. + Config(ConfigCommand), + /// Manage saved profiles. + Profile(ProfileCommand), +} /// Top-level CLI arguments for star-setup. #[derive(Parser)] @@ -22,6 +30,13 @@ pub struct Args { #[arg(short = 'y', long)] pub yes: bool, + /// Select a named configuration to use + #[arg(long = "config")] + pub config_name: Option, + + #[command(subcommand)] + pub command: Option, + #[command(flatten)] pub connection: ConnectionFlags, @@ -31,12 +46,6 @@ pub struct Args { #[command(flatten)] pub mono: MonoRepoFlags, - #[command(flatten)] - pub config: ConfigFlags, - - #[command(flatten)] - pub profile: ProfileFlags, - #[command(flatten)] pub diagnostic: DiagnosticFlags, } diff --git a/src/cli/commands.rs b/src/cli/commands.rs new file mode 100644 index 0000000..aa5dcf3 --- /dev/null +++ b/src/cli/commands.rs @@ -0,0 +1,62 @@ +use crate::cli::{BuildFlags, ConnectionFlags, DiagnosticFlags, MonoRepoFlags}; +use clap::{Parser, Subcommand}; + +/// Config subcommand. +#[derive(Parser)] +pub struct ConfigCommand { + #[command(subcommand)] + pub action: ConfigAction, +} + +/// Config subcommand actions. +#[derive(Subcommand)] +pub enum ConfigAction { + /// Create a default config file in the current directory. + Init, + /// Add or overwrite a named configuration entry. + Add { + /// Name of the configuration entry. + name: String, + #[command(flatten)] + connection: ConnectionFlags, + #[command(flatten)] + build: BuildFlags, + #[command(flatten)] + mono: MonoRepoFlags, + #[command(flatten)] + diagnostic: DiagnosticFlags, + }, + /// Remove a named configuration entry. + Remove { + /// Name of the configuration entry to remove. + name: String, + }, + /// List all saved configuration entries. + List, +} + +/// Profile subcommand. +#[derive(Parser)] +pub struct ProfileCommand { + #[command(subcommand)] + pub action: ProfileAction, +} + +/// Profile subcommand actions. +#[derive(Subcommand)] +pub enum ProfileAction { + /// Add or overwrite a named profile. + Add { + /// Name of the profile. + name: String, + /// Repository list (username/repo ...). + repos: Vec, + }, + /// Remove a named profile. + Remove { + /// Name of the profile to remove. + name: String, + }, + /// List all saved profiles. + List, +} diff --git a/src/cli/flags.rs b/src/cli/flags.rs index 39633f6..1916065 100644 --- a/src/cli/flags.rs +++ b/src/cli/flags.rs @@ -76,45 +76,6 @@ pub struct MonoRepoFlags { pub profile: Option, } -#[derive(ClapArgs)] -#[allow(clippy::struct_excessive_bools)] -pub struct ConfigFlags { - /// Create a default config file in the current directory - #[arg(long)] - pub init_config: bool, - - /// Select a named configuration to use - #[arg(long = "config")] - pub config_name: Option, - - /// Add a new config - #[arg(long)] - pub config_add: Option, - - /// Remove a saved configuration - #[arg(long)] - pub config_remove: Option, - - /// List all saved configs - #[arg(long)] - pub list_configs: bool, -} - -#[derive(ClapArgs)] -pub struct ProfileFlags { - /// Add a new profile: NAME REPO1 [REPO2 ...] - #[arg(long, num_args = 2..)] - pub profile_add: Option>, - - /// Remove a saved profile - #[arg(long)] - pub profile_remove: Option, - - /// List all saved profiles - #[arg(long)] - pub list_profiles: bool, -} - #[derive(ClapArgs)] pub struct DiagnosticFlags { /// Show timing information for each phase diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 383b938..7bac394 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -3,9 +3,7 @@ pub use args::Args; pub mod build; pub use build::{detect_build_system, detect_mono_build_system, BuildSystem, BuildType}; pub mod flags; -pub use flags::{ - BuildFlags, ConfigFlags, ConnectionFlags, DiagnosticFlags, MonoRepoFlags, ProfileFlags, -}; +pub use flags::{BuildFlags, ConnectionFlags, DiagnosticFlags, MonoRepoFlags}; pub mod resolve; pub use resolve::{resolve_bool, resolve_with_config}; pub mod resolved; @@ -13,3 +11,5 @@ pub use resolved::{ ResolvedArgs, ResolvedBuildFlags, ResolvedConnectionFlags, ResolvedDiagnosticFlags, ResolvedMonoFlags, }; +pub mod commands; +pub use commands::{ConfigAction, ConfigCommand, ProfileAction, ProfileCommand}; diff --git a/src/cli/resolve.rs b/src/cli/resolve.rs index a4076b8..bb316f7 100644 --- a/src/cli/resolve.rs +++ b/src/cli/resolve.rs @@ -23,8 +23,8 @@ pub fn resolve_bool(positive: bool, negative: bool, config: Option, defaul /// # Errors /// Returns an error if the named config does not exist in the provided `SetupConfig`. pub fn resolve_with_config(mut args: Args, config: &SetupConfig) -> Result { - let config_name = args.config.config_name.as_deref().unwrap_or("default"); - if let Some(name) = &args.config.config_name { + let config_name = args.config_name.as_deref().unwrap_or("default"); + if let Some(name) = &args.config_name { if !config.configs.contains_key(name.as_str()) { return Err(format!("Configuration '{name}' not found")); } @@ -111,7 +111,5 @@ pub fn resolve_with_config(mut args: Args, config: &SetupConfig) -> Result, } +impl ConfigEntry { + /// Creates a `ConfigEntry` from raw flag structs. + /// # Errors + /// Returns an error if the build type string cannot be parsed. + #[must_use] + pub fn from_flags( + connection: &ConnectionFlags, + build: &BuildFlags, + mono: &MonoRepoFlags, + diagnostic: &DiagnosticFlags, + ) -> 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()), + no_build: build.no_build, + clean: build.clean, + verbose: connection.verbose, + timing: diagnostic.timing, + dry_run: diagnostic.dry_run, + cmake_flags: build.cmake_flags.clone(), + meson_flags: build.meson_flags.clone(), + } + } +} + impl From<&ResolvedArgs> for ConfigEntry { fn from(args: &ResolvedArgs) -> Self { Self { diff --git a/src/run.rs b/src/run.rs index 202fdda..99b44b7 100644 --- a/src/run.rs +++ b/src/run.rs @@ -1,64 +1,14 @@ use crate::{ - cli::{Args, ResolvedArgs}, - commands::{mono_repo_mode, single_repo_mode}, - config::{ - add_config, create_default_config, list_configs, load_config, remove_config, ConfigEntry, - SetupConfig, - }, - ctx::{DryRunRunner, IoCtx, ProcessRunner, RunCtx, Runner}, - interactive::interactive_mode, - profile::{add_profile, list_profiles, remove_profile}, - utils::check_prerequisites, + 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, }; use std::{ error::Error, io::{self, IsTerminal}, path::{Path, PathBuf}, }; - -fn handle_early_commands( - args: &ResolvedArgs, - config: &mut SetupConfig, - io: &mut IoCtx<'_>, -) -> Result> { - if args.config.init_config { - create_default_config(PathBuf::from(CONFIG_FILE_NAME), args.yes, io)?; - return Ok(true); - } - - if args.config.list_configs { - list_configs(config, io); - return Ok(true); - } - - if args.profile.list_profiles { - list_profiles(config, io); - return Ok(true); - } - - if let Some(name) = args.config.config_remove.as_deref() { - remove_config(config, name, args.yes, io)?; - return Ok(true); - } - - if let Some(name) = args.config.config_add.as_deref() { - let entry = ConfigEntry::from(args); - add_config(config, name, entry, args.yes, io)?; - return Ok(true); - } - - if let Some(name) = args.profile.profile_remove.as_deref() { - remove_profile(config, name, args.yes, io)?; - return Ok(true); - } - - if let Some(vals) = args.profile.profile_add.as_ref() { - add_profile(config, vals, args.yes, io)?; - return Ok(true); - } - - Ok(false) -} +use clap::Parser; const CONFIG_FILE_NAME: &str = ".star-setup.json"; @@ -78,20 +28,52 @@ pub fn run() -> Result<(), Box> { ]; let mut config = load_config(&locations, &mut stdout); - let mut args = Args::parse_with_config(&config)?; + let raw = Args::parse(); let mut io = IoCtx { input: &mut stdin, output: &mut stdout, - verbose: args.connection.verbose, - timing: args.diagnostic.timing, - dry_run: args.diagnostic.dry_run, + verbose: raw.connection.verbose, + timing: raw.diagnostic.timing, + dry_run: raw.diagnostic.dry_run, }; - if handle_early_commands(&args, &mut config, &mut io)? { + if let Some(command) = raw.command { + match command { + Command::Config(cmd) => match cmd.action { + ConfigAction::Init => { + create_default_config(PathBuf::from(CONFIG_FILE_NAME), raw.yes, &mut io)?; + } + ConfigAction::List => list_configs(&config, &mut io), + ConfigAction::Remove { name } => { + remove_config(&mut config, &name, raw.yes, &mut io)?; + } + ConfigAction::Add { + name, + connection, + build, + mono, + diagnostic, + } => { + let entry = ConfigEntry::from_flags(&connection, &build, &mono, &diagnostic); + add_config(&mut config, &name, entry, raw.yes, &mut io)?; + } + }, + Command::Profile(cmd) => match cmd.action { + ProfileAction::List => list_profiles(&config, &mut io), + ProfileAction::Remove { name } => { + remove_profile(&mut config, &name, raw.yes, &mut io)?; + } + ProfileAction::Add { name, repos } => { + let vals = std::iter::once(name).chain(repos).collect::>(); + add_profile(&mut config, &vals, raw.yes, &mut io)?; + } + }, + } return Ok(()); } + let mut args = resolve_with_config(raw, &config).map_err(Box::::from)?; if args.repo.is_none() { if is_terminal { interactive_mode(&mut args, &mut io)?; diff --git a/tests/cli/resolve.rs b/tests/cli/resolve.rs index 4e56b54..708e1a5 100644 --- a/tests/cli/resolve.rs +++ b/tests/cli/resolve.rs @@ -143,7 +143,7 @@ fn test_resolve_with_config_cli_overrides_config() { fn test_resolve_with_config_errors_on_missing_config_name() { let config = SetupConfig::new(); let mut args = default_args(); - args.config.config_name = Some("nonexistent".to_string()); + args.config_name = Some("nonexistent".to_string()); let result = resolve_with_config(args, &config); assert!(result.is_err()); } @@ -186,7 +186,7 @@ fn test_resolve_with_config_named_config_pulls_correct_values() { }, ); let mut args = default_args(); - args.config.config_name = Some("myconfig".to_string()); + args.config_name = Some("myconfig".to_string()); let resolved = resolve_with_config(args, &config).unwrap(); assert!(resolved.connection.ssh); assert_eq!(resolved.build.build_type, BuildType::RelWithDebInfo); diff --git a/tests/common/args.rs b/tests/common/args.rs index de566fb..e8237a7 100644 --- a/tests/common/args.rs +++ b/tests/common/args.rs @@ -2,8 +2,7 @@ use star_setup::{ cli::{ - resolve_with_config, Args, BuildFlags, ConfigFlags, ConnectionFlags, DiagnosticFlags, - MonoRepoFlags, ProfileFlags, + resolve_with_config, Args, BuildFlags, ConnectionFlags, DiagnosticFlags, MonoRepoFlags, }, config::SetupConfig, }; @@ -12,6 +11,8 @@ pub fn default_args() -> Args { Args { repo: None, yes: false, + config_name: None, + command: None, diagnostic: DiagnosticFlags { timing: false, dry_run: false, @@ -39,18 +40,6 @@ pub fn default_args() -> Args { repos: None, profile: None, }, - config: ConfigFlags { - init_config: false, - config_name: None, - config_add: None, - config_remove: None, - list_configs: false, - }, - profile: ProfileFlags { - profile_add: None, - profile_remove: None, - list_profiles: false, - }, } } From 949dd1a7b92a55b1da8a07e52a8eb0112ddb17b5 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Fri, 26 Jun 2026 20:24:36 -0400 Subject: [PATCH 2/4] docs: update README for subcommands --- README.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 8e59f37..ac8492a 100644 --- a/README.md +++ b/README.md @@ -171,13 +171,13 @@ build-mono/ Profiles represent a saved ecosystem of libraries commonly used together. ```bash # Add a profile -star-setup --profile-add myprofile user/lib1 user/lib2 +star-setup profile add myprofile user/lib1 user/lib2 # List profiles -star-setup --list-profiles +star-setup profile list # Remove a profile -star-setup --profile-remove myprofile +star-setup profile remove myprofile # Use a profile star-setup username/repo --profile myprofile @@ -190,16 +190,16 @@ Config files are checked in this order: ```bash # Initialize a default config file -star-setup --init-config +star-setup config init # Add a named config -star-setup --config-add myconfig --ssh --build-type Release +star-setup config add myconfig --ssh --build-type Release # List configs -star-setup --list-configs +star-setup config list # Remove a config -star-setup --config-remove myconfig +star-setup config remove myconfig # Use a config star-setup username/repo --config myconfig From fe065298a070d00e8f3f3cfa8ff0a950c6f592bf Mon Sep 17 00:00:00 2001 From: Masonlet Date: Fri, 26 Jun 2026 20:24:57 -0400 Subject: [PATCH 3/4] chore: bump v0.3.3 --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index b67bc2a..6414520 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "star-setup" -version = "0.3.2" +version = "0.3.3" edition = "2021" repository = "https://github.com/star-setup/core" description = "Lightweight CLI to clone, configure, and wire single or multi-repo ecosystems" From b06dd75ebaa4b0eade0f30d3fdf1ca1aeaa385b1 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Fri, 26 Jun 2026 20:29:44 -0400 Subject: [PATCH 4/4] test: add ConfigEntry from_flags and From tests --- tests/config.rs | 2 ++ tests/config/types.rs | 77 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+) create mode 100644 tests/config/types.rs diff --git a/tests/config.rs b/tests/config.rs index ef1106c..b0d5811 100644 --- a/tests/config.rs +++ b/tests/config.rs @@ -8,3 +8,5 @@ mod display; mod fixtures; #[path = "config/io.rs"] mod io; +#[path = "config/types.rs"] +mod types; diff --git a/tests/config/types.rs b/tests/config/types.rs new file mode 100644 index 0000000..b23ce37 --- /dev/null +++ b/tests/config/types.rs @@ -0,0 +1,77 @@ +use star_setup::{ + cli::{BuildFlags, BuildType, ConnectionFlags, DiagnosticFlags, MonoRepoFlags}, + 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 build = BuildFlags { + build_type: None, + build_dir: None, + build_system: None, + no_build: false, + build: false, + clean: false, + no_clean: false, + 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 entry = ConfigEntry::from_flags(&connection, &build, &mono, &diagnostic); + + assert!(!entry.ssh); + assert_eq!(entry.build_type, BuildType::Debug); + assert_eq!(entry.build_dir, "build"); + assert_eq!(entry.mono_dir, "build-mono"); + assert!(!entry.no_build); + assert!(!entry.clean); + assert!(!entry.verbose); + assert!(!entry.timing); + assert!(!entry.dry_run); +} + +#[test] +fn test_from_flags_with_values() { + 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()), + build_system: None, + no_build: true, + build: false, + clean: true, + no_clean: false, + 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 entry = ConfigEntry::from_flags(&connection, &build, &mono, &diagnostic); + + assert!(entry.ssh); + assert_eq!(entry.build_type, BuildType::Release); + assert_eq!(entry.build_dir, "out"); + assert_eq!(entry.mono_dir, "workspace"); + assert!(entry.no_build); + assert!(entry.clean); + assert!(entry.verbose); + assert!(entry.timing); + assert!(entry.dry_run); + assert_eq!(entry.cmake_flags, vec!["-DFOO=ON"]); +} + +#[test] +fn test_from_resolved_args() { + let args = default_resolved(); + let entry = ConfigEntry::from(&args); + + assert!(!entry.ssh); + assert_eq!(entry.build_type, BuildType::Debug); + assert_eq!(entry.build_dir, "build"); + assert!(entry.no_build); +}