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