Skip to main content

flodl_cli/
add.rs

1//! `fdl add flodl-hf` -- scaffold a flodl-hf playground inside the current flodl project.
2//!
3//! Drops `./flodl-hf/` as a standalone cargo crate: pinned `flodl` +
4//! `flodl-hf` deps, a one-file `AutoModel` example, `fdl.yml` with
5//! runnable commands, and a README documenting feature flavors and
6//! the convert workflow.
7//!
8//! Scope contract: no mutation of the user's root `Cargo.toml` or
9//! `fdl.yml`. The playground is a side crate the user runs for
10//! discovery; wiring flodl-hf into their main code stays their call,
11//! documented in the generated README.
12//!
13//! Targets accepted: `flodl-hf` and its alias `hf`. Other targets
14//! surface a loud error listing the supported set.
15
16use std::fs;
17use std::path::{Path, PathBuf};
18
19/// Scaffold templates baked into the binary at compile time. Live
20/// under `flodl-cli/src/scaffold/` so they travel inside the
21/// `flodl-cli` crate tarball on `cargo publish`.
22// `.in` suffix avoids cargo treating this as a nested package manifest
23// during `cargo package`; it is written out as `Cargo.toml` when the
24// scaffold is generated.
25const TEMPLATE_CARGO_TOML: &str = include_str!("scaffold/Cargo.toml.in");
26const TEMPLATE_MAIN_RS: &str = include_str!("scaffold/src/main.rs");
27const TEMPLATE_FDL_YML: &str = include_str!("scaffold/fdl.yml.example");
28const TEMPLATE_README: &str = include_str!("scaffold/README.md");
29const TEMPLATE_GITIGNORE: &str = include_str!("scaffold/.gitignore");
30
31pub fn run(target: Option<&str>) -> Result<(), String> {
32    let target = target.ok_or(
33        "usage: fdl add <target>\n\nSupported targets:\n    flodl-hf    HuggingFace integration (pre-built BERT / RoBERTa / DistilBERT, Hub loader, tokenizer)",
34    )?;
35    let cwd = std::env::current_dir()
36        .map_err(|e| format!("cannot read current directory: {e}"))?;
37    match target {
38        "flodl-hf" | "hf" => add_flodl_hf_at(&cwd),
39        other => Err(format!(
40            "unknown target: {other:?}\n\n\
41             Supported targets:\n    \
42             flodl-hf    HuggingFace integration\n\n\
43             (More targets land as the flodl ecosystem grows.)",
44        )),
45    }
46}
47
48/// Scaffold `flodl-hf/` under `base`. Entry point for `fdl add flodl-hf`
49/// (with `base = cwd`) and `fdl init --with-hf` / interactive-mode
50/// follow-up (with `base = the freshly-scaffolded project dir`). The
51/// base dir must contain a `Cargo.toml` with a pinnable `flodl`
52/// dependency.
53pub fn add_flodl_hf_at(cwd: &Path) -> Result<(), String> {
54    // Must be run from a flodl project root (Cargo.toml with flodl dep).
55    let cargo_toml = cwd.join("Cargo.toml");
56    if !cargo_toml.exists() {
57        return Err(format!(
58            "no Cargo.toml in {}.\n\n\
59             fdl add flodl-hf must run from a flodl project root.\n\
60             Start with `fdl init <name>` if you don't have one yet.",
61            cwd.display(),
62        ));
63    }
64
65    // flodl-hf makes no sense without a functioning flodl project: every
66    // runnable command assumes a Cargo.toml + fdl.yml pair are already
67    // present. Enforce that invariant loudly so the user isn't left
68    // with a dead sub-crate.
69    if !has_fdl_config(cwd) {
70        return Err(format!(
71            "no fdl.yml (nor fdl.yml.example) in {}.\n\n\
72             fdl add flodl-hf expects an initialised flodl project: \
73             Docker or native mode already chosen, fdl.yml present. \
74             Run `fdl init <name>` first, or cd into an existing flodl project.",
75            cwd.display(),
76        ));
77    }
78
79    let flodl_version = detect_flodl_version(&cargo_toml)?;
80    let mode = detect_project_mode(cwd);
81
82    // Refuse to overwrite an existing flodl-hf/ dir.
83    let dest = cwd.join("flodl-hf");
84    if dest.exists() {
85        return Err(format!(
86            "{} already exists.\n\n\
87             Remove it first, or keep it. `fdl add flodl-hf` does not overwrite.",
88            dest.display(),
89        ));
90    }
91
92    // Scaffold.
93    fs::create_dir_all(dest.join("src"))
94        .map_err(|e| format!("cannot create {}: {e}", dest.join("src").display()))?;
95
96    write_file(
97        &dest.join("Cargo.toml"),
98        &substitute_version(TEMPLATE_CARGO_TOML, &flodl_version),
99    )?;
100    write_file(&dest.join("src/main.rs"), TEMPLATE_MAIN_RS)?;
101    let fdl_yml = render_fdl_yml(TEMPLATE_FDL_YML, mode);
102    write_file(&dest.join("fdl.yml.example"), &fdl_yml)?;
103    write_file(&dest.join("fdl.yml"), &fdl_yml)?;
104    write_file(
105        &dest.join("README.md"),
106        &substitute_version(TEMPLATE_README, &flodl_version),
107    )?;
108    write_file(&dest.join(".gitignore"), TEMPLATE_GITIGNORE)?;
109
110    print_next_steps(&flodl_version, mode);
111    Ok(())
112}
113
114/// Host-project execution mode, inferred from file presence.
115///
116/// `fdl init` writes `docker-compose.yml` for its Mounted and Docker
117/// modes, and omits it for Native. Scaffolded commands follow the
118/// same convention: Docker modes dispatch to the `dev` service,
119/// Native runs directly on the host.
120#[derive(Debug, Clone, Copy, PartialEq, Eq)]
121enum ProjectMode {
122    Docker,
123    Native,
124}
125
126fn has_fdl_config(cwd: &Path) -> bool {
127    cwd.join("fdl.yml").exists() || cwd.join("fdl.yml.example").exists()
128}
129
130fn detect_project_mode(cwd: &Path) -> ProjectMode {
131    if cwd.join("docker-compose.yml").exists() {
132        ProjectMode::Docker
133    } else {
134        ProjectMode::Native
135    }
136}
137
138/// In Native mode, strip the `    docker: dev` lines from the scaffold
139/// `fdl.yml` so cargo commands run directly on the host instead of
140/// trying to dispatch into a non-existent Docker service. Matches the
141/// indentation produced by the template exactly; anything else is left
142/// alone.
143fn render_fdl_yml(template: &str, mode: ProjectMode) -> String {
144    match mode {
145        ProjectMode::Docker => template.to_string(),
146        ProjectMode::Native => template
147            .lines()
148            .filter(|l| l.trim() != "docker: dev")
149            .collect::<Vec<&str>>()
150            .join("\n")
151            + "\n",
152    }
153}
154
155/// Parse `Cargo.toml` for the `flodl` dependency version.
156///
157/// Recognises three forms:
158/// - `flodl = "0.5.1"` — plain version string
159/// - `flodl = { version = "0.5.1", ... }` — table form
160/// - `flodl = { workspace = true }` — workspace inheritance (reads from
161///   the workspace root's `Cargo.toml`)
162///
163/// Errors on: no flodl dep found, git-only dep (no pinnable version),
164/// or path-only dep outside this repo (no version to pin against).
165fn detect_flodl_version(cargo_toml: &Path) -> Result<String, String> {
166    let content = fs::read_to_string(cargo_toml)
167        .map_err(|e| format!("cannot read {}: {e}", cargo_toml.display()))?;
168
169    if let Some(v) = parse_flodl_dep(&content)? {
170        return Ok(v);
171    }
172
173    // Workspace inheritance: climb to find the workspace root.
174    if let Some(ws_root) = find_workspace_root(cargo_toml) {
175        let ws_content = fs::read_to_string(&ws_root)
176            .map_err(|e| format!("cannot read workspace {}: {e}", ws_root.display()))?;
177        if let Some(v) = parse_flodl_dep(&ws_content)? {
178            return Ok(v);
179        }
180    }
181
182    Err(format!(
183        "no flodl dependency found in {}.\n\n\
184         fdl add flodl-hf needs to pin flodl-hf to the same version as \
185         flodl. Add `flodl = \"X.Y.Z\"` to [dependencies] first, or run \
186         `fdl init <name>` to scaffold a flodl project.",
187        cargo_toml.display(),
188    ))
189}
190
191/// Extract the flodl version from a Cargo.toml's textual content.
192///
193/// Returns `Ok(Some(version))` on a pinnable version, `Ok(None)` when
194/// no flodl dep is present, and `Err(...)` when the dep exists but is
195/// git-only / path-only (no version to pin against).
196fn parse_flodl_dep(content: &str) -> Result<Option<String>, String> {
197    let lines: Vec<&str> = content.lines().collect();
198
199    // Find a line that declares `flodl = ...` under a [dependencies]
200    // or [workspace.dependencies] table. We accept any form whose LHS
201    // matches `flodl`; the inline value on the RHS tells us the shape.
202    let mut in_dep_table = false;
203    for line in &lines {
204        let t = line.trim();
205        if t.starts_with('[') {
206            // Only consider tables that declare dependencies.
207            in_dep_table = matches!(
208                t,
209                "[dependencies]" | "[workspace.dependencies]" | "[dev-dependencies]",
210            );
211            continue;
212        }
213        if !in_dep_table {
214            continue;
215        }
216        // Match `flodl = ...` exactly (not flodl-hf, flodl-sys, ...).
217        let after_key = match t.strip_prefix("flodl") {
218            Some(rest) => rest.trim_start(),
219            None => continue,
220        };
221        let Some(rhs) = after_key.strip_prefix('=') else {
222            continue;
223        };
224        let rhs = rhs.trim();
225
226        // Three RHS shapes: "X.Y.Z", { version = "...", ... }, { workspace = true }
227        if let Some(v) = rhs.strip_prefix('"').and_then(|r| r.strip_suffix('"')) {
228            return Ok(Some(v.to_string()));
229        }
230        if let Some(v) = extract_version_from_table(rhs) {
231            return Ok(Some(v));
232        }
233        if rhs.contains("workspace") && rhs.contains("true") {
234            // Caller resolves workspace inheritance.
235            return Ok(None);
236        }
237        if rhs.contains("git =") || rhs.contains("git=") {
238            return Err(
239                "flodl is declared as a git dependency. \
240                 fdl add flodl-hf needs a pinnable crates.io version. \
241                 Switch to `flodl = \"X.Y.Z\"` first."
242                    .into(),
243            );
244        }
245        if rhs.contains("path =") || rhs.contains("path=") {
246            // Path-only dep: read version from the referenced Cargo.toml.
247            // For MVP, error with guidance.
248            return Err(
249                "flodl is declared as a path dependency only. \
250                 Add an explicit `version = \"X.Y.Z\"` so fdl add can \
251                 pin the matching flodl-hf release."
252                    .into(),
253            );
254        }
255    }
256    Ok(None)
257}
258
259/// Extract `version = "X.Y.Z"` from an inline table like
260/// `{ version = "0.5.1", features = [...] }`. Returns `None` when the
261/// string doesn't look like a table or carries no `version` key.
262fn extract_version_from_table(rhs: &str) -> Option<String> {
263    let rhs = rhs.strip_prefix('{')?.strip_suffix('}')?;
264    for part in rhs.split(',') {
265        let part = part.trim();
266        let Some(after) = part.strip_prefix("version") else {
267            continue;
268        };
269        let after = after.trim_start();
270        let Some(after) = after.strip_prefix('=') else {
271            continue;
272        };
273        let after = after.trim_start();
274        let Some(v) = after.strip_prefix('"').and_then(|r| r.strip_suffix('"')) else {
275            continue;
276        };
277        return Some(v.to_string());
278    }
279    None
280}
281
282/// Climb the directory tree looking for a Cargo.toml with a
283/// `[workspace]` table. Returns the path when found, else None.
284fn find_workspace_root(from: &Path) -> Option<PathBuf> {
285    let mut dir = from.parent()?.parent()?.to_path_buf();
286    loop {
287        let candidate = dir.join("Cargo.toml");
288        if candidate.exists() {
289            if let Ok(content) = fs::read_to_string(&candidate) {
290                if content.lines().any(|l| l.trim() == "[workspace]") {
291                    return Some(candidate);
292                }
293            }
294        }
295        if !dir.pop() {
296            return None;
297        }
298    }
299}
300
301fn substitute_version(template: &str, version: &str) -> String {
302    template.replace("{{FLODL_VERSION}}", version)
303}
304
305fn write_file(path: &Path, content: &str) -> Result<(), String> {
306    fs::write(path, content).map_err(|e| format!("cannot write {}: {e}", path.display()))
307}
308
309fn print_next_steps(version: &str, mode: ProjectMode) {
310    println!();
311    println!(
312        "Scaffolded flodl-hf/ playground (flodl {version}, {} mode).",
313        match mode {
314            ProjectMode::Docker => "Docker",
315            ProjectMode::Native => "native",
316        },
317    );
318    println!();
319    println!("Next steps:");
320    println!("  cd flodl-hf");
321    println!("  fdl classify                 # run with the default RoBERTa sentiment checkpoint");
322    println!("  fdl classify -- bert-base-uncased   # or any other BERT-family repo id");
323    println!();
324    println!("See flodl-hf/README.md for feature flavors (offline / vision-only),");
325    println!("`.bin` to safetensors conversion for older checkpoints, and how to wire");
326    println!("flodl-hf into your main crate when you're ready.");
327}
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332
333    #[test]
334    fn parse_plain_version_string() {
335        let c = r#"
336[dependencies]
337flodl = "0.6.0"
338other = "1.0"
339"#;
340        assert_eq!(parse_flodl_dep(c).unwrap(), Some("0.6.0".into()));
341    }
342
343    #[test]
344    fn parse_table_version() {
345        let c = r#"
346[dependencies]
347flodl = { version = "0.5.1", features = ["cuda"] }
348"#;
349        assert_eq!(parse_flodl_dep(c).unwrap(), Some("0.5.1".into()));
350    }
351
352    #[test]
353    fn parse_workspace_inheritance_returns_none() {
354        let c = r#"
355[dependencies]
356flodl = { workspace = true }
357"#;
358        // Workspace inheritance returns None; caller climbs to workspace root.
359        assert_eq!(parse_flodl_dep(c).unwrap(), None);
360    }
361
362    #[test]
363    fn parse_git_dep_errors() {
364        let c = r#"
365[dependencies]
366flodl = { git = "https://github.com/fab2s/floDl" }
367"#;
368        let err = parse_flodl_dep(c).unwrap_err();
369        assert!(err.contains("git dependency"), "got: {err}");
370    }
371
372    #[test]
373    fn parse_no_flodl_returns_none() {
374        let c = r#"
375[dependencies]
376other = "1.0"
377"#;
378        assert_eq!(parse_flodl_dep(c).unwrap(), None);
379    }
380
381    #[test]
382    fn parse_ignores_flodl_hf_and_flodl_sys() {
383        // `flodl = ...` must match exactly — neighbouring crate names
384        // (flodl-hf, flodl-sys) must not false-positive.
385        let c = r#"
386[dependencies]
387flodl-hf = "0.6.0"
388flodl-sys = "0.6.0"
389"#;
390        assert_eq!(parse_flodl_dep(c).unwrap(), None);
391    }
392
393    #[test]
394    fn parse_ignores_non_dep_tables() {
395        let c = r#"
396[package]
397flodl = "0.6.0"   # not actually a dep; this is bogus but must not match
398"#;
399        assert_eq!(parse_flodl_dep(c).unwrap(), None);
400    }
401
402    #[test]
403    fn substitute_version_replaces_all_occurrences() {
404        let t = "flodl = \"={{FLODL_VERSION}}\"\nflodl-hf = \"={{FLODL_VERSION}}\"";
405        let out = substitute_version(t, "0.6.0");
406        assert_eq!(out, "flodl = \"=0.6.0\"\nflodl-hf = \"=0.6.0\"");
407    }
408
409    #[test]
410    fn render_fdl_yml_docker_preserves_docker_lines() {
411        let t = "commands:\n  classify:\n    run: cargo run --release\n    docker: dev\n";
412        assert_eq!(render_fdl_yml(t, ProjectMode::Docker), t);
413    }
414
415    #[test]
416    fn render_fdl_yml_native_strips_docker_lines() {
417        let t = "commands:\n  classify:\n    run: cargo run --release\n    docker: dev\n  check:\n    run: cargo check\n    docker: dev\n";
418        let out = render_fdl_yml(t, ProjectMode::Native);
419        assert!(
420            !out.contains("docker: dev"),
421            "native output must not contain docker: dev lines: {out}"
422        );
423        // Non-docker lines stay in place — `cargo run --release` and
424        // `cargo check` must both survive.
425        assert!(out.contains("cargo run --release"));
426        assert!(out.contains("cargo check"));
427    }
428
429    #[test]
430    fn render_fdl_yml_native_only_strips_exact_docker_line() {
431        // Indentation-sensitive: lines like `    docker: hf-parity` or
432        // `description: docker: dev stuff` must NOT be stripped.
433        let t = "\
434commands:
435  classify:
436    run: cargo run
437    docker: dev
438  other:
439    description: docker: dev isn't a literal directive here
440    docker: hf-parity
441";
442        let out = render_fdl_yml(t, ProjectMode::Native);
443        assert!(!out.contains("    docker: dev\n"), "exact match stripped: {out}");
444        assert!(out.contains("hf-parity"), "other services preserved: {out}");
445        assert!(
446            out.contains("docker: dev isn't a literal"),
447            "description text preserved: {out}",
448        );
449    }
450}