Skip to main content

es_fluent_cli/generation/
runner.rs

1//! Shared functionality for creating and running the runner crate.
2//!
3//! The CLI uses a monolithic runner crate at workspace root that links ALL workspace
4//! crates to access their inventory registrations through a single binary.
5
6use 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
19/// Configuration derived from cargo metadata for temp crate generation.
20///
21/// This calls cargo_metadata once and extracts all needed information:
22/// - es-fluent dependency string
23/// - es-fluent-cli-helpers dependency string
24/// - target directory for sharing compiled dependencies
25pub 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    /// Create config by querying cargo metadata once, or from cache if valid.
33    pub fn from_manifest(manifest_path: &Path) -> Self {
34        use super::cache::MetadataCache;
35
36        // Check CARGO_TARGET_DIR env first (doesn't need metadata)
37        let target_dir_from_env = std::env::var("CARGO_TARGET_DIR").ok();
38
39        // Determine workspace root and temp directory for caching
40        let workspace_root = manifest_path.parent().unwrap_or(Path::new("."));
41        let temp_dir = get_es_fluent_temp_dir(workspace_root);
42
43        // Try to use cached metadata if Cargo.lock hasn't changed
44        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        // Cache miss or invalid, run cargo metadata
55        // Use no_deps() to skip full dependency resolution - we only need workspace packages
56        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        // Save to cache for next time
96        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    /// Fallback: find es-fluent from the CLI's own workspace (compile-time location)
125    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    /// Fallback: find es-fluent-cli-helpers from the CLI's own workspace (compile-time location)
140    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    /// Write the Cargo.toml for the runner crate.
169    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    /// Write the .cargo/config.toml for the runner crate.
175    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    /// Run `cargo run` on the runner crate.
183    ///
184    /// Returns the command stdout if cargo succeeds (captured to support diffs), or an error if it fails.
185    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        // Force colored output only if NO_COLOR is NOT set
200        if std::env::var("NO_COLOR").is_err() {
201            cmd.env("CLICOLOR_FORCE", "1");
202        }
203
204        // Capture stdout/stderr
205        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    /// Run `cargo run` on the runner crate and capture output.
216    ///
217    /// Returns the command output if successful, or an error with stderr if it fails.
218    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("--") // Add -- to pass args to the binary
232            .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
247/// Run `cargo run` on the runner crate.
248///
249/// Returns the command stdout if cargo succeeds (captured to support diffs), or an error if it fails.
250pub 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
254/// Run `cargo run` on the runner crate and capture output.
255///
256/// Returns the command output if successful, or an error with stderr if it fails.
257pub 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
265// --- Monolithic temp crate support ---
266
267use 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, // Treat as stale if we can't read metadata
290        };
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        // Compute current content hashes for each crate (including i18n.toml)
298        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        // Check runner cache
307        if let Some(cache) = RunnerCache::load(&self.temp_dir) {
308            // Check CLI version first - if upgraded, force rebuild to pick up helper changes
309            if cache.cli_version != CLI_VERSION {
310                return true;
311            }
312
313            if cache.runner_mtime == runner_mtime_secs {
314                // Runner hasn't been rebuilt - check if any crate content changed
315                for (name, current_hash) in &current_hashes {
316                    match cache.crate_hashes.get(name) {
317                        Some(cached_hash) if cached_hash == current_hash => continue,
318                        _ => return true, // Hash mismatch or new crate
319                    }
320                }
321                // Also check for removed crates
322                for cached_name in cache.crate_hashes.keys() {
323                    if !current_hashes.contains_key(cached_name) {
324                        return true;
325                    }
326                }
327                // All hashes match, runner is fresh
328                return false;
329            }
330
331            // Runner was rebuilt - update cache with current hashes and version
332            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        // No cache exists - be conservative and trigger rebuild
342        true
343    }
344}
345
346/// Prepare the monolithic runner crate at workspace root that links ALL workspace crates.
347/// This enables fast subsequent runs by caching a single binary that can access all inventory.
348pub 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    // Create .gitignore
356    fs::write(
357        temp_dir.join(".gitignore"),
358        GitignoreTemplate.render().unwrap(),
359    )
360    .context("Failed to write .es-fluent/.gitignore")?;
361
362    // Get es-fluent dependency info from workspace root
363    let root_manifest = workspace.root_dir.join("Cargo.toml");
364    let config = TempCrateConfig::from_manifest(&root_manifest);
365
366    // Build crate dependency list
367    let crate_deps: Vec<MonolithicCrateDep> = workspace
368        .crates
369        .iter()
370        .filter(|c| c.has_lib_rs) // Only crates with lib.rs can be linked
371        .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    // Write Cargo.toml
381    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    // Write .cargo/config.toml for target-dir
389    let config_toml = ConfigTomlTemplate {
390        target_dir: &config.target_dir,
391    };
392    runner_crate.write_cargo_config(&config_toml.render().unwrap())?;
393
394    // Write main.rs
395    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    // Copy workspace Cargo.lock to ensure identical dependency versions.
400    // The runner is a separate workspace (required to avoid "not a workspace member" errors),
401    // so we copy the lock file to get the same dependency resolution as the user's workspace.
402    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
412/// Get the path to the monolithic binary if it exists.
413pub fn get_monolithic_binary_path(workspace: &WorkspaceInfo) -> PathBuf {
414    workspace.target_dir.join("debug").join("es-fluent-runner")
415}
416
417/// Run the monolithic binary directly (fast path) or build+run (slow path).
418///
419/// If `force_run` is true, the staleness check is skipped and the runner is always rebuilt.
420pub 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 binary exists, check if it's stale (unless force_run is set)
430    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) // Put extra_args (including i18n_path) first
434                .arg("--crate")
435                .arg(crate_name)
436                .current_dir(&runner.temp_dir);
437
438        // Force colored output only if NO_COLOR is NOT set
439        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    // Otherwise, fall back to cargo run (will build)
454    // Args order: command, extra_args..., --crate, crate_name
455    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    // After successful cargo run, write runner cache with current per-crate hashes
462    {
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            // Compute per-crate content hashes (including i18n.toml)
474            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        // With fallback, should find local es-fluent from CLI workspace
567        // If running in CI or different environment, may still be crates.io
568        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        // With fallback, should find local es-fluent from CLI workspace
594        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        // No binary metadata available -> stale.
870        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}