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