Skip to main content

pitchfork_cli/cli/
mod.rs

1use crate::Result;
2use miette::IntoDiagnostic as _;
3use std::ffi::{OsStr, OsString};
4use tokio::io::AsyncWriteExt as _;
5use usage_rs::Cli;
6
7mod activate;
8mod api_schema;
9mod boot;
10mod cd;
11mod clean;
12mod command_effects;
13mod completion;
14mod config;
15mod daemons;
16mod disable;
17mod enable;
18mod interactive;
19mod interrupt;
20pub mod json_output;
21mod list;
22pub mod log_sink;
23pub mod logs;
24mod mcp;
25mod project;
26mod proxy;
27mod restart;
28mod run;
29mod schema;
30mod settings;
31mod sponsors;
32mod start;
33mod status;
34mod stop;
35mod supervisor;
36mod tui;
37mod usage;
38mod wait;
39
40#[derive(Debug, usage_rs::Cli)]
41#[usage(
42    name = "pitchfork",
43    version = env!("CARGO_PKG_VERSION"),
44    about = env!("CARGO_PKG_DESCRIPTION"),
45    completion,
46    unknown_flags = "error",
47    arg_required_else_help,
48    config = crate::settings::Settings
49)]
50struct Cli {
51    #[usage(subcommand)]
52    command: Commands,
53}
54
55#[derive(Debug, usage_rs::Subcommands)]
56#[allow(clippy::large_enum_variant)]
57enum Commands {
58    Activate(activate::Activate),
59    #[usage(hide)]
60    ApiSchema(api_schema::ApiSchema),
61    Boot(boot::Boot),
62    #[usage(hide)]
63    Cd(cd::Cd),
64    #[usage(alias = "c")]
65    Clean(clean::Clean),
66    #[usage(alias = "daemon")]
67    Daemons(daemons::Daemons),
68    Completion(completion::Completion),
69    Config(config::Config),
70    #[usage(alias = "d")]
71    Disable(disable::Disable),
72    #[usage(alias = "e")]
73    Enable(enable::Enable),
74    #[usage(hide)]
75    Interrupt(interrupt::Interrupt),
76    #[usage(alias = "ls")]
77    List(list::List),
78    #[usage(hide)]
79    LogSink(log_sink::LogSink),
80    #[usage(alias = "l")]
81    Logs(logs::Logs),
82    Mcp(mcp::Mcp),
83    Proxy(proxy::Proxy),
84    Project(project::Project),
85    Restart(restart::Restart),
86    #[usage(alias = "r")]
87    Run(run::Run),
88    #[usage(hide)]
89    Schema(schema::Schema),
90    #[usage(alias = "setting")]
91    Settings(settings::Settings),
92    Sponsors(sponsors::Sponsors),
93    #[usage(alias = "s")]
94    Start(start::Start),
95    #[usage(alias = "stat")]
96    Status(status::Status),
97    #[usage(alias = "kill")]
98    Stop(stop::Stop),
99    #[usage(alias = "sup")]
100    Supervisor(supervisor::Supervisor),
101    Tui(tui::Tui),
102    #[usage(hide)]
103    Usage(usage::Usage),
104    #[usage(alias = "w")]
105    Wait(wait::Wait),
106    #[usage(external_subcommand)]
107    Fallback(Vec<OsString>),
108}
109
110/// Parses tokens captured by the implicit subcommand fallback as a
111/// `pitchfork start` invocation, so usage/help/error output reflects that.
112#[derive(Debug, usage_rs::Cli)]
113#[usage(
114    name = "pitchfork",
115    bin = "pitchfork start",
116    version = env!("CARGO_PKG_VERSION"),
117    long_about = start::LONG_ABOUT,
118    unknown_flags = "error"
119)]
120struct StartFallback {
121    #[usage(flatten)]
122    start: start::Start,
123}
124
125fn fallback_failure(argv: &[&OsStr], error: &usage_rs::Error<'_, '_>) -> (String, i32) {
126    (
127        usage_rs::render_failure(StartFallback::spec(), argv, error),
128        2,
129    )
130}
131
132pub async fn run() -> Result<()> {
133    let argv: Vec<OsString> = std::env::args_os().collect();
134    if argv.get(1).and_then(|arg| arg.to_str()) == Some("__complete_word__") {
135        if let Some(answer) = completion::app().completion_request(&argv[1..]).await {
136            print!("{answer}");
137        }
138        return Ok(());
139    }
140
141    let args = Cli::parse();
142    match args.command {
143        Commands::Activate(activate) => activate.run().await,
144        Commands::Boot(boot) => boot.run().await,
145        Commands::Cd(cd) => cd.run().await,
146        Commands::Clean(clean) => clean.run().await,
147        Commands::Daemons(daemons) => daemons.run().await,
148        Commands::Completion(completion) => completion.run().await,
149        Commands::Config(config) => config.run().await,
150        Commands::Disable(disable) => disable.run().await,
151        Commands::Enable(enable) => enable.run().await,
152        Commands::Interrupt(interrupt) => interrupt.run().await,
153        Commands::List(list) => list.run().await,
154        Commands::LogSink(log_sink) => log_sink.run().await,
155        Commands::Logs(logs) => logs.run().await,
156        Commands::Mcp(mcp) => mcp.run().await,
157        Commands::Proxy(proxy) => proxy.run().await,
158        Commands::Project(project) => project.run().await,
159        Commands::Restart(restart) => restart.run().await,
160        Commands::Run(run) => run.run().await,
161        Commands::ApiSchema(api_schema) => api_schema.run().await,
162        Commands::Schema(schema) => schema.run().await,
163        Commands::Settings(settings) => settings.run().await,
164        Commands::Sponsors(_) => sponsors::Sponsors::run().await,
165        Commands::Start(start) => start.run().await,
166        Commands::Status(status) => status.run().await,
167        Commands::Stop(stop) => stop.run().await,
168        Commands::Supervisor(supervisor) => supervisor.run().await,
169        Commands::Tui(tui) => tui.run().await,
170        Commands::Usage(usage) => usage.run().await,
171        Commands::Wait(wait) => wait.run().await,
172        Commands::Fallback(tokens) => {
173            let argv: Vec<_> = tokens.iter().map(OsString::as_os_str).collect();
174            match StartFallback::parse_from(&argv) {
175                Ok(fallback) => fallback.start.run().await,
176                Err(usage_rs::Error::Help { cmd, long }) => {
177                    let rendered = usage_rs::help::render(StartFallback::spec(), cmd, long)
178                        .expect("derived help metadata should render");
179                    let mut stdout = tokio::io::stdout();
180                    stdout
181                        .write_all(rendered.as_bytes())
182                        .await
183                        .into_diagnostic()?;
184                    stdout.flush().await.into_diagnostic()?;
185                    Ok(())
186                }
187                Err(usage_rs::Error::Version { .. }) => {
188                    let rendered = format!("pitchfork {}\n", env!("CARGO_PKG_VERSION"));
189                    let mut stdout = tokio::io::stdout();
190                    stdout
191                        .write_all(rendered.as_bytes())
192                        .await
193                        .into_diagnostic()?;
194                    stdout.flush().await.into_diagnostic()?;
195                    Ok(())
196                }
197                Err(error) => {
198                    let (rendered, status) = fallback_failure(&argv, &error);
199                    eprint!("{rendered}");
200                    std::process::exit(status);
201                }
202            }
203        }
204    }
205}
206
207/// Drain and display any pending notifications from the supervisor.
208///
209/// Notifications are queued by the supervisor for events that happen
210/// asynchronously (e.g. proxy bind failure) and would otherwise be invisible
211/// to CLI users.  Call this at the end of user-facing commands that connect
212/// to the supervisor via IPC.
213pub(crate) async fn drain_notifications(ipc: &crate::ipc::client::IpcClient) {
214    use log::LevelFilter;
215    if let Ok(notifications) = ipc.get_notifications().await {
216        for (level, msg) in notifications {
217            match level {
218                LevelFilter::Trace => trace!("{msg}"),
219                LevelFilter::Debug => debug!("{msg}"),
220                LevelFilter::Info => info!("{msg}"),
221                LevelFilter::Warn => warn!("{msg}"),
222                LevelFilter::Error => error!("{msg}"),
223                _ => {}
224            }
225        }
226    }
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232    use std::ffi::OsStr;
233
234    #[test]
235    fn bare_invocation_requests_help_instead_of_entering_the_start_fallback() {
236        assert!(matches!(
237            Cli::parse_from(&[]),
238            Err(usage_rs::Error::MissingArgsHelp { .. })
239        ));
240    }
241
242    #[test]
243    fn help_topics_are_claimed_before_the_external_start_fallback() {
244        assert!(matches!(
245            Cli::parse_from(&[OsStr::new("help")]),
246            Err(usage_rs::Error::Help { .. })
247        ));
248        assert!(matches!(
249            Cli::parse_from(&[OsStr::new("help"), OsStr::new("start")]),
250            Err(usage_rs::Error::Help { .. })
251        ));
252    }
253
254    #[test]
255    fn unknown_subcommand_captured_as_fallback() {
256        let argv = [OsStr::new("mydaemon"), OsStr::new("--force")];
257        let args = Cli::parse_from(&argv).unwrap();
258        match args.command {
259            Commands::Fallback(tokens) => {
260                assert_eq!(
261                    tokens,
262                    vec![OsString::from("mydaemon"), OsString::from("--force")]
263                );
264            }
265            _ => panic!("expected Fallback variant, got {:?}", args.command),
266        }
267    }
268
269    #[test]
270    fn unknown_subcommand_captures_multiple_args() {
271        let argv = [
272            OsStr::new("api"),
273            OsStr::new("worker"),
274            OsStr::new("--force"),
275        ];
276        let args = Cli::parse_from(&argv).unwrap();
277        match args.command {
278            Commands::Fallback(tokens) => {
279                assert_eq!(
280                    tokens,
281                    vec![
282                        OsString::from("api"),
283                        OsString::from("worker"),
284                        OsString::from("--force")
285                    ]
286                );
287            }
288            _ => panic!("expected Fallback variant, got {:?}", args.command),
289        }
290    }
291
292    #[test]
293    fn known_start_parses_as_start() {
294        let argv = [OsStr::new("start"), OsStr::new("mydaemon")];
295        let args = Cli::parse_from(&argv).unwrap();
296        match args.command {
297            Commands::Start(_) => {}
298            _ => panic!("expected Start variant, got {:?}", args.command),
299        }
300    }
301
302    #[test]
303    fn start_alias_still_works() {
304        let argv = [OsStr::new("s"), OsStr::new("mydaemon")];
305        let args = Cli::parse_from(&argv).unwrap();
306        match args.command {
307            Commands::Start(_) => {}
308            _ => panic!("expected Start variant, got {:?}", args.command),
309        }
310    }
311
312    #[test]
313    fn fallback_reparse_as_start() {
314        let argv = [OsStr::new("mydaemon"), OsStr::new("--force")];
315        StartFallback::parse_from(&argv).expect("should re-parse captured tokens as Start");
316    }
317
318    #[test]
319    fn fallback_reparse_rejects_invalid_start_flag() {
320        let argv = [OsStr::new("mydaemon"), OsStr::new("--not-a-start-flag")];
321        let result = StartFallback::parse_from(&argv);
322        assert!(
323            result.is_err(),
324            "expected re-parse to fail for invalid Start flag"
325        );
326    }
327
328    #[test]
329    fn fallback_invalid_start_usage_renders_pitchfork_start() {
330        let argv = [OsStr::new("mydaemon"), OsStr::new("--not-a-start-flag")];
331        let err = StartFallback::parse_from(&argv).unwrap_err();
332        let (rendered, status) = fallback_failure(&argv, &err);
333        assert_eq!(status, 2);
334        assert!(
335            rendered.contains("Usage: pitchfork start"),
336            "expected usage to contain 'pitchfork start', got: {rendered}"
337        );
338    }
339
340    #[test]
341    fn fallback_help_shows_start_long_about() {
342        let argv = [OsStr::new("mydaemon"), OsStr::new("--help")];
343        let usage_rs::Error::Help { cmd, long } = StartFallback::parse_from(&argv).unwrap_err()
344        else {
345            panic!("expected help")
346        };
347        let rendered = usage_rs::help::render(StartFallback::spec(), cmd, long).unwrap();
348        assert!(
349            rendered.contains("Examples:"),
350            "expected help to include Start long_about examples, got: {rendered}"
351        );
352        assert!(
353            rendered.contains("pitchfork start api"),
354            "expected help to reference `pitchfork start api`, got: {rendered}"
355        );
356    }
357}