Skip to main content

flodl_cli/
init.rs

1//! `fdl init <name>` -- scaffold a new floDl project.
2//!
3//! Three modes, selected by flag or interactive prompt:
4//! - `Mounted` (default): Docker with libtorch host-mounted at runtime.
5//! - `Docker` (`--docker`): Docker with libtorch baked into the image.
6//! - `Native` (`--native`): no Docker; libtorch and cargo provided on the host.
7
8use std::fs;
9use std::path::Path;
10use std::process::Command;
11
12use crate::util::prompt;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15enum Mode {
16    Mounted,
17    Docker,
18    Native,
19}
20
21pub fn run(
22    name: Option<&str>,
23    docker: bool,
24    native: bool,
25    with_hf: bool,
26) -> Result<(), String> {
27    let name = name.ok_or("usage: fdl init <project-name>")?;
28    validate_name(name)?;
29
30    if Path::new(name).exists() {
31        return Err(format!("'{}' already exists", name));
32    }
33
34    if docker && native {
35        return Err("--docker and --native are mutually exclusive".into());
36    }
37    let flag_driven = docker || native || with_hf;
38    let mode = if docker {
39        Mode::Docker
40    } else if native {
41        Mode::Native
42    } else {
43        pick_mode_interactively()
44    };
45    // `--with-hf` bypasses the prompt entirely for scripted init.
46    // Without any flag, ask after mode selection; with *any* flag set
47    // the user signalled non-interactive intent, so respect `--with-hf`
48    // verbatim and skip the prompt.
49    let include_hf = if flag_driven {
50        with_hf
51    } else {
52        prompt::ask_yn(
53            "Include flodl-hf (HuggingFace: BERT/RoBERTa/DistilBERT, Hub loader, tokenizer)?",
54            false,
55        )
56    };
57
58    let crate_name = name.replace('-', "_");
59    let flodl_dep = resolve_flodl_dep();
60
61    fs::create_dir_all(format!("{}/src", name))
62        .map_err(|e| format!("cannot create directory: {}", e))?;
63
64    match mode {
65        Mode::Mounted => scaffold_mounted(name, &crate_name, &flodl_dep)?,
66        Mode::Docker => scaffold_docker(name, &crate_name, &flodl_dep)?,
67        Mode::Native => scaffold_native(name, &crate_name, &flodl_dep)?,
68    }
69
70    // Shared across all modes.
71    write_file(
72        &format!("{}/src/main.rs", name),
73        &main_rs_template(),
74    )?;
75    write_file(
76        &format!("{}/.gitignore", name),
77        &gitignore_template(mode),
78    )?;
79    write_file(
80        &format!("{}/fdl.yml.example", name),
81        &fdl_yml_example_template(name, mode),
82    )?;
83    // Native mode generates no docker-compose, so there is nothing to read a
84    // `.env`. Docker modes get the template that documents the knobs their
85    // compose actually substitutes.
86    if mode != Mode::Native {
87        write_file(
88            &format!("{}/.env.example", name),
89            &env_example_template(mode),
90        )?;
91    }
92    write_fdl_bootstrap(name)?;
93
94    if include_hf {
95        let project_dir = Path::new(name);
96        if let Err(e) = crate::add::add_flodl_hf_at(project_dir) {
97            // Scaffolded project is still usable even if the HF sub-crate
98            // failed; surface the error but don't roll back.
99            eprintln!("warning: flodl-hf scaffold failed: {e}");
100            eprintln!("You can retry after `cd {}` with `fdl add flodl-hf`.", name);
101        }
102    }
103
104    print_next_steps(name, mode, include_hf);
105    crate::util::install_prompt::offer_global_install();
106    Ok(())
107}
108
109/// Ask the user interactively which mode to generate. Falls through to
110/// `Mounted` when no TTY is attached (the same default as passing no flag
111/// to `--non-interactive` tooling).
112fn pick_mode_interactively() -> Mode {
113    println!();
114    if !prompt::ask_yn("Use Docker for builds?", true) {
115        return Mode::Native;
116    }
117
118    // On macOS, Docker runs Linux containers under Rosetta/QEMU emulation.
119    // Builds and training are substantially slower than native cargo on the
120    // host. Warn once and offer a chance to drop to Native before the user
121    // commits to a Docker scaffold.
122    if cfg!(target_os = "macos") {
123        println!();
124        println!("  Heads up: on macOS, Docker runs Linux containers under emulation");
125        println!("  (Rosetta / QEMU). Builds and training will be substantially slower");
126        println!("  than running cargo natively on the host. Native mode keeps");
127        println!("  everything on the Mac and uses macOS libtorch directly.");
128        println!();
129        if !prompt::ask_yn("Continue with Docker?", true) {
130            return Mode::Native;
131        }
132    }
133
134    // 1-based: 1 = mounted (default), 2 = baked-in.
135    let choice = prompt::ask_choice(
136        "How should libtorch be provided to the container?",
137        &[
138            "Mount it from the host (recommended: lighter image, swap CUDA variants)",
139            "Bake it into the image at build time (zero host setup)",
140        ],
141        1,
142    );
143    match choice {
144        2 => Mode::Docker,
145        _ => Mode::Mounted,
146    }
147}
148
149fn print_next_steps(name: &str, mode: Mode, include_hf: bool) {
150    println!();
151    println!("Project '{}' created. Next steps:", name);
152    println!();
153    println!("  cd {}", name);
154    match mode {
155        Mode::Mounted => {
156            println!("  ./fdl setup   # detect hardware + download libtorch");
157            println!("  ./fdl build   # build the project");
158        }
159        Mode::Docker => {
160            println!("  ./fdl build   # first build (downloads libtorch, ~5 min)");
161        }
162        Mode::Native => {
163            println!("  ./fdl libtorch download --cpu     # or --cuda 12.8");
164            println!("  ./fdl build                       # cargo build on the host");
165        }
166    }
167    println!("  ./fdl test    # run tests");
168    println!("  ./fdl run     # train the model");
169    if mode != Mode::Native {
170        println!("  ./fdl shell   # interactive shell");
171    }
172    if include_hf {
173        println!();
174        println!("  cd flodl-hf && fdl classify   # try the HuggingFace playground");
175    }
176    println!();
177    println!("`./fdl --help` lists every command defined in fdl.yml.");
178    println!("Edit src/main.rs to build your model.");
179    println!();
180    println!("Guides:");
181    println!("  Tutorials:         https://flodl.dev/guide/tensors");
182    println!("  Graph Tree:        https://flodl.dev/guide/graph-tree");
183    println!("  PyTorch migration: https://flodl.dev/guide/migration");
184    println!("  Troubleshooting:   https://flodl.dev/guide/troubleshooting");
185}
186
187fn write_fdl_bootstrap(name: &str) -> Result<(), String> {
188    let fdl_script = include_str!("../assets/fdl");
189    write_file(&format!("{}/fdl", name), fdl_script)?;
190    #[cfg(unix)]
191    {
192        use std::os::unix::fs::PermissionsExt;
193        let _ = fs::set_permissions(
194            format!("{}/fdl", name),
195            fs::Permissions::from_mode(0o755),
196        );
197    }
198    Ok(())
199}
200
201fn validate_name(name: &str) -> Result<(), String> {
202    if name.is_empty() {
203        return Err("project name cannot be empty".into());
204    }
205    if !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') {
206        return Err("project name must contain only letters, digits, hyphens, underscores".into());
207    }
208    Ok(())
209}
210
211fn resolve_flodl_dep() -> String {
212    // Try crates.io for the latest version
213    if let Some(version) = crates_io_version() {
214        format!("flodl = \"{}\"", version)
215    } else {
216        "flodl = { git = \"https://github.com/flodl-labs/flodl.git\" }".into()
217    }
218}
219
220fn crates_io_version() -> Option<String> {
221    let output = Command::new("curl")
222        .args(["-sL", "https://crates.io/api/v1/crates/flodl"])
223        .output()
224        .ok()?;
225    let body = String::from_utf8_lossy(&output.stdout);
226    // Extract "max_stable_version":"X.Y.Z"
227    let marker = "\"max_stable_version\":\"";
228    let start = body.find(marker)? + marker.len();
229    let end = start + body[start..].find('"')?;
230    let version = &body[start..end];
231    if version.is_empty() { None } else { Some(version.to_string()) }
232}
233
234// ---------------------------------------------------------------------------
235// Docker scaffold (standalone, libtorch baked into images)
236// ---------------------------------------------------------------------------
237
238fn scaffold_docker(name: &str, crate_name: &str, flodl_dep: &str) -> Result<(), String> {
239    write_file(
240        &format!("{}/Cargo.toml", name),
241        &cargo_toml_template(crate_name, flodl_dep),
242    )?;
243    write_file(
244        &format!("{}/Dockerfile.cpu", name),
245        DOCKERFILE_CPU,
246    )?;
247    write_file(
248        &format!("{}/Dockerfile.cuda", name),
249        DOCKERFILE_CUDA,
250    )?;
251    write_file(
252        &format!("{}/docker-compose.yml", name),
253        &docker_compose_template(crate_name, true),
254    )?;
255    Ok(())
256}
257
258// ---------------------------------------------------------------------------
259// Mounted scaffold (libtorch from host, like the main repo)
260// ---------------------------------------------------------------------------
261
262fn scaffold_mounted(name: &str, crate_name: &str, flodl_dep: &str) -> Result<(), String> {
263    write_file(
264        &format!("{}/Cargo.toml", name),
265        &cargo_toml_template(crate_name, flodl_dep),
266    )?;
267    write_file(
268        &format!("{}/Dockerfile", name),
269        DOCKERFILE_MOUNTED,
270    )?;
271    write_file(
272        &format!("{}/Dockerfile.cuda", name),
273        DOCKERFILE_CUDA_MOUNTED,
274    )?;
275    write_file(
276        &format!("{}/docker-compose.yml", name),
277        &docker_compose_template(crate_name, false),
278    )?;
279    Ok(())
280}
281
282// ---------------------------------------------------------------------------
283// Native scaffold (no Docker; libtorch and cargo live on the host)
284// ---------------------------------------------------------------------------
285
286fn scaffold_native(name: &str, crate_name: &str, flodl_dep: &str) -> Result<(), String> {
287    write_file(
288        &format!("{}/Cargo.toml", name),
289        &cargo_toml_template(crate_name, flodl_dep),
290    )?;
291    // Intentionally no Dockerfile*/docker-compose.yml -- the user opted out
292    // of Docker. They can switch later by regenerating or adding their own.
293    Ok(())
294}
295
296// ---------------------------------------------------------------------------
297// Templates
298// ---------------------------------------------------------------------------
299
300fn cargo_toml_template(crate_name: &str, flodl_dep: &str) -> String {
301    format!(
302        r#"[package]
303name = "{crate_name}"
304version = "0.1.0"
305edition = "2024"
306
307[dependencies]
308{flodl_dep}
309
310# Optimize floDl in dev builds -- your code stays fast to compile.
311# After the first build, only your graph code recompiles (~2s).
312[profile.dev.package.flodl]
313opt-level = 3
314
315[profile.dev.package.flodl-sys]
316opt-level = 3
317
318# Release: cross-crate optimization for maximum throughput.
319[profile.release]
320lto = "thin"
321codegen-units = 1
322"#
323    )
324}
325
326fn main_rs_template() -> String {
327    r#"//! floDl training template.
328//!
329//! This is a starting point for your model. Edit the architecture,
330//! data loading, and training loop to fit your task.
331//!
332//! New to Rust? Read: https://flodl.dev/guide/rust-primer
333//! Stuck?       Read: https://flodl.dev/guide/troubleshooting
334
335use flodl::*;
336use flodl::monitor::Monitor;
337
338fn main() -> Result<()> {
339    // --- Model ---
340    let model = FlowBuilder::from(Linear::new(4, 32)?)
341        .through(GELU)
342        .through(LayerNorm::new(32)?)
343        .also(Linear::new(32, 32)?)       // residual connection
344        .through(Linear::new(32, 1)?)
345        .build()?;
346
347    // --- Optimizer ---
348    let params = model.parameters();
349    let mut optimizer = Adam::new(&params, 0.001);
350    let scheduler = CosineScheduler::new(0.001, 1e-6, 100);
351    model.train();
352
353    // --- Data ---
354    // Replace this with your data loading.
355    let opts = TensorOptions::default();
356    let batches: Vec<(Tensor, Tensor)> = (0..32)
357        .map(|_| {
358            let x = Tensor::randn(&[16, 4], opts).unwrap();
359            let y = Tensor::randn(&[16, 1], opts).unwrap();
360            (x, y)
361        })
362        .collect();
363
364    // --- Training loop ---
365    let num_epochs = 100usize;
366    let mut monitor = Monitor::new(num_epochs);
367    // monitor.serve(3000)?;              // uncomment for live dashboard
368    // monitor.watch(&model);             // uncomment to show graph SVG
369    // monitor.save_html("report.html");  // uncomment to save HTML report
370
371    for epoch in 0..num_epochs {
372        let t = std::time::Instant::now();
373        let mut epoch_loss = 0.0;
374
375        for (input_t, target_t) in &batches {
376            let input = Variable::new(input_t.clone(), true);
377            let target = Variable::new(target_t.clone(), false);
378
379            optimizer.zero_grad();
380            let pred = model.forward(&input)?;
381            let loss = mse_loss(&pred, &target)?;
382            loss.backward()?;
383            clip_grad_norm(&params, 1.0)?;
384            optimizer.step()?;
385
386            epoch_loss += loss.item()?;
387        }
388
389        let avg_loss = epoch_loss / batches.len() as f64;
390        let lr = scheduler.lr(epoch);
391        optimizer.set_lr(lr);
392        monitor.log(epoch, t.elapsed(), &[("loss", avg_loss), ("lr", lr)]);
393    }
394
395    monitor.finish();
396    Ok(())
397}
398"#
399    .into()
400}
401
402fn gitignore_template(mode: Mode) -> String {
403    let mut s = String::from(
404        "/target
405*.fdl
406*.log
407*.csv
408*.html
409
410# Local fdl config (fdl.yml.example is committed; fdl copies it on first run)
411fdl.yml
412fdl.yaml
413
414# Local docker-compose env (per-machine: UID/GID, libtorch variant override,
415# cargo job throttle)
416.env
417",
418    );
419    match mode {
420        Mode::Docker => {
421            // libtorch is baked into the image, nothing on host to ignore.
422            s.push_str(
423                ".cargo-cache/
424.cargo-git/
425.cargo-cache-cuda/
426.cargo-git-cuda/
427",
428            );
429        }
430        Mode::Mounted => {
431            // Mounted libtorch + separate cargo caches per docker service.
432            s.push_str(
433                ".cargo-cache/
434.cargo-git/
435.cargo-cache-cuda/
436.cargo-git-cuda/
437libtorch/
438",
439            );
440        }
441        Mode::Native => {
442            // No docker, no container caches. libtorch/ is still ignored
443            // because `./fdl libtorch download` installs it locally.
444            s.push_str("libtorch/\n");
445        }
446    }
447    s
448}
449
450fn docker_compose_template(crate_name: &str, baked: bool) -> String {
451    if baked {
452        format!(
453            r#"services:
454  dev:
455    build:
456      context: .
457      dockerfile: Dockerfile.cpu
458    image: {crate_name}-dev
459    user: "${{UID:-1000}}:${{GID:-1000}}"
460    volumes:
461      - .:/workspace
462      - ./.cargo-cache:/usr/local/cargo/registry
463      - ./.cargo-git:/usr/local/cargo/git
464    working_dir: /workspace
465    stdin_open: true
466    tty: true
467    environment:
468      # Throttle cargo's link parallelism. Unset on Linux native (empty →
469      # cargo's default); Mac hosts set it in `.env` to keep `ld` within what
470      # a virtiofs-backed workspace can serve. See docs/mac-apple-silicon.md.
471      - CARGO_BUILD_JOBS
472      # flodl runtime knobs, forwarded from the host (or `.env`):
473      # verbosity is what `fdl -v/-vv/...` sets per invocation, and the
474      # timeout scale stretches distributed network deadlines on slow links.
475      - FLODL_VERBOSITY
476      - FLODL_NET_TIMEOUT_SCALE
477
478  cuda:
479    build:
480      context: .
481      dockerfile: Dockerfile.cuda
482    image: {crate_name}-cuda
483    user: "${{UID:-1000}}:${{GID:-1000}}"
484    volumes:
485      - .:/workspace
486      - ./.cargo-cache-cuda:/usr/local/cargo/registry
487      - ./.cargo-git-cuda:/usr/local/cargo/git
488    working_dir: /workspace
489    stdin_open: true
490    tty: true
491    environment:
492      # flodl runtime knobs, forwarded from the host (or `.env`):
493      # verbosity is what `fdl -v/-vv/...` sets per invocation, and the
494      # timeout scale stretches distributed network deadlines on slow links.
495      - FLODL_VERBOSITY
496      - FLODL_NET_TIMEOUT_SCALE
497    deploy:
498      resources:
499        reservations:
500          devices:
501            - driver: nvidia
502              count: all
503              capabilities: [gpu]
504"#
505        )
506    } else {
507        format!(
508            r#"services:
509  dev:
510    build:
511      context: .
512      dockerfile: Dockerfile
513    image: {crate_name}-dev
514    user: "${{UID:-1000}}:${{GID:-1000}}"
515    volumes:
516      - .:/workspace
517      - ./.cargo-cache:/usr/local/cargo/registry
518      - ./.cargo-git:/usr/local/cargo/git
519      - ${{LIBTORCH_CPU_PATH:-./libtorch/precompiled/cpu}}:/usr/local/libtorch:ro
520    working_dir: /workspace
521    stdin_open: true
522    tty: true
523    environment:
524      # Throttle cargo's link parallelism. Required on macOS Docker / OrbStack,
525      # where the virtiofs-mounted libtorch directory cannot serve many
526      # concurrent `ld` lookups — the linker reports `cannot find -ltorch`
527      # spuriously. Unset on Linux native (empty → cargo's default).
528      - CARGO_BUILD_JOBS
529      # flodl runtime knobs, forwarded from the host (or `.env`):
530      # verbosity is what `fdl -v/-vv/...` sets per invocation, and the
531      # timeout scale stretches distributed network deadlines on slow links.
532      - FLODL_VERBOSITY
533      - FLODL_NET_TIMEOUT_SCALE
534
535  cuda:
536    build:
537      context: .
538      dockerfile: Dockerfile.cuda
539      args:
540        CUDA_VERSION: ${{CUDA_VERSION:-12.8.0}}
541    image: {crate_name}-cuda:${{CUDA_TAG:-12.8}}
542    user: "${{UID:-1000}}:${{GID:-1000}}"
543    volumes:
544      - .:/workspace
545      - ./.cargo-cache-cuda:/usr/local/cargo/registry
546      - ./.cargo-git-cuda:/usr/local/cargo/git
547      - ${{LIBTORCH_HOST_PATH:-./libtorch/precompiled/cu128}}:/usr/local/libtorch:ro
548    working_dir: /workspace
549    stdin_open: true
550    tty: true
551    environment:
552      # flodl runtime knobs, forwarded from the host (or `.env`):
553      # verbosity is what `fdl -v/-vv/...` sets per invocation, and the
554      # timeout scale stretches distributed network deadlines on slow links.
555      - FLODL_VERBOSITY
556      - FLODL_NET_TIMEOUT_SCALE
557    deploy:
558      resources:
559        reservations:
560          devices:
561            - driver: nvidia
562              count: all
563              capabilities: [gpu]
564"#
565        )
566    }
567}
568
569// ---------------------------------------------------------------------------
570// Dockerfile templates
571// ---------------------------------------------------------------------------
572
573// Docker mode: libtorch baked into images
574const DOCKERFILE_CPU: &str = r#"# CPU-only dev image for floDl projects.
575FROM ubuntu:24.04
576
577ENV DEBIAN_FRONTEND=noninteractive
578
579RUN apt-get update && apt-get install -y --no-install-recommends \
580    wget curl unzip ca-certificates git gcc g++ pkg-config graphviz \
581    && rm -rf /var/lib/apt/lists/*
582
583# Rust
584ENV CARGO_HOME="/usr/local/cargo"
585ENV RUSTUP_HOME="/usr/local/rustup"
586RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable \
587    && chmod -R a+rwx "$CARGO_HOME" "$RUSTUP_HOME"
588ENV PATH="${CARGO_HOME}/bin:${PATH}"
589
590# libtorch (CPU-only, ~200MB)
591ARG LIBTORCH_VERSION=2.10.0
592RUN wget -q https://download.pytorch.org/libtorch/cpu/libtorch-shared-with-deps-${LIBTORCH_VERSION}%2Bcpu.zip \
593    && unzip -q libtorch-shared-with-deps-${LIBTORCH_VERSION}+cpu.zip -d /usr/local \
594    && rm libtorch-shared-with-deps-${LIBTORCH_VERSION}+cpu.zip
595
596ENV LIBTORCH_PATH="/usr/local/libtorch"
597ENV LD_LIBRARY_PATH="${LIBTORCH_PATH}/lib"
598ENV LIBRARY_PATH="${LIBTORCH_PATH}/lib"
599
600WORKDIR /workspace
601"#;
602
603const DOCKERFILE_CUDA: &str = r#"# CUDA dev image for floDl projects.
604# Requires: docker run --gpus all ...
605FROM nvidia/cuda:12.8.0-devel-ubuntu24.04
606
607ENV DEBIAN_FRONTEND=noninteractive
608
609RUN apt-get update && apt-get install -y --no-install-recommends \
610    wget curl unzip ca-certificates git gcc g++ pkg-config graphviz \
611    && rm -rf /var/lib/apt/lists/*
612
613# Rust
614ENV CARGO_HOME="/usr/local/cargo"
615ENV RUSTUP_HOME="/usr/local/rustup"
616RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable \
617    && chmod -R a+rwx "$CARGO_HOME" "$RUSTUP_HOME"
618ENV PATH="${CARGO_HOME}/bin:${PATH}"
619
620# libtorch (CUDA 12.8)
621ARG LIBTORCH_VERSION=2.10.0
622RUN wget -q "https://download.pytorch.org/libtorch/cu128/libtorch-shared-with-deps-${LIBTORCH_VERSION}%2Bcu128.zip" \
623    && unzip -q "libtorch-shared-with-deps-${LIBTORCH_VERSION}+cu128.zip" -d /usr/local \
624    && rm "libtorch-shared-with-deps-${LIBTORCH_VERSION}+cu128.zip"
625
626ENV LIBTORCH_PATH="/usr/local/libtorch"
627ENV LD_LIBRARY_PATH="${LIBTORCH_PATH}/lib:/usr/local/cuda/lib64"
628ENV LIBRARY_PATH="${LIBTORCH_PATH}/lib:/usr/local/cuda/lib64"
629ENV CUDA_HOME="/usr/local/cuda"
630
631WORKDIR /workspace
632"#;
633
634// Mounted mode: libtorch provided at runtime via volume mount
635const DOCKERFILE_MOUNTED: &str = r#"# CPU dev image for floDl projects (libtorch mounted at runtime).
636FROM ubuntu:24.04
637
638ENV DEBIAN_FRONTEND=noninteractive
639
640RUN apt-get update && apt-get install -y --no-install-recommends \
641    wget curl unzip ca-certificates git gcc g++ pkg-config graphviz \
642    && rm -rf /var/lib/apt/lists/*
643
644# Rust
645ENV CARGO_HOME="/usr/local/cargo"
646ENV RUSTUP_HOME="/usr/local/rustup"
647RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable \
648    && chmod -R a+rwx "$CARGO_HOME" "$RUSTUP_HOME"
649ENV PATH="${CARGO_HOME}/bin:${PATH}"
650
651ENV LIBTORCH_PATH="/usr/local/libtorch"
652ENV LD_LIBRARY_PATH="${LIBTORCH_PATH}/lib"
653ENV LIBRARY_PATH="${LIBTORCH_PATH}/lib"
654
655WORKDIR /workspace
656"#;
657
658const DOCKERFILE_CUDA_MOUNTED: &str = r#"# CUDA dev image for floDl projects (libtorch mounted at runtime).
659# Requires: docker run --gpus all ...
660ARG CUDA_VERSION=12.8.0
661FROM nvidia/cuda:${CUDA_VERSION}-devel-ubuntu24.04
662
663ENV DEBIAN_FRONTEND=noninteractive
664
665RUN apt-get update && apt-get install -y --no-install-recommends \
666    wget curl unzip ca-certificates git gcc g++ pkg-config graphviz \
667    && rm -rf /var/lib/apt/lists/*
668
669# Rust
670ENV CARGO_HOME="/usr/local/cargo"
671ENV RUSTUP_HOME="/usr/local/rustup"
672RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable \
673    && chmod -R a+rwx "$CARGO_HOME" "$RUSTUP_HOME"
674ENV PATH="${CARGO_HOME}/bin:${PATH}"
675
676ENV LIBTORCH_PATH="/usr/local/libtorch"
677ENV LD_LIBRARY_PATH="${LIBTORCH_PATH}/lib:/usr/local/cuda/lib64"
678ENV LIBRARY_PATH="${LIBTORCH_PATH}/lib:/usr/local/cuda/lib64"
679ENV CUDA_HOME="/usr/local/cuda"
680
681WORKDIR /workspace
682"#;
683
684// ---------------------------------------------------------------------------
685// fdl.yml.example template
686// ---------------------------------------------------------------------------
687
688/// The scaffold ships `fdl.yml.example` (committed) and fdl auto-copies it to
689/// the gitignored `fdl.yml` on first use. Docker modes attach `docker:` to
690/// every command; native mode drops `docker:` so the commands run directly
691/// on the host. Libtorch env vars (`LIBTORCH_HOST_PATH`, `CUDA_VERSION`,
692/// `CUDA_TAG`, etc.) are derived from `libtorch/.active` by
693/// `flodl-cli/src/run.rs::libtorch_env` before each `docker compose run`
694/// (Docker modes) or exported into the child process (native mode).
695/// `.env.example` for a scaffolded project: the compose knobs that this
696/// mode's generated `docker-compose.yml` actually substitutes, and no others.
697/// Committed template, gitignored working copy — the same convention as
698/// `fdl.yml.example`.
699fn env_example_template(mode: Mode) -> String {
700    let mut s = String::from(
701        "# Local Docker environment overrides for docker-compose.yml.
702# Copy this to `.env` (gitignored) and uncomment what you need:
703#   cp .env.example .env
704# docker-compose auto-reads `.env` from this directory. This `.env.example`
705# is only a template; compose never reads it directly.
706
707# Host user/group mapping, so files created in the container are owned by you
708# rather than root. Defaults to 1000:1000 when unset; macOS is usually 501:20
709# (`id -u` / `id -g`).
710#UID=1000
711#GID=1000
712",
713    );
714    if mode == Mode::Mounted {
715        s.push_str(
716            "
717# libtorch mount points (host paths). Defaults live in docker-compose.yml.
718# Override to point at a different variant, e.g. an extracted linux-aarch64
719# build on Apple Silicon.
720#LIBTORCH_CPU_PATH=./libtorch/precompiled/cpu
721#LIBTORCH_HOST_PATH=./libtorch/precompiled/cu128
722
723# CUDA base image version and image tag for the `cuda` service.
724# Only affects direct `docker compose` calls: `fdl` derives both from the active
725# libtorch variant's `.arch` metadata and overrides whatever is set here.
726#CUDA_VERSION=12.8.0
727#CUDA_TAG=12.8
728",
729        );
730    }
731    s.push_str(
732        "
733# Throttle cargo build/link parallelism. Leave unset on Linux (uses all cores).
734# On Apple Silicon via Docker/OrbStack, set to 2 to avoid spurious
735# \"cannot find -ltorch\" linker errors caused by virtiofs mount latency.
736#CARGO_BUILD_JOBS=2
737
738# flodl log verbosity. `fdl -v/-vv/...` sets this per invocation; setting it
739# here makes a level stick without the flag.
740#FLODL_VERBOSITY=1
741
742# Scale every distributed network timeout (socket setup, coordinator deadlines).
743# Raise it on slow or congested links where the 30s LAN defaults are too tight.
744#FLODL_NET_TIMEOUT_SCALE=2
745",
746    );
747    s
748}
749
750fn fdl_yml_example_template(project_name: &str, mode: Mode) -> String {
751    let use_docker = matches!(mode, Mode::Mounted | Mode::Docker);
752    let (cpu_svc, cuda_svc) = if use_docker {
753        ("\n    docker: dev", "\n    docker: cuda")
754    } else {
755        ("", "")
756    };
757    let cuda_note = if use_docker {
758        "(requires NVIDIA Container Toolkit)"
759    } else {
760        "(requires a matching CUDA toolkit on the host)"
761    };
762    let preamble = if use_docker {
763        "# Run any of these with `./fdl <cmd>` (or `fdl <cmd>` once installed\n\
764         # globally via `./fdl install`). Libtorch env vars are derived from\n\
765         # `libtorch/.active` automatically; missing libtorch surfaces as a\n\
766         # clean linker error, with `./fdl setup` one call away."
767    } else {
768        "# Native mode: commands run on the host. Make sure libtorch is\n\
769         # installed (`./fdl libtorch download --cpu` or `--cuda 12.8`)\n\
770         # and that `$LIBTORCH` / `$LD_LIBRARY_PATH` are exported so\n\
771         # cargo can link. `./fdl libtorch info` prints the commands you\n\
772         # need after a download."
773    };
774
775    let shell_block = if use_docker {
776        format!(
777            r#"  shell:
778    description: Interactive shell (CPU container)
779    run: bash{cpu_svc}
780
781"#
782        )
783    } else {
784        // Native mode: no container to drop into; users open their own shell.
785        String::new()
786    };
787
788    let cuda_shell_block = if use_docker {
789        format!(
790            r#"  cuda-shell:
791    description: Interactive shell (CUDA container)
792    run: bash{cuda_svc}
793"#
794        )
795    } else {
796        String::new()
797    };
798
799    format!(
800        r#"description: {project_name}
801
802{preamble}
803
804commands:
805  # --- CPU ---
806  build:
807    description: Build (debug)
808    run: cargo build{cpu_svc}
809  test:
810    description: Run CPU tests
811    run: cargo test -- --nocapture{cpu_svc}
812  run:
813    description: cargo run
814    run: cargo run{cpu_svc}
815  check:
816    description: Type-check without building
817    run: cargo check{cpu_svc}
818  clippy:
819    description: Lint
820    run: cargo clippy -- -W clippy::all{cpu_svc}
821{shell_block}  # --- CUDA {cuda_note} ---
822  cuda-build:
823    description: Build with CUDA feature
824    run: cargo build --features cuda{cuda_svc}
825  cuda-test:
826    description: Run CUDA tests
827    run: cargo test --features cuda -- --nocapture{cuda_svc}
828  cuda-run:
829    description: cargo run --features cuda
830    run: cargo run --features cuda{cuda_svc}
831{cuda_shell_block}"#
832    )
833}
834
835// ---------------------------------------------------------------------------
836// File writing helper
837// ---------------------------------------------------------------------------
838
839fn write_file(path: &str, content: &str) -> Result<(), String> {
840    fs::write(path, content).map_err(|e| format!("cannot write {}: {}", path, e))
841}
842
843#[cfg(test)]
844mod tests {
845    use super::validate_name;
846
847    #[test]
848    fn validate_name_accepts_alnum_hyphen_underscore() {
849        for ok in ["my_project", "my-project", "Proj123", "a", "x_1-2"] {
850            assert!(validate_name(ok).is_ok(), "{ok:?} should be valid");
851        }
852    }
853
854    #[test]
855    fn validate_name_rejects_empty() {
856        let err = validate_name("").unwrap_err();
857        assert!(err.contains("empty"), "unexpected: {err}");
858    }
859
860    #[test]
861    fn validate_name_rejects_disallowed_chars() {
862        // Spaces, dots, and path separators are the realistic footguns
863        // (a project name becomes a directory + a crate name).
864        for bad in ["my project", "my.project", "a/b", "../evil", "name!"] {
865            let err = validate_name(bad).unwrap_err();
866            assert!(err.contains("only letters"), "{bad:?} -> unexpected: {err}");
867        }
868    }
869}