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
155/// Write the Cargo.toml for the runner crate.
156fn write_cargo_toml(temp_dir: &Path, cargo_toml_content: &str) -> Result<()> {
157    fs::write(temp_dir.join("Cargo.toml"), cargo_toml_content)
158        .context("Failed to write .es-fluent/Cargo.toml")
159}
160
161/// Write the .cargo/config.toml for the runner crate.
162fn write_cargo_config(temp_dir: &Path, config_content: &str) -> Result<()> {
163    let cargo_dir = temp_dir.join(".cargo");
164    fs::create_dir_all(&cargo_dir).context("Failed to create .es-fluent/.cargo directory")?;
165    fs::write(cargo_dir.join("config.toml"), config_content)
166        .context("Failed to write .es-fluent/.cargo/config.toml")
167}
168
169/// Run `cargo run` on the runner crate.
170///
171/// Returns the command stdout if cargo succeeds (captured to support diffs), or an error if it fails.
172pub fn run_cargo(temp_dir: &Path, bin_name: Option<&str>, args: &[String]) -> Result<String> {
173    let manifest_path = temp_dir.join("Cargo.toml");
174
175    let mut cmd = Command::new("cargo");
176    cmd.arg("run");
177    if let Some(bin) = bin_name {
178        cmd.arg("--bin").arg(bin);
179    }
180    cmd.arg("--manifest-path")
181        .arg(&manifest_path)
182        .arg("--quiet")
183        .arg("--")
184        .args(args)
185        .current_dir(temp_dir)
186        .env("RUSTFLAGS", "-A warnings");
187
188    // Force colored output only if NO_COLOR is NOT set
189    if std::env::var("NO_COLOR").is_err() {
190        cmd.env("CLICOLOR_FORCE", "1");
191    }
192
193    // Capture stdout/stderr
194    let output = cmd.output().context("Failed to run cargo")?;
195
196    if !output.status.success() {
197        let stderr = String::from_utf8_lossy(&output.stderr);
198        bail!("Cargo run failed: {}", stderr)
199    }
200
201    Ok(String::from_utf8_lossy(&output.stdout).to_string())
202}
203
204/// Run `cargo run` on the runner crate and capture output.
205///
206/// Returns the command output if successful, or an error with stderr if it fails.
207pub fn run_cargo_with_output(
208    temp_dir: &Path,
209    bin_name: Option<&str>,
210    args: &[String],
211) -> Result<std::process::Output> {
212    let manifest_path = temp_dir.join("Cargo.toml");
213
214    let mut cmd = Command::new("cargo");
215    cmd.arg("run");
216    if let Some(bin) = bin_name {
217        cmd.arg("--bin").arg(bin);
218    }
219    cmd.arg("--manifest-path")
220        .arg(&manifest_path)
221        .arg("--quiet")
222        .arg("--") // Add -- to pass args to the binary
223        .args(args)
224        .current_dir(temp_dir)
225        .env("RUSTFLAGS", "-A warnings");
226
227    let output = cmd.output().context("Failed to run cargo")?;
228
229    if output.status.success() {
230        Ok(output)
231    } else {
232        let stderr = String::from_utf8_lossy(&output.stderr);
233        bail!("Cargo run failed: {}", stderr)
234    }
235}
236
237// --- Monolithic temp crate support ---
238
239use crate::core::WorkspaceInfo;
240
241/// Prepare the monolithic runner crate at workspace root that links ALL workspace crates.
242/// This enables fast subsequent runs by caching a single binary that can access all inventory.
243pub fn prepare_monolithic_runner_crate(workspace: &WorkspaceInfo) -> Result<PathBuf> {
244    let temp_dir = get_es_fluent_temp_dir(&workspace.root_dir);
245    let src_dir = temp_dir.join("src");
246
247    fs::create_dir_all(&src_dir).context("Failed to create .es-fluent directory")?;
248
249    // Create .gitignore
250    fs::write(
251        temp_dir.join(".gitignore"),
252        GitignoreTemplate.render().unwrap(),
253    )
254    .context("Failed to write .es-fluent/.gitignore")?;
255
256    // Get es-fluent dependency info from workspace root
257    let root_manifest = workspace.root_dir.join("Cargo.toml");
258    let config = TempCrateConfig::from_manifest(&root_manifest);
259
260    // Build crate dependency list
261    let crate_deps: Vec<MonolithicCrateDep> = workspace
262        .crates
263        .iter()
264        .filter(|c| c.has_lib_rs) // Only crates with lib.rs can be linked
265        .map(|c| MonolithicCrateDep {
266            name: &c.name,
267            path: c.manifest_dir.display().to_string(),
268            ident: c.name.replace('-', "_"),
269            has_features: !c.fluent_features.is_empty(),
270            features: &c.fluent_features,
271        })
272        .collect();
273
274    // Write Cargo.toml
275    let cargo_toml = MonolithicCargoTomlTemplate {
276        crates: crate_deps.clone(),
277        es_fluent_dep: &config.es_fluent_dep,
278        es_fluent_cli_helpers_dep: &config.es_fluent_cli_helpers_dep,
279    };
280    write_cargo_toml(&temp_dir, &cargo_toml.render().unwrap())?;
281
282    // Write .cargo/config.toml for target-dir
283    let config_toml = ConfigTomlTemplate {
284        target_dir: &config.target_dir,
285    };
286    write_cargo_config(&temp_dir, &config_toml.render().unwrap())?;
287
288    // Write main.rs
289    let main_rs = MonolithicMainRsTemplate { crates: crate_deps };
290    fs::write(src_dir.join("main.rs"), main_rs.render().unwrap())
291        .context("Failed to write .es-fluent/src/main.rs")?;
292
293    // Copy workspace Cargo.lock to ensure identical dependency versions.
294    // The runner is a separate workspace (required to avoid "not a workspace member" errors),
295    // so we copy the lock file to get the same dependency resolution as the user's workspace.
296    let workspace_lock = workspace.root_dir.join("Cargo.lock");
297    let runner_lock = temp_dir.join("Cargo.lock");
298    if workspace_lock.exists() {
299        fs::copy(&workspace_lock, &runner_lock)
300            .context("Failed to copy Cargo.lock to .es-fluent/")?;
301    }
302
303    Ok(temp_dir)
304}
305
306/// Get the path to the monolithic binary if it exists.
307pub fn get_monolithic_binary_path(workspace: &WorkspaceInfo) -> PathBuf {
308    workspace.target_dir.join("debug").join("es-fluent-runner")
309}
310
311/// Run the monolithic binary directly (fast path) or build+run (slow path).
312///
313/// If `force_run` is true, the staleness check is skipped and the runner is always rebuilt.
314pub fn run_monolithic(
315    workspace: &WorkspaceInfo,
316    command: &str,
317    crate_name: &str,
318    extra_args: &[String],
319    force_run: bool,
320) -> Result<String> {
321    let temp_dir = get_es_fluent_temp_dir(&workspace.root_dir);
322    let binary_path = get_monolithic_binary_path(workspace);
323
324    // If binary exists, check if it's stale (unless force_run is set)
325    if !force_run && binary_path.exists() && !is_runner_stale(workspace, &binary_path) {
326        let mut cmd = Command::new(&binary_path);
327        cmd.arg(command)
328                .args(extra_args) // Put extra_args (including i18n_path) first
329                .arg("--crate")
330                .arg(crate_name)
331                .current_dir(&temp_dir);
332
333        // Force colored output only if NO_COLOR is NOT set
334        if std::env::var("NO_COLOR").is_err() {
335            cmd.env("CLICOLOR_FORCE", "1");
336        }
337
338        let output = cmd.output().context("Failed to run monolithic binary")?;
339
340        if !output.status.success() {
341            let stderr = String::from_utf8_lossy(&output.stderr);
342            bail!("Monolithic binary failed: {}", stderr);
343        }
344
345        return Ok(String::from_utf8_lossy(&output.stdout).to_string());
346    }
347
348    // Otherwise, fall back to cargo run (will build)
349    // Args order: command, extra_args..., --crate, crate_name
350    let mut args = vec![command.to_string()];
351    args.extend(extra_args.iter().cloned());
352    args.push("--crate".to_string());
353    args.push(crate_name.to_string());
354    let result = run_cargo(&temp_dir, Some("es-fluent-runner"), &args)?;
355
356    // After successful cargo run, write runner cache with current per-crate hashes
357    {
358        use super::cache::{RunnerCache, compute_content_hash};
359
360        let binary_path = get_monolithic_binary_path(workspace);
361        if let Ok(meta) = fs::metadata(&binary_path)
362            && let Ok(mtime) = meta.modified()
363        {
364            let runner_mtime_secs = mtime
365                .duration_since(std::time::SystemTime::UNIX_EPOCH)
366                .map(|d| d.as_secs())
367                .unwrap_or(0);
368
369            // Compute per-crate content hashes (including i18n.toml)
370            let mut crate_hashes = indexmap::IndexMap::new();
371            for krate in &workspace.crates {
372                if krate.src_dir.exists() {
373                    let hash = compute_content_hash(&krate.src_dir, Some(&krate.i18n_config_path));
374                    crate_hashes.insert(krate.name.clone(), hash);
375                }
376            }
377
378            let cache = RunnerCache {
379                crate_hashes,
380                runner_mtime: runner_mtime_secs,
381                cli_version: CLI_VERSION.to_string(),
382            };
383            let _ = cache.save(&temp_dir);
384        }
385    }
386
387    Ok(result)
388}
389
390/// Check if the runner binary is stale compared to workspace source files.
391///
392/// Uses per-crate blake3 content hashing to detect actual changes - saving a file
393/// without modifications won't trigger a rebuild. Hashes are stored in runner_cache.json.
394///
395/// Also checks CLI version - if the CLI was upgraded, the runner needs to be rebuilt
396/// to pick up changes in es-fluent-cli-helpers.
397fn is_runner_stale(workspace: &WorkspaceInfo, runner_path: &Path) -> bool {
398    use super::cache::{RunnerCache, compute_content_hash};
399
400    let runner_mtime = match fs::metadata(runner_path).and_then(|m| m.modified()) {
401        Ok(t) => t,
402        Err(_) => return true, // Treat as stale if we can't read metadata
403    };
404
405    let runner_mtime_secs = runner_mtime
406        .duration_since(std::time::SystemTime::UNIX_EPOCH)
407        .map(|d| d.as_secs())
408        .unwrap_or(0);
409
410    let temp_dir = get_es_fluent_temp_dir(&workspace.root_dir);
411
412    // Compute current content hashes for each crate (including i18n.toml)
413    let mut current_hashes = indexmap::IndexMap::new();
414    for krate in &workspace.crates {
415        if krate.src_dir.exists() {
416            let hash = compute_content_hash(&krate.src_dir, Some(&krate.i18n_config_path));
417            current_hashes.insert(krate.name.clone(), hash);
418        }
419    }
420
421    // Check runner cache
422    if let Some(cache) = RunnerCache::load(&temp_dir) {
423        // Check CLI version first - if upgraded, force rebuild to pick up helper changes
424        if cache.cli_version != CLI_VERSION {
425            return true;
426        }
427
428        if cache.runner_mtime == runner_mtime_secs {
429            // Runner hasn't been rebuilt - check if any crate content changed
430            for (name, current_hash) in &current_hashes {
431                match cache.crate_hashes.get(name) {
432                    Some(cached_hash) if cached_hash == current_hash => continue,
433                    _ => return true, // Hash mismatch or new crate
434                }
435            }
436            // Also check for removed crates
437            for cached_name in cache.crate_hashes.keys() {
438                if !current_hashes.contains_key(cached_name) {
439                    return true;
440                }
441            }
442            // All hashes match, runner is fresh
443            return false;
444        }
445
446        // Runner was rebuilt - update cache with current hashes and version
447        let new_cache = RunnerCache {
448            crate_hashes: current_hashes,
449            runner_mtime: runner_mtime_secs,
450            cli_version: CLI_VERSION.to_string(),
451        };
452        let _ = new_cache.save(&temp_dir);
453        return false;
454    }
455
456    // No cache exists - be conservative and trigger rebuild
457    true
458}
459
460#[cfg(test)]
461mod tests {
462    use super::*;
463    use std::io::Write;
464
465    #[test]
466    fn test_temp_crate_config_nonexistent_manifest() {
467        let config = TempCrateConfig::from_manifest(Path::new("/nonexistent/Cargo.toml"));
468        // With fallback, should find local es-fluent from CLI workspace
469        // If running in CI or different environment, may still be crates.io
470        assert!(config.es_fluent_dep.contains("es-fluent"));
471    }
472
473    #[test]
474    fn test_temp_crate_config_non_workspace_member() {
475        let temp_dir = tempfile::tempdir().unwrap();
476        let manifest_path = temp_dir.path().join("Cargo.toml");
477
478        let cargo_toml = r#"
479[package]
480name = "test-crate"
481version = "0.1.0"
482edition = "2024"
483
484[dependencies]
485es-fluent = { version = "*" }
486"#;
487        let mut file = fs::File::create(&manifest_path).unwrap();
488        file.write_all(cargo_toml.as_bytes()).unwrap();
489
490        let src_dir = temp_dir.path().join("src");
491        fs::create_dir_all(&src_dir).unwrap();
492        fs::write(src_dir.join("lib.rs"), "").unwrap();
493
494        let config = TempCrateConfig::from_manifest(&manifest_path);
495        // With fallback, should find local es-fluent from CLI workspace
496        assert!(config.es_fluent_dep.contains("es-fluent"));
497    }
498}