1use std::fs;
19use std::path::{Path, PathBuf};
20
21use crate::util::{cargo_toml as cargo_edit, fdl_yml as yml_edit, prompt};
22
23const 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
35const 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 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
74fn 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
109fn has_tty() -> bool {
111 prompt::has_tty()
112}
113
114pub 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
155pub fn add_flodl_hf_at(cwd: &Path) -> Result<(), String> {
161 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 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 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 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_into_root_fdl_yml(cwd)?;
221
222 print_next_steps(&flodl_version, mode);
223 Ok(())
224}
225
226fn 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#[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
264fn 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
283fn 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 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
319fn parse_flodl_dep(content: &str) -> Result<Option<String>, String> {
325 let lines: Vec<&str> = content.lines().collect();
326
327 let mut in_dep_table = false;
331 for line in &lines {
332 let t = line.trim();
333 if t.starts_with('[') {
334 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 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 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 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 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
383fn 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
406fn 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 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 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 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 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 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 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 assert!(yml.contains("build:"));
641 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}