Skip to main content

flodl_cli/
builtins.rs

1//! Registry of built-in commands. Single source of truth for dispatch,
2//! help listing, collision detection, and shell completion.
3//!
4//! Each leaf sub-command owns a `#[derive(FdlArgs)]` struct that carries
5//! the canonical flag set. `BuiltinSpec::schema_fn` returns the
6//! `Schema` derived from that struct, so completion rules
7//! (`--cuda <TAB>` → `12.6 12.8`, etc.) flow through the same pipeline
8//! as project commands rather than a hand-mirrored flag table.
9
10use crate::args::FdlArgsTrait;
11use crate::config::Schema;
12
13// ---------------------------------------------------------------------------
14// FdlArgs structs (one per leaf sub-command)
15//
16// These dogfood the derive macro across flodl-cli itself. Each is parsed
17// with `parse_or_schema_from(&argv)` from a sliced argv tail; the derive
18// handles argv, `--help`, and `--fdl-schema` uniformly.
19// ---------------------------------------------------------------------------
20
21/// Interactive guided setup wizard.
22#[derive(crate::FdlArgs, Debug)]
23pub struct SetupArgs {
24    /// Skip all prompts and use auto-detected defaults.
25    #[option(short = 'y')]
26    pub non_interactive: bool,
27    /// Re-download or rebuild even if libtorch exists.
28    #[option]
29    pub force: bool,
30}
31
32/// System and GPU diagnostics.
33#[derive(crate::FdlArgs, Debug)]
34pub struct DiagnoseArgs {
35    /// Emit machine-readable JSON.
36    #[option]
37    pub json: bool,
38}
39
40/// Generate flodl API reference.
41#[derive(crate::FdlArgs, Debug)]
42pub struct ApiRefArgs {
43    /// Emit machine-readable JSON.
44    #[option]
45    pub json: bool,
46    /// Explicit flodl source path (defaults to detected project root).
47    #[option]
48    pub path: Option<String>,
49}
50
51/// Scaffold a new floDl project.
52#[derive(crate::FdlArgs, Debug)]
53pub struct InitArgs {
54    /// New project directory name.
55    #[arg]
56    pub name: Option<String>,
57    /// Generate a Docker-based scaffold (libtorch baked into the image).
58    #[option]
59    pub docker: bool,
60}
61
62/// Install or update fdl globally (~/.local/bin/fdl).
63#[derive(crate::FdlArgs, Debug)]
64pub struct InstallArgs {
65    /// Check for updates without installing.
66    #[option]
67    pub check: bool,
68    /// Symlink to the current binary (tracks local builds).
69    #[option]
70    pub dev: bool,
71}
72
73/// List installed libtorch variants.
74#[derive(crate::FdlArgs, Debug)]
75pub struct LibtorchListArgs {
76    /// Emit machine-readable JSON.
77    #[option]
78    pub json: bool,
79}
80
81/// Activate a libtorch variant.
82#[derive(crate::FdlArgs, Debug)]
83pub struct LibtorchActivateArgs {
84    /// Variant to activate (as shown by `fdl libtorch list`).
85    #[arg]
86    pub variant: Option<String>,
87}
88
89/// Remove a libtorch variant.
90#[derive(crate::FdlArgs, Debug)]
91pub struct LibtorchRemoveArgs {
92    /// Variant to remove (as shown by `fdl libtorch list`).
93    #[arg]
94    pub variant: Option<String>,
95}
96
97/// Download a pre-built libtorch variant.
98#[derive(crate::FdlArgs, Debug)]
99pub struct LibtorchDownloadArgs {
100    /// Force the CPU variant.
101    #[option]
102    pub cpu: bool,
103    /// Pick a specific CUDA version (instead of auto-detect).
104    #[option(choices = &["12.6", "12.8"])]
105    pub cuda: Option<String>,
106    /// Install libtorch to this directory (default: project libtorch/).
107    #[option]
108    pub path: Option<String>,
109    /// Do not activate after download.
110    #[option]
111    pub no_activate: bool,
112    /// Show what would happen without downloading.
113    #[option]
114    pub dry_run: bool,
115}
116
117/// Build libtorch from source.
118#[derive(crate::FdlArgs, Debug)]
119pub struct LibtorchBuildArgs {
120    /// Override CUDA architectures (semicolon-separated, e.g. "6.1;12.0").
121    #[option]
122    pub archs: Option<String>,
123    /// Parallel compilation jobs.
124    #[option(default = "6")]
125    pub jobs: usize,
126    /// Force Docker build (isolated, reproducible).
127    #[option]
128    pub docker: bool,
129    /// Force native build (faster, requires host toolchain).
130    #[option]
131    pub native: bool,
132    /// Show what would happen without building.
133    #[option]
134    pub dry_run: bool,
135}
136
137/// Install AI coding assistant skills.
138#[derive(crate::FdlArgs, Debug)]
139pub struct SkillInstallArgs {
140    /// Target tool (defaults to auto-detect).
141    #[option]
142    pub tool: Option<String>,
143    /// Specific skill name (defaults to all detected skills).
144    #[option]
145    pub skill: Option<String>,
146}
147
148/// List cached `--fdl-schema` outputs.
149#[derive(crate::FdlArgs, Debug)]
150pub struct SchemaListArgs {
151    /// Emit machine-readable JSON.
152    #[option]
153    pub json: bool,
154}
155
156/// Clear cached schemas. No command name clears all.
157#[derive(crate::FdlArgs, Debug)]
158pub struct SchemaClearArgs {
159    /// Command name to clear (defaults to all).
160    #[arg]
161    pub cmd: Option<String>,
162}
163
164/// Re-probe each entry and rewrite the cache.
165#[derive(crate::FdlArgs, Debug)]
166pub struct SchemaRefreshArgs {
167    /// Command name to refresh (defaults to all).
168    #[arg]
169    pub cmd: Option<String>,
170}
171
172// ---------------------------------------------------------------------------
173// Registry
174// ---------------------------------------------------------------------------
175
176/// One built-in command (or sub-command) slot.
177pub struct BuiltinSpec {
178    /// Path from the top-level command name. `["install"]`,
179    /// `["libtorch", "download"]`.
180    pub path: &'static [&'static str],
181    /// One-line description for `fdl -h` listing. `None` = hidden
182    /// (reserved for collision detection but not shown in help).
183    pub description: Option<&'static str>,
184    /// Constructor for the command's schema. `None` for parent commands
185    /// that only group sub-commands (e.g. `libtorch` itself has no args)
186    /// or for leaves whose argv is parsed by hand (`config show`,
187    /// `completions`, `autocomplete`).
188    pub schema_fn: Option<fn() -> Schema>,
189}
190
191/// Ordered registry of every built-in. Order drives `fdl -h` and the
192/// top-level completion word list, so it mirrors today's `BUILTINS`
193/// const in `main.rs`.
194pub fn registry() -> &'static [BuiltinSpec] {
195    static REG: &[BuiltinSpec] = &[
196        BuiltinSpec {
197            path: &["setup"],
198            description: Some("Interactive guided setup"),
199            schema_fn: Some(SetupArgs::schema),
200        },
201        BuiltinSpec {
202            path: &["libtorch"],
203            description: Some("Manage libtorch installations"),
204            schema_fn: None,
205        },
206        BuiltinSpec {
207            path: &["libtorch", "download"],
208            description: Some("Download pre-built libtorch"),
209            schema_fn: Some(LibtorchDownloadArgs::schema),
210        },
211        BuiltinSpec {
212            path: &["libtorch", "build"],
213            description: Some("Build libtorch from source"),
214            schema_fn: Some(LibtorchBuildArgs::schema),
215        },
216        BuiltinSpec {
217            path: &["libtorch", "list"],
218            description: Some("Show installed variants"),
219            schema_fn: Some(LibtorchListArgs::schema),
220        },
221        BuiltinSpec {
222            path: &["libtorch", "activate"],
223            description: Some("Set active variant"),
224            schema_fn: Some(LibtorchActivateArgs::schema),
225        },
226        BuiltinSpec {
227            path: &["libtorch", "remove"],
228            description: Some("Remove a variant"),
229            schema_fn: Some(LibtorchRemoveArgs::schema),
230        },
231        BuiltinSpec {
232            path: &["libtorch", "info"],
233            description: Some("Show active variant details"),
234            schema_fn: None,
235        },
236        BuiltinSpec {
237            path: &["init"],
238            description: Some("Scaffold a new floDl project"),
239            schema_fn: Some(InitArgs::schema),
240        },
241        BuiltinSpec {
242            path: &["diagnose"],
243            description: Some("System and GPU diagnostics"),
244            schema_fn: Some(DiagnoseArgs::schema),
245        },
246        BuiltinSpec {
247            path: &["install"],
248            description: Some("Install or update fdl globally"),
249            schema_fn: Some(InstallArgs::schema),
250        },
251        BuiltinSpec {
252            path: &["skill"],
253            description: Some("Manage AI coding assistant skills"),
254            schema_fn: None,
255        },
256        BuiltinSpec {
257            path: &["skill", "install"],
258            description: Some("Install skills for the detected tool"),
259            schema_fn: Some(SkillInstallArgs::schema),
260        },
261        BuiltinSpec {
262            path: &["skill", "list"],
263            description: Some("Show available skills"),
264            schema_fn: None,
265        },
266        BuiltinSpec {
267            path: &["api-ref"],
268            description: Some("Generate flodl API reference"),
269            schema_fn: Some(ApiRefArgs::schema),
270        },
271        BuiltinSpec {
272            path: &["config"],
273            description: Some("Inspect resolved project configuration"),
274            schema_fn: None,
275        },
276        BuiltinSpec {
277            path: &["config", "show"],
278            description: Some("Print the resolved merged config"),
279            schema_fn: None,
280        },
281        BuiltinSpec {
282            path: &["schema"],
283            description: Some("Inspect, clear, or refresh cached --fdl-schema outputs"),
284            schema_fn: None,
285        },
286        BuiltinSpec {
287            path: &["schema", "list"],
288            description: Some("Show every cached schema with status"),
289            schema_fn: Some(SchemaListArgs::schema),
290        },
291        BuiltinSpec {
292            path: &["schema", "clear"],
293            description: Some("Delete cached schema(s)"),
294            schema_fn: Some(SchemaClearArgs::schema),
295        },
296        BuiltinSpec {
297            path: &["schema", "refresh"],
298            description: Some("Re-probe each entry and rewrite the cache"),
299            schema_fn: Some(SchemaRefreshArgs::schema),
300        },
301        BuiltinSpec {
302            path: &["completions"],
303            description: Some("Emit shell completion script (bash|zsh|fish)"),
304            schema_fn: None,
305        },
306        BuiltinSpec {
307            path: &["autocomplete"],
308            description: Some("Install completions into the detected shell"),
309            schema_fn: None,
310        },
311        // Hidden: `version` is covered by `-V` / `--version` but still
312        // reserved so first-arg env detection doesn't hijack it.
313        BuiltinSpec {
314            path: &["version"],
315            description: None,
316            schema_fn: None,
317        },
318    ];
319    REG
320}
321
322/// True when `name` is a reserved top-level built-in (visible or hidden).
323/// Drives env-collision detection in first-arg resolution.
324pub fn is_builtin_name(name: &str) -> bool {
325    registry()
326        .iter()
327        .any(|s| s.path.len() == 1 && s.path[0] == name)
328}
329
330/// Visible top-level built-ins as `(name, description)` pairs, in
331/// registry order. Feeds `run::print_project_help` and the fallback
332/// `print_usage`.
333pub fn visible_top_level() -> Vec<(&'static str, &'static str)> {
334    registry()
335        .iter()
336        .filter(|s| s.path.len() == 1)
337        .filter_map(|s| s.description.map(|d| (s.path[0], d)))
338        .collect()
339}
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344    use std::collections::HashSet;
345
346
347
348    #[test]
349    fn registry_has_no_duplicate_paths() {
350        let mut seen = HashSet::new();
351        for s in registry() {
352            let key = s.path.join(" ");
353            assert!(
354                seen.insert(key.clone()),
355                "duplicate registry path: {key}"
356            );
357        }
358    }
359
360    #[test]
361    fn hidden_entries_have_no_description() {
362        for s in registry() {
363            if s.path == ["version"] {
364                assert!(s.description.is_none(),
365                    "`version` is hidden but carries a description");
366            }
367        }
368    }
369
370    #[test]
371    fn every_parent_has_at_least_one_child() {
372        let parents: HashSet<&str> = registry()
373            .iter()
374            .filter(|s| s.path.len() == 1 && s.schema_fn.is_none()
375                && s.description.is_some())
376            .map(|s| s.path[0])
377            .collect();
378
379        // `completions`, `autocomplete` are leaves with no schema — exclude
380        // them by checking that parents have at least one 2-path child.
381        for parent in &parents {
382            let has_child = registry().iter().any(|s| s.path.len() == 2 && s.path[0] == *parent);
383            if !has_child {
384                // `completions` / `autocomplete` / `version` end up here by
385                // virtue of having no children; they are leaf built-ins.
386                continue;
387            }
388            assert!(has_child, "parent `{parent}` has no child entries");
389        }
390    }
391
392    #[test]
393    fn top_level_dispatched_by_main_is_in_registry() {
394        // Compile-time guard: every match arm target in main.rs is listed
395        // here. Keeping the list local (rather than introspecting main.rs)
396        // documents the coupling explicitly.
397        let dispatched = [
398            "setup", "libtorch", "diagnose", "api-ref", "init", "install",
399            "skill", "schema", "completions", "autocomplete", "config",
400            "version",
401        ];
402        for name in &dispatched {
403            assert!(
404                is_builtin_name(name),
405                "`{name}` dispatched by main.rs but missing from registry"
406            );
407        }
408    }
409
410    #[test]
411    fn visible_top_level_matches_help_ordering() {
412        let top = visible_top_level();
413        let names: Vec<&str> = top.iter().map(|(n, _)| *n).collect();
414        // Lock in the order that `fdl -h` depends on.
415        assert_eq!(
416            names,
417            vec![
418                "setup", "libtorch", "init", "diagnose", "install", "skill",
419                "api-ref", "config", "schema", "completions", "autocomplete",
420            ]
421        );
422    }
423
424    #[test]
425    fn libtorch_download_schema_carries_cuda_choices() {
426        let spec = registry()
427            .iter()
428            .find(|s| s.path == ["libtorch", "download"])
429            .expect("libtorch download entry present");
430        let schema = (spec.schema_fn.expect("download has schema"))();
431        let cuda = schema
432            .options
433            .get("cuda")
434            .expect("`--cuda` option declared");
435        let choices = cuda.choices.as_ref().expect("--cuda has choices");
436        let values: Vec<String> = choices
437            .iter()
438            .filter_map(|v| v.as_str().map(str::to_string))
439            .collect();
440        assert_eq!(values, vec!["12.6".to_string(), "12.8".into()]);
441    }
442}