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///
53/// Three modes, mutually exclusive:
54///   default (no flag) — Docker with host-mounted libtorch (recommended)
55///   --docker          — Docker with libtorch baked into the image
56///   --native          — no Docker, host-provided libtorch + cargo
57#[derive(crate::FdlArgs, Debug)]
58pub struct InitArgs {
59    /// New project directory name.
60    #[arg]
61    pub name: Option<String>,
62    /// Generate a Docker scaffold with libtorch baked into the image.
63    #[option]
64    pub docker: bool,
65    /// Generate a native scaffold (no Docker; libtorch provided on the host).
66    #[option]
67    pub native: bool,
68    /// Also scaffold the flodl-hf HuggingFace playground (skips the prompt).
69    #[option]
70    pub with_hf: bool,
71}
72
73/// Add a flodl ecosystem crate to the current flodl project.
74///
75/// Currently supports `flodl-hf` (alias: `hf`). Two modes (combinable):
76///
77/// - `--playground`: drops a standalone cargo crate under `./flodl-hf/`
78///   with pinned deps and a one-file AutoModel example, plus a
79///   `flodl-hf:` entry in the root `fdl.yml` so `fdl flodl-hf <cmd>`
80///   routes into it. Try-it-out path; doesn't touch `Cargo.toml`.
81/// - `--install`: appends `flodl-hf = "=X.Y.Z"` (default features) to
82///   the root `Cargo.toml` `[dependencies]`. Wires the crate into the
83///   user's own code; doesn't create a subdir.
84///
85/// With neither flag, an interactive prompt asks. Non-tty stdin errors
86/// loudly rather than silently picking a default.
87#[derive(crate::FdlArgs, Debug)]
88pub struct AddArgs {
89    /// Target to scaffold (currently: `flodl-hf` or the alias `hf`).
90    #[arg]
91    pub target: Option<String>,
92    /// Drop a sandbox playground under `./flodl-hf/`.
93    #[option]
94    pub playground: bool,
95    /// Add as a dependency in the root `Cargo.toml`.
96    #[option]
97    pub install: bool,
98}
99
100/// Install or update fdl globally (~/.local/bin/fdl).
101#[derive(crate::FdlArgs, Debug)]
102pub struct InstallArgs {
103    /// Check for updates without installing.
104    #[option]
105    pub check: bool,
106    /// Symlink to the current binary (tracks local builds).
107    #[option]
108    pub dev: bool,
109}
110
111/// List installed libtorch variants.
112#[derive(crate::FdlArgs, Debug)]
113pub struct LibtorchListArgs {
114    /// Emit machine-readable JSON.
115    #[option]
116    pub json: bool,
117}
118
119/// Activate a libtorch variant.
120#[derive(crate::FdlArgs, Debug)]
121pub struct LibtorchActivateArgs {
122    /// Variant to activate (as shown by `fdl libtorch list`).
123    #[arg]
124    pub variant: Option<String>,
125}
126
127/// Remove a libtorch variant.
128#[derive(crate::FdlArgs, Debug)]
129pub struct LibtorchRemoveArgs {
130    /// Variant to remove (as shown by `fdl libtorch list`).
131    #[arg]
132    pub variant: Option<String>,
133}
134
135/// Download a pre-built libtorch variant.
136#[derive(crate::FdlArgs, Debug)]
137pub struct LibtorchDownloadArgs {
138    /// Force the CPU variant.
139    #[option]
140    pub cpu: bool,
141    /// Pick a specific CUDA version (instead of auto-detect).
142    #[option(choices = &["12.6", "12.8"])]
143    pub cuda: Option<String>,
144    /// Install libtorch to this directory (default: project libtorch/).
145    #[option]
146    pub path: Option<String>,
147    /// Do not activate after download.
148    #[option]
149    pub no_activate: bool,
150    /// Show what would happen without downloading.
151    #[option]
152    pub dry_run: bool,
153}
154
155/// Build libtorch from source.
156#[derive(crate::FdlArgs, Debug)]
157pub struct LibtorchBuildArgs {
158    /// Override CUDA architectures (semicolon-separated, e.g. "6.1;12.0").
159    #[option]
160    pub archs: Option<String>,
161    /// Parallel compilation jobs.
162    #[option(default = "6")]
163    pub jobs: usize,
164    /// Force Docker build (isolated, reproducible).
165    #[option]
166    pub docker: bool,
167    /// Force native build (faster, requires host toolchain).
168    #[option]
169    pub native: bool,
170    /// Show what would happen without building.
171    #[option]
172    pub dry_run: bool,
173}
174
175/// Install AI coding assistant skills.
176#[derive(crate::FdlArgs, Debug)]
177pub struct SkillInstallArgs {
178    /// Target tool (defaults to auto-detect).
179    #[option]
180    pub tool: Option<String>,
181    /// Specific skill name (defaults to all detected skills).
182    #[option]
183    pub skill: Option<String>,
184}
185
186/// List cached `--fdl-schema` outputs.
187#[derive(crate::FdlArgs, Debug)]
188pub struct SchemaListArgs {
189    /// Emit machine-readable JSON.
190    #[option]
191    pub json: bool,
192}
193
194/// Clear cached schemas. No command name clears all.
195#[derive(crate::FdlArgs, Debug)]
196pub struct SchemaClearArgs {
197    /// Command name to clear (defaults to all).
198    #[arg]
199    pub cmd: Option<String>,
200}
201
202/// Re-probe each entry and rewrite the cache.
203#[derive(crate::FdlArgs, Debug)]
204pub struct SchemaRefreshArgs {
205    /// Command name to refresh (defaults to all).
206    #[arg]
207    pub cmd: Option<String>,
208}
209
210// ---------------------------------------------------------------------------
211// Registry
212// ---------------------------------------------------------------------------
213
214/// One built-in command (or sub-command) slot.
215pub struct BuiltinSpec {
216    /// Path from the top-level command name. `["install"]`,
217    /// `["libtorch", "download"]`.
218    pub path: &'static [&'static str],
219    /// One-line description for `fdl -h` listing. `None` = hidden
220    /// (reserved for collision detection but not shown in help).
221    pub description: Option<&'static str>,
222    /// Constructor for the command's schema. `None` for parent commands
223    /// that only group sub-commands (e.g. `libtorch` itself has no args)
224    /// or for leaves whose argv is parsed by hand (`config show`,
225    /// `completions`, `autocomplete`).
226    pub schema_fn: Option<fn() -> Schema>,
227}
228
229/// Ordered registry of every built-in. Order drives `fdl -h` and the
230/// top-level completion word list, so it mirrors today's `BUILTINS`
231/// const in `main.rs`.
232pub fn registry() -> &'static [BuiltinSpec] {
233    static REG: &[BuiltinSpec] = &[
234        BuiltinSpec {
235            path: &["setup"],
236            description: Some("Interactive guided setup"),
237            schema_fn: Some(SetupArgs::schema),
238        },
239        BuiltinSpec {
240            path: &["libtorch"],
241            description: Some("Manage libtorch installations"),
242            schema_fn: None,
243        },
244        BuiltinSpec {
245            path: &["libtorch", "download"],
246            description: Some("Download pre-built libtorch"),
247            schema_fn: Some(LibtorchDownloadArgs::schema),
248        },
249        BuiltinSpec {
250            path: &["libtorch", "build"],
251            description: Some("Build libtorch from source"),
252            schema_fn: Some(LibtorchBuildArgs::schema),
253        },
254        BuiltinSpec {
255            path: &["libtorch", "list"],
256            description: Some("Show installed variants"),
257            schema_fn: Some(LibtorchListArgs::schema),
258        },
259        BuiltinSpec {
260            path: &["libtorch", "activate"],
261            description: Some("Set active variant"),
262            schema_fn: Some(LibtorchActivateArgs::schema),
263        },
264        BuiltinSpec {
265            path: &["libtorch", "remove"],
266            description: Some("Remove a variant"),
267            schema_fn: Some(LibtorchRemoveArgs::schema),
268        },
269        BuiltinSpec {
270            path: &["libtorch", "info"],
271            description: Some("Show active variant details"),
272            schema_fn: None,
273        },
274        BuiltinSpec {
275            path: &["init"],
276            description: Some("Scaffold a new floDl project"),
277            schema_fn: Some(InitArgs::schema),
278        },
279        BuiltinSpec {
280            path: &["add"],
281            description: Some("Add a flodl ecosystem crate (currently: flodl-hf)"),
282            schema_fn: Some(AddArgs::schema),
283        },
284        BuiltinSpec {
285            path: &["diagnose"],
286            description: Some("System and GPU diagnostics"),
287            schema_fn: Some(DiagnoseArgs::schema),
288        },
289        BuiltinSpec {
290            path: &["install"],
291            description: Some("Install or update fdl globally"),
292            schema_fn: Some(InstallArgs::schema),
293        },
294        BuiltinSpec {
295            path: &["skill"],
296            description: Some("Manage AI coding assistant skills"),
297            schema_fn: None,
298        },
299        BuiltinSpec {
300            path: &["skill", "install"],
301            description: Some("Install skills for the detected tool"),
302            schema_fn: Some(SkillInstallArgs::schema),
303        },
304        BuiltinSpec {
305            path: &["skill", "list"],
306            description: Some("Show available skills"),
307            schema_fn: None,
308        },
309        BuiltinSpec {
310            path: &["api-ref"],
311            description: Some("Generate flodl API reference"),
312            schema_fn: Some(ApiRefArgs::schema),
313        },
314        BuiltinSpec {
315            path: &["config"],
316            description: Some("Inspect resolved project configuration"),
317            schema_fn: None,
318        },
319        BuiltinSpec {
320            path: &["config", "show"],
321            description: Some("Print the resolved merged config"),
322            schema_fn: None,
323        },
324        BuiltinSpec {
325            path: &["schema"],
326            description: Some("Inspect, clear, or refresh cached --fdl-schema outputs"),
327            schema_fn: None,
328        },
329        BuiltinSpec {
330            path: &["schema", "list"],
331            description: Some("Show every cached schema with status"),
332            schema_fn: Some(SchemaListArgs::schema),
333        },
334        BuiltinSpec {
335            path: &["schema", "clear"],
336            description: Some("Delete cached schema(s)"),
337            schema_fn: Some(SchemaClearArgs::schema),
338        },
339        BuiltinSpec {
340            path: &["schema", "refresh"],
341            description: Some("Re-probe each entry and rewrite the cache"),
342            schema_fn: Some(SchemaRefreshArgs::schema),
343        },
344        BuiltinSpec {
345            path: &["completions"],
346            description: Some("Emit shell completion script (bash|zsh|fish)"),
347            schema_fn: None,
348        },
349        BuiltinSpec {
350            path: &["autocomplete"],
351            description: Some("Install completions into the detected shell"),
352            schema_fn: None,
353        },
354        // Hidden: `version` is covered by `-V` / `--version` but still
355        // reserved so first-arg env detection doesn't hijack it.
356        BuiltinSpec {
357            path: &["version"],
358            description: None,
359            schema_fn: None,
360        },
361    ];
362    REG
363}
364
365/// True when `name` is a reserved top-level built-in (visible or hidden).
366/// Drives env-collision detection in first-arg resolution.
367pub fn is_builtin_name(name: &str) -> bool {
368    registry()
369        .iter()
370        .any(|s| s.path.len() == 1 && s.path[0] == name)
371}
372
373/// Visible top-level built-ins as `(name, description)` pairs, in
374/// registry order. Feeds `run::print_project_help` and the fallback
375/// `print_usage`.
376pub fn visible_top_level() -> Vec<(&'static str, &'static str)> {
377    registry()
378        .iter()
379        .filter(|s| s.path.len() == 1)
380        .filter_map(|s| s.description.map(|d| (s.path[0], d)))
381        .collect()
382}
383
384#[cfg(test)]
385mod tests {
386    use super::*;
387    use std::collections::HashSet;
388
389
390
391    #[test]
392    fn registry_has_no_duplicate_paths() {
393        let mut seen = HashSet::new();
394        for s in registry() {
395            let key = s.path.join(" ");
396            assert!(
397                seen.insert(key.clone()),
398                "duplicate registry path: {key}"
399            );
400        }
401    }
402
403    #[test]
404    fn hidden_entries_have_no_description() {
405        for s in registry() {
406            if s.path == ["version"] {
407                assert!(s.description.is_none(),
408                    "`version` is hidden but carries a description");
409            }
410        }
411    }
412
413    #[test]
414    fn every_parent_has_at_least_one_child() {
415        let parents: HashSet<&str> = registry()
416            .iter()
417            .filter(|s| s.path.len() == 1 && s.schema_fn.is_none()
418                && s.description.is_some())
419            .map(|s| s.path[0])
420            .collect();
421
422        // `completions`, `autocomplete` are leaves with no schema — exclude
423        // them by checking that parents have at least one 2-path child.
424        for parent in &parents {
425            let has_child = registry().iter().any(|s| s.path.len() == 2 && s.path[0] == *parent);
426            if !has_child {
427                // `completions` / `autocomplete` / `version` end up here by
428                // virtue of having no children; they are leaf built-ins.
429                continue;
430            }
431            assert!(has_child, "parent `{parent}` has no child entries");
432        }
433    }
434
435    #[test]
436    fn top_level_dispatched_by_main_is_in_registry() {
437        // Compile-time guard: every match arm target in main.rs is listed
438        // here. Keeping the list local (rather than introspecting main.rs)
439        // documents the coupling explicitly.
440        let dispatched = [
441            "setup", "libtorch", "diagnose", "api-ref", "init", "add",
442            "install", "skill", "schema", "completions", "autocomplete",
443            "config", "version",
444        ];
445        for name in &dispatched {
446            assert!(
447                is_builtin_name(name),
448                "`{name}` dispatched by main.rs but missing from registry"
449            );
450        }
451    }
452
453    #[test]
454    fn visible_top_level_matches_help_ordering() {
455        let top = visible_top_level();
456        let names: Vec<&str> = top.iter().map(|(n, _)| *n).collect();
457        // Lock in the order that `fdl -h` depends on.
458        assert_eq!(
459            names,
460            vec![
461                "setup", "libtorch", "init", "add", "diagnose", "install",
462                "skill", "api-ref", "config", "schema", "completions",
463                "autocomplete",
464            ]
465        );
466    }
467
468    #[test]
469    fn libtorch_download_schema_carries_cuda_choices() {
470        let spec = registry()
471            .iter()
472            .find(|s| s.path == ["libtorch", "download"])
473            .expect("libtorch download entry present");
474        let schema = (spec.schema_fn.expect("download has schema"))();
475        let cuda = schema
476            .options
477            .get("cuda")
478            .expect("`--cuda` option declared");
479        let choices = cuda.choices.as_ref().expect("--cuda has choices");
480        let values: Vec<String> = choices
481            .iter()
482            .filter_map(|v| v.as_str().map(str::to_string))
483            .collect();
484        assert_eq!(values, vec!["12.6".to_string(), "12.8".into()]);
485    }
486}