1use anyhow::Result;
4
5struct 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
286fn 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 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 assert!(!CATALOG.is_empty());
379 assert!(CATALOG.iter().all(|entry| !entry.command.contains('│')));
380 }
381}