1use crate::generation::templates::{
7 ConfigTomlTemplate, GitignoreTemplate, MonolithicCargoTomlTemplate, MonolithicCrateDep,
8 MonolithicMainRsTemplate,
9};
10use anyhow::{Context as _, Result, bail};
11use askama::Template as _;
12use es_fluent_derive_core::get_es_fluent_temp_dir;
13use std::path::{Path, PathBuf};
14use std::process::Command;
15use std::{env, fs};
16
17const CLI_VERSION: &str = env!("CARGO_PKG_VERSION");
18
19pub struct TempCrateConfig {
26 pub es_fluent_dep: String,
27 pub es_fluent_cli_helpers_dep: String,
28 pub target_dir: String,
29}
30
31impl TempCrateConfig {
32 pub fn from_manifest(manifest_path: &Path) -> Self {
34 use super::cache::MetadataCache;
35
36 let target_dir_from_env = std::env::var("CARGO_TARGET_DIR").ok();
38
39 let workspace_root = manifest_path.parent().unwrap_or(Path::new("."));
41 let temp_dir = get_es_fluent_temp_dir(workspace_root);
42
43 if let Some(cache) = MetadataCache::load(&temp_dir)
45 && cache.is_valid(workspace_root)
46 {
47 return Self {
48 es_fluent_dep: cache.es_fluent_dep,
49 es_fluent_cli_helpers_dep: cache.es_fluent_cli_helpers_dep,
50 target_dir: target_dir_from_env.unwrap_or(cache.target_dir),
51 };
52 }
53
54 let metadata = cargo_metadata::MetadataCommand::new()
57 .manifest_path(manifest_path)
58 .no_deps()
59 .exec()
60 .ok();
61
62 let (es_fluent_dep, es_fluent_cli_helpers_dep, target_dir) = match metadata {
63 Some(ref meta) => {
64 let es_fluent = Self::find_local_dep(meta, "es-fluent")
65 .or_else(Self::find_cli_workspace_dep_es_fluent)
66 .unwrap_or_else(|| format!(r#"es-fluent = {{ version = "{}" }}"#, CLI_VERSION));
67 let helpers = Self::find_local_dep(meta, "es-fluent-cli-helpers")
68 .or_else(Self::find_cli_workspace_dep_helpers)
69 .unwrap_or_else(|| {
70 format!(
71 r#"es-fluent-cli-helpers = {{ version = "{}" }}"#,
72 CLI_VERSION
73 )
74 });
75 let target = target_dir_from_env
76 .clone()
77 .unwrap_or_else(|| meta.target_directory.to_string());
78 (es_fluent, helpers, target)
79 },
80 None => (
81 Self::find_cli_workspace_dep_es_fluent()
82 .unwrap_or_else(|| format!(r#"es-fluent = {{ version = "{}" }}"#, CLI_VERSION)),
83 Self::find_cli_workspace_dep_helpers().unwrap_or_else(|| {
84 format!(
85 r#"es-fluent-cli-helpers = {{ version = "{}" }}"#,
86 CLI_VERSION
87 )
88 }),
89 target_dir_from_env
90 .clone()
91 .unwrap_or_else(|| "../target".to_string()),
92 ),
93 };
94
95 if let Some(cargo_lock_hash) = MetadataCache::hash_cargo_lock(workspace_root) {
97 let _ = std::fs::create_dir_all(&temp_dir);
98 let cache = MetadataCache {
99 cargo_lock_hash,
100 es_fluent_dep: es_fluent_dep.clone(),
101 es_fluent_cli_helpers_dep: es_fluent_cli_helpers_dep.clone(),
102 target_dir: target_dir.clone(),
103 };
104 let _ = cache.save(&temp_dir);
105 }
106
107 Self {
108 es_fluent_dep,
109 es_fluent_cli_helpers_dep,
110 target_dir,
111 }
112 }
113
114 fn find_local_dep(meta: &cargo_metadata::Metadata, crate_name: &str) -> Option<String> {
115 meta.packages
116 .iter()
117 .find(|p| p.name.as_str() == crate_name && p.source.is_none())
118 .map(|pkg| {
119 let path = pkg.manifest_path.parent().unwrap();
120 format!(r#"{} = {{ path = "{}" }}"#, crate_name, path)
121 })
122 }
123
124 fn find_cli_workspace_dep_es_fluent() -> Option<String> {
126 let cli_manifest_dir = env!("CARGO_MANIFEST_DIR");
127 let cli_path = Path::new(cli_manifest_dir);
128 let es_fluent_path = cli_path.parent()?.join("es-fluent");
129 if es_fluent_path.join("Cargo.toml").exists() {
130 Some(format!(
131 r#"es-fluent = {{ path = "{}" }}"#,
132 es_fluent_path.display()
133 ))
134 } else {
135 None
136 }
137 }
138
139 fn find_cli_workspace_dep_helpers() -> Option<String> {
141 let cli_manifest_dir = env!("CARGO_MANIFEST_DIR");
142 let cli_path = Path::new(cli_manifest_dir);
143 let helpers_path = cli_path.parent()?.join("es-fluent-cli-helpers");
144 if helpers_path.join("Cargo.toml").exists() {
145 Some(format!(
146 r#"es-fluent-cli-helpers = {{ path = "{}" }}"#,
147 helpers_path.display()
148 ))
149 } else {
150 None
151 }
152 }
153}
154
155struct RunnerCrate<'a> {
156 temp_dir: &'a Path,
157}
158
159impl RunnerCrate<'_> {
160 fn new(temp_dir: &Path) -> RunnerCrate<'_> {
161 RunnerCrate { temp_dir }
162 }
163
164 fn manifest_path(&self) -> PathBuf {
165 self.temp_dir.join("Cargo.toml")
166 }
167
168 fn write_cargo_toml(&self, cargo_toml_content: &str) -> Result<()> {
170 fs::write(self.temp_dir.join("Cargo.toml"), cargo_toml_content)
171 .context("Failed to write .es-fluent/Cargo.toml")
172 }
173
174 fn write_cargo_config(&self, config_content: &str) -> Result<()> {
176 let cargo_dir = self.temp_dir.join(".cargo");
177 fs::create_dir_all(&cargo_dir).context("Failed to create .es-fluent/.cargo directory")?;
178 fs::write(cargo_dir.join("config.toml"), config_content)
179 .context("Failed to write .es-fluent/.cargo/config.toml")
180 }
181
182 fn run_cargo(&self, bin_name: Option<&str>, args: &[String]) -> Result<String> {
186 let mut cmd = Command::new("cargo");
187 cmd.arg("run");
188 if let Some(bin) = bin_name {
189 cmd.arg("--bin").arg(bin);
190 }
191 cmd.arg("--manifest-path")
192 .arg(self.manifest_path())
193 .arg("--quiet")
194 .arg("--")
195 .args(args)
196 .current_dir(self.temp_dir)
197 .env("RUSTFLAGS", "-A warnings");
198
199 if std::env::var("NO_COLOR").is_err() {
201 cmd.env("CLICOLOR_FORCE", "1");
202 }
203
204 let output = cmd.output().context("Failed to run cargo")?;
206
207 if !output.status.success() {
208 let stderr = String::from_utf8_lossy(&output.stderr);
209 bail!("Cargo run failed: {}", stderr)
210 }
211
212 Ok(String::from_utf8_lossy(&output.stdout).to_string())
213 }
214
215 fn run_cargo_with_output(
219 &self,
220 bin_name: Option<&str>,
221 args: &[String],
222 ) -> Result<std::process::Output> {
223 let mut cmd = Command::new("cargo");
224 cmd.arg("run");
225 if let Some(bin) = bin_name {
226 cmd.arg("--bin").arg(bin);
227 }
228 cmd.arg("--manifest-path")
229 .arg(self.manifest_path())
230 .arg("--quiet")
231 .arg("--") .args(args)
233 .current_dir(self.temp_dir)
234 .env("RUSTFLAGS", "-A warnings");
235
236 let output = cmd.output().context("Failed to run cargo")?;
237
238 if output.status.success() {
239 Ok(output)
240 } else {
241 let stderr = String::from_utf8_lossy(&output.stderr);
242 bail!("Cargo run failed: {}", stderr)
243 }
244 }
245}
246
247pub fn run_cargo(temp_dir: &Path, bin_name: Option<&str>, args: &[String]) -> Result<String> {
251 RunnerCrate::new(temp_dir).run_cargo(bin_name, args)
252}
253
254pub fn run_cargo_with_output(
258 temp_dir: &Path,
259 bin_name: Option<&str>,
260 args: &[String],
261) -> Result<std::process::Output> {
262 RunnerCrate::new(temp_dir).run_cargo_with_output(bin_name, args)
263}
264
265use crate::core::WorkspaceInfo;
268
269struct MonolithicRunner<'a> {
270 workspace: &'a WorkspaceInfo,
271 temp_dir: PathBuf,
272 binary_path: PathBuf,
273}
274
275impl<'a> MonolithicRunner<'a> {
276 fn new(workspace: &'a WorkspaceInfo) -> Self {
277 Self {
278 workspace,
279 temp_dir: get_es_fluent_temp_dir(&workspace.root_dir),
280 binary_path: get_monolithic_binary_path(workspace),
281 }
282 }
283
284 fn is_stale(&self) -> bool {
285 use super::cache::{RunnerCache, compute_content_hash};
286
287 let runner_mtime = match fs::metadata(&self.binary_path).and_then(|m| m.modified()) {
288 Ok(t) => t,
289 Err(_) => return true, };
291
292 let runner_mtime_secs = runner_mtime
293 .duration_since(std::time::SystemTime::UNIX_EPOCH)
294 .map(|d| d.as_secs())
295 .unwrap_or(0);
296
297 let mut current_hashes = indexmap::IndexMap::new();
299 for krate in &self.workspace.crates {
300 if krate.src_dir.exists() {
301 let hash = compute_content_hash(&krate.src_dir, Some(&krate.i18n_config_path));
302 current_hashes.insert(krate.name.clone(), hash);
303 }
304 }
305
306 if let Some(cache) = RunnerCache::load(&self.temp_dir) {
308 if cache.cli_version != CLI_VERSION {
310 return true;
311 }
312
313 if cache.runner_mtime == runner_mtime_secs {
314 for (name, current_hash) in ¤t_hashes {
316 match cache.crate_hashes.get(name) {
317 Some(cached_hash) if cached_hash == current_hash => continue,
318 _ => return true, }
320 }
321 for cached_name in cache.crate_hashes.keys() {
323 if !current_hashes.contains_key(cached_name) {
324 return true;
325 }
326 }
327 return false;
329 }
330
331 let new_cache = RunnerCache {
333 crate_hashes: current_hashes,
334 runner_mtime: runner_mtime_secs,
335 cli_version: CLI_VERSION.to_string(),
336 };
337 let _ = new_cache.save(&self.temp_dir);
338 return false;
339 }
340
341 true
343 }
344}
345
346pub fn prepare_monolithic_runner_crate(workspace: &WorkspaceInfo) -> Result<PathBuf> {
349 let temp_dir = get_es_fluent_temp_dir(&workspace.root_dir);
350 let src_dir = temp_dir.join("src");
351 let runner_crate = RunnerCrate::new(&temp_dir);
352
353 fs::create_dir_all(&src_dir).context("Failed to create .es-fluent directory")?;
354
355 fs::write(
357 temp_dir.join(".gitignore"),
358 GitignoreTemplate.render().unwrap(),
359 )
360 .context("Failed to write .es-fluent/.gitignore")?;
361
362 let root_manifest = workspace.root_dir.join("Cargo.toml");
364 let config = TempCrateConfig::from_manifest(&root_manifest);
365
366 let crate_deps: Vec<MonolithicCrateDep> = workspace
368 .crates
369 .iter()
370 .filter(|c| c.has_lib_rs) .map(|c| MonolithicCrateDep {
372 name: &c.name,
373 path: c.manifest_dir.display().to_string(),
374 ident: c.name.replace('-', "_"),
375 has_features: !c.fluent_features.is_empty(),
376 features: &c.fluent_features,
377 })
378 .collect();
379
380 let cargo_toml = MonolithicCargoTomlTemplate {
382 crates: crate_deps.clone(),
383 es_fluent_dep: &config.es_fluent_dep,
384 es_fluent_cli_helpers_dep: &config.es_fluent_cli_helpers_dep,
385 };
386 runner_crate.write_cargo_toml(&cargo_toml.render().unwrap())?;
387
388 let config_toml = ConfigTomlTemplate {
390 target_dir: &config.target_dir,
391 };
392 runner_crate.write_cargo_config(&config_toml.render().unwrap())?;
393
394 let main_rs = MonolithicMainRsTemplate { crates: crate_deps };
396 fs::write(src_dir.join("main.rs"), main_rs.render().unwrap())
397 .context("Failed to write .es-fluent/src/main.rs")?;
398
399 let workspace_lock = workspace.root_dir.join("Cargo.lock");
403 let runner_lock = temp_dir.join("Cargo.lock");
404 if workspace_lock.exists() {
405 fs::copy(&workspace_lock, &runner_lock)
406 .context("Failed to copy Cargo.lock to .es-fluent/")?;
407 }
408
409 Ok(temp_dir)
410}
411
412pub fn get_monolithic_binary_path(workspace: &WorkspaceInfo) -> PathBuf {
414 workspace.target_dir.join("debug").join("es-fluent-runner")
415}
416
417pub fn run_monolithic(
421 workspace: &WorkspaceInfo,
422 command: &str,
423 crate_name: &str,
424 extra_args: &[String],
425 force_run: bool,
426) -> Result<String> {
427 let runner = MonolithicRunner::new(workspace);
428
429 if !force_run && runner.binary_path.exists() && !runner.is_stale() {
431 let mut cmd = Command::new(&runner.binary_path);
432 cmd.arg(command)
433 .args(extra_args) .arg("--crate")
435 .arg(crate_name)
436 .current_dir(&runner.temp_dir);
437
438 if std::env::var("NO_COLOR").is_err() {
440 cmd.env("CLICOLOR_FORCE", "1");
441 }
442
443 let output = cmd.output().context("Failed to run monolithic binary")?;
444
445 if !output.status.success() {
446 let stderr = String::from_utf8_lossy(&output.stderr);
447 bail!("Monolithic binary failed: {}", stderr);
448 }
449
450 return Ok(String::from_utf8_lossy(&output.stdout).to_string());
451 }
452
453 let mut args = vec![command.to_string()];
456 args.extend(extra_args.iter().cloned());
457 args.push("--crate".to_string());
458 args.push(crate_name.to_string());
459 let result = RunnerCrate::new(&runner.temp_dir).run_cargo(Some("es-fluent-runner"), &args)?;
460
461 {
463 use super::cache::{RunnerCache, compute_content_hash};
464
465 if let Ok(meta) = fs::metadata(&runner.binary_path)
466 && let Ok(mtime) = meta.modified()
467 {
468 let runner_mtime_secs = mtime
469 .duration_since(std::time::SystemTime::UNIX_EPOCH)
470 .map(|d| d.as_secs())
471 .unwrap_or(0);
472
473 let mut crate_hashes = indexmap::IndexMap::new();
475 for krate in &workspace.crates {
476 if krate.src_dir.exists() {
477 let hash = compute_content_hash(&krate.src_dir, Some(&krate.i18n_config_path));
478 crate_hashes.insert(krate.name.clone(), hash);
479 }
480 }
481
482 let cache = RunnerCache {
483 crate_hashes,
484 runner_mtime: runner_mtime_secs,
485 cli_version: CLI_VERSION.to_string(),
486 };
487 let _ = cache.save(&runner.temp_dir);
488 }
489 }
490
491 Ok(result)
492}
493
494#[cfg(test)]
495mod tests {
496 use super::*;
497 use crate::core::CrateInfo;
498 use crate::generation::cache::{MetadataCache, RunnerCache, compute_content_hash};
499 use std::io::Write;
500 use std::time::SystemTime;
501
502 #[cfg(unix)]
503 fn set_executable(path: &Path) {
504 use std::os::unix::fs::PermissionsExt;
505 let mut perms = std::fs::metadata(path).expect("metadata").permissions();
506 perms.set_mode(0o755);
507 std::fs::set_permissions(path, perms).expect("set executable");
508 }
509
510 #[cfg(not(unix))]
511 fn set_executable(_path: &Path) {}
512
513 fn create_workspace_fixture(
514 crate_name: &str,
515 has_lib_rs: bool,
516 ) -> (tempfile::TempDir, WorkspaceInfo) {
517 let temp = tempfile::tempdir().expect("tempdir");
518
519 std::fs::write(
520 temp.path().join("Cargo.toml"),
521 format!(
522 r#"[package]
523name = "{crate_name}"
524version = "0.1.0"
525edition = "2024"
526"#
527 ),
528 )
529 .expect("write Cargo.toml");
530
531 let src_dir = temp.path().join("src");
532 std::fs::create_dir_all(&src_dir).expect("create src");
533 if has_lib_rs {
534 std::fs::write(src_dir.join("lib.rs"), "pub struct Demo;\n").expect("write lib.rs");
535 }
536
537 let i18n_config_path = temp.path().join("i18n.toml");
538 std::fs::write(
539 &i18n_config_path,
540 "fallback_language = \"en\"\nassets_dir = \"i18n\"\n",
541 )
542 .expect("write i18n.toml");
543
544 let krate = CrateInfo {
545 name: crate_name.to_string(),
546 manifest_dir: temp.path().to_path_buf(),
547 src_dir,
548 i18n_config_path,
549 ftl_output_dir: temp.path().join("i18n/en"),
550 has_lib_rs,
551 fluent_features: Vec::new(),
552 };
553
554 let workspace = WorkspaceInfo {
555 root_dir: temp.path().to_path_buf(),
556 target_dir: temp.path().join("target"),
557 crates: vec![krate],
558 };
559
560 (temp, workspace)
561 }
562
563 #[test]
564 fn test_temp_crate_config_nonexistent_manifest() {
565 let config = TempCrateConfig::from_manifest(Path::new("/nonexistent/Cargo.toml"));
566 assert!(config.es_fluent_dep.contains("es-fluent"));
569 }
570
571 #[test]
572 fn test_temp_crate_config_non_workspace_member() {
573 let temp_dir = tempfile::tempdir().unwrap();
574 let manifest_path = temp_dir.path().join("Cargo.toml");
575
576 let cargo_toml = r#"
577[package]
578name = "test-crate"
579version = "0.1.0"
580edition = "2024"
581
582[dependencies]
583es-fluent = { version = "*" }
584"#;
585 let mut file = fs::File::create(&manifest_path).unwrap();
586 file.write_all(cargo_toml.as_bytes()).unwrap();
587
588 let src_dir = temp_dir.path().join("src");
589 fs::create_dir_all(&src_dir).unwrap();
590 fs::write(src_dir.join("lib.rs"), "").unwrap();
591
592 let config = TempCrateConfig::from_manifest(&manifest_path);
593 assert!(config.es_fluent_dep.contains("es-fluent"));
595 }
596
597 #[test]
598 fn temp_crate_config_uses_valid_cached_metadata() {
599 let temp = tempfile::tempdir().expect("tempdir");
600 let manifest_path = temp.path().join("Cargo.toml");
601 std::fs::write(
602 &manifest_path,
603 r#"[package]
604name = "cached"
605version = "0.1.0"
606edition = "2024"
607"#,
608 )
609 .expect("write Cargo.toml");
610 std::fs::write(temp.path().join("Cargo.lock"), "lock").expect("write Cargo.lock");
611
612 let temp_dir = es_fluent_derive_core::get_es_fluent_temp_dir(temp.path());
613 std::fs::create_dir_all(&temp_dir).expect("create .es-fluent");
614 MetadataCache {
615 cargo_lock_hash: MetadataCache::hash_cargo_lock(temp.path()).expect("hash lock"),
616 es_fluent_dep: "es-fluent = { path = \"/tmp/es\" }".to_string(),
617 es_fluent_cli_helpers_dep: "es-fluent-cli-helpers = { path = \"/tmp/helpers\" }"
618 .to_string(),
619 target_dir: "/tmp/target".to_string(),
620 }
621 .save(&temp_dir)
622 .expect("save metadata cache");
623
624 let config = TempCrateConfig::from_manifest(&manifest_path);
625 assert_eq!(config.es_fluent_dep, "es-fluent = { path = \"/tmp/es\" }");
626 assert_eq!(
627 config.es_fluent_cli_helpers_dep,
628 "es-fluent-cli-helpers = { path = \"/tmp/helpers\" }"
629 );
630 assert_eq!(config.target_dir, "/tmp/target");
631 }
632
633 #[test]
634 fn temp_crate_config_writes_metadata_cache_when_lock_exists() {
635 let temp = tempfile::tempdir().expect("tempdir");
636 let manifest_path = temp.path().join("Cargo.toml");
637 std::fs::write(
638 &manifest_path,
639 r#"[package]
640name = "cache-write"
641version = "0.1.0"
642edition = "2024"
643"#,
644 )
645 .expect("write Cargo.toml");
646 std::fs::write(temp.path().join("Cargo.lock"), "lock-content").expect("write lock");
647
648 let _ = TempCrateConfig::from_manifest(&manifest_path);
649 let temp_dir = es_fluent_derive_core::get_es_fluent_temp_dir(temp.path());
650 let cache = MetadataCache::load(&temp_dir);
651 assert!(cache.is_some(), "metadata cache should be written");
652 }
653
654 #[test]
655 fn runner_crate_writes_manifest_and_config_files() {
656 let temp = tempfile::tempdir().expect("tempdir");
657 let runner = RunnerCrate::new(temp.path());
658
659 let manifest = runner.manifest_path();
660 assert_eq!(manifest, temp.path().join("Cargo.toml"));
661
662 runner
663 .write_cargo_toml("[package]\nname = \"runner\"\nversion = \"0.1.0\"\n")
664 .expect("write Cargo.toml");
665 runner
666 .write_cargo_config("[build]\ntarget-dir = \"../target\"\n")
667 .expect("write config.toml");
668
669 assert!(temp.path().join("Cargo.toml").exists());
670 assert!(temp.path().join(".cargo/config.toml").exists());
671 }
672
673 #[test]
674 fn prepare_monolithic_runner_crate_writes_expected_files() {
675 let (_temp, workspace) = create_workspace_fixture("test-runner", true);
676
677 let runner_dir = prepare_monolithic_runner_crate(&workspace).expect("prepare runner");
678 assert!(runner_dir.join("Cargo.toml").exists());
679 assert!(runner_dir.join("src/main.rs").exists());
680 assert!(runner_dir.join(".cargo/config.toml").exists());
681 assert!(runner_dir.join(".gitignore").exists());
682 }
683
684 #[test]
685 fn monolithic_runner_staleness_detects_hash_changes() {
686 let (_temp, workspace) = create_workspace_fixture("stale-check", true);
687 let runner = MonolithicRunner::new(&workspace);
688 std::fs::create_dir_all(runner.binary_path.parent().expect("binary parent"))
689 .expect("create binary dir");
690 std::fs::create_dir_all(&runner.temp_dir).expect("create temp dir");
691
692 std::fs::write(&runner.binary_path, "#!/bin/sh\necho monolithic-runner\n")
693 .expect("write fake runner");
694 set_executable(&runner.binary_path);
695
696 let mtime = std::fs::metadata(&runner.binary_path)
697 .and_then(|m| m.modified())
698 .expect("runner mtime")
699 .duration_since(SystemTime::UNIX_EPOCH)
700 .expect("mtime duration")
701 .as_secs();
702
703 let krate = &workspace.crates[0];
704 let hash = compute_content_hash(&krate.src_dir, Some(&krate.i18n_config_path));
705 let mut crate_hashes = indexmap::IndexMap::new();
706 crate_hashes.insert(krate.name.clone(), hash);
707 RunnerCache {
708 crate_hashes,
709 runner_mtime: mtime,
710 cli_version: CLI_VERSION.to_string(),
711 }
712 .save(&runner.temp_dir)
713 .expect("save cache");
714
715 assert!(!runner.is_stale(), "cache should mark runner as fresh");
716
717 std::fs::write(krate.src_dir.join("lib.rs"), "pub struct Changed;\n").expect("rewrite src");
718 assert!(runner.is_stale(), "content change should mark runner stale");
719 }
720
721 #[test]
722 fn run_monolithic_uses_fast_path_binary_when_cache_is_fresh() {
723 let (_temp, workspace) = create_workspace_fixture("fast-path", true);
724 let runner = MonolithicRunner::new(&workspace);
725 std::fs::create_dir_all(runner.binary_path.parent().expect("binary parent"))
726 .expect("create binary dir");
727 std::fs::create_dir_all(&runner.temp_dir).expect("create temp dir");
728
729 std::fs::write(&runner.binary_path, "#!/bin/sh\necho \"$@\"\n").expect("write fake runner");
730 set_executable(&runner.binary_path);
731
732 let mtime = std::fs::metadata(&runner.binary_path)
733 .and_then(|m| m.modified())
734 .expect("runner mtime")
735 .duration_since(SystemTime::UNIX_EPOCH)
736 .expect("mtime duration")
737 .as_secs();
738
739 let krate = &workspace.crates[0];
740 let hash = compute_content_hash(&krate.src_dir, Some(&krate.i18n_config_path));
741 let mut crate_hashes = indexmap::IndexMap::new();
742 crate_hashes.insert(krate.name.clone(), hash);
743 RunnerCache {
744 crate_hashes,
745 runner_mtime: mtime,
746 cli_version: CLI_VERSION.to_string(),
747 }
748 .save(&runner.temp_dir)
749 .expect("save cache");
750
751 let output = run_monolithic(
752 &workspace,
753 "generate",
754 &krate.name,
755 &["--dry-run".to_string()],
756 false,
757 )
758 .expect("run monolithic");
759
760 assert!(
761 output.contains("generate --dry-run --crate fast-path"),
762 "unexpected fast-path output: {output}"
763 );
764 }
765
766 #[test]
767 fn run_monolithic_fast_path_reports_binary_failure() {
768 let (_temp, workspace) = create_workspace_fixture("fast-fail", true);
769 let runner = MonolithicRunner::new(&workspace);
770 std::fs::create_dir_all(runner.binary_path.parent().expect("binary parent"))
771 .expect("create binary dir");
772 std::fs::create_dir_all(&runner.temp_dir).expect("create temp dir");
773
774 std::fs::write(&runner.binary_path, "#!/bin/sh\necho boom 1>&2\nexit 1\n")
775 .expect("write failing runner");
776 set_executable(&runner.binary_path);
777
778 let mtime = std::fs::metadata(&runner.binary_path)
779 .and_then(|m| m.modified())
780 .expect("runner mtime")
781 .duration_since(SystemTime::UNIX_EPOCH)
782 .expect("mtime duration")
783 .as_secs();
784
785 let krate = &workspace.crates[0];
786 let hash = compute_content_hash(&krate.src_dir, Some(&krate.i18n_config_path));
787 let mut crate_hashes = indexmap::IndexMap::new();
788 crate_hashes.insert(krate.name.clone(), hash);
789 RunnerCache {
790 crate_hashes,
791 runner_mtime: mtime,
792 cli_version: CLI_VERSION.to_string(),
793 }
794 .save(&runner.temp_dir)
795 .expect("save cache");
796
797 let err = run_monolithic(&workspace, "generate", &krate.name, &[], false)
798 .err()
799 .expect("expected fast-path failure");
800 let msg = err.to_string();
801 assert!(
802 msg.contains("Monolithic binary failed")
803 || msg.contains("Failed to run monolithic binary"),
804 "unexpected error: {msg}"
805 );
806 }
807
808 #[test]
809 fn run_cargo_helpers_execute_simple_temp_crate() {
810 let temp = tempfile::tempdir().expect("tempdir");
811 std::fs::create_dir_all(temp.path().join("src")).expect("create src");
812 std::fs::write(
813 temp.path().join("Cargo.toml"),
814 r#"[package]
815name = "runner-test"
816version = "0.1.0"
817edition = "2024"
818"#,
819 )
820 .expect("write Cargo.toml");
821 std::fs::write(
822 temp.path().join("src/main.rs"),
823 r#"fn main() {
824 let args: Vec<String> = std::env::args().skip(1).collect();
825 println!("{}", args.join(" "));
826}
827"#,
828 )
829 .expect("write main.rs");
830
831 let output = run_cargo(temp.path(), None, &["hello".to_string()]).expect("run cargo");
832 assert!(output.contains("hello"));
833
834 let output = run_cargo_with_output(temp.path(), None, &["world".to_string()])
835 .expect("run cargo with output");
836 let stdout = String::from_utf8_lossy(&output.stdout);
837 assert!(stdout.contains("world"));
838
839 let output = run_cargo_with_output(temp.path(), Some("runner-test"), &["bin".to_string()])
840 .expect("run named bin");
841 let stdout = String::from_utf8_lossy(&output.stdout);
842 assert!(stdout.contains("bin"));
843
844 let err = run_cargo(temp.path(), Some("missing-bin"), &[])
845 .err()
846 .expect("missing bin should fail");
847 assert!(err.to_string().contains("Cargo run failed"));
848
849 let err = run_cargo_with_output(temp.path(), Some("missing-bin"), &[])
850 .err()
851 .expect("missing bin should fail");
852 assert!(err.to_string().contains("Cargo run failed"));
853 }
854
855 #[test]
856 fn create_workspace_fixture_without_lib_skips_lib_file_creation() {
857 let (_temp, workspace) = create_workspace_fixture("no-lib-fixture", false);
858 assert!(
859 !workspace.crates[0].src_dir.join("lib.rs").exists(),
860 "lib.rs should not be created when has_lib_rs is false"
861 );
862 }
863
864 #[test]
865 fn monolithic_runner_staleness_handles_missing_cache_and_metadata_variants() {
866 let (_temp, workspace) = create_workspace_fixture("stale-variants", true);
867 let runner = MonolithicRunner::new(&workspace);
868
869 assert!(runner.is_stale());
871
872 std::fs::create_dir_all(runner.binary_path.parent().expect("binary parent"))
873 .expect("create binary dir");
874 std::fs::create_dir_all(&runner.temp_dir).expect("create temp dir");
875 std::fs::write(&runner.binary_path, "#!/bin/sh\necho ok\n").expect("write fake runner");
876 set_executable(&runner.binary_path);
877
878 let mtime = std::fs::metadata(&runner.binary_path)
879 .and_then(|m| m.modified())
880 .expect("runner mtime")
881 .duration_since(SystemTime::UNIX_EPOCH)
882 .expect("mtime duration")
883 .as_secs();
884
885 let krate = &workspace.crates[0];
886 let hash = compute_content_hash(&krate.src_dir, Some(&krate.i18n_config_path));
887 let mut crate_hashes = indexmap::IndexMap::new();
888 crate_hashes.insert(krate.name.clone(), hash);
889 RunnerCache {
890 crate_hashes: crate_hashes.clone(),
891 runner_mtime: mtime,
892 cli_version: "0.0.0".to_string(),
893 }
894 .save(&runner.temp_dir)
895 .expect("save old-version cache");
896 assert!(runner.is_stale(), "version mismatch should be stale");
897
898 crate_hashes.insert("removed-crate".to_string(), "abc".to_string());
899 RunnerCache {
900 crate_hashes,
901 runner_mtime: mtime,
902 cli_version: CLI_VERSION.to_string(),
903 }
904 .save(&runner.temp_dir)
905 .expect("save removed-crate cache");
906 assert!(runner.is_stale(), "removed crate should be stale");
907 }
908
909 #[test]
910 fn monolithic_runner_staleness_updates_cache_when_mtime_changes() {
911 let (_temp, workspace) = create_workspace_fixture("mtime-refresh", true);
912 let runner = MonolithicRunner::new(&workspace);
913 std::fs::create_dir_all(runner.binary_path.parent().expect("binary parent"))
914 .expect("create binary dir");
915 std::fs::create_dir_all(&runner.temp_dir).expect("create temp dir");
916 std::fs::write(&runner.binary_path, "#!/bin/sh\necho ok\n").expect("write fake runner");
917 set_executable(&runner.binary_path);
918
919 let current_mtime = std::fs::metadata(&runner.binary_path)
920 .and_then(|m| m.modified())
921 .expect("runner mtime")
922 .duration_since(SystemTime::UNIX_EPOCH)
923 .expect("mtime duration")
924 .as_secs();
925 let krate = &workspace.crates[0];
926 let hash = compute_content_hash(&krate.src_dir, Some(&krate.i18n_config_path));
927 let mut crate_hashes = indexmap::IndexMap::new();
928 crate_hashes.insert(krate.name.clone(), hash);
929 RunnerCache {
930 crate_hashes,
931 runner_mtime: current_mtime.saturating_sub(1),
932 cli_version: CLI_VERSION.to_string(),
933 }
934 .save(&runner.temp_dir)
935 .expect("save stale-mtime cache");
936
937 assert!(
938 !runner.is_stale(),
939 "mtime mismatch should refresh cache and stay fresh"
940 );
941 let updated = RunnerCache::load(&runner.temp_dir).expect("load updated cache");
942 assert_eq!(updated.runner_mtime, current_mtime);
943 }
944
945 #[test]
946 fn prepare_monolithic_runner_crate_copies_workspace_lock_file() {
947 let (_temp, workspace) = create_workspace_fixture("lock-copy", true);
948 std::fs::write(workspace.root_dir.join("Cargo.lock"), "workspace-lock")
949 .expect("write workspace lock");
950
951 let runner_dir = prepare_monolithic_runner_crate(&workspace).expect("prepare runner");
952 assert!(runner_dir.join("Cargo.lock").exists());
953 }
954
955 #[cfg(unix)]
956 #[test]
957 fn run_monolithic_fast_path_surfaces_execution_errors() {
958 let (_temp, workspace) = create_workspace_fixture("fast-exec-error", true);
959 let runner = MonolithicRunner::new(&workspace);
960 std::fs::create_dir_all(runner.binary_path.parent().expect("binary parent"))
961 .expect("create binary dir");
962 std::fs::create_dir_all(&runner.temp_dir).expect("create temp dir");
963
964 std::fs::write(&runner.binary_path, "not executable").expect("write non-executable file");
965
966 let mtime = std::fs::metadata(&runner.binary_path)
967 .and_then(|m| m.modified())
968 .expect("runner mtime")
969 .duration_since(SystemTime::UNIX_EPOCH)
970 .expect("mtime duration")
971 .as_secs();
972 let krate = &workspace.crates[0];
973 let hash = compute_content_hash(&krate.src_dir, Some(&krate.i18n_config_path));
974 let mut crate_hashes = indexmap::IndexMap::new();
975 crate_hashes.insert(krate.name.clone(), hash);
976 RunnerCache {
977 crate_hashes,
978 runner_mtime: mtime,
979 cli_version: CLI_VERSION.to_string(),
980 }
981 .save(&runner.temp_dir)
982 .expect("save cache");
983
984 let err = run_monolithic(&workspace, "generate", &krate.name, &[], false)
985 .err()
986 .expect("expected execution failure");
987 assert!(err.to_string().contains("Failed to run monolithic binary"));
988 }
989
990 #[test]
991 fn run_monolithic_force_run_uses_slow_path_and_writes_runner_cache() {
992 let (_temp, workspace) = create_workspace_fixture("slow-path", true);
993 let runner_dir = es_fluent_derive_core::get_es_fluent_temp_dir(&workspace.root_dir);
994 std::fs::create_dir_all(runner_dir.join("src")).expect("create runner src");
995 std::fs::write(
996 runner_dir.join("Cargo.toml"),
997 r#"[package]
998name = "dummy-runner"
999version = "0.1.0"
1000edition = "2024"
1001
1002[[bin]]
1003name = "es-fluent-runner"
1004path = "src/main.rs"
1005"#,
1006 )
1007 .expect("write runner Cargo.toml");
1008 std::fs::write(
1009 runner_dir.join("src/main.rs"),
1010 r#"fn main() {
1011 let args: Vec<String> = std::env::args().skip(1).collect();
1012 println!("{}", args.join(" "));
1013}
1014"#,
1015 )
1016 .expect("write runner main.rs");
1017
1018 let binary_path = workspace.target_dir.join("debug/es-fluent-runner");
1019 std::fs::create_dir_all(binary_path.parent().unwrap()).expect("create target/debug");
1020 std::fs::write(&binary_path, "#!/bin/sh\necho cache-metadata\n").expect("write binary");
1021 set_executable(&binary_path);
1022
1023 let output = run_monolithic(
1024 &workspace,
1025 "generate",
1026 &workspace.crates[0].name,
1027 &["--dry-run".to_string()],
1028 true,
1029 )
1030 .expect("slow path run should succeed");
1031 assert!(
1032 output.contains("generate --dry-run --crate slow-path"),
1033 "unexpected slow-path output: {output}"
1034 );
1035
1036 let cache = RunnerCache::load(&runner_dir).expect("runner cache should be written");
1037 assert!(cache.crate_hashes.contains_key("slow-path"));
1038 }
1039}