Skip to main content

studio_worker/
cli.rs

1//! Clap CLI definitions, kept out of `main.rs` so they're testable.
2use clap::{Parser, Subcommand};
3
4#[derive(Parser, Debug)]
5#[command(
6    name = "studio-worker",
7    version,
8    about = "Studio worker — pull-based generation agent (image / llm / audio / video)"
9)]
10pub struct Cli {
11    /// Override the path to config.toml.
12    #[arg(long, global = true)]
13    pub config: Option<String>,
14    #[command(subcommand)]
15    pub command: Command,
16}
17
18#[derive(Subcommand, Debug, PartialEq)]
19pub enum Command {
20    /// Start the daemon: local API, model host, studio session.
21    Run {
22        /// Under a supervisor (PM2, systemd): if another daemon holds the
23        /// lock, wait and take over when it ends, instead of exiting.
24        #[arg(long)]
25        wait_for_lock: bool,
26    },
27    /// Pre-set registration metadata before the next launch.
28    ///
29    /// On a fresh install you don't need this — `run` and `ui`
30    /// auto-register themselves.  Use it explicitly to:
31    ///   * point the worker at a different studio (`--api-base-url`)
32    ///   * clear local registration state after a rejection or
33    ///     between studios (`--reset`)
34    Register {
35        #[arg(long)]
36        api_base_url: Option<String>,
37        #[arg(long)]
38        reset: bool,
39    },
40    /// Print local config + last heartbeat info.
41    Status,
42    /// Turnkey finish line: install + start the auto-start service and
43    /// print the studio-approval + local-API guidance.  Run this once
44    /// after installing.
45    Setup,
46    /// Install platform-appropriate auto-start service.
47    InstallService,
48    /// Uninstall the auto-start service.
49    UninstallService,
50    /// Set the VRAM threshold (GB) the worker reports.
51    SetThreshold { gb: f32 },
52    /// Print resolved config + relevant paths.
53    Config,
54    /// Check the release feed for a newer version (does not install).
55    CheckUpdate,
56    /// Launch the desktop UI (requires the `ui` cargo feature).
57    Ui,
58}
59
60impl Command {
61    /// Stable kebab-case label for the subcommand.  Used as the
62    /// structured `command` field in the CLI startup breadcrumb so
63    /// operators can filter `journalctl` by which subcommand a
64    /// process is running.  Matches clap's derived subcommand names.
65    pub fn name(&self) -> &'static str {
66        match self {
67            Command::Run { .. } => "run",
68            Command::Setup => "setup",
69            Command::Register { .. } => "register",
70            Command::Status => "status",
71            Command::InstallService => "install-service",
72            Command::UninstallService => "uninstall-service",
73            Command::SetThreshold { .. } => "set-threshold",
74            Command::Config => "config",
75            Command::CheckUpdate => "check-update",
76            Command::Ui => "ui",
77        }
78    }
79}
80
81#[cfg(test)]
82mod tests {
83    use super::*;
84    use clap::Parser;
85
86    #[test]
87    fn run_can_wait_for_the_lock_under_a_supervisor() {
88        let cli = Cli::try_parse_from(["studio-worker", "run", "--wait-for-lock"]).unwrap();
89        assert!(matches!(
90            cli.command,
91            Command::Run {
92                wait_for_lock: true
93            }
94        ));
95    }
96
97    #[test]
98    fn parses_run() {
99        let cli = Cli::parse_from(["studio-worker", "run"]);
100        assert!(matches!(
101            cli.command,
102            Command::Run {
103                wait_for_lock: false
104            }
105        ));
106        assert!(cli.config.is_none());
107    }
108
109    #[test]
110    fn parses_run_with_config_override() {
111        let cli = Cli::parse_from(["studio-worker", "--config", "/etc/x.toml", "run"]);
112        assert_eq!(cli.config.as_deref(), Some("/etc/x.toml"));
113        assert!(matches!(
114            cli.command,
115            Command::Run {
116                wait_for_lock: false
117            }
118        ));
119    }
120
121    #[test]
122    fn parses_register_with_overrides() {
123        let cli = Cli::parse_from([
124            "studio-worker",
125            "register",
126            "--api-base-url",
127            "https://example.invalid",
128        ]);
129        match cli.command {
130            Command::Register {
131                api_base_url,
132                reset,
133            } => {
134                assert_eq!(api_base_url.as_deref(), Some("https://example.invalid"));
135                assert!(!reset);
136            }
137            other => panic!("expected register, got {other:?}"),
138        }
139    }
140
141    #[test]
142    fn parses_register_with_reset() {
143        let cli = Cli::parse_from(["studio-worker", "register", "--reset"]);
144        match cli.command {
145            Command::Register {
146                api_base_url,
147                reset,
148            } => {
149                assert!(api_base_url.is_none());
150                assert!(reset);
151            }
152            other => panic!("expected register, got {other:?}"),
153        }
154    }
155
156    #[test]
157    fn parses_bare_register() {
158        let cli = Cli::parse_from(["studio-worker", "register"]);
159        assert!(matches!(
160            cli.command,
161            Command::Register {
162                api_base_url: None,
163                reset: false,
164            }
165        ));
166    }
167
168    #[test]
169    fn parses_set_threshold_with_float() {
170        let cli = Cli::parse_from(["studio-worker", "set-threshold", "12.5"]);
171        match cli.command {
172            Command::SetThreshold { gb } => assert!((gb - 12.5).abs() < 1e-6),
173            other => panic!("expected set-threshold, got {other:?}"),
174        }
175    }
176
177    #[test]
178    fn name_is_stable_kebab_case_for_every_subcommand() {
179        assert_eq!(
180            Command::Run {
181                wait_for_lock: false
182            }
183            .name(),
184            "run"
185        );
186        assert_eq!(
187            Command::Register {
188                api_base_url: None,
189                reset: false
190            }
191            .name(),
192            "register"
193        );
194        assert_eq!(Command::Status.name(), "status");
195        assert_eq!(Command::Setup.name(), "setup");
196        assert_eq!(Command::InstallService.name(), "install-service");
197        assert_eq!(Command::UninstallService.name(), "uninstall-service");
198        assert_eq!(Command::SetThreshold { gb: 1.0 }.name(), "set-threshold");
199        assert_eq!(Command::Config.name(), "config");
200        assert_eq!(Command::CheckUpdate.name(), "check-update");
201        assert_eq!(Command::Ui.name(), "ui");
202    }
203
204    #[test]
205    fn parses_all_simple_subcommands() {
206        let cases = [
207            ("status", Command::Status),
208            ("setup", Command::Setup),
209            ("install-service", Command::InstallService),
210            ("uninstall-service", Command::UninstallService),
211            ("config", Command::Config),
212            ("check-update", Command::CheckUpdate),
213            ("ui", Command::Ui),
214        ];
215        for (name, expected) in cases {
216            let cli = Cli::parse_from(["studio-worker", name]);
217            assert_eq!(cli.command, expected, "parsing `{name}`");
218        }
219    }
220}