cargo_context_core/
expand.rs1use 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
30pub 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
39pub 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 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 return Ok(None);
75 }
76
77 let expanded = String::from_utf8_lossy(&output.stdout).into_owned();
78
79 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 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 let _ = expand_available();
197 }
198
199 #[test]
200 fn expand_returns_none_when_tool_missing() {
201 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}