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)]
22pub enum ExpandMode {
23 Off,
24 #[default]
25 Auto,
26 On,
27}
28
29pub fn expand_available() -> bool {
31 Command::new("cargo-expand")
32 .arg("--version")
33 .output()
34 .map(|o| o.status.success())
35 .unwrap_or(false)
36}
37
38pub fn expand_file(workspace_root: &Path, crate_name: &str, file: &Path) -> Result<Option<String>> {
47 if !expand_available() {
48 return Ok(None);
49 }
50
51 let cache = cache_dir(workspace_root);
52 let key = cache_key(workspace_root, crate_name, file)?;
53 let cached_path = cache.join(format!("{key}.rs"));
54
55 if cached_path.exists() {
56 return Ok(Some(std::fs::read_to_string(&cached_path)?));
57 }
58
59 let output = Command::new("cargo")
62 .arg("expand")
63 .arg("-p")
64 .arg(crate_name)
65 .arg("--color=never")
66 .current_dir(workspace_root)
67 .output()
68 .map_err(|e| Error::Config(format!("failed to spawn cargo-expand: {e}")))?;
69
70 if !output.status.success() {
71 return Ok(None);
74 }
75
76 let expanded = String::from_utf8_lossy(&output.stdout).into_owned();
77
78 std::fs::create_dir_all(&cache)?;
80 std::fs::write(&cached_path, &expanded)?;
81 write_manifest(&cache, &key, workspace_root, crate_name, file)?;
82
83 Ok(Some(expanded))
84}
85
86fn cache_dir(workspace_root: &Path) -> PathBuf {
87 workspace_root.join("target/cargo-context/expand")
88}
89
90fn cache_key(workspace_root: &Path, crate_name: &str, file: &Path) -> Result<String> {
91 let mtime = file_mtime_secs(file).unwrap_or(0);
92 let lock_hash = cargo_lock_hash(workspace_root).unwrap_or_default();
93
94 let mut h = Sha256::new();
95 h.update(crate_name.as_bytes());
96 h.update([0]);
97 h.update(file.to_string_lossy().as_bytes());
98 h.update([0]);
99 h.update(mtime.to_le_bytes());
100 h.update([0]);
101 h.update(lock_hash.as_bytes());
102 let digest = h.finalize();
103 Ok(hex_short(&digest[..12]))
104}
105
106fn file_mtime_secs(path: &Path) -> Option<u64> {
107 let meta = std::fs::metadata(path).ok()?;
108 let mtime = meta.modified().ok()?;
109 mtime
110 .duration_since(std::time::UNIX_EPOCH)
111 .ok()
112 .map(|d| d.as_secs())
113}
114
115fn cargo_lock_hash(workspace_root: &Path) -> Option<String> {
116 let lock = workspace_root.join("Cargo.lock");
117 let bytes = std::fs::read(&lock).ok()?;
118 let mut h = Sha256::new();
119 h.update(&bytes);
120 let d = h.finalize();
121 Some(hex_short(&d[..8]))
122}
123
124fn hex_short(bytes: &[u8]) -> String {
125 use std::fmt::Write as _;
126 let mut s = String::with_capacity(bytes.len() * 2);
127 for b in bytes {
128 write!(&mut s, "{b:02x}").unwrap();
129 }
130 s
131}
132
133#[derive(Serialize, Deserialize)]
134struct CacheManifest {
135 crate_name: String,
136 file: PathBuf,
137 workspace_root: PathBuf,
138 key: String,
139}
140
141fn write_manifest(
142 cache: &Path,
143 key: &str,
144 workspace_root: &Path,
145 crate_name: &str,
146 file: &Path,
147) -> Result<()> {
148 let manifest = CacheManifest {
149 crate_name: crate_name.to_string(),
150 file: file.to_path_buf(),
151 workspace_root: workspace_root.to_path_buf(),
152 key: key.to_string(),
153 };
154 let json = serde_json::to_string_pretty(&manifest)?;
155 std::fs::write(cache.join(format!("{key}.json")), json)?;
156 Ok(())
157}
158
159#[cfg(test)]
160mod tests {
161 use super::*;
162
163 #[test]
164 fn cache_key_depends_on_inputs() {
165 let tmp = tempfile::tempdir().unwrap();
166 let f = tmp.path().join("a.rs");
167 std::fs::write(&f, "fn x() {}").unwrap();
168 let k1 = cache_key(tmp.path(), "crate_a", &f).unwrap();
169 let k2 = cache_key(tmp.path(), "crate_b", &f).unwrap();
170 assert_ne!(k1, k2, "different crate should produce different key");
171 }
172
173 #[test]
174 fn cache_key_depends_on_mtime() {
175 let tmp = tempfile::tempdir().unwrap();
176 let f = tmp.path().join("a.rs");
177 std::fs::write(&f, "fn x() {}").unwrap();
178 let k1 = cache_key(tmp.path(), "c", &f).unwrap();
179 std::thread::sleep(std::time::Duration::from_millis(1100));
181 std::fs::write(&f, "fn x() { let _ = 1; }").unwrap();
182 let k2 = cache_key(tmp.path(), "c", &f).unwrap();
183 assert_ne!(k1, k2, "touching the file should change the cache key");
184 }
185
186 #[test]
187 fn hex_short_formats_lowercase() {
188 assert_eq!(hex_short(&[0x0a, 0xff, 0x00]), "0aff00");
189 }
190
191 #[test]
192 fn expand_available_returns_bool() {
193 let _ = expand_available();
196 }
197
198 #[test]
199 fn expand_returns_none_when_tool_missing() {
200 if expand_available() {
202 return;
203 }
204 let tmp = tempfile::tempdir().unwrap();
205 let f = tmp.path().join("src.rs");
206 std::fs::write(&f, "fn main() {}").unwrap();
207 let out = expand_file(tmp.path(), "nonexistent_crate", &f).unwrap();
208 assert!(out.is_none());
209 }
210}