Skip to main content

cargo_context_core/
expand.rs

1//! `cargo expand` integration.
2//!
3//! Shells out to the user's `cargo-expand` binary and caches the result under
4//! `target/cargo-context/expand/` keyed by `(crate, file_mtime, lock_hash)`.
5//! Requires the user to have installed `cargo-expand` (`cargo install
6//! cargo-expand`); when absent, expansion is a silent no-op and the caller
7//! keeps the original source.
8//!
9//! The cache is intentionally inside `target/` so `cargo clean` invalidates
10//! it. Its entries are plain `.rs` files with a sidecar `.json` manifest
11//! recording inputs for debuggability.
12
13use std::path::{Path, PathBuf};
14use std::process::Command;
15
16use serde::{Deserialize, Serialize};
17use sha2::{Digest, Sha256};
18
19use crate::error::{Error, Result};
20
21#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "lowercase")]
23pub enum ExpandMode {
24    Off,
25    #[default]
26    Auto,
27    On,
28}
29
30/// Return `true` if `cargo-expand` is callable on `PATH`.
31pub fn expand_available() -> bool {
32    Command::new("cargo-expand")
33        .arg("--version")
34        .output()
35        .map(|o| o.status.success())
36        .unwrap_or(false)
37}
38
39/// Expand macros in `file` (a Rust source path) via `cargo expand`.
40///
41/// `crate_name` is the Cargo package that owns the file; `cargo-expand` runs
42/// scoped to that crate. `workspace_root` is used to locate `Cargo.lock`
43/// (for cache keying) and the `target/` directory.
44///
45/// Returns `None` when `cargo-expand` isn't installed — the caller should
46/// treat this as "fallback to the original source, no error".
47pub fn expand_file(workspace_root: &Path, crate_name: &str, file: &Path) -> Result<Option<String>> {
48    if !expand_available() {
49        return Ok(None);
50    }
51
52    let cache = cache_dir(workspace_root);
53    let key = cache_key(workspace_root, crate_name, file)?;
54    let cached_path = cache.join(format!("{key}.rs"));
55
56    if cached_path.exists() {
57        return Ok(Some(std::fs::read_to_string(&cached_path)?));
58    }
59
60    // Run `cargo expand -p <crate>` and capture stdout. The `--color=never`
61    // keeps ANSI escapes out of the cache.
62    let output = Command::new("cargo")
63        .arg("expand")
64        .arg("-p")
65        .arg(crate_name)
66        .arg("--color=never")
67        .current_dir(workspace_root)
68        .output()
69        .map_err(|e| Error::Tool(format!("failed to spawn cargo-expand: {e}")))?;
70
71    if !output.status.success() {
72        // Expansion failed (e.g. the crate doesn't compile). Fall through
73        // without caching — next run will retry.
74        return Ok(None);
75    }
76
77    let expanded = String::from_utf8_lossy(&output.stdout).into_owned();
78
79    // Persist to cache.
80    std::fs::create_dir_all(&cache)?;
81    std::fs::write(&cached_path, &expanded)?;
82    write_manifest(&cache, &key, workspace_root, crate_name, file)?;
83
84    Ok(Some(expanded))
85}
86
87fn cache_dir(workspace_root: &Path) -> PathBuf {
88    workspace_root.join("target/cargo-context/expand")
89}
90
91fn cache_key(workspace_root: &Path, crate_name: &str, file: &Path) -> Result<String> {
92    let mtime = file_mtime_secs(file).unwrap_or(0);
93    let lock_hash = cargo_lock_hash(workspace_root).unwrap_or_default();
94
95    let mut h = Sha256::new();
96    h.update(crate_name.as_bytes());
97    h.update([0]);
98    h.update(file.to_string_lossy().as_bytes());
99    h.update([0]);
100    h.update(mtime.to_le_bytes());
101    h.update([0]);
102    h.update(lock_hash.as_bytes());
103    let digest = h.finalize();
104    Ok(hex_short(&digest[..12]))
105}
106
107fn file_mtime_secs(path: &Path) -> Option<u64> {
108    let meta = std::fs::metadata(path).ok()?;
109    let mtime = meta.modified().ok()?;
110    mtime
111        .duration_since(std::time::UNIX_EPOCH)
112        .ok()
113        .map(|d| d.as_secs())
114}
115
116fn cargo_lock_hash(workspace_root: &Path) -> Option<String> {
117    let lock = workspace_root.join("Cargo.lock");
118    let bytes = std::fs::read(&lock).ok()?;
119    let mut h = Sha256::new();
120    h.update(&bytes);
121    let d = h.finalize();
122    Some(hex_short(&d[..8]))
123}
124
125fn hex_short(bytes: &[u8]) -> String {
126    use std::fmt::Write as _;
127    let mut s = String::with_capacity(bytes.len() * 2);
128    for b in bytes {
129        write!(&mut s, "{b:02x}").unwrap();
130    }
131    s
132}
133
134#[derive(Serialize, Deserialize)]
135struct CacheManifest {
136    crate_name: String,
137    file: PathBuf,
138    workspace_root: PathBuf,
139    key: String,
140}
141
142fn write_manifest(
143    cache: &Path,
144    key: &str,
145    workspace_root: &Path,
146    crate_name: &str,
147    file: &Path,
148) -> Result<()> {
149    let manifest = CacheManifest {
150        crate_name: crate_name.to_string(),
151        file: file.to_path_buf(),
152        workspace_root: workspace_root.to_path_buf(),
153        key: key.to_string(),
154    };
155    let json = serde_json::to_string_pretty(&manifest)?;
156    std::fs::write(cache.join(format!("{key}.json")), json)?;
157    Ok(())
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163
164    #[test]
165    fn cache_key_depends_on_inputs() {
166        let tmp = tempfile::tempdir().unwrap();
167        let f = tmp.path().join("a.rs");
168        std::fs::write(&f, "fn x() {}").unwrap();
169        let k1 = cache_key(tmp.path(), "crate_a", &f).unwrap();
170        let k2 = cache_key(tmp.path(), "crate_b", &f).unwrap();
171        assert_ne!(k1, k2, "different crate should produce different key");
172    }
173
174    #[test]
175    fn cache_key_depends_on_mtime() {
176        let tmp = tempfile::tempdir().unwrap();
177        let f = tmp.path().join("a.rs");
178        std::fs::write(&f, "fn x() {}").unwrap();
179        let k1 = cache_key(tmp.path(), "c", &f).unwrap();
180        // Sleep briefly to ensure mtime changes.
181        std::thread::sleep(std::time::Duration::from_millis(1100));
182        std::fs::write(&f, "fn x() { let _ = 1; }").unwrap();
183        let k2 = cache_key(tmp.path(), "c", &f).unwrap();
184        assert_ne!(k1, k2, "touching the file should change the cache key");
185    }
186
187    #[test]
188    fn hex_short_formats_lowercase() {
189        assert_eq!(hex_short(&[0x0a, 0xff, 0x00]), "0aff00");
190    }
191
192    #[test]
193    fn expand_available_returns_bool() {
194        // Don't assert truthiness — we don't know what the CI/dev machine has.
195        // Just verify the probe doesn't panic.
196        let _ = expand_available();
197    }
198
199    #[test]
200    fn expand_returns_none_when_tool_missing() {
201        // If cargo-expand is actually installed this test is skipped.
202        if expand_available() {
203            return;
204        }
205        let tmp = tempfile::tempdir().unwrap();
206        let f = tmp.path().join("src.rs");
207        std::fs::write(&f, "fn main() {}").unwrap();
208        let out = expand_file(tmp.path(), "nonexistent_crate", &f).unwrap();
209        assert!(out.is_none());
210    }
211}