1use std::fs;
17use std::path::{Path, PathBuf};
18
19const TEMPLATE_CARGO_TOML: &str = include_str!("scaffold/Cargo.toml.in");
26const TEMPLATE_MAIN_RS: &str = include_str!("scaffold/src/main.rs");
27const TEMPLATE_FDL_YML: &str = include_str!("scaffold/fdl.yml.example");
28const TEMPLATE_README: &str = include_str!("scaffold/README.md");
29const TEMPLATE_GITIGNORE: &str = include_str!("scaffold/.gitignore");
30
31pub fn run(target: Option<&str>) -> Result<(), String> {
32 let target = target.ok_or(
33 "usage: fdl add <target>\n\nSupported targets:\n flodl-hf HuggingFace integration (pre-built BERT / RoBERTa / DistilBERT, Hub loader, tokenizer)",
34 )?;
35 let cwd = std::env::current_dir()
36 .map_err(|e| format!("cannot read current directory: {e}"))?;
37 match target {
38 "flodl-hf" | "hf" => add_flodl_hf_at(&cwd),
39 other => Err(format!(
40 "unknown target: {other:?}\n\n\
41 Supported targets:\n \
42 flodl-hf HuggingFace integration\n\n\
43 (More targets land as the flodl ecosystem grows.)",
44 )),
45 }
46}
47
48pub fn add_flodl_hf_at(cwd: &Path) -> Result<(), String> {
54 let cargo_toml = cwd.join("Cargo.toml");
56 if !cargo_toml.exists() {
57 return Err(format!(
58 "no Cargo.toml in {}.\n\n\
59 fdl add flodl-hf must run from a flodl project root.\n\
60 Start with `fdl init <name>` if you don't have one yet.",
61 cwd.display(),
62 ));
63 }
64
65 if !has_fdl_config(cwd) {
70 return Err(format!(
71 "no fdl.yml (nor fdl.yml.example) in {}.\n\n\
72 fdl add flodl-hf expects an initialised flodl project: \
73 Docker or native mode already chosen, fdl.yml present. \
74 Run `fdl init <name>` first, or cd into an existing flodl project.",
75 cwd.display(),
76 ));
77 }
78
79 let flodl_version = detect_flodl_version(&cargo_toml)?;
80 let mode = detect_project_mode(cwd);
81
82 let dest = cwd.join("flodl-hf");
84 if dest.exists() {
85 return Err(format!(
86 "{} already exists.\n\n\
87 Remove it first, or keep it. `fdl add flodl-hf` does not overwrite.",
88 dest.display(),
89 ));
90 }
91
92 fs::create_dir_all(dest.join("src"))
94 .map_err(|e| format!("cannot create {}: {e}", dest.join("src").display()))?;
95
96 write_file(
97 &dest.join("Cargo.toml"),
98 &substitute_version(TEMPLATE_CARGO_TOML, &flodl_version),
99 )?;
100 write_file(&dest.join("src/main.rs"), TEMPLATE_MAIN_RS)?;
101 let fdl_yml = render_fdl_yml(TEMPLATE_FDL_YML, mode);
102 write_file(&dest.join("fdl.yml.example"), &fdl_yml)?;
103 write_file(&dest.join("fdl.yml"), &fdl_yml)?;
104 write_file(
105 &dest.join("README.md"),
106 &substitute_version(TEMPLATE_README, &flodl_version),
107 )?;
108 write_file(&dest.join(".gitignore"), TEMPLATE_GITIGNORE)?;
109
110 print_next_steps(&flodl_version, mode);
111 Ok(())
112}
113
114#[derive(Debug, Clone, Copy, PartialEq, Eq)]
121enum ProjectMode {
122 Docker,
123 Native,
124}
125
126fn has_fdl_config(cwd: &Path) -> bool {
127 cwd.join("fdl.yml").exists() || cwd.join("fdl.yml.example").exists()
128}
129
130fn detect_project_mode(cwd: &Path) -> ProjectMode {
131 if cwd.join("docker-compose.yml").exists() {
132 ProjectMode::Docker
133 } else {
134 ProjectMode::Native
135 }
136}
137
138fn render_fdl_yml(template: &str, mode: ProjectMode) -> String {
144 match mode {
145 ProjectMode::Docker => template.to_string(),
146 ProjectMode::Native => template
147 .lines()
148 .filter(|l| l.trim() != "docker: dev")
149 .collect::<Vec<&str>>()
150 .join("\n")
151 + "\n",
152 }
153}
154
155fn detect_flodl_version(cargo_toml: &Path) -> Result<String, String> {
166 let content = fs::read_to_string(cargo_toml)
167 .map_err(|e| format!("cannot read {}: {e}", cargo_toml.display()))?;
168
169 if let Some(v) = parse_flodl_dep(&content)? {
170 return Ok(v);
171 }
172
173 if let Some(ws_root) = find_workspace_root(cargo_toml) {
175 let ws_content = fs::read_to_string(&ws_root)
176 .map_err(|e| format!("cannot read workspace {}: {e}", ws_root.display()))?;
177 if let Some(v) = parse_flodl_dep(&ws_content)? {
178 return Ok(v);
179 }
180 }
181
182 Err(format!(
183 "no flodl dependency found in {}.\n\n\
184 fdl add flodl-hf needs to pin flodl-hf to the same version as \
185 flodl. Add `flodl = \"X.Y.Z\"` to [dependencies] first, or run \
186 `fdl init <name>` to scaffold a flodl project.",
187 cargo_toml.display(),
188 ))
189}
190
191fn parse_flodl_dep(content: &str) -> Result<Option<String>, String> {
197 let lines: Vec<&str> = content.lines().collect();
198
199 let mut in_dep_table = false;
203 for line in &lines {
204 let t = line.trim();
205 if t.starts_with('[') {
206 in_dep_table = matches!(
208 t,
209 "[dependencies]" | "[workspace.dependencies]" | "[dev-dependencies]",
210 );
211 continue;
212 }
213 if !in_dep_table {
214 continue;
215 }
216 let after_key = match t.strip_prefix("flodl") {
218 Some(rest) => rest.trim_start(),
219 None => continue,
220 };
221 let Some(rhs) = after_key.strip_prefix('=') else {
222 continue;
223 };
224 let rhs = rhs.trim();
225
226 if let Some(v) = rhs.strip_prefix('"').and_then(|r| r.strip_suffix('"')) {
228 return Ok(Some(v.to_string()));
229 }
230 if let Some(v) = extract_version_from_table(rhs) {
231 return Ok(Some(v));
232 }
233 if rhs.contains("workspace") && rhs.contains("true") {
234 return Ok(None);
236 }
237 if rhs.contains("git =") || rhs.contains("git=") {
238 return Err(
239 "flodl is declared as a git dependency. \
240 fdl add flodl-hf needs a pinnable crates.io version. \
241 Switch to `flodl = \"X.Y.Z\"` first."
242 .into(),
243 );
244 }
245 if rhs.contains("path =") || rhs.contains("path=") {
246 return Err(
249 "flodl is declared as a path dependency only. \
250 Add an explicit `version = \"X.Y.Z\"` so fdl add can \
251 pin the matching flodl-hf release."
252 .into(),
253 );
254 }
255 }
256 Ok(None)
257}
258
259fn extract_version_from_table(rhs: &str) -> Option<String> {
263 let rhs = rhs.strip_prefix('{')?.strip_suffix('}')?;
264 for part in rhs.split(',') {
265 let part = part.trim();
266 let Some(after) = part.strip_prefix("version") else {
267 continue;
268 };
269 let after = after.trim_start();
270 let Some(after) = after.strip_prefix('=') else {
271 continue;
272 };
273 let after = after.trim_start();
274 let Some(v) = after.strip_prefix('"').and_then(|r| r.strip_suffix('"')) else {
275 continue;
276 };
277 return Some(v.to_string());
278 }
279 None
280}
281
282fn find_workspace_root(from: &Path) -> Option<PathBuf> {
285 let mut dir = from.parent()?.parent()?.to_path_buf();
286 loop {
287 let candidate = dir.join("Cargo.toml");
288 if candidate.exists() {
289 if let Ok(content) = fs::read_to_string(&candidate) {
290 if content.lines().any(|l| l.trim() == "[workspace]") {
291 return Some(candidate);
292 }
293 }
294 }
295 if !dir.pop() {
296 return None;
297 }
298 }
299}
300
301fn substitute_version(template: &str, version: &str) -> String {
302 template.replace("{{FLODL_VERSION}}", version)
303}
304
305fn write_file(path: &Path, content: &str) -> Result<(), String> {
306 fs::write(path, content).map_err(|e| format!("cannot write {}: {e}", path.display()))
307}
308
309fn print_next_steps(version: &str, mode: ProjectMode) {
310 println!();
311 println!(
312 "Scaffolded flodl-hf/ playground (flodl {version}, {} mode).",
313 match mode {
314 ProjectMode::Docker => "Docker",
315 ProjectMode::Native => "native",
316 },
317 );
318 println!();
319 println!("Next steps:");
320 println!(" cd flodl-hf");
321 println!(" fdl classify # run with the default RoBERTa sentiment checkpoint");
322 println!(" fdl classify -- bert-base-uncased # or any other BERT-family repo id");
323 println!();
324 println!("See flodl-hf/README.md for feature flavors (offline / vision-only),");
325 println!("`.bin` to safetensors conversion for older checkpoints, and how to wire");
326 println!("flodl-hf into your main crate when you're ready.");
327}
328
329#[cfg(test)]
330mod tests {
331 use super::*;
332
333 #[test]
334 fn parse_plain_version_string() {
335 let c = r#"
336[dependencies]
337flodl = "0.6.0"
338other = "1.0"
339"#;
340 assert_eq!(parse_flodl_dep(c).unwrap(), Some("0.6.0".into()));
341 }
342
343 #[test]
344 fn parse_table_version() {
345 let c = r#"
346[dependencies]
347flodl = { version = "0.5.1", features = ["cuda"] }
348"#;
349 assert_eq!(parse_flodl_dep(c).unwrap(), Some("0.5.1".into()));
350 }
351
352 #[test]
353 fn parse_workspace_inheritance_returns_none() {
354 let c = r#"
355[dependencies]
356flodl = { workspace = true }
357"#;
358 assert_eq!(parse_flodl_dep(c).unwrap(), None);
360 }
361
362 #[test]
363 fn parse_git_dep_errors() {
364 let c = r#"
365[dependencies]
366flodl = { git = "https://github.com/fab2s/floDl" }
367"#;
368 let err = parse_flodl_dep(c).unwrap_err();
369 assert!(err.contains("git dependency"), "got: {err}");
370 }
371
372 #[test]
373 fn parse_no_flodl_returns_none() {
374 let c = r#"
375[dependencies]
376other = "1.0"
377"#;
378 assert_eq!(parse_flodl_dep(c).unwrap(), None);
379 }
380
381 #[test]
382 fn parse_ignores_flodl_hf_and_flodl_sys() {
383 let c = r#"
386[dependencies]
387flodl-hf = "0.6.0"
388flodl-sys = "0.6.0"
389"#;
390 assert_eq!(parse_flodl_dep(c).unwrap(), None);
391 }
392
393 #[test]
394 fn parse_ignores_non_dep_tables() {
395 let c = r#"
396[package]
397flodl = "0.6.0" # not actually a dep; this is bogus but must not match
398"#;
399 assert_eq!(parse_flodl_dep(c).unwrap(), None);
400 }
401
402 #[test]
403 fn substitute_version_replaces_all_occurrences() {
404 let t = "flodl = \"={{FLODL_VERSION}}\"\nflodl-hf = \"={{FLODL_VERSION}}\"";
405 let out = substitute_version(t, "0.6.0");
406 assert_eq!(out, "flodl = \"=0.6.0\"\nflodl-hf = \"=0.6.0\"");
407 }
408
409 #[test]
410 fn render_fdl_yml_docker_preserves_docker_lines() {
411 let t = "commands:\n classify:\n run: cargo run --release\n docker: dev\n";
412 assert_eq!(render_fdl_yml(t, ProjectMode::Docker), t);
413 }
414
415 #[test]
416 fn render_fdl_yml_native_strips_docker_lines() {
417 let t = "commands:\n classify:\n run: cargo run --release\n docker: dev\n check:\n run: cargo check\n docker: dev\n";
418 let out = render_fdl_yml(t, ProjectMode::Native);
419 assert!(
420 !out.contains("docker: dev"),
421 "native output must not contain docker: dev lines: {out}"
422 );
423 assert!(out.contains("cargo run --release"));
426 assert!(out.contains("cargo check"));
427 }
428
429 #[test]
430 fn render_fdl_yml_native_only_strips_exact_docker_line() {
431 let t = "\
434commands:
435 classify:
436 run: cargo run
437 docker: dev
438 other:
439 description: docker: dev isn't a literal directive here
440 docker: hf-parity
441";
442 let out = render_fdl_yml(t, ProjectMode::Native);
443 assert!(!out.contains(" docker: dev\n"), "exact match stripped: {out}");
444 assert!(out.contains("hf-parity"), "other services preserved: {out}");
445 assert!(
446 out.contains("docker: dev isn't a literal"),
447 "description text preserved: {out}",
448 );
449 }
450}