Skip to main content

flodl_cli/
add.rs

1//! `fdl add flodl-hf` -- two modes for wiring flodl-hf into a project.
2//!
3//! - **playground**: drops `./flodl-hf/` as a standalone cargo crate
4//!   with a one-file `AutoModel` example, plus a `flodl-hf:` entry in
5//!   the root `fdl.yml` so `fdl flodl-hf <cmd>` routes into the
6//!   playground from the project root. Try-it-out path; the user's own
7//!   `Cargo.toml` is untouched.
8//! - **install**: appends `flodl-hf = "=X.Y.Z"` to root
9//!   `Cargo.toml` `[dependencies]` (default features). Wires the crate
10//!   into the user's own code; nothing else mutated.
11//!
12//! Modes are combinable on the same invocation. Without flags, an
13//! interactive prompt asks; non-tty stdin errors loudly.
14//!
15//! Targets accepted: `flodl-hf` and its alias `hf`. Other targets
16//! surface a loud error listing the supported set.
17
18use std::fs;
19use std::path::{Path, PathBuf};
20
21use crate::util::{cargo_toml as cargo_edit, fdl_yml as yml_edit, prompt};
22
23/// Scaffold templates baked into the binary at compile time. Live
24/// under `flodl-cli/src/scaffold/` so they travel inside the
25/// `flodl-cli` crate tarball on `cargo publish`.
26// `.in` suffix avoids cargo treating this as a nested package manifest
27// during `cargo package`; it is written out as `Cargo.toml` when the
28// scaffold is generated.
29const TEMPLATE_CARGO_TOML: &str = include_str!("scaffold/Cargo.toml.in");
30const TEMPLATE_MAIN_RS: &str = include_str!("scaffold/src/main.rs");
31const TEMPLATE_FDL_YML: &str = include_str!("scaffold/fdl.yml.example");
32const TEMPLATE_README: &str = include_str!("scaffold/README.md");
33const TEMPLATE_GITIGNORE: &str = include_str!("scaffold/.gitignore");
34
35/// Description written into the root `fdl.yml` `flodl-hf:` entry.
36const FDL_YML_HF_DESCRIPTION: &str = "HuggingFace integration (BERT, RoBERTa, DistilBERT, ...)";
37
38pub fn run(target: Option<&str>, playground: bool, install: bool) -> Result<(), String> {
39    let target = target.ok_or(
40        "usage: fdl add <target> [--playground] [--install]\n\n\
41         Supported targets:\n    \
42         flodl-hf    HuggingFace integration (pre-built BERT / RoBERTa / DistilBERT, Hub loader, tokenizer)",
43    )?;
44    match target {
45        "flodl-hf" | "hf" => {}
46        other => {
47            return Err(format!(
48                "unknown target: {other:?}\n\n\
49                 Supported targets:\n    \
50                 flodl-hf    HuggingFace integration\n\n\
51                 (More targets land as the flodl ecosystem grows.)",
52            ));
53        }
54    }
55
56    let cwd = std::env::current_dir().map_err(|e| format!("cannot read current directory: {e}"))?;
57
58    // No flag: interactive prompt (or loud error on non-tty).
59    let (do_playground, do_install) = if !playground && !install {
60        resolve_interactive()?
61    } else {
62        (playground, install)
63    };
64
65    if do_install {
66        install_flodl_hf_at(&cwd)?;
67    }
68    if do_playground {
69        add_flodl_hf_at(&cwd)?;
70    }
71    Ok(())
72}
73
74/// Ask the user which mode(s) to run. Errors when no controlling
75/// terminal is available — in CI / scripted contexts the caller must
76/// pass `--playground` and/or `--install` explicitly.
77fn resolve_interactive() -> Result<(bool, bool), String> {
78    if !has_tty() {
79        return Err(
80            "fdl add flodl-hf needs an interactive terminal to prompt.\n\
81             Pass --playground (sandbox at ./flodl-hf/) or --install \
82             (add to Cargo.toml), or both."
83                .into(),
84        );
85    }
86
87    println!("Add flodl-hf to your project?");
88    println!();
89    let choice = prompt::ask_choice(
90        "Choose",
91        &[
92            "playground   sandbox at ./flodl-hf/ (try it without touching your project)",
93            "install      add flodl-hf to your root Cargo.toml as a dependency",
94            "both         playground + install (try it, and wire it in)",
95            "cancel",
96        ],
97        1,
98    );
99    println!();
100
101    match choice {
102        1 => Ok((true, false)),
103        2 => Ok((false, true)),
104        3 => Ok((true, true)),
105        _ => Err("cancelled.".into()),
106    }
107}
108
109/// Detect a usable controlling terminal — see [`prompt::has_tty`].
110fn has_tty() -> bool {
111    prompt::has_tty()
112}
113
114/// Append `flodl-hf` to the root `Cargo.toml` `[dependencies]` table.
115/// Idempotent — already-present is a friendly no-op.
116pub fn install_flodl_hf_at(cwd: &Path) -> Result<(), String> {
117    let cargo_toml = cwd.join("Cargo.toml");
118    if !cargo_toml.exists() {
119        return Err(format!(
120            "no Cargo.toml in {}.\n\n\
121             fdl add flodl-hf --install must run from a flodl project root.\n\
122             Start with `fdl init <name>` if you don't have one yet.",
123            cwd.display(),
124        ));
125    }
126
127    let flodl_version = detect_flodl_version(&cargo_toml)?;
128    let version_spec = format!("={flodl_version}");
129    let outcome = cargo_edit::add_dep(&cargo_toml, "flodl-hf", &version_spec)?;
130
131    match outcome {
132        cargo_edit::AddDepOutcome::AlreadyPresent => {
133            println!("flodl-hf is already declared in {}.", cargo_toml.display());
134            println!("Edit the entry directly to change version or features.");
135        }
136        cargo_edit::AddDepOutcome::Added => {
137            println!();
138            println!(
139                "Added flodl-hf = \"={flodl_version}\" to {} with default features (hub, tokenizer).",
140                cargo_toml.display(),
141            );
142            println!();
143            println!("Default features include the HuggingFace Hub loader and tokenizer.");
144            println!("To switch to offline / vision-only flavors, edit the entry manually:");
145            println!(
146                "  flodl-hf = {{ version = \"={flodl_version}\", default-features = false, features = [...] }}"
147            );
148            println!();
149            println!("Run `fdl build` (or `cargo build`) to pull and compile the new dependency.");
150        }
151    }
152    Ok(())
153}
154
155/// Scaffold `flodl-hf/` playground under `base` and link it into the
156/// root `fdl.yml`. Entry point for `fdl add flodl-hf --playground`
157/// (with `base = cwd`) and `fdl init --with-hf` follow-up (with
158/// `base = the freshly-scaffolded project dir`). The base dir must
159/// contain a `Cargo.toml` with a pinnable `flodl` dependency.
160pub fn add_flodl_hf_at(cwd: &Path) -> Result<(), String> {
161    // Must be run from a flodl project root (Cargo.toml with flodl dep).
162    let cargo_toml = cwd.join("Cargo.toml");
163    if !cargo_toml.exists() {
164        return Err(format!(
165            "no Cargo.toml in {}.\n\n\
166             fdl add flodl-hf must run from a flodl project root.\n\
167             Start with `fdl init <name>` if you don't have one yet.",
168            cwd.display(),
169        ));
170    }
171
172    // flodl-hf makes no sense without a functioning flodl project: every
173    // runnable command assumes a Cargo.toml + fdl.yml pair are already
174    // present. Enforce that invariant loudly so the user isn't left
175    // with a dead sub-crate.
176    if !has_fdl_config(cwd) {
177        return Err(format!(
178            "no fdl.yml (nor fdl.yml.example) in {}.\n\n\
179             fdl add flodl-hf expects an initialised flodl project: \
180             Docker or native mode already chosen, fdl.yml present. \
181             Run `fdl init <name>` first, or cd into an existing flodl project.",
182            cwd.display(),
183        ));
184    }
185
186    let flodl_version = detect_flodl_version(&cargo_toml)?;
187    let mode = detect_project_mode(cwd);
188
189    // Refuse to overwrite an existing flodl-hf/ dir.
190    let dest = cwd.join("flodl-hf");
191    if dest.exists() {
192        return Err(format!(
193            "{} already exists.\n\n\
194             Remove it first, or keep it. `fdl add flodl-hf` does not overwrite.",
195            dest.display(),
196        ));
197    }
198
199    // Scaffold.
200    fs::create_dir_all(dest.join("src"))
201        .map_err(|e| format!("cannot create {}: {e}", dest.join("src").display()))?;
202
203    write_file(
204        &dest.join("Cargo.toml"),
205        &substitute_version(TEMPLATE_CARGO_TOML, &flodl_version),
206    )?;
207    write_file(&dest.join("src/main.rs"), TEMPLATE_MAIN_RS)?;
208    let fdl_yml = render_fdl_yml(TEMPLATE_FDL_YML, mode);
209    write_file(&dest.join("fdl.yml.example"), &fdl_yml)?;
210    write_file(&dest.join("fdl.yml"), &fdl_yml)?;
211    write_file(
212        &dest.join("README.md"),
213        &substitute_version(TEMPLATE_README, &flodl_version),
214    )?;
215    write_file(&dest.join(".gitignore"), TEMPLATE_GITIGNORE)?;
216
217    // Link `flodl-hf:` into root fdl.yml so `fdl flodl-hf <cmd>` works
218    // from project root. Idempotent — re-runs after a manual delete of
219    // the playground dir do the right thing.
220    link_into_root_fdl_yml(cwd)?;
221
222    print_next_steps(&flodl_version, mode);
223    Ok(())
224}
225
226/// Append a `flodl-hf:` entry under `commands:` in the root fdl.yml
227/// (and fdl.yml.example when present) so `fdl flodl-hf` routes into
228/// `./flodl-hf/fdl.yml` via the convention-default Path command.
229fn link_into_root_fdl_yml(cwd: &Path) -> Result<(), String> {
230    for filename in ["fdl.yml", "fdl.yml.example"] {
231        let path = cwd.join(filename);
232        if !path.exists() {
233            continue;
234        }
235        yml_edit::add_command(&path, "flodl-hf", FDL_YML_HF_DESCRIPTION)?;
236    }
237    Ok(())
238}
239
240/// Host-project execution mode, inferred from file presence.
241///
242/// `fdl init` writes `docker-compose.yml` for its Mounted and Docker
243/// modes, and omits it for Native. Scaffolded commands follow the
244/// same convention: Docker modes dispatch to the `dev` service,
245/// Native runs directly on the host.
246#[derive(Debug, Clone, Copy, PartialEq, Eq)]
247enum ProjectMode {
248    Docker,
249    Native,
250}
251
252fn has_fdl_config(cwd: &Path) -> bool {
253    cwd.join("fdl.yml").exists() || cwd.join("fdl.yml.example").exists()
254}
255
256fn detect_project_mode(cwd: &Path) -> ProjectMode {
257    if cwd.join("docker-compose.yml").exists() {
258        ProjectMode::Docker
259    } else {
260        ProjectMode::Native
261    }
262}
263
264/// In Native mode, strip the `    docker: dev` lines from the scaffold
265/// `fdl.yml` so cargo commands run directly on the host instead of
266/// trying to dispatch into a non-existent Docker service. Matches the
267/// indentation produced by the template exactly; anything else is left
268/// alone.
269fn render_fdl_yml(template: &str, mode: ProjectMode) -> String {
270    match mode {
271        ProjectMode::Docker => template.to_string(),
272        ProjectMode::Native => {
273            template
274                .lines()
275                .filter(|l| l.trim() != "docker: dev")
276                .collect::<Vec<&str>>()
277                .join("\n")
278                + "\n"
279        }
280    }
281}
282
283/// Parse `Cargo.toml` for the `flodl` dependency version.
284///
285/// Recognises three forms:
286/// - `flodl = "0.5.1"` — plain version string
287/// - `flodl = { version = "0.5.1", ... }` — table form
288/// - `flodl = { workspace = true }` — workspace inheritance (reads from
289///   the workspace root's `Cargo.toml`)
290///
291/// Errors on: no flodl dep found, git-only dep (no pinnable version),
292/// or path-only dep outside this repo (no version to pin against).
293fn detect_flodl_version(cargo_toml: &Path) -> Result<String, String> {
294    let content = fs::read_to_string(cargo_toml)
295        .map_err(|e| format!("cannot read {}: {e}", cargo_toml.display()))?;
296
297    if let Some(v) = parse_flodl_dep(&content)? {
298        return Ok(v);
299    }
300
301    // Workspace inheritance: climb to find the workspace root.
302    if let Some(ws_root) = find_workspace_root(cargo_toml) {
303        let ws_content = fs::read_to_string(&ws_root)
304            .map_err(|e| format!("cannot read workspace {}: {e}", ws_root.display()))?;
305        if let Some(v) = parse_flodl_dep(&ws_content)? {
306            return Ok(v);
307        }
308    }
309
310    Err(format!(
311        "no flodl dependency found in {}.\n\n\
312         fdl add flodl-hf needs to pin flodl-hf to the same version as \
313         flodl. Add `flodl = \"X.Y.Z\"` to [dependencies] first, or run \
314         `fdl init <name>` to scaffold a flodl project.",
315        cargo_toml.display(),
316    ))
317}
318
319/// Extract the flodl version from a Cargo.toml's textual content.
320///
321/// Returns `Ok(Some(version))` on a pinnable version, `Ok(None)` when
322/// no flodl dep is present, and `Err(...)` when the dep exists but is
323/// git-only / path-only (no version to pin against).
324fn parse_flodl_dep(content: &str) -> Result<Option<String>, String> {
325    let lines: Vec<&str> = content.lines().collect();
326
327    // Find a line that declares `flodl = ...` under a [dependencies]
328    // or [workspace.dependencies] table. We accept any form whose LHS
329    // matches `flodl`; the inline value on the RHS tells us the shape.
330    let mut in_dep_table = false;
331    for line in &lines {
332        let t = line.trim();
333        if t.starts_with('[') {
334            // Only consider tables that declare dependencies.
335            in_dep_table = matches!(
336                t,
337                "[dependencies]" | "[workspace.dependencies]" | "[dev-dependencies]",
338            );
339            continue;
340        }
341        if !in_dep_table {
342            continue;
343        }
344        // Match `flodl = ...` exactly (not flodl-hf, flodl-sys, ...).
345        let after_key = match t.strip_prefix("flodl") {
346            Some(rest) => rest.trim_start(),
347            None => continue,
348        };
349        let Some(rhs) = after_key.strip_prefix('=') else {
350            continue;
351        };
352        let rhs = rhs.trim();
353
354        // Three RHS shapes: "X.Y.Z", { version = "...", ... }, { workspace = true }
355        if let Some(v) = rhs.strip_prefix('"').and_then(|r| r.strip_suffix('"')) {
356            return Ok(Some(v.to_string()));
357        }
358        if let Some(v) = extract_version_from_table(rhs) {
359            return Ok(Some(v));
360        }
361        if rhs.contains("workspace") && rhs.contains("true") {
362            // Caller resolves workspace inheritance.
363            return Ok(None);
364        }
365        if rhs.contains("git =") || rhs.contains("git=") {
366            return Err("flodl is declared as a git dependency. \
367                 fdl add flodl-hf needs a pinnable crates.io version. \
368                 Switch to `flodl = \"X.Y.Z\"` first."
369                .into());
370        }
371        if rhs.contains("path =") || rhs.contains("path=") {
372            // Path-only dep: read version from the referenced Cargo.toml.
373            // For MVP, error with guidance.
374            return Err("flodl is declared as a path dependency only. \
375                 Add an explicit `version = \"X.Y.Z\"` so fdl add can \
376                 pin the matching flodl-hf release."
377                .into());
378        }
379    }
380    Ok(None)
381}
382
383/// Extract `version = "X.Y.Z"` from an inline table like
384/// `{ version = "0.5.1", features = [...] }`. Returns `None` when the
385/// string doesn't look like a table or carries no `version` key.
386fn extract_version_from_table(rhs: &str) -> Option<String> {
387    let rhs = rhs.strip_prefix('{')?.strip_suffix('}')?;
388    for part in rhs.split(',') {
389        let part = part.trim();
390        let Some(after) = part.strip_prefix("version") else {
391            continue;
392        };
393        let after = after.trim_start();
394        let Some(after) = after.strip_prefix('=') else {
395            continue;
396        };
397        let after = after.trim_start();
398        let Some(v) = after.strip_prefix('"').and_then(|r| r.strip_suffix('"')) else {
399            continue;
400        };
401        return Some(v.to_string());
402    }
403    None
404}
405
406/// Climb the directory tree looking for a Cargo.toml with a
407/// `[workspace]` table. Returns the path when found, else None.
408fn find_workspace_root(from: &Path) -> Option<PathBuf> {
409    let mut dir = from.parent()?.parent()?.to_path_buf();
410    loop {
411        let candidate = dir.join("Cargo.toml");
412        if candidate.exists()
413            && let Ok(content) = fs::read_to_string(&candidate)
414            && content.lines().any(|l| l.trim() == "[workspace]")
415        {
416            return Some(candidate);
417        }
418        if !dir.pop() {
419            return None;
420        }
421    }
422}
423
424fn substitute_version(template: &str, version: &str) -> String {
425    template.replace("{{FLODL_VERSION}}", version)
426}
427
428fn write_file(path: &Path, content: &str) -> Result<(), String> {
429    fs::write(path, content).map_err(|e| format!("cannot write {}: {e}", path.display()))
430}
431
432fn print_next_steps(version: &str, mode: ProjectMode) {
433    println!();
434    println!(
435        "Scaffolded flodl-hf/ playground (flodl {version}, {} mode).",
436        match mode {
437            ProjectMode::Docker => "Docker",
438            ProjectMode::Native => "native",
439        },
440    );
441    println!();
442    println!("Next steps:");
443    println!("  fdl flodl-hf classify                 # default RoBERTa sentiment checkpoint");
444    println!("  fdl flodl-hf classify -- bert-base-uncased   # any other BERT-family repo id");
445    println!();
446    println!("(Or `cd flodl-hf` and run `fdl classify` directly.)");
447    println!();
448    println!("See flodl-hf/README.md for feature flavors (offline / vision-only),");
449    println!("`.bin` to safetensors conversion for older checkpoints, and how to wire");
450    println!("flodl-hf into your main crate when you're ready (`fdl add flodl-hf --install`).");
451}
452
453#[cfg(test)]
454mod tests {
455    use super::*;
456
457    #[test]
458    fn parse_plain_version_string() {
459        let c = r#"
460[dependencies]
461flodl = "0.6.0"
462other = "1.0"
463"#;
464        assert_eq!(parse_flodl_dep(c).unwrap(), Some("0.6.0".into()));
465    }
466
467    #[test]
468    fn parse_table_version() {
469        let c = r#"
470[dependencies]
471flodl = { version = "0.5.1", features = ["cuda"] }
472"#;
473        assert_eq!(parse_flodl_dep(c).unwrap(), Some("0.5.1".into()));
474    }
475
476    #[test]
477    fn parse_workspace_inheritance_returns_none() {
478        let c = r#"
479[dependencies]
480flodl = { workspace = true }
481"#;
482        // Workspace inheritance returns None; caller climbs to workspace root.
483        assert_eq!(parse_flodl_dep(c).unwrap(), None);
484    }
485
486    #[test]
487    fn parse_git_dep_errors() {
488        let c = r#"
489[dependencies]
490flodl = { git = "https://github.com/flodl-labs/flodl" }
491"#;
492        let err = parse_flodl_dep(c).unwrap_err();
493        assert!(err.contains("git dependency"), "got: {err}");
494    }
495
496    #[test]
497    fn parse_no_flodl_returns_none() {
498        let c = r#"
499[dependencies]
500other = "1.0"
501"#;
502        assert_eq!(parse_flodl_dep(c).unwrap(), None);
503    }
504
505    #[test]
506    fn parse_ignores_flodl_hf_and_flodl_sys() {
507        // `flodl = ...` must match exactly — neighbouring crate names
508        // (flodl-hf, flodl-sys) must not false-positive.
509        let c = r#"
510[dependencies]
511flodl-hf = "0.6.0"
512flodl-sys = "0.6.0"
513"#;
514        assert_eq!(parse_flodl_dep(c).unwrap(), None);
515    }
516
517    #[test]
518    fn parse_ignores_non_dep_tables() {
519        let c = r#"
520[package]
521flodl = "0.6.0"   # not actually a dep; this is bogus but must not match
522"#;
523        assert_eq!(parse_flodl_dep(c).unwrap(), None);
524    }
525
526    #[test]
527    fn substitute_version_replaces_all_occurrences() {
528        let t = "flodl = \"={{FLODL_VERSION}}\"\nflodl-hf = \"={{FLODL_VERSION}}\"";
529        let out = substitute_version(t, "0.6.0");
530        assert_eq!(out, "flodl = \"=0.6.0\"\nflodl-hf = \"=0.6.0\"");
531    }
532
533    #[test]
534    fn render_fdl_yml_docker_preserves_docker_lines() {
535        let t = "commands:\n  classify:\n    run: cargo run --release\n    docker: dev\n";
536        assert_eq!(render_fdl_yml(t, ProjectMode::Docker), t);
537    }
538
539    #[test]
540    fn render_fdl_yml_native_strips_docker_lines() {
541        let t = "commands:\n  classify:\n    run: cargo run --release\n    docker: dev\n  check:\n    run: cargo check\n    docker: dev\n";
542        let out = render_fdl_yml(t, ProjectMode::Native);
543        assert!(
544            !out.contains("docker: dev"),
545            "native output must not contain docker: dev lines: {out}"
546        );
547        // Non-docker lines stay in place — `cargo run --release` and
548        // `cargo check` must both survive.
549        assert!(out.contains("cargo run --release"));
550        assert!(out.contains("cargo check"));
551    }
552
553    #[test]
554    fn render_fdl_yml_native_only_strips_exact_docker_line() {
555        // Indentation-sensitive: lines like `    docker: hf-parity` or
556        // `description: docker: dev stuff` must NOT be stripped.
557        let t = "\
558commands:
559  classify:
560    run: cargo run
561    docker: dev
562  other:
563    description: docker: dev isn't a literal directive here
564    docker: hf-parity
565";
566        let out = render_fdl_yml(t, ProjectMode::Native);
567        assert!(
568            !out.contains("    docker: dev\n"),
569            "exact match stripped: {out}"
570        );
571        assert!(out.contains("hf-parity"), "other services preserved: {out}");
572        assert!(
573            out.contains("docker: dev isn't a literal"),
574            "description text preserved: {out}",
575        );
576    }
577
578    /// Build a minimal flodl project tree under a unique temp dir for
579    /// integration tests. Returns the project root.
580    fn temp_project(tag: &str) -> PathBuf {
581        use std::sync::atomic::{AtomicU64, Ordering};
582        static N: AtomicU64 = AtomicU64::new(0);
583        let n = N.fetch_add(1, Ordering::Relaxed);
584        let pid = std::process::id();
585        let dir = std::env::temp_dir().join(format!("fdl-add-test-{pid}-{n}-{tag}"));
586        let _ = fs::remove_dir_all(&dir);
587        fs::create_dir_all(&dir).unwrap();
588        fs::write(
589            dir.join("Cargo.toml"),
590            "[package]\nname = \"x\"\nversion = \"0.1.0\"\nedition = \"2024\"\n\n[dependencies]\nflodl = \"0.5.2\"\n",
591        )
592        .unwrap();
593        fs::write(
594            dir.join("fdl.yml"),
595            "description: test project\n\ncommands:\n  build:\n    run: cargo build\n",
596        )
597        .unwrap();
598        dir
599    }
600
601    #[test]
602    fn install_appends_dep_and_is_idempotent() {
603        let dir = temp_project("install-idem");
604        install_flodl_hf_at(&dir).unwrap();
605        let toml = fs::read_to_string(dir.join("Cargo.toml")).unwrap();
606        assert!(
607            toml.contains("flodl-hf = \"=0.5.2\""),
608            "first install: {toml}"
609        );
610
611        // Re-run: no-op, file unchanged.
612        install_flodl_hf_at(&dir).unwrap();
613        let toml2 = fs::read_to_string(dir.join("Cargo.toml")).unwrap();
614        assert_eq!(toml, toml2, "install is idempotent");
615
616        let _ = fs::remove_dir_all(&dir);
617    }
618
619    #[test]
620    fn install_errors_without_cargo_toml() {
621        use std::sync::atomic::{AtomicU64, Ordering};
622        static N: AtomicU64 = AtomicU64::new(9000);
623        let n = N.fetch_add(1, Ordering::Relaxed);
624        let pid = std::process::id();
625        let dir = std::env::temp_dir().join(format!("fdl-add-test-no-cargo-{pid}-{n}"));
626        let _ = fs::remove_dir_all(&dir);
627        fs::create_dir_all(&dir).unwrap();
628        let err = install_flodl_hf_at(&dir).unwrap_err();
629        assert!(err.contains("no Cargo.toml"), "got: {err}");
630        let _ = fs::remove_dir_all(&dir);
631    }
632
633    #[test]
634    fn playground_links_root_fdl_yml() {
635        let dir = temp_project("playground-link");
636        add_flodl_hf_at(&dir).unwrap();
637        let yml = fs::read_to_string(dir.join("fdl.yml")).unwrap();
638        assert!(yml.contains("flodl-hf:"), "linked into root fdl.yml: {yml}");
639        // Existing `build:` entry preserved.
640        assert!(yml.contains("build:"));
641        // Playground crate also exists.
642        assert!(dir.join("flodl-hf/Cargo.toml").exists());
643        assert!(dir.join("flodl-hf/fdl.yml").exists());
644        let _ = fs::remove_dir_all(&dir);
645    }
646}