From f0cf29747e54ed2c2e4178b5574995a8f7281fa3 Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Wed, 30 Sep 2026 17:09:36 +0200 Subject: [PATCH] feat(cli): add a studio subcommand behind the studio feature The studio shipped as a second binary, so a CLI built with `--features studio` answered `unrecognized subcommand 'studio'` to the one name a user would try first. `rustmotion studio` now opens it, with the same `-f` / `-d` as `rustmotion-studio`, which stays installed for anyone who scripted it. Without the feature the variant is still compiled, hidden from `--help` per the rule already in CLAUDE.md, and dispatched to the `cargo install --features studio` line. A subcommand that names the flag granting it is the whole reason the report came in; `unrecognized subcommand` names nothing. `studio::run` parsed its own argv, which would have choked on the `studio` token, so the body moved to `run_with(file, dir)` and both entrypoints call it. `studio::command()` had no caller and went with it. Closes #458 --- README.md | 9 ++- crates/rustmotion/CLAUDE.md | 31 +++++---- crates/rustmotion/src/cli/mod.rs | 24 +++++++ crates/rustmotion/src/studio/mod.rs | 17 +++-- crates/rustmotion/tests/studio_subcommand.rs | 68 ++++++++++++++++++++ 5 files changed, 124 insertions(+), 25 deletions(-) create mode 100644 crates/rustmotion/tests/studio_subcommand.rs diff --git a/README.md b/README.md index fcb47e8..dcbf636 100644 --- a/README.md +++ b/README.md @@ -21,12 +21,17 @@ cargo install --git https://github.com/LeadcodeDev/rustmotion ``` Either command installs the `rustmotion` CLI. Add the `studio` feature to get -the live preview window (`rustmotion-studio`) alongside it: +the live preview window alongside it: ```bash cargo install --git https://github.com/LeadcodeDev/rustmotion --features studio ``` +That build answers to `rustmotion studio -f scenario.json`, and also installs +the same window as a standalone `rustmotion-studio` binary. A CLI built without +the feature hides `studio` from `--help` and, when asked for it anyway, prints +the `cargo install` line above. + The studio is not in the default build because it pulls gpui and a native GUI toolchain, which do not build everywhere the CLI builds — a headless server being the obvious case. @@ -2190,7 +2195,7 @@ crates/ │ └── *.rs # one file per component (Painter implementation) └── rustmotion/src/ ├── cli/ # the `rustmotion` binary (clap subcommands) - ├── studio/ # the `rustmotion-studio` binary (feature `studio`) + ├── studio/ # `rustmotion studio` and the `rustmotion-studio` binary (feature `studio`) ├── encode/ # video/audio encoders and muxing └── loader.rs # JSON/HTML → ResolvedScenario ``` diff --git a/crates/rustmotion/CLAUDE.md b/crates/rustmotion/CLAUDE.md index 10bffb3..5b6e222 100644 --- a/crates/rustmotion/CLAUDE.md +++ b/crates/rustmotion/CLAUDE.md @@ -262,26 +262,29 @@ crates/ └── loader.rs # Chargement JSON/HTML → ResolvedScenario ``` -> Les deux binaires vivent dans la crate publiée `rustmotion` : une crate qui -> n'a qu'une `[lib]` n'installe rien d'exécutable via `cargo install`, et -> `cargo install --git ` **refuse** un dépôt où plus d'un paquet déclare un -> `[[bin]]` (« multiple packages with binaries found » — ni `default-members` ni -> `required-features` ne changent ce décompte). C'est pour ça que le studio est -> un module de cette crate et non un paquet à part : en paquet, il dépendait de -> `loader`/`encode`, donc en faire une dépendance de `rustmotion` était un cycle, -> que cargo refuse même optionnel. +> Both binaries live in the published `rustmotion` crate: a crate with only a +> `[lib]` installs nothing executable through `cargo install`, and `cargo install +> --git ` **refuses** a repository where more than one package declares a +> `[[bin]]` ("multiple packages with binaries found" — neither `default-members` +> nor `required-features` changes that count). That is why the studio is a module +> of this crate rather than a package of its own: as a package it depended on +> `loader`/`encode`, so making it a dependency of `rustmotion` was a cycle, which +> cargo refuses even when optional. > > ```bash > cargo install --git https://github.com/LeadcodeDev/rustmotion # CLI > cargo install --git https://github.com/LeadcodeDev/rustmotion --features studio # CLI + studio > ``` > -> `studio` est hors du build par défaut : il tire gpui et une toolchain GUI, qui -> ne compilent pas partout où le CLI compile. Il n'y a pas de **sous-commande** -> `studio` non plus — elle devrait disparaître du `--help` selon le feature. -> `--workspace` seul ne compile plus le studio : CI passe -> `--features rustmotion/studio` à clippy et aux tests, sans quoi 11 600 lignes -> cessent d'être vérifiées en restant vertes. +> `studio` is out of the default build: it pulls gpui and a GUI toolchain, which +> do not compile everywhere the CLI does. The `rustmotion studio` subcommand +> opens the same studio as the `rustmotion-studio` binary, with the same `-f` / +> `-d`. Without the feature the subcommand still exists but stays out of +> `--help`, and running it answers with the `cargo install` line that grants it +> instead of "unrecognized subcommand". +> `--workspace` alone no longer compiles the studio: CI passes +> `--features rustmotion/studio` to clippy and to the tests, without which 11,600 +> lines stop being checked while staying green. ### Aucun commentaire, nulle part diff --git a/crates/rustmotion/src/cli/mod.rs b/crates/rustmotion/src/cli/mod.rs index 3168357..2046441 100644 --- a/crates/rustmotion/src/cli/mod.rs +++ b/crates/rustmotion/src/cli/mod.rs @@ -401,6 +401,18 @@ enum Commands { action: SkillsAction, }, + /// Open the live preview studio on a scenario + #[cfg_attr(not(feature = "studio"), command(hide = true))] + Studio { + /// Path to a JSON scenario to open directly in the editor + #[arg(short, long)] + file: Option, + + /// Workspace directory to scan for scenarios (default: current directory) + #[arg(short, long)] + dir: Option, + }, + /// Generate or install shell completions Completions { #[command(subcommand)] @@ -408,6 +420,12 @@ enum Commands { }, } +#[cfg(not(feature = "studio"))] +const STUDIO_NOT_BUILT: &str = "\ +this build of rustmotion has no studio: it is behind the `studio` feature, which is \ +off by default because it pulls gpui and a native GUI toolchain. Reinstall with it on: + cargo install --git https://github.com/LeadcodeDev/rustmotion --features studio"; + const CAPTIONS_EXAMPLES: &str = "\ Examples: # Transcribe audio into word-level timings (requires whisper.cpp) @@ -963,6 +981,12 @@ pub fn run() -> Result<()> { } SkillsAction::Show { name } => skills::show(&name), }, + #[cfg(feature = "studio")] + Commands::Studio { file, dir } => rustmotion::studio::run_with(file, dir), + + #[cfg(not(feature = "studio"))] + Commands::Studio { .. } => Err(RustmotionError::Generic(STUDIO_NOT_BUILT.to_string())), + Commands::Completions { action } => match action { CompletionsAction::Generate { shell } => { clap_complete::generate( diff --git a/crates/rustmotion/src/studio/mod.rs b/crates/rustmotion/src/studio/mod.rs index 957b878..eba7947 100644 --- a/crates/rustmotion/src/studio/mod.rs +++ b/crates/rustmotion/src/studio/mod.rs @@ -8,7 +8,7 @@ pub use app::{run_preview, run_preview_with_error}; use std::path::PathBuf; -use clap::{CommandFactory, Parser}; +use clap::Parser; use rustmotion::error::Result; use rustmotion::loader::load_scenario; @@ -29,16 +29,15 @@ pub struct Cli { dir: Option, } -pub fn command() -> clap::Command { - Cli::command() -} - pub fn run() -> Result<()> { let cli = Cli::parse(); - let workspace = cli - .dir - .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))); - match cli.file { + run_with(cli.file, cli.dir) +} + +pub fn run_with(file: Option, dir: Option) -> Result<()> { + let workspace = + dir.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))); + match file { Some(f) => match load_scenario(&f) { Ok(scenario) => app::run_preview_root(scenario, None, Some(f), workspace, true, true), Err(e) => app::run_preview_root( diff --git a/crates/rustmotion/tests/studio_subcommand.rs b/crates/rustmotion/tests/studio_subcommand.rs new file mode 100644 index 0000000..a1b1d31 --- /dev/null +++ b/crates/rustmotion/tests/studio_subcommand.rs @@ -0,0 +1,68 @@ +use std::process::Command; + +fn rustmotion() -> Command { + Command::new(env!("CARGO_BIN_EXE_rustmotion")) +} + +fn top_level_help() -> String { + let out = rustmotion().arg("--help").output().expect("run --help"); + assert!(out.status.success(), "`rustmotion --help` failed"); + String::from_utf8_lossy(&out.stdout).into_owned() +} + +fn lists_studio(help: &str) -> bool { + help.lines() + .any(|line| line.trim_start().starts_with("studio ")) +} + +#[cfg(feature = "studio")] +#[test] +fn studio_is_listed_when_the_feature_is_on() { + let help = top_level_help(); + assert!( + lists_studio(&help), + "`studio` is missing from the subcommand list:\n{help}" + ); +} + +#[cfg(feature = "studio")] +#[test] +fn studio_takes_the_same_flags_as_the_standalone_binary() { + let out = rustmotion() + .args(["studio", "--help"]) + .output() + .expect("run studio --help"); + assert!(out.status.success(), "`rustmotion studio --help` failed"); + let help = String::from_utf8_lossy(&out.stdout); + assert!(help.contains("--file"), "--file missing:\n{help}"); + assert!(help.contains("--dir"), "--dir missing:\n{help}"); +} + +#[cfg(not(feature = "studio"))] +#[test] +fn studio_stays_out_of_help_when_the_feature_is_off() { + let help = top_level_help(); + assert!( + !lists_studio(&help), + "`studio` should not be advertised by a build that has no studio:\n{help}" + ); +} + +#[cfg(not(feature = "studio"))] +#[test] +fn studio_names_the_feature_when_the_feature_is_off() { + let out = rustmotion().arg("studio").output().expect("run studio"); + assert!( + !out.status.success(), + "`rustmotion studio` should fail in a build with no studio" + ); + let err = String::from_utf8_lossy(&out.stderr); + assert!( + err.contains("--features studio"), + "the error should say how to get the studio:\n{err}" + ); + assert!( + !err.contains("unrecognized subcommand"), + "the subcommand should exist and explain itself:\n{err}" + ); +}