1use std::path::{Path, PathBuf};
24use std::process::Command;
25
26use serde::{Deserialize, Serialize};
27
28use crate::config::SshConfig;
29use crate::prepare::Fail;
30use crate::spec::{SshTarget, parse_ssh_target, split_scheme};
31
32const RSYNC_EXCLUDES: [&str; 3] = ["target/", "libtorch/", ".git/"];
49
50const ROOT_ANCHORED: [&str; 1] = ["libtorch/"];
53
54const DEFAULT_BUILD: &str = "cargo build --release";
60
61#[derive(Debug, Clone, PartialEq, Eq)]
63pub enum Source {
64 Local(PathBuf),
67 Rsync(SshTarget),
71 Git { url: String, git_ref: String },
73}
74
75#[derive(Debug, Clone, PartialEq, Eq)]
77pub struct Built {
78 pub bin: PathBuf,
79 pub cwd: PathBuf,
80}
81
82pub const MANIFEST_FILE: &str = ".fdl-run.yml";
84
85#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
102#[serde(deny_unknown_fields)]
103pub struct Manifest {
104 #[serde(default, skip_serializing_if = "Option::is_none")]
106 pub cwd: Option<String>,
107 #[serde(default, skip_serializing_if = "Option::is_none")]
109 pub build: Option<String>,
110 pub bin: String,
112 #[serde(default)]
114 pub args: Vec<String>,
115 #[serde(default, skip_serializing_if = "Option::is_none")]
117 pub origin: Option<String>,
118 #[serde(default, skip_serializing_if = "Option::is_none")]
125 pub rustc: Option<String>,
126 #[serde(default, skip_serializing_if = "Option::is_none")]
128 pub published_epoch: Option<u64>,
129 #[serde(default, skip_serializing_if = "Option::is_none")]
139 pub run: Option<String>,
140 #[serde(default)]
143 pub built: bool,
144}
145
146impl Manifest {
147 pub fn read(tree: &Path) -> Result<Option<Manifest>, Fail> {
151 let path = tree.join(MANIFEST_FILE);
152 let text = match std::fs::read_to_string(&path) {
153 Ok(t) => t,
154 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
155 Err(e) => {
156 return Err(Fail::Permanent(format!(
157 "cannot read the run manifest {}: {e}",
158 path.display(),
159 )));
160 }
161 };
162 serde_yaml_ng::from_str(&text)
163 .map(Some)
164 .map_err(|e| Fail::Permanent(format!("{} is not a run manifest: {e}", path.display())))
165 }
166
167 pub fn write(&self, tree: &Path) -> Result<(), Fail> {
169 let path = tree.join(MANIFEST_FILE);
170 let body = serde_yaml_ng::to_string(self)
171 .map_err(|e| Fail::Permanent(format!("cannot serialize the run manifest: {e}")))?;
172 std::fs::write(
173 &path,
174 format!(
175 "# Written by `fdl publish`. The controller is the authority \
176 for a run:\n# a worker merges this over its own config, \
177 because args must match the run\n# (rank children re-enter \
178 the binary with them). Do not hand-edit — the next\n# \
179 publish overwrites it, and its presence is what tells a \
180 worker the run\n# is ready.\n{body}"
181 ),
182 )
183 .map_err(|e| {
184 Fail::Permanent(format!(
185 "cannot write the run manifest {}: {e}",
186 path.display()
187 ))
188 })
189 }
190
191 pub fn remove(tree: &Path) -> Result<(), Fail> {
193 match std::fs::remove_file(tree.join(MANIFEST_FILE)) {
194 Ok(()) => Ok(()),
195 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
196 Err(e) => Err(Fail::Permanent(format!(
197 "cannot clear the run manifest: {e}"
198 ))),
199 }
200 }
201}
202
203pub fn parse(spec: &str) -> Result<Source, Fail> {
217 let forms = "Expected `file:///abs/path`, \
218 `rsync://[user@]host[:port]:/abs/path`, or \
219 `git+https://host/owner/repo#<tag|branch|sha>`";
220 match split_scheme(spec) {
221 (Some("file"), rest) => {
222 if !rest.starts_with('/') {
223 return Err(Fail::Permanent(format!(
224 "invalid source `{spec}` — a `file://` path must be \
225 absolute (three slashes: `file:///srv/train`). {forms}"
226 )));
227 }
228 Ok(Source::Local(PathBuf::from(rest)))
229 }
230 (Some("rsync"), rest) => parse_ssh_target(rest)
231 .map(Source::Rsync)
232 .map_err(|why| Fail::Permanent(format!("invalid source `{spec}` — {why}. {forms}"))),
233 (Some(scheme), rest)
236 if scheme == "git+https" || scheme == "git+ssh" || scheme == "git+file" =>
237 {
238 parse_git(scheme, rest, spec, forms)
239 }
240 (Some(scheme), _) => Err(Fail::Permanent(format!(
241 "unsupported source scheme `{scheme}://` — {forms}"
242 ))),
243 (None, _) => Err(Fail::Permanent(format!(
244 "source `{spec}` names no transport — a directory already on \
245 this box is `file://` plus its absolute path. {forms}"
246 ))),
247 }
248}
249
250fn parse_git(scheme: &str, rest: &str, spec: &str, forms: &str) -> Result<Source, Fail> {
259 let transport = scheme.trim_start_matches("git+");
260 let (path, git_ref) = rest.split_once('#').ok_or_else(|| {
261 Fail::Permanent(format!(
262 "source `{spec}` names no ref — add `#<tag|branch|sha>`. \
263 Without one the remote's default branch decides what a box \
264 builds, which is not a pin: two boxes provisioned an hour \
265 apart would not agree. {forms}"
266 ))
267 })?;
268 if git_ref.is_empty() {
269 return Err(Fail::Permanent(format!(
270 "source `{spec}` ends at `#` with no ref. {forms}"
271 )));
272 }
273 if path.is_empty() {
274 return Err(Fail::Permanent(format!(
275 "source `{spec}` names no repository. {forms}"
276 )));
277 }
278 Ok(Source::Git {
279 url: format!("{transport}://{path}"),
280 git_ref: git_ref.to_string(),
281 })
282}
283
284pub fn materialize(
292 source: &Source,
293 dest: &Path,
294 ssh: Option<&SshConfig>,
295 notes: &mut Vec<String>,
296) -> Result<(), Fail> {
297 std::fs::create_dir_all(dest).map_err(|e| {
298 Fail::Permanent(format!(
299 "cannot create source directory {}: {e}",
300 dest.display()
301 ))
302 })?;
303 match source {
304 Source::Local(path) => {
305 if !path.is_dir() {
306 return Err(Fail::Permanent(format!(
307 "source {} is not a readable directory — provision it, \
308 or point `from:` somewhere that exists",
309 path.display(),
310 )));
311 }
312 run_rsync(
313 &rsync_argv(&format!("{}/", path.display()), dest, None, None),
314 dest,
315 )?;
316 notes.push(format!(
317 "source: copied {} into {}",
318 path.display(),
319 dest.display()
320 ));
321 }
322 Source::Rsync(target) => {
323 let argv = rsync_argv(&format!("{}/", target.remote), dest, Some(target), ssh);
324 run_rsync(&argv, dest)?;
325 notes.push(format!(
326 "source: pulled {} into {}",
327 target.remote,
328 dest.display()
329 ));
330 }
331 Source::Git { url, git_ref } => {
332 run_git(url, git_ref, dest)?;
333 notes.push(format!(
334 "source: checked out {url} at {git_ref} in {}",
335 dest.display()
336 ));
337 }
338 }
339 Ok(())
340}
341
342fn rsync_argv(
347 src: &str,
348 dest: &Path,
349 target: Option<&SshTarget>,
350 ssh: Option<&SshConfig>,
351) -> Vec<String> {
352 let mut argv: Vec<String> = vec!["rsync".into(), "-a".into(), "--delete".into()];
353 for ex in RSYNC_EXCLUDES {
354 let anchor = if ROOT_ANCHORED.contains(&ex) { "/" } else { "" };
355 argv.push(format!("--exclude={anchor}{ex}"));
356 }
357 if let Some(target) = target {
358 let mut ssh_cmd = String::from("ssh");
364 if let Some(port) = target.port {
365 ssh_cmd.push_str(&format!(" -p {port}"));
366 }
367 if let Some(ssh) = ssh {
368 if let Some(id) = &ssh.identity_file {
369 ssh_cmd.push_str(&format!(" -i {id}"));
370 }
371 for opt in &ssh.options {
372 ssh_cmd.push_str(&format!(" -o {opt}"));
373 }
374 }
375 ssh_cmd.push_str(" -o BatchMode=yes");
378 argv.push("-e".into());
379 argv.push(ssh_cmd);
380 }
381 argv.push(src.to_string());
382 argv.push(format!("{}/", dest.display()));
383 argv
384}
385
386fn run_rsync(argv: &[String], dest: &Path) -> Result<(), Fail> {
387 if !crate::util::system::has_command("rsync") {
388 return Err(Fail::Permanent(
389 "a source spec needs rsync, which is not installed — \
390 `sudo apt install rsync` (it is what preserves mtimes, so \
391 cargo stays incremental instead of rebuilding everything \
392 every dial)"
393 .to_string(),
394 ));
395 }
396 let out = Command::new(&argv[0])
397 .args(&argv[1..])
398 .output()
399 .map_err(|e| Fail::Permanent(format!("spawn rsync: {e}")))?;
400 if !out.status.success() {
401 return Err(Fail::Transient(format!(
406 "fetching the source into {} failed ({}): {} — check the \
407 remote path, the key, and whether that key's forced command \
408 permits rsync (a join key guardrailed with \
409 `command=\"/usr/sbin/nologin\"` does not)",
410 dest.display(),
411 out.status,
412 String::from_utf8_lossy(&out.stderr).trim(),
413 )));
414 }
415 Ok(())
416}
417
418fn run_git(url: &str, git_ref: &str, dest: &Path) -> Result<(), Fail> {
425 if !crate::util::system::has_command("git") {
426 return Err(Fail::Permanent(
427 "a `git+` source spec needs git, which is not installed — \
428 `sudo apt install git`"
429 .to_string(),
430 ));
431 }
432 let dest_s = dest.display().to_string();
433 git(&["init", "--quiet", &dest_s], "initialise")?;
434 let fetch = git_output(&[
436 "-C", &dest_s, "fetch", "--quiet", "--depth", "1", url, git_ref,
437 ]);
438 match fetch {
439 Ok(()) => {}
440 Err(stderr) => {
441 if stderr.contains("unadvertised object") || stderr.contains("allow request for") {
446 return Err(Fail::Permanent(format!(
447 "the server refused a shallow fetch of `{git_ref}` \
448 ({url}): fetching a bare commit needs \
449 `uploadpack.allowReachableSHA1InWant` on the remote. \
450 Name a tag or branch instead, or push the commit to \
451 a ref. ({stderr})"
452 )));
453 }
454 return Err(Fail::Transient(format!(
455 "fetching {url} at `{git_ref}` failed: {stderr}"
456 )));
457 }
458 }
459 git(
463 &[
464 "-C",
465 &dest_s,
466 "checkout",
467 "--quiet",
468 "--detach",
469 "--force",
470 "FETCH_HEAD",
471 ],
472 "check out",
473 )?;
474 Ok(())
475}
476
477fn git(args: &[&str], what: &str) -> Result<(), Fail> {
478 git_output(args).map_err(|stderr| {
479 Fail::Permanent(format!("git failed to {what} the source tree: {stderr}"))
480 })
481}
482
483fn git_output(args: &[&str]) -> Result<(), String> {
485 let out = Command::new("git")
486 .args(args)
487 .output()
488 .map_err(|e| format!("spawn git: {e}"))?;
489 if out.status.success() {
490 return Ok(());
491 }
492 let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
493 Err(if stderr.is_empty() {
494 format!("exited {}", out.status)
495 } else {
496 stderr
497 })
498}
499
500pub fn build_env(libtorch: Option<&(PathBuf, String)>) -> Vec<(String, String)> {
510 let Some((dir, variant)) = libtorch else {
511 return Vec::new();
512 };
513 let lib = dir.join("lib").display().to_string();
514 let vendor = crate::libtorch::detect::variant_vendor(variant);
515 vec![
516 ("LIBTORCH_PATH".to_string(), dir.display().to_string()),
518 (
530 "FDL_GPU_FEATURE".to_string(),
531 vendor
532 .map(|v| v.cargo_feature().to_string())
533 .unwrap_or_default(),
534 ),
535 (
539 "LD_LIBRARY_PATH".to_string(),
540 crate::libtorch::detect::ld_library_path_value(
541 vendor,
542 &lib,
543 &crate::libtorch::detect::local_rocm_lib_dir(),
544 ),
545 ),
546 ]
547}
548
549pub fn build(
559 tree: &Path,
560 cwd: Option<&str>,
561 cmd: Option<&str>,
562 bin: &str,
563 env: &[(String, String)],
564 notes: &mut Vec<String>,
565) -> Result<Built, Fail> {
566 let dir = run_recipe(tree, cwd, cmd, env, notes)?;
567 let path = dir.join(bin);
568 if !path.is_file() {
569 return Err(Fail::Permanent(format!(
570 "the build succeeded but `bin: {bin}` is not there ({}) — it \
571 is the artifact path relative to `cwd:`, e.g. \
572 `target/release/<name>`",
573 path.display(),
574 )));
575 }
576 Ok(Built {
577 bin: path,
578 cwd: dir,
579 })
580}
581
582pub fn check_build(
586 tree: &Path,
587 cwd: Option<&str>,
588 cmd: Option<&str>,
589 env: &[(String, String)],
590 notes: &mut Vec<String>,
591) -> Result<(), Fail> {
592 run_recipe(tree, cwd, cmd, env, notes).map(|_| ())
593}
594
595fn run_recipe(
599 tree: &Path,
600 cwd: Option<&str>,
601 cmd: Option<&str>,
602 env: &[(String, String)],
603 notes: &mut Vec<String>,
604) -> Result<PathBuf, Fail> {
605 let dir = match cwd {
606 Some(sub) => tree.join(sub),
607 None => tree.to_path_buf(),
608 };
609 if !dir.is_dir() {
610 return Err(Fail::Permanent(format!(
611 "`cwd: {}` names no directory in the fetched source ({}) — it \
612 is a path inside the tree, not on this box",
613 cwd.unwrap_or(""),
614 dir.display(),
615 )));
616 }
617 let recipe = cmd.unwrap_or(DEFAULT_BUILD);
618 if cmd.is_none() && !crate::util::system::has_command("cargo") {
621 return Err(Fail::Permanent(
622 "building the source needs cargo, which is not installed — \
623 install a toolchain (https://rustup.rs), or set `build:` to \
624 a recipe that does not need one"
625 .to_string(),
626 ));
627 }
628
629 notes.push(format!("source: building in {} — {recipe}", dir.display()));
630 let mut command = Command::new("sh");
631 command.args(["-c", recipe]).current_dir(&dir);
632 for (k, v) in env {
633 command.env(k, v);
634 }
635 let status = command
636 .status()
637 .map_err(|e| Fail::Permanent(format!("spawn `{recipe}`: {e}")))?;
638 if !status.success() {
639 return Err(Fail::Transient(format!(
647 "the source does not build ({status}) — see the compiler \
648 output above"
649 )));
650 }
651 Ok(dir)
652}
653
654#[cfg(test)]
655mod tests {
656 use super::*;
657
658 #[test]
659 fn every_shipped_spelling_parses() {
660 assert_eq!(
661 parse("file:///srv/train").unwrap(),
662 Source::Local("/srv/train".into())
663 );
664 assert_eq!(
665 parse("rsync://flodl@exa:/home/op/train").unwrap(),
666 Source::Rsync(SshTarget {
667 remote: "flodl@exa:/home/op/train".into(),
668 port: None
669 }),
670 );
671 assert_eq!(
672 parse("rsync://exa:2222/home/op/train").unwrap(),
673 Source::Rsync(SshTarget {
674 remote: "exa:/home/op/train".into(),
675 port: Some(2222)
676 }),
677 );
678 assert_eq!(
680 parse("rsync://exa:2222:/home/op/train").unwrap(),
681 Source::Rsync(SshTarget {
682 remote: "exa:/home/op/train".into(),
683 port: Some(2222)
684 }),
685 );
686 assert_eq!(
687 parse("git+https://github.com/flodl-labs/flodl#0.7.0").unwrap(),
688 Source::Git {
689 url: "https://github.com/flodl-labs/flodl".into(),
690 git_ref: "0.7.0".into(),
691 },
692 );
693 assert_eq!(
696 parse("git+ssh://git@github.com/me/train#feature/wip").unwrap(),
697 Source::Git {
698 url: "ssh://git@github.com/me/train".into(),
699 git_ref: "feature/wip".into(),
700 },
701 );
702 }
703
704 #[test]
710 fn the_git_resolver_fetches_a_ref_and_then_moves_to_another() {
711 if !crate::util::system::has_command("git") {
712 return;
713 }
714 let base = std::env::temp_dir().join(format!("fdl-src-git-{}", std::process::id()));
715 let (origin, dest) = (base.join("origin"), base.join("dest"));
716 std::fs::create_dir_all(&origin).unwrap();
717 let git = |args: &[&str]| {
718 let out = Command::new("git")
719 .args(args)
720 .current_dir(&origin)
721 .env("GIT_AUTHOR_NAME", "fdl")
722 .env("GIT_AUTHOR_EMAIL", "fdl@example.com")
723 .env("GIT_COMMITTER_NAME", "fdl")
724 .env("GIT_COMMITTER_EMAIL", "fdl@example.com")
725 .output()
726 .unwrap();
727 assert!(
728 out.status.success(),
729 "git {args:?}: {}",
730 String::from_utf8_lossy(&out.stderr)
731 );
732 };
733 git(&["init", "--quiet"]);
734 std::fs::write(origin.join("main.rs"), "// one").unwrap();
735 git(&["add", "."]);
736 git(&["commit", "--quiet", "-m", "one"]);
737 git(&["tag", "v1"]);
738 std::fs::write(origin.join("main.rs"), "// two").unwrap();
739 git(&["add", "."]);
740 git(&["commit", "--quiet", "-m", "two"]);
741 git(&["tag", "v2"]);
742
743 let url = format!("git+file://{}", origin.display());
744 let at_v1 = parse(&format!("{url}#v1")).unwrap();
745 materialize(&at_v1, &dest, None, &mut Vec::new()).unwrap();
746 assert_eq!(
747 std::fs::read_to_string(dest.join("main.rs")).unwrap(),
748 "// one"
749 );
750
751 std::fs::create_dir_all(dest.join("target/release")).unwrap();
754 std::fs::write(dest.join("target/release/train"), "binary").unwrap();
755
756 let at_v2 = parse(&format!("{url}#v2")).unwrap();
757 materialize(&at_v2, &dest, None, &mut Vec::new()).unwrap();
758 assert_eq!(
759 std::fs::read_to_string(dest.join("main.rs")).unwrap(),
760 "// two"
761 );
762 assert!(
763 dest.join("target/release/train").is_file(),
764 "the checkout swept the build"
765 );
766
767 let missing = parse(&format!("{url}#v9")).unwrap();
770 assert!(materialize(&missing, &dest, None, &mut Vec::new()).is_err());
771 let _ = std::fs::remove_dir_all(&base);
772 }
773
774 #[test]
775 fn a_broken_spec_is_permanent_and_names_the_forms() {
776 for spec in [
777 "/srv/train", "file://srv/train", "smb://server/share", "rsync://exa", "git+https://github.com/me/train", "git+https://github.com/me/train#", "git+ssh://#0.7.0", ] {
785 let err = parse(spec).unwrap_err();
786 assert!(err.is_permanent(), "{spec} should be permanent: {err:?}");
787 assert!(
788 err.message().contains("file:///") || err.message().contains("`#<"),
789 "{spec} should name the accepted forms: {err:?}",
790 );
791 }
792 }
793
794 #[test]
795 fn a_missing_ref_explains_why_a_default_branch_is_not_a_pin() {
796 let err = parse("git+https://github.com/me/train").unwrap_err();
797 assert!(err.message().contains("not a pin"), "got: {err:?}");
798 }
799
800 #[test]
801 fn rsync_argv_preserves_times_and_protects_the_target_dir() {
802 let argv = rsync_argv("/mnt/rdl/", Path::new("/home/op/.flodl/source"), None, None);
803 assert_eq!(argv[0], "rsync");
804 assert!(argv.contains(&"-a".to_string()));
806 assert!(argv.contains(&"--delete".to_string()));
807 assert!(argv.contains(&"--exclude=target/".to_string()));
811 assert!(!argv.contains(&"--exclude=/target/".to_string()));
812 assert!(argv.contains(&"--exclude=/libtorch/".to_string()));
815 assert_eq!(argv[argv.len() - 2], "/mnt/rdl/");
818 assert_eq!(argv[argv.len() - 1], "/home/op/.flodl/source/");
819 assert!(!argv.contains(&"-e".to_string()));
821 }
822
823 #[test]
824 fn rsync_argv_carries_the_ssh_hops_port_key_and_options() {
825 let ssh = SshConfig {
826 target: Some("exa".into()),
827 port: Some(22),
828 user: None,
829 identity_file: Some("/etc/flodl/join_key".into()),
830 options: vec!["StrictHostKeyChecking=accept-new".into()],
831 };
832 let target = SshTarget {
833 remote: "op@exa:/srv/train".into(),
834 port: Some(2222),
835 };
836 let argv = rsync_argv(
837 "op@exa:/srv/train/",
838 Path::new("/t"),
839 Some(&target),
840 Some(&ssh),
841 );
842 let e = argv
843 .iter()
844 .position(|a| a == "-e")
845 .expect("-e for a remote source");
846 let cmd = &argv[e + 1];
847 assert!(cmd.contains("-p 2222"), "got: {cmd}");
850 assert!(cmd.contains("-i /etc/flodl/join_key"), "got: {cmd}");
851 assert!(
852 cmd.contains("-o StrictHostKeyChecking=accept-new"),
853 "got: {cmd}"
854 );
855 assert!(cmd.contains("-o BatchMode=yes"), "got: {cmd}");
856 }
857
858 #[test]
865 fn a_refetch_keeps_an_old_mtime_and_a_nested_build() {
866 if !crate::util::system::has_command("rsync") {
867 return;
868 }
869 let base = std::env::temp_dir().join(format!("fdl-src-refetch-{}", std::process::id()));
870 let (src, dest) = (base.join("src"), base.join("dest"));
871 std::fs::create_dir_all(src.join("sub")).unwrap();
872 std::fs::write(src.join("sub/lib.rs"), "fn main() {}").unwrap();
873 std::fs::write(src.join("gone.txt"), "temporary").unwrap();
874 let old = std::time::SystemTime::now() - std::time::Duration::from_secs(86_400);
877 std::fs::File::options()
878 .write(true)
879 .open(src.join("sub/lib.rs"))
880 .unwrap()
881 .set_times(std::fs::FileTimes::new().set_modified(old))
882 .unwrap();
883
884 let source = Source::Local(src.clone());
885 materialize(&source, &dest, None, &mut Vec::new()).unwrap();
886 std::fs::create_dir_all(dest.join("sub/target/release")).unwrap();
889 std::fs::write(dest.join("sub/target/release/train"), "binary").unwrap();
890 std::fs::remove_file(src.join("gone.txt")).unwrap();
891
892 materialize(&source, &dest, None, &mut Vec::new()).unwrap();
893
894 assert!(
895 dest.join("sub/target/release/train").is_file(),
896 "the refetch deleted a nested build",
897 );
898 assert!(
899 !dest.join("gone.txt").exists(),
900 "--delete must drop a removed file"
901 );
902 let copied = std::fs::metadata(dest.join("sub/lib.rs"))
903 .unwrap()
904 .modified()
905 .unwrap();
906 let drift = copied.duration_since(old).unwrap_or_default();
907 assert!(
908 drift < std::time::Duration::from_secs(2),
909 "the fetch restamped the file ({drift:?} newer than the source)",
910 );
911 let _ = std::fs::remove_dir_all(&base);
912 }
913
914 #[test]
915 fn a_cwd_outside_the_fetched_tree_is_permanent() {
916 let tree = std::env::temp_dir().join("fdl-src-no-such-tree");
917 let err = build(&tree, Some("nope"), Some("true"), "x", &[], &mut Vec::new()).unwrap_err();
918 assert!(err.is_permanent(), "got: {err:?}");
919 assert!(err.message().contains("inside the tree"), "got: {err:?}");
920 }
921
922 #[test]
923 fn a_build_that_fails_is_transient_so_the_fleet_survives_a_typo() {
924 let dir = std::env::temp_dir().join(format!("fdl-src-build-{}", std::process::id()));
925 std::fs::create_dir_all(&dir).unwrap();
926 let err = build(&dir, None, Some("exit 3"), "bin", &[], &mut Vec::new()).unwrap_err();
927 assert!(
928 !err.is_permanent(),
929 "a compile error must not stop the box: {err:?}"
930 );
931 let _ = std::fs::remove_dir_all(&dir);
932 }
933
934 #[test]
935 fn a_build_that_produces_nothing_at_bin_is_permanent() {
936 let dir = std::env::temp_dir().join(format!("fdl-src-nobin-{}", std::process::id()));
939 std::fs::create_dir_all(&dir).unwrap();
940 let err = build(
941 &dir,
942 None,
943 Some("true"),
944 "target/release/x",
945 &[],
946 &mut Vec::new(),
947 )
948 .unwrap_err();
949 assert!(err.is_permanent(), "got: {err:?}");
950 assert!(err.message().contains("bin:"), "got: {err:?}");
951 let _ = std::fs::remove_dir_all(&dir);
952 }
953
954 #[test]
955 fn the_env_reaches_the_recipe_and_the_binary_is_returned() {
956 let dir = std::env::temp_dir().join(format!("fdl-src-env-{}", std::process::id()));
957 std::fs::create_dir_all(dir.join("sub")).unwrap();
958 let env = vec![("FDL_TEST_MARKER".to_string(), "ok".to_string())];
959 let built = build(
962 &dir,
963 Some("sub"),
964 Some("printf %s \"$FDL_TEST_MARKER\" > out"),
965 "out",
966 &env,
967 &mut Vec::new(),
968 )
969 .unwrap();
970 assert_eq!(built.cwd, dir.join("sub"));
971 assert_eq!(built.bin, dir.join("sub").join("out"));
972 assert_eq!(std::fs::read_to_string(&built.bin).unwrap(), "ok");
973 let _ = std::fs::remove_dir_all(&dir);
974 }
975}