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