Skip to main content

pitchfork_cli/cli/
mod.rs

1use crate::Result;
2use clap::Parser;
3use std::ffi::OsString;
4
5mod activate;
6mod api_schema;
7mod boot;
8mod cd;
9mod clean;
10mod completion;
11mod daemons;
12mod disable;
13mod enable;
14mod json_output;
15mod list;
16pub mod log_sink;
17pub mod logs;
18mod mcp;
19mod project;
20mod proxy;
21mod restart;
22mod run;
23mod schema;
24mod settings;
25mod sponsors;
26mod start;
27mod status;
28mod stop;
29mod supervisor;
30mod tui;
31mod usage;
32mod wait;
33
34#[derive(Debug, clap::Parser)]
35#[clap(name = "pitchfork", version = env!("CARGO_PKG_VERSION"), about = env!("CARGO_PKG_DESCRIPTION"))]
36struct Cli {
37    #[clap(subcommand)]
38    command: Commands,
39}
40
41#[derive(Debug, clap::Subcommand)]
42#[allow(clippy::large_enum_variant)]
43enum Commands {
44    Activate(activate::Activate),
45    ApiSchema(api_schema::ApiSchema),
46    Boot(boot::Boot),
47    Cd(cd::Cd),
48    Clean(clean::Clean),
49    Daemons(daemons::Daemons),
50    Completion(completion::Completion),
51    Disable(disable::Disable),
52    Enable(enable::Enable),
53    List(list::List),
54    LogSink(log_sink::LogSink),
55    Logs(logs::Logs),
56    Mcp(mcp::Mcp),
57    Proxy(proxy::Proxy),
58    Project(project::Project),
59    Restart(restart::Restart),
60    Run(run::Run),
61    Schema(schema::Schema),
62    Settings(settings::Settings),
63    Sponsors(sponsors::Sponsors),
64    Start(start::Start),
65    Status(status::Status),
66    Stop(stop::Stop),
67    Supervisor(supervisor::Supervisor),
68    Tui(tui::Tui),
69    Usage(usage::Usage),
70    Wait(wait::Wait),
71    #[clap(external_subcommand)]
72    Fallback(Vec<OsString>),
73}
74
75/// Parses tokens captured by the implicit subcommand fallback as a
76/// `pitchfork start` invocation, so usage/help/error output reflects that.
77#[derive(Debug, clap::Parser)]
78#[clap(
79    name = "pitchfork",
80    bin_name = "pitchfork start",
81    version = env!("CARGO_PKG_VERSION"),
82    long_about = start::LONG_ABOUT
83)]
84struct StartFallback {
85    #[clap(flatten)]
86    start: start::Start,
87}
88
89pub async fn run() -> Result<()> {
90    let args = Cli::parse();
91    let program = std::env::args_os()
92        .next()
93        .unwrap_or_else(|| "pitchfork".into());
94    match args.command {
95        Commands::Activate(activate) => activate.run().await,
96        Commands::Boot(boot) => boot.run().await,
97        Commands::Cd(cd) => cd.run().await,
98        Commands::Clean(clean) => clean.run().await,
99        Commands::Daemons(daemons) => daemons.run().await,
100        Commands::Completion(completion) => completion.run().await,
101        Commands::Disable(disable) => disable.run().await,
102        Commands::Enable(enable) => enable.run().await,
103        Commands::List(list) => list.run().await,
104        Commands::LogSink(log_sink) => log_sink.run().await,
105        Commands::Logs(logs) => logs.run().await,
106        Commands::Mcp(mcp) => mcp.run().await,
107        Commands::Proxy(proxy) => proxy.run().await,
108        Commands::Project(project) => project.run().await,
109        Commands::Restart(restart) => restart.run().await,
110        Commands::Run(run) => run.run().await,
111        Commands::ApiSchema(api_schema) => api_schema.run().await,
112        Commands::Schema(schema) => schema.run().await,
113        Commands::Settings(settings) => settings.run().await,
114        Commands::Sponsors(_) => sponsors::Sponsors::run().await,
115        Commands::Start(start) => start.run().await,
116        Commands::Status(status) => status.run().await,
117        Commands::Stop(stop) => stop.run().await,
118        Commands::Supervisor(supervisor) => supervisor.run().await,
119        Commands::Tui(tui) => tui.run().await,
120        Commands::Usage(usage) => usage.run().await,
121        Commands::Wait(wait) => wait.run().await,
122        Commands::Fallback(tokens) => {
123            let mut argv = vec![program];
124            argv.extend(tokens);
125            StartFallback::parse_from(argv).start.run().await
126        }
127    }
128}
129
130/// Drain and display any pending notifications from the supervisor.
131///
132/// Notifications are queued by the supervisor for events that happen
133/// asynchronously (e.g. proxy bind failure) and would otherwise be invisible
134/// to CLI users.  Call this at the end of user-facing commands that connect
135/// to the supervisor via IPC.
136pub(crate) async fn drain_notifications(ipc: &crate::ipc::client::IpcClient) {
137    use log::LevelFilter;
138    if let Ok(notifications) = ipc.get_notifications().await {
139        for (level, msg) in notifications {
140            match level {
141                LevelFilter::Trace => trace!("{msg}"),
142                LevelFilter::Debug => debug!("{msg}"),
143                LevelFilter::Info => info!("{msg}"),
144                LevelFilter::Warn => warn!("{msg}"),
145                LevelFilter::Error => error!("{msg}"),
146                _ => {}
147            }
148        }
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155    use clap::Parser;
156
157    #[test]
158    fn unknown_subcommand_captured_as_fallback() {
159        let args = Cli::parse_from(["pitchfork", "mydaemon", "--force"]);
160        match args.command {
161            Commands::Fallback(tokens) => {
162                assert_eq!(
163                    tokens,
164                    vec![OsString::from("mydaemon"), OsString::from("--force")]
165                );
166            }
167            _ => panic!("expected Fallback variant, got {:?}", args.command),
168        }
169    }
170
171    #[test]
172    fn unknown_subcommand_captures_multiple_args() {
173        let args = Cli::parse_from(["pitchfork", "api", "worker", "--force"]);
174        match args.command {
175            Commands::Fallback(tokens) => {
176                assert_eq!(
177                    tokens,
178                    vec![
179                        OsString::from("api"),
180                        OsString::from("worker"),
181                        OsString::from("--force")
182                    ]
183                );
184            }
185            _ => panic!("expected Fallback variant, got {:?}", args.command),
186        }
187    }
188
189    #[test]
190    fn known_start_parses_as_start() {
191        let args = Cli::parse_from(["pitchfork", "start", "mydaemon"]);
192        match args.command {
193            Commands::Start(_) => {}
194            _ => panic!("expected Start variant, got {:?}", args.command),
195        }
196    }
197
198    #[test]
199    fn start_alias_still_works() {
200        let args = Cli::parse_from(["pitchfork", "s", "mydaemon"]);
201        match args.command {
202            Commands::Start(_) => {}
203            _ => panic!("expected Start variant, got {:?}", args.command),
204        }
205    }
206
207    #[test]
208    fn fallback_reparse_as_start() {
209        StartFallback::try_parse_from(["pitchfork", "mydaemon", "--force"])
210            .expect("should re-parse captured tokens as Start");
211    }
212
213    #[test]
214    fn fallback_reparse_rejects_invalid_start_flag() {
215        let result = StartFallback::try_parse_from(["pitchfork", "mydaemon", "--not-a-start-flag"]);
216        assert!(
217            result.is_err(),
218            "expected re-parse to fail for invalid Start flag"
219        );
220    }
221
222    #[test]
223    fn fallback_invalid_start_usage_renders_pitchfork_start() {
224        let err = StartFallback::try_parse_from(["pitchfork", "mydaemon", "--not-a-start-flag"])
225            .unwrap_err();
226        let rendered = err.to_string();
227        assert!(
228            rendered.contains("Usage: pitchfork start"),
229            "expected usage to contain 'pitchfork start', got: {rendered}"
230        );
231    }
232
233    #[test]
234    fn fallback_help_shows_start_long_about() {
235        let err = StartFallback::try_parse_from(["pitchfork", "mydaemon", "--help"]).unwrap_err();
236        let rendered = err.to_string();
237        assert!(
238            rendered.contains("Examples:"),
239            "expected help to include Start long_about examples, got: {rendered}"
240        );
241        assert!(
242            rendered.contains("pitchfork start api"),
243            "expected help to reference `pitchfork start api`, got: {rendered}"
244        );
245    }
246}