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 std::path::{Path, PathBuf};
13use std::process::Command;
14use std::{env, fs};
15
16pub const TEMP_DIR: &str = ".es-fluent";
17
18const CLI_VERSION: &str = env!("CARGO_PKG_VERSION");
19
20/// Configuration derived from cargo metadata for temp crate generation.
21///
22/// This calls cargo_metadata once and extracts all needed information:
23/// - es-fluent dependency string
24/// - es-fluent-cli-helpers dependency string
25/// - target directory for sharing compiled dependencies
26pub struct TempCrateConfig {
27    pub es_fluent_dep: String,
28    pub es_fluent_cli_helpers_dep: String,
29    pub target_dir: String,
30}
31
32impl TempCrateConfig {
33    /// Create config by querying cargo metadata once, or from cache if valid.
34    pub fn from_manifest(manifest_path: &Path) -> Self {
35        use super::cache::MetadataCache;
36
37        // Check CARGO_TARGET_DIR env first (doesn't need metadata)
38        let target_dir_from_env = std::env::var("CARGO_TARGET_DIR").ok();
39
40        // Determine workspace root and temp directory for caching
41        let workspace_root = manifest_path.parent().unwrap_or(Path::new("."));
42        let temp_dir = workspace_root.join(TEMP_DIR);
43
44        // Try to use cached metadata if Cargo.lock hasn't changed
45        if let Some(cache) = MetadataCache::load(&temp_dir)
46            && cache.is_valid(workspace_root)
47        {
48            return Self {
49                es_fluent_dep: cache.es_fluent_dep,
50                es_fluent_cli_helpers_dep: cache.es_fluent_cli_helpers_dep,
51                target_dir: target_dir_from_env.unwrap_or(cache.target_dir),
52            };
53        }
54
55        // Cache miss or invalid, run cargo metadata
56        // Use no_deps() to skip full dependency resolution - we only need workspace packages
57        let metadata = cargo_metadata::MetadataCommand::new()
58            .manifest_path(manifest_path)
59            .no_deps()
60            .exec()
61            .ok();
62
63        let (es_fluent_dep, es_fluent_cli_helpers_dep, target_dir) = match metadata {
64            Some(ref meta) => {
65                let es_fluent = Self::find_local_dep(meta, "es-fluent")
66                    .or_else(Self::find_cli_workspace_dep_es_fluent)
67                    .unwrap_or_else(|| format!(r#"es-fluent = {{ version = "{}" }}"#, CLI_VERSION));
68                let helpers = Self::find_local_dep(meta, "es-fluent-cli-helpers")
69                    .or_else(Self::find_cli_workspace_dep_helpers)
70                    .unwrap_or_else(|| {
71                        format!(
72                            r#"es-fluent-cli-helpers = {{ version = "{}" }}"#,
73                            CLI_VERSION
74                        )
75                    });
76                let target = target_dir_from_env
77                    .clone()
78                    .unwrap_or_else(|| meta.target_directory.to_string());
79                (es_fluent, helpers, target)
80            },
81            None => (
82                Self::find_cli_workspace_dep_es_fluent()
83                    .unwrap_or_else(|| format!(r#"es-fluent = {{ version = "{}" }}"#, CLI_VERSION)),
84                Self::find_cli_workspace_dep_helpers().unwrap_or_else(|| {
85                    format!(
86                        r#"es-fluent-cli-helpers = {{ version = "{}" }}"#,
87                        CLI_VERSION
88                    )
89                }),
90                target_dir_from_env
91                    .clone()
92                    .unwrap_or_else(|| "../target".to_string()),
93            ),
94        };
95
96        // Save to cache for next time
97        if let Some(cargo_lock_hash) = MetadataCache::hash_cargo_lock(workspace_root) {
98            let _ = std::fs::create_dir_all(&temp_dir);
99            let cache = MetadataCache {
100                cargo_lock_hash,
101                es_fluent_dep: es_fluent_dep.clone(),
102                es_fluent_cli_helpers_dep: es_fluent_cli_helpers_dep.clone(),
103                target_dir: target_dir.clone(),
104            };
105            let _ = cache.save(&temp_dir);
106        }
107
108        Self {
109            es_fluent_dep,
110            es_fluent_cli_helpers_dep,
111            target_dir,
112        }
113    }
114
115    fn find_local_dep(meta: &cargo_metadata::Metadata, crate_name: &str) -> Option<String> {
116        meta.packages
117            .iter()
118            .find(|p| p.name.as_str() == crate_name && p.source.is_none())
119            .map(|pkg| {
120                let path = pkg.manifest_path.parent().unwrap();
121                format!(r#"{} = {{ path = "{}" }}"#, crate_name, path)
122            })
123    }
124
125    /// Fallback: find es-fluent from the CLI's own workspace (compile-time location)
126    fn find_cli_workspace_dep_es_fluent() -> Option<String> {
127        let cli_manifest_dir = env!("CARGO_MANIFEST_DIR");
128        let cli_path = Path::new(cli_manifest_dir);
129        let es_fluent_path = cli_path.parent()?.join("es-fluent");
130        if es_fluent_path.join("Cargo.toml").exists() {
131            Some(format!(
132                r#"es-fluent = {{ path = "{}" }}"#,
133                es_fluent_path.display()
134            ))
135        } else {
136            None
137        }
138    }
139
140    /// Fallback: find es-fluent-cli-helpers from the CLI's own workspace (compile-time location)
141    fn find_cli_workspace_dep_helpers() -> Option<String> {
142        let cli_manifest_dir = env!("CARGO_MANIFEST_DIR");
143        let cli_path = Path::new(cli_manifest_dir);
144        let helpers_path = cli_path.parent()?.join("es-fluent-cli-helpers");
145        if helpers_path.join("Cargo.toml").exists() {
146            Some(format!(
147                r#"es-fluent-cli-helpers = {{ path = "{}" }}"#,
148                helpers_path.display()
149            ))
150        } else {
151            None
152        }
153    }
154}
155
156/// Write the Cargo.toml for the runner crate.
157fn write_cargo_toml(temp_dir: &Path, cargo_toml_content: &str) -> Result<()> {
158    fs::write(temp_dir.join("Cargo.toml"), cargo_toml_content)
159        .context("Failed to write .es-fluent/Cargo.toml")
160}
161
162/// Write the .cargo/config.toml for the runner crate.
163fn write_cargo_config(temp_dir: &Path, config_content: &str) -> Result<()> {
164    let cargo_dir = temp_dir.join(".cargo");
165    fs::create_dir_all(&cargo_dir).context("Failed to create .es-fluent/.cargo directory")?;
166    fs::write(cargo_dir.join("config.toml"), config_content)
167        .context("Failed to write .es-fluent/.cargo/config.toml")
168}
169
170/// Run `cargo run` on the runner crate.
171///
172/// Returns the command stdout if cargo succeeds (captured to support diffs), or an error if it fails.
173pub fn run_cargo(temp_dir: &Path, bin_name: Option<&str>, args: &[String]) -> Result<String> {
174    let manifest_path = temp_dir.join("Cargo.toml");
175
176    let mut cmd = Command::new("cargo");
177    cmd.arg("run");
178    if let Some(bin) = bin_name {
179        cmd.arg("--bin").arg(bin);
180    }
181    cmd.arg("--manifest-path")
182        .arg(&manifest_path)
183        .arg("--quiet")
184        .arg("--")
185        .args(args)
186        .current_dir(temp_dir)
187        .env("RUSTFLAGS", "-A warnings");
188
189    // Force colored output only if NO_COLOR is NOT set
190    if std::env::var("NO_COLOR").is_err() {
191        cmd.env("CLICOLOR_FORCE", "1");
192    }
193
194    // Capture stdout/stderr
195    let output = cmd.output().context("Failed to run cargo")?;
196
197    if !output.status.success() {
198        let stderr = String::from_utf8_lossy(&output.stderr);
199        bail!("Cargo run failed: {}", stderr)
200    }
201
202    Ok(String::from_utf8_lossy(&output.stdout).to_string())
203}
204
205/// Run `cargo run` on the runner crate and capture output.
206///
207/// Returns the command output if successful, or an error with stderr if it fails.
208pub fn run_cargo_with_output(
209    temp_dir: &Path,
210    bin_name: Option<&str>,
211    args: &[String],
212) -> Result<std::process::Output> {
213    let manifest_path = temp_dir.join("Cargo.toml");
214
215    let mut cmd = Command::new("cargo");
216    cmd.arg("run");
217    if let Some(bin) = bin_name {
218        cmd.arg("--bin").arg(bin);
219    }
220    cmd.arg("--manifest-path")
221        .arg(&manifest_path)
222        .arg("--quiet")
223        .arg("--") // Add -- to pass args to the binary
224        .args(args)
225        .current_dir(temp_dir)
226        .env("RUSTFLAGS", "-A warnings");
227
228    let output = cmd.output().context("Failed to run cargo")?;
229
230    if output.status.success() {
231        Ok(output)
232    } else {
233        let stderr = String::from_utf8_lossy(&output.stderr);
234        bail!("Cargo run failed: {}", stderr)
235    }
236}
237
238// --- Monolithic temp crate support ---
239
240use crate::core::WorkspaceInfo;
241
242/// Prepare the monolithic runner crate at workspace root that links ALL workspace crates.
243/// This enables fast subsequent runs by caching a single binary that can access all inventory.
244pub fn prepare_monolithic_runner_crate(workspace: &WorkspaceInfo) -> Result<PathBuf> {
245    let temp_dir = workspace.root_dir.join(TEMP_DIR);
246    let src_dir = temp_dir.join("src");
247
248    fs::create_dir_all(&src_dir).context("Failed to create .es-fluent directory")?;
249
250    // Create .gitignore
251    fs::write(
252        temp_dir.join(".gitignore"),
253        GitignoreTemplate.render().unwrap(),
254    )
255    .context("Failed to write .es-fluent/.gitignore")?;
256
257    // Get es-fluent dependency info from workspace root
258    let root_manifest = workspace.root_dir.join("Cargo.toml");
259    let config = TempCrateConfig::from_manifest(&root_manifest);
260
261    // Build crate dependency list
262    let crate_deps: Vec<MonolithicCrateDep> = workspace
263        .crates
264        .iter()
265        .filter(|c| c.has_lib_rs) // Only crates with lib.rs can be linked
266        .map(|c| MonolithicCrateDep {
267            name: &c.name,
268            path: c.manifest_dir.display().to_string(),
269            ident: c.name.replace('-', "_"),
270            has_features: !c.fluent_features.is_empty(),
271            features: &c.fluent_features,
272        })
273        .collect();
274
275    // Write Cargo.toml
276    let cargo_toml = MonolithicCargoTomlTemplate {
277        crates: crate_deps.clone(),
278        es_fluent_dep: &config.es_fluent_dep,
279        es_fluent_cli_helpers_dep: &config.es_fluent_cli_helpers_dep,
280    };
281    write_cargo_toml(&temp_dir, &cargo_toml.render().unwrap())?;
282
283    // Write .cargo/config.toml for target-dir
284    let config_toml = ConfigTomlTemplate {
285        target_dir: &config.target_dir,
286    };
287    write_cargo_config(&temp_dir, &config_toml.render().unwrap())?;
288
289    // Write main.rs
290    let main_rs = MonolithicMainRsTemplate { crates: crate_deps };
291    fs::write(src_dir.join("main.rs"), main_rs.render().unwrap())
292        .context("Failed to write .es-fluent/src/main.rs")?;
293
294    // Copy workspace Cargo.lock to ensure identical dependency versions.
295    // The runner is a separate workspace (required to avoid "not a workspace member" errors),
296    // so we copy the lock file to get the same dependency resolution as the user's workspace.
297    let workspace_lock = workspace.root_dir.join("Cargo.lock");
298    let runner_lock = temp_dir.join("Cargo.lock");
299    if workspace_lock.exists() {
300        fs::copy(&workspace_lock, &runner_lock)
301            .context("Failed to copy Cargo.lock to .es-fluent/")?;
302    }
303
304    Ok(temp_dir)
305}
306
307/// Get the path to the monolithic binary if it exists.
308pub fn get_monolithic_binary_path(workspace: &WorkspaceInfo) -> PathBuf {
309    workspace.target_dir.join("debug").join("es-fluent-runner")
310}
311
312/// Run the monolithic binary directly (fast path) or build+run (slow path).
313///
314/// If `force_run` is true, the staleness check is skipped and the runner is always rebuilt.
315pub fn run_monolithic(
316    workspace: &WorkspaceInfo,
317    command: &str,
318    crate_name: &str,
319    extra_args: &[String],
320    force_run: bool,
321) -> Result<String> {
322    let temp_dir = workspace.root_dir.join(TEMP_DIR);
323    let binary_path = get_monolithic_binary_path(workspace);
324
325    // If binary exists, check if it's stale (unless force_run is set)
326    if !force_run && binary_path.exists() && !is_runner_stale(workspace, &binary_path) {
327        let mut cmd = Command::new(&binary_path);
328        cmd.arg(command)
329                .args(extra_args) // Put extra_args (including i18n_path) first
330                .arg("--crate")
331                .arg(crate_name)
332                .current_dir(&temp_dir);
333
334        // Force colored output only if NO_COLOR is NOT set
335        if std::env::var("NO_COLOR").is_err() {
336            cmd.env("CLICOLOR_FORCE", "1");
337        }
338
339        let output = cmd.output().context("Failed to run monolithic binary")?;
340
341        if !output.status.success() {
342            let stderr = String::from_utf8_lossy(&output.stderr);
343            bail!("Monolithic binary failed: {}", stderr);
344        }
345
346        return Ok(String::from_utf8_lossy(&output.stdout).to_string());
347    }
348
349    // Otherwise, fall back to cargo run (will build)
350    // Args order: command, extra_args..., --crate, crate_name
351    let mut args = vec![command.to_string()];
352    args.extend(extra_args.iter().cloned());
353    args.push("--crate".to_string());
354    args.push(crate_name.to_string());
355    let result = run_cargo(&temp_dir, Some("es-fluent-runner"), &args)?;
356
357    // After successful cargo run, write runner cache with current per-crate hashes
358    {
359        use super::cache::{RunnerCache, compute_content_hash};
360
361        let binary_path = get_monolithic_binary_path(workspace);
362        if let Ok(meta) = fs::metadata(&binary_path)
363            && let Ok(mtime) = meta.modified()
364        {
365            let runner_mtime_secs = mtime
366                .duration_since(std::time::SystemTime::UNIX_EPOCH)
367                .map(|d| d.as_secs())
368                .unwrap_or(0);
369
370            // Compute per-crate content hashes (including i18n.toml)
371            let mut crate_hashes = indexmap::IndexMap::new();
372            for krate in &workspace.crates {
373                if krate.src_dir.exists() {
374                    let hash = compute_content_hash(&krate.src_dir, Some(&krate.i18n_config_path));
375                    crate_hashes.insert(krate.name.clone(), hash);
376                }
377            }
378
379            let cache = RunnerCache {
380                crate_hashes,
381                runner_mtime: runner_mtime_secs,
382                cli_version: CLI_VERSION.to_string(),
383            };
384            let _ = cache.save(&temp_dir);
385        }
386    }
387
388    Ok(result)
389}
390
391/// Check if the runner binary is stale compared to workspace source files.
392///
393/// Uses per-crate blake3 content hashing to detect actual changes - saving a file
394/// without modifications won't trigger a rebuild. Hashes are stored in runner_cache.json.
395///
396/// Also checks CLI version - if the CLI was upgraded, the runner needs to be rebuilt
397/// to pick up changes in es-fluent-cli-helpers.
398fn is_runner_stale(workspace: &WorkspaceInfo, runner_path: &Path) -> bool {
399    use super::cache::{RunnerCache, compute_content_hash};
400
401    let runner_mtime = match fs::metadata(runner_path).and_then(|m| m.modified()) {
402        Ok(t) => t,
403        Err(_) => return true, // Treat as stale if we can't read metadata
404    };
405
406    let runner_mtime_secs = runner_mtime
407        .duration_since(std::time::SystemTime::UNIX_EPOCH)
408        .map(|d| d.as_secs())
409        .unwrap_or(0);
410
411    let temp_dir = workspace.root_dir.join(TEMP_DIR);
412
413    // Compute current content hashes for each crate (including i18n.toml)
414    let mut current_hashes = indexmap::IndexMap::new();
415    for krate in &workspace.crates {
416        if krate.src_dir.exists() {
417            let hash = compute_content_hash(&krate.src_dir, Some(&krate.i18n_config_path));
418            current_hashes.insert(krate.name.clone(), hash);
419        }
420    }
421
422    // Check runner cache
423    if let Some(cache) = RunnerCache::load(&temp_dir) {
424        // Check CLI version first - if upgraded, force rebuild to pick up helper changes
425        if cache.cli_version != CLI_VERSION {
426            return true;
427        }
428
429        if cache.runner_mtime == runner_mtime_secs {
430            // Runner hasn't been rebuilt - check if any crate content changed
431            for (name, current_hash) in &current_hashes {
432                match cache.crate_hashes.get(name) {
433                    Some(cached_hash) if cached_hash == current_hash => continue,
434                    _ => return true, // Hash mismatch or new crate
435                }
436            }
437            // Also check for removed crates
438            for cached_name in cache.crate_hashes.keys() {
439                if !current_hashes.contains_key(cached_name) {
440                    return true;
441                }
442            }
443            // All hashes match, runner is fresh
444            return false;
445        }
446
447        // Runner was rebuilt - update cache with current hashes and version
448        let new_cache = RunnerCache {
449            crate_hashes: current_hashes,
450            runner_mtime: runner_mtime_secs,
451            cli_version: CLI_VERSION.to_string(),
452        };
453        let _ = new_cache.save(&temp_dir);
454        return false;
455    }
456
457    // No cache exists - be conservative and trigger rebuild
458    true
459}
460
461#[cfg(test)]
462mod tests {
463    use super::*;
464    use std::io::Write;
465
466    #[test]
467    fn test_temp_crate_config_nonexistent_manifest() {
468        let config = TempCrateConfig::from_manifest(Path::new("/nonexistent/Cargo.toml"));
469        // With fallback, should find local es-fluent from CLI workspace
470        // If running in CI or different environment, may still be crates.io
471        assert!(config.es_fluent_dep.contains("es-fluent"));
472    }
473
474    #[test]
475    fn test_temp_crate_config_non_workspace_member() {
476        let temp_dir = tempfile::tempdir().unwrap();
477        let manifest_path = temp_dir.path().join("Cargo.toml");
478
479        let cargo_toml = r#"
480[package]
481name = "test-crate"
482version = "0.1.0"
483edition = "2024"
484
485[dependencies]
486es-fluent = { version = "*" }
487"#;
488        let mut file = fs::File::create(&manifest_path).unwrap();
489        file.write_all(cargo_toml.as_bytes()).unwrap();
490
491        let src_dir = temp_dir.path().join("src");
492        fs::create_dir_all(&src_dir).unwrap();
493        fs::write(src_dir.join("lib.rs"), "").unwrap();
494
495        let config = TempCrateConfig::from_manifest(&manifest_path);
496        // With fallback, should find local es-fluent from CLI workspace
497        assert!(config.es_fluent_dep.contains("es-fluent"));
498    }
499}