Skip to main content

proc_cli/
cli.rs

1//! The command-line definition: `Cli` and its subcommands.
2//!
3//! Lives in the library so tests can read the clap definitions
4//! (`Cli::command()`) and check them against what they exercise.
5
6use crate::commands::{
7    ByCommand, ForCommand, FreeCommand, FreezeCommand, InCommand, InfoCommand, KillCommand,
8    ListCommand, OnCommand, OrphansCommand, PortsCommand, StopCommand, StuckCommand, ThawCommand,
9    TreeCommand, UnstickCommand, WaitCommand, WatchCommand, WhyCommand,
10};
11use clap::builder::styling::Styles;
12use clap::builder::StyledStr;
13use clap::{Parser, Subcommand};
14use clap_complete::Shell;
15
16const VERSION_INFO: &str = concat!(
17    env!("PROC_VERSION"),
18    "\nhttps://github.com/yazeed/proc-cli",
19    "\nLicense: MIT"
20);
21
22/// The look of the help. This is clap's own default, named here so the help
23/// footers below are drawn from the same styles as the `Usage:` and `Options:`
24/// headers around them.
25pub const STYLES: Styles = Styles::styled();
26
27/// The examples that open the long help, in groups; the footer follows them
28const EXAMPLES: &[(&str, &[(&str, &str)])] = &[
29    (
30        "Port/Process Lookup",
31        &[
32            ("proc on :3000", "What's on port 3000?"),
33            ("proc on :3000 :8080", "What's on several ports?"),
34            (
35                "proc on node",
36                "What ports are the processes named node using?",
37            ),
38        ],
39    ),
40    (
41        "Find by File",
42        &[
43            (
44                "proc for ./server",
45                "Processes running this program (or holding it open)",
46            ),
47            (
48                "proc for /usr/bin/node",
49                "Processes running this executable",
50            ),
51            (
52                "proc for app.log",
53                "What has this file open? (not searched on Windows)",
54            ),
55        ],
56    ),
57    (
58        "Filter by Name",
59        &[
60            ("proc by node", "Processes named 'node' (the whole name)"),
61            (
62                "proc by node --cmd",
63                "Anything with 'node' in its name or command line",
64            ),
65            ("proc by node --in .", "Node processes in current directory"),
66            ("proc by node --min-cpu 5", "Node processes using >5% CPU"),
67        ],
68    ),
69    (
70        "Filter by Directory",
71        &[
72            ("proc in .", "Processes in current directory"),
73            ("proc in . --by node", "Node processes in cwd"),
74        ],
75    ),
76    (
77        "List All",
78        &[
79            ("proc list", "All processes"),
80            ("proc list --min-cpu 10", "Processes using >10% CPU"),
81        ],
82    ),
83    (
84        "Info/Kill/Stop (multi-target)",
85        &[
86            ("proc info :3000 :8080", "Info for several targets"),
87            (
88                "proc kill :3000 node",
89                "Kill port 3000 and the processes named node",
90            ),
91            (
92                "proc kill serve --cmd",
93                "Kill processes with 'serve' in their command line",
94            ),
95            (
96                "proc kill 1234 5678 :8080",
97                "Kill two PIDs and whatever is on 8080",
98            ),
99            ("proc stop :3000 :8080", "Stop several targets gracefully"),
100        ],
101    ),
102    (
103        "Watch (Real-Time)",
104        &[
105            ("proc watch", "Watch all processes (alias: proc top)"),
106            ("proc watch node", "Watch the processes named node"),
107            ("proc watch :3000", "Watch process on port 3000"),
108            ("proc watch :3000 :8080 node", "Watch several targets"),
109            ("proc watch -n 1 --sort mem", "1s refresh, sorted by memory"),
110            (
111                "proc watch --in . --by node",
112                "Node processes in current directory",
113            ),
114        ],
115    ),
116    (
117        "Freeze/Thaw",
118        &[
119            (
120                "proc freeze node",
121                "Pause the processes named node (SIGSTOP)",
122            ),
123            ("proc freeze :3000 --yes", "Freeze by port, skip confirm"),
124            ("proc thaw node", "Resume frozen processes (SIGCONT)"),
125        ],
126    ),
127    (
128        "Free Ports",
129        &[
130            ("proc free :3000", "Kill process, verify port freed"),
131            ("proc free :3000 :8080 --yes", "Free several ports"),
132        ],
133    ),
134    (
135        "Diagnostics",
136        &[
137            ("proc why :3000", "Why is port 3000 busy? (ancestry)"),
138            ("proc orphans", "Find orphaned processes"),
139            ("proc orphans --kill", "Find and kill orphans"),
140        ],
141    ),
142    (
143        "Wait",
144        &[
145            ("proc wait node", "Wait until node processes exit"),
146            ("proc wait 1234 5678", "Wait until both exit"),
147            (
148                "proc wait :3000 --timeout 60",
149                "Wait up to 60s for port to free",
150            ),
151            ("proc wait node -n 10 -q", "Check every 10s, quiet mode"),
152        ],
153    ),
154    (
155        "Other",
156        &[
157            ("proc ports", "List all listening TCP ports"),
158            ("proc tree --min-cpu 5", "Process tree filtered by CPU"),
159            ("proc stuck", "Find hung processes"),
160            (
161                "proc unstick --force",
162                "Recover or terminate stuck processes",
163            ),
164            (
165                "proc stop nginx --signal USR1",
166                "Send a different first signal (force-killed after --timeout if it lives)",
167            ),
168        ],
169    ),
170];
171
172/// One labelled block of the footer: a header, then rows of a literal (what to
173/// type) and what it means. A row with an empty literal continues the line above.
174struct Section {
175    header: &'static str,
176    rows: &'static [(&'static str, &'static str)],
177}
178
179const TARGETS: Section = Section {
180    header: "Targets:",
181    rows: &[
182        (":3000", "A port"),
183        ("1234", "A PID"),
184        ("node", "A name"),
185        (":3000 node", "Several: separate arguments"),
186    ],
187};
188
189const NAMES: Section = Section {
190    header: "Names:",
191    rows: &[
192        (
193            "node",
194            "The process name, the file name of its executable or of argv[0],",
195        ),
196        (
197            "",
198            "ignoring case (like pkill -x); 'node' is not 'Claude Helper'",
199        ),
200        (
201            "--cmd",
202            "Any part of the name or the full command line (like pkill -f)",
203        ),
204        ("./server", "Not a name: use --cmd, or proc for <file>"),
205        (
206            "node*",
207            "Not expanded by proc, but by your shell if unquoted: use --cmd",
208        ),
209    ],
210};
211
212const NOTES: Section = Section {
213    header: "Notes:",
214    rows: &[
215        (
216            ":3000,:8080",
217            "Deprecated, to be removed; prints a note on stderr",
218        ),
219        ("", "(not with --json)"),
220        (
221            "--in DIR",
222            "Takes the next word as DIR unless it starts with '-':",
223        ),
224        ("", "put it after the targets, or write --in=DIR"),
225    ],
226};
227
228const REPOSITORY: &str = "https://github.com/yazeed/proc-cli";
229
230/// Sections in the look of clap's `Options:` list: the header in the header
231/// style, each literal in the literal style, the meanings aligned in one column.
232fn render_sections(sections: &[&Section]) -> String {
233    let literal_width = sections
234        .iter()
235        .flat_map(|section| section.rows.iter())
236        .map(|(literal, _)| literal.chars().count())
237        .max()
238        .unwrap_or(0);
239    let (header, literal) = (STYLES.get_header(), STYLES.get_literal());
240    let mut out = String::new();
241    for section in sections {
242        out.push_str(&format!("{header}{}{header:#}\n", section.header));
243        for (text, meaning) in section.rows {
244            let pad = " ".repeat(literal_width - text.chars().count());
245            if text.is_empty() {
246                out.push_str(&format!("  {pad}  {meaning}\n"));
247            } else {
248                out.push_str(&format!("  {literal}{text}{literal:#}{pad}  {meaning}\n"));
249            }
250        }
251        out.push('\n');
252    }
253    out
254}
255
256/// The examples in the footer's look: one `Examples:` header, each group's
257/// label, then the commands in the literal style with their meanings aligned
258fn render_examples() -> String {
259    let width = EXAMPLES
260        .iter()
261        .flat_map(|(_, rows)| rows.iter())
262        .map(|(command, _)| command.chars().count())
263        .max()
264        .unwrap_or(0);
265    let (header, literal) = (STYLES.get_header(), STYLES.get_literal());
266    let mut out = format!("{header}Examples:{header:#}\n");
267    for (label, rows) in EXAMPLES {
268        out.push_str(&format!("\n  {label}\n"));
269        for (command, meaning) in *rows {
270            let pad = " ".repeat(width - command.chars().count());
271            out.push_str(&format!(
272                "    {literal}{command}{literal:#}{pad}  {meaning}\n"
273            ));
274        }
275    }
276    out
277}
278
279/// The closing line of both helps
280fn more(what: &str) -> String {
281    let header = STYLES.get_header();
282    format!("{header}More:{header:#} {what}")
283}
284
285/// The footer of `proc -h`
286fn short_footer() -> StyledStr {
287    let mut out = String::from("\n");
288    out.push_str(&render_sections(&[&TARGETS]));
289    out.push_str(&more(&format!("proc --help for examples, or {REPOSITORY}")));
290    StyledStr::from(out)
291}
292
293/// The long help's closing text: the examples, then the footer
294fn long_footer() -> StyledStr {
295    let mut out = render_examples();
296    out.push('\n');
297    out.push_str(&render_sections(&[&TARGETS, &NAMES, &NOTES]));
298    out.push_str(&more(REPOSITORY));
299    StyledStr::from(out)
300}
301
302/// Semantic CLI tool for process management. Target by port, PID, name or path.
303#[derive(Parser)]
304#[command(name = "proc")]
305#[command(author, version = VERSION_INFO, about, long_about = None)]
306#[command(propagate_version = true)]
307#[command(styles = STYLES)]
308#[command(after_help = short_footer())]
309#[command(after_long_help = long_footer())]
310pub struct Cli {
311    /// The subcommand; none prints the orientation text
312    #[command(subcommand)]
313    pub command: Option<Commands>,
314}
315
316/// Every `proc` subcommand.
317#[derive(Subcommand)]
318pub enum Commands {
319    /// Port/process lookup (bidirectional)
320    #[command(visible_alias = ":")]
321    On(OnCommand),
322
323    /// Find processes by file path
324    #[command(visible_alias = "f")]
325    For(ForCommand),
326
327    /// Filter processes by name
328    #[command(visible_alias = "b")]
329    By(ByCommand),
330
331    /// Filter processes by working directory
332    In(InCommand),
333
334    /// List processes
335    #[command(visible_aliases = ["l", "ps"])]
336    List(ListCommand),
337
338    /// Show detailed process information
339    #[command(visible_aliases = ["i", "inspect"])]
340    Info(InfoCommand),
341
342    /// List all listening TCP ports
343    #[command(visible_alias = "p")]
344    Ports(PortsCommand),
345
346    /// Kill process(es) forcefully
347    #[command(visible_alias = "k")]
348    Kill(KillCommand),
349
350    /// Stop process(es) gracefully
351    #[command(visible_alias = "s")]
352    Stop(StopCommand),
353
354    /// Show process tree
355    #[command(visible_alias = "t")]
356    Tree(TreeCommand),
357
358    /// Watch processes in real-time
359    #[command(visible_aliases = ["w", "top"])]
360    Watch(WatchCommand),
361
362    /// Find stuck/hung processes
363    #[command(visible_alias = "x")]
364    Stuck(StuckCommand),
365
366    /// Attempt to recover stuck processes
367    #[command(visible_alias = "u")]
368    Unstick(UnstickCommand),
369
370    /// Freeze (pause) process(es) with SIGSTOP
371    Freeze(FreezeCommand),
372
373    /// Resume frozen process(es) with SIGCONT
374    Thaw(ThawCommand),
375
376    /// Find orphaned processes
377    #[command(visible_alias = "o")]
378    Orphans(OrphansCommand),
379
380    /// Trace why a port is busy or show process ancestry
381    Why(WhyCommand),
382
383    /// Free port(s) by killing process and verifying availability
384    Free(FreeCommand),
385
386    /// Wait for process(es) to exit
387    Wait(WaitCommand),
388
389    /// Generate shell completions
390    #[command(hide = true)]
391    Completions {
392        /// Shell to generate completions for
393        #[arg(value_enum)]
394        shell: Shell,
395    },
396
397    /// Generate man page
398    #[command(hide = true)]
399    Manpage,
400}