Skip to main content

run_stack/
commands.rs

1//! `rst commands` — every CLI entry in a terminal table.
2
3use anyhow::Result;
4
5/// One row in the command catalogue.
6struct Entry {
7    command: &'static str,
8    aliases: &'static str,
9    group: &'static str,
10    description: &'static str,
11}
12
13const CATALOG: &[Entry] = &[
14    Entry {
15        command: "create",
16        aliases: "new",
17        group: "Global",
18        description: "Write .run/run.config.toml and run init",
19    },
20    Entry {
21        command: "migrate",
22        aliases: "—",
23        group: "Global",
24        description: "Convert legacy run/ to .run/ (config + env)",
25    },
26    Entry {
27        command: "upgrade",
28        aliases: "update",
29        group: "Global",
30        description: "Refresh legacy run/ or self-update",
31    },
32    Entry {
33        command: "self-update",
34        aliases: "—",
35        group: "Global",
36        description: "Install latest from crates.io and migrate configs",
37    },
38    Entry {
39        command: "completion",
40        aliases: "—",
41        group: "Global",
42        description: "Shell / Kiro autocompletion (bash|zsh|fig|install|services)",
43    },
44    Entry {
45        command: "init",
46        aliases: "—",
47        group: "Setup",
48        description: "Write .run/run.config.toml and .run/.env",
49    },
50    Entry {
51        command: "init --update",
52        aliases: "—",
53        group: "Setup",
54        description: "Find and add apps added to the workspace since setup",
55    },
56    Entry {
57        command: "up",
58        aliases: "run",
59        group: "Lifecycle",
60        description: "Build if needed and start (--essential = config essentials)",
61    },
62    Entry {
63        command: "down",
64        aliases: "—",
65        group: "Lifecycle",
66        description: "Stop everything, or named services (keeps data)",
67    },
68    Entry {
69        command: "restart",
70        aliases: "—",
71        group: "Lifecycle",
72        description: "Stop then start (down + up); --build, --essential",
73    },
74    Entry {
75        command: "rebuild",
76        aliases: "—",
77        group: "Lifecycle",
78        description: "Rebuild images from scratch and recreate containers",
79    },
80    Entry {
81        command: "clean",
82        aliases: "—",
83        group: "Lifecycle",
84        description: "Stop and DELETE all volumes",
85    },
86    Entry {
87        command: "commands",
88        aliases: "—",
89        group: "Inspection",
90        description: "List every CLI command in a table",
91    },
92    Entry {
93        command: "apps",
94        aliases: "list",
95        group: "Inspection",
96        description: "List configured apps (backend, web, mobile, …)",
97    },
98    Entry {
99        command: "services",
100        aliases: "—",
101        group: "Inspection",
102        description: "List compose services (and status when Docker is up)",
103    },
104    Entry {
105        command: "ps",
106        aliases: "status",
107        group: "Inspection",
108        description: "Service status",
109    },
110    Entry {
111        command: "ports",
112        aliases: "—",
113        group: "Inspection",
114        description: "Host ports + env URLs table",
115    },
116    Entry {
117        command: "logs",
118        aliases: "—",
119        group: "Inspection",
120        description: "Follow logs",
121    },
122    Entry {
123        command: "shell",
124        aliases: "sh",
125        group: "Inspection",
126        description: "Shell into a container (default: backend)",
127    },
128    Entry {
129        command: "config",
130        aliases: "—",
131        group: "Inspection",
132        description: "Print the resolved configuration",
133    },
134    Entry {
135        command: "doctor",
136        aliases: "—",
137        group: "Inspection",
138        description: "Report workspace faults; --fix repairs the clear ones",
139    },
140    Entry {
141        command: "explain",
142        aliases: "—",
143        group: "Inspection",
144        description: "Print the docker compose command, run nothing",
145    },
146    Entry {
147        command: "env",
148        aliases: "—",
149        group: "Inspection",
150        description: "Print the environment compose is given",
151    },
152    Entry {
153        command: "generate",
154        aliases: "—",
155        group: "Inspection",
156        description: "Write the compose overlays, start nothing",
157    },
158    Entry {
159        command: "backend",
160        aliases: "—",
161        group: "Backend",
162        description: "Run any command inside the backend container",
163    },
164    Entry {
165        command: "migrate",
166        aliases: "—",
167        group: "Backend",
168        description: "Run pending migrations",
169    },
170    Entry {
171        command: "seed",
172        aliases: "—",
173        group: "Backend",
174        description: "Run database seeders",
175    },
176    Entry {
177        command: "fresh",
178        aliases: "—",
179        group: "Backend",
180        description: "Rebuild the schema and seed it (destroys data)",
181    },
182    Entry {
183        command: "artisan",
184        aliases: "—",
185        group: "Backend",
186        description: "php artisan … (BACKEND_STACK=laravel)",
187    },
188    Entry {
189        command: "composer",
190        aliases: "—",
191        group: "Backend",
192        description: "composer … (BACKEND_STACK=laravel)",
193    },
194    Entry {
195        command: "pnpm",
196        aliases: "—",
197        group: "Frontend",
198        description: "pnpm … inside the frontend workspace",
199    },
200    Entry {
201        command: "ios",
202        aliases: "—",
203        group: "Mobile",
204        description: "Open mobile-client in the iOS Simulator",
205    },
206    Entry {
207        command: "android",
208        aliases: "—",
209        group: "Mobile",
210        description: "Open mobile-client on an Android emulator",
211    },
212    Entry {
213        command: "device",
214        aliases: "—",
215        group: "Mobile",
216        description: "Open on a USB phone, or print the Expo Go URL",
217    },
218    Entry {
219        command: "mobile",
220        aliases: "—",
221        group: "Mobile",
222        description: "Restart mobile-deps / mobile-packages / Metro",
223    },
224    Entry {
225        command: "reload",
226        aliases: "—",
227        group: "Mobile",
228        description: "Metro /status + /reload; --clear wipes caches",
229    },
230    Entry {
231        command: "prebuild",
232        aliases: "—",
233        group: "Mobile",
234        description: "expo prebuild (Expo apps only)",
235    },
236    Entry {
237        command: "sync-mobile-port",
238        aliases: "—",
239        group: "Mobile",
240        description: "Sync MOBILE_CLIENT_PORT into the mobile app configs",
241    },
242    Entry {
243        command: "bundler",
244        aliases: "configure-bundler",
245        group: "Mobile",
246        description: "Point the simulator at Metro",
247    },
248    Entry {
249        command: "desktop",
250        aliases: "—",
251        group: "Desktop",
252        description: "Launch the Electron / Tauri shell on the host",
253    },
254    Entry {
255        command: "deploy",
256        aliases: "—",
257        group: "Deploy",
258        description: "Deploy web, mobile or desktop",
259    },
260    Entry {
261        command: "dash",
262        aliases: "dashboard",
263        group: "Dashboard",
264        description: "Open the status dashboard in a browser",
265    },
266];
267
268pub fn run(raw: bool) -> Result<i32> {
269    if raw {
270        let mut seen = std::collections::BTreeSet::new();
271        for entry in CATALOG {
272            if seen.insert(entry.command) {
273                println!("{}", entry.command);
274            }
275        }
276        return Ok(0);
277    }
278    print_table();
279    Ok(0)
280}
281
282fn print_table() {
283    print!("{}", render());
284}
285
286/// The catalogue as a bordered table.
287///
288/// Column widths count characters, not bytes: the em dash used for "no alias"
289/// is three bytes and one column, and Rust pads by characters — measuring in
290/// bytes would push every border after it out of line.
291fn render() -> String {
292    let cmd_w = width(CATALOG.iter().map(|e| e.command), "COMMAND");
293    let alias_w = width(CATALOG.iter().map(|e| e.aliases), "ALIASES");
294    let group_w = width(CATALOG.iter().map(|e| e.group), "GROUP");
295    let desc_w = width(CATALOG.iter().map(|e| e.description), "DESCRIPTION");
296    let widths = [cmd_w, alias_w, group_w, desc_w];
297
298    let mut out = String::from("Commands\n");
299    out.push_str(&rule('┌', '┬', '┐', &widths));
300    out.push_str(&row("COMMAND", "ALIASES", "GROUP", "DESCRIPTION", &widths));
301    out.push_str(&rule('├', '┼', '┤', &widths));
302    for entry in CATALOG {
303        out.push_str(&row(
304            entry.command,
305            entry.aliases,
306            entry.group,
307            entry.description,
308            &widths,
309        ));
310    }
311    out.push_str(&rule('└', '┴', '┘', &widths));
312    out
313}
314
315fn rule(left: char, join: char, right: char, widths: &[usize; 4]) -> String {
316    let segments: Vec<String> = widths.iter().map(|w| "─".repeat(w + 2)).collect();
317    format!("{left}{}{right}\n", segments.join(&join.to_string()))
318}
319
320fn row(command: &str, aliases: &str, group: &str, description: &str, widths: &[usize; 4]) -> String {
321    let [cmd_w, alias_w, group_w, desc_w] = *widths;
322    format!(
323        "│ {command:<cmd_w$} │ {aliases:<alias_w$} │ {group:<group_w$} │ {description:<desc_w$} │\n"
324    )
325}
326
327fn width<'a>(values: impl Iterator<Item = &'a str>, header: &str) -> usize {
328    values
329        .map(|value| value.chars().count())
330        .chain(std::iter::once(header.chars().count()))
331        .max()
332        .unwrap_or_else(|| header.chars().count())
333}
334
335#[cfg(test)]
336mod tests {
337    use super::*;
338
339    #[test]
340    fn doctor_is_in_the_catalogue() {
341        assert!(CATALOG.iter().any(|entry| entry.command == "doctor"));
342    }
343
344    #[test]
345    fn every_row_is_bordered_and_the_same_width() {
346        let table = render();
347        let lines: Vec<&str> = table.lines().skip(1).collect();
348
349        let expected = lines[0].chars().count();
350        for line in &lines {
351            assert_eq!(
352                line.chars().count(),
353                expected,
354                "ragged border on line: {line}"
355            );
356        }
357        assert!(lines.first().unwrap().starts_with('┌'));
358        assert!(lines.last().unwrap().starts_with('└'));
359    }
360
361    #[test]
362    fn an_em_dash_alias_does_not_skew_the_border() {
363        // Measured in bytes the em dash counts three, and every border after it
364        // would shift left by two.
365        assert!(CATALOG.iter().any(|entry| entry.aliases == "—"));
366        let rows: Vec<usize> = render()
367            .lines()
368            .filter(|line| line.starts_with('│'))
369            .map(|line| line.chars().count())
370            .collect();
371
372        assert!(rows.windows(2).all(|pair| pair[0] == pair[1]));
373    }
374
375    #[test]
376    fn raw_mode_lists_plain_names() {
377        // `rst commands --raw` feeds shell completion, so it must stay unadorned.
378        assert!(!CATALOG.is_empty());
379        assert!(CATALOG.iter().all(|entry| !entry.command.contains('│')));
380    }
381}