1use std::{
2 ffi::OsStr,
3 fs,
4 io::{self, Write},
5 path::{Path, PathBuf},
6 process,
7 time::SystemTime,
8};
9
10use serde_json::Value;
11use sha1::{Digest, Sha1};
12
13use crate::nix_command;
14
15#[derive(Debug, Clone)]
16pub struct NixProfileCache {
17 cache_dir: PathBuf,
18 flake_inputs_dir: PathBuf,
19 flake_reference: FlakeReference,
20 evaluation_mode: EvaluationMode,
21 files_to_watch: Vec<PathBuf>,
22 profile_symlink: PathBuf,
23 profile_rc_file: PathBuf,
24}
25
26impl NixProfileCache {
27 pub fn new(
28 cache_dir: PathBuf,
29 flake_reference: &str,
30 evaluation_mode: EvaluationMode,
31 ) -> anyhow::Result<Self> {
32 let flake_inputs_dir = cache_dir.join("flake-inputs");
33
34 let flake_reference = FlakeReference::parse(flake_reference)?;
35
36 let mut files_to_watch = vec![];
37 let hash = if let Some(flake_dir) = &flake_reference.flake_dir {
38 files_to_watch.extend_from_slice(&[
39 flake_dir.join("flake.nix"),
40 flake_dir.join("flake.lock"),
41 flake_dir.join("devshell.toml"),
42 ]);
43 hash_files(&files_to_watch)?
44 } else {
45 hash_flake_reference(&flake_reference.flake_reference_string)?
46 };
47
48 let profile_symlink = cache_dir.join(format!("flake-profile-{}", hash));
49 let profile_rc_file = profile_symlink.with_extension("rc");
50 Ok(Self {
51 cache_dir,
52 flake_inputs_dir,
53 flake_reference,
54 evaluation_mode,
55 files_to_watch,
56 profile_symlink,
57 profile_rc_file,
58 })
59 }
60
61 pub fn needs_update(&self) -> anyhow::Result<bool> {
62 let mut need_update = true;
63
64 if self.profile_rc_file.is_file() && self.profile_symlink.is_symlink() {
65 let profile_rc_mtime = fs::metadata(&self.profile_rc_file)?.modified()?;
66
67 need_update = self.files_to_watch.iter().any(|file| {
68 fs::metadata(file)
69 .and_then(|meta| meta.modified())
70 .unwrap_or(SystemTime::UNIX_EPOCH)
71 > profile_rc_mtime
72 });
73 }
74
75 Ok(need_update)
76 }
77
78 pub fn update(&self) -> anyhow::Result<()> {
79 clean_old_gcroots(&self.cache_dir, &self.flake_inputs_dir)?;
80
81 let tmp_profile = self
82 .cache_dir
83 .join(format!("flake-tmp-profile.{}", process::id()));
84
85 let mut args = vec![OsStr::new("print-dev-env")];
86 if let Some(impure_arg) = self.impure_arg() {
87 args.push(OsStr::new(impure_arg));
88 }
89 args.extend_from_slice(&[
90 OsStr::new("--no-write-lock-file"),
91 OsStr::new("--profile"),
92 tmp_profile.as_os_str(),
93 OsStr::new(&self.flake_reference.flake_reference_string),
94 ]);
95 let stdout_content = nix_command::nix(args)?;
96
97 fs::File::create(&self.profile_rc_file)?.write_all(stdout_content.as_bytes())?;
98
99 self.add_gcroot(&tmp_profile, &self.profile_symlink)?;
100 fs::remove_file(&tmp_profile)?;
101
102 if self.flake_reference.flake_dir.is_some() {
103 for input in self.get_flake_input_paths()? {
104 let store_path = PathBuf::from("/nix/store").join(&input);
105 let symlink_path = self.flake_inputs_dir.join(&input);
106 self.add_gcroot(&store_path, &symlink_path)?;
107 }
108 }
109
110 Ok(())
111 }
112
113 pub fn profile_rc(&self) -> &Path {
114 &self.profile_rc_file
115 }
116
117 fn impure_arg(&self) -> Option<&str> {
118 match self.evaluation_mode {
119 EvaluationMode::Impure => Some("--impure"),
120 EvaluationMode::Pure => None,
121 }
122 }
123
124 fn add_gcroot(&self, store_path: &Path, symlink: &Path) -> anyhow::Result<()> {
125 let mut args = vec![OsStr::new("build")];
126 if let Some(impure_arg) = self.impure_arg() {
127 args.push(OsStr::new(impure_arg));
128 }
129 args.extend_from_slice(&[
130 OsStr::new("--out-link"),
131 symlink.as_os_str(),
132 store_path.as_os_str(),
133 ]);
134 nix_command::nix(args)?;
135 Ok(())
136 }
137
138 fn get_flake_input_paths(&self) -> anyhow::Result<Vec<PathBuf>> {
139 let mut args = vec!["flake", "archive"];
140 if let Some(impure_arg) = self.impure_arg() {
141 args.push(impure_arg);
142 }
143 args.extend_from_slice(&[
144 "--json",
145 "--no-write-lock-file",
146 &self.flake_reference.flake_reference_string,
147 ]);
148 let stdout_content = nix_command::nix(args)?;
149 let json = serde_json::from_str::<Value>(&stdout_content)?;
150 Ok(get_paths_from_doc(&json))
151 }
152}
153
154#[derive(Debug, Clone, Copy)]
155pub enum EvaluationMode {
156 Impure,
157 Pure,
158}
159
160#[derive(Debug, Clone)]
161struct FlakeReference {
162 pub flake_reference_string: String,
163 pub flake_dir: Option<PathBuf>,
164}
165
166impl FlakeReference {
167 pub fn parse(flake_reference: &str) -> anyhow::Result<Self> {
168 let mut flake_reference_iter = flake_reference.split('#');
169 let flake_uri = flake_reference_iter
170 .next()
171 .ok_or_else(|| anyhow::anyhow!("Missing flake URI"))?;
172 let flake_specifier = flake_reference_iter.next();
173
174 let expanded_flake_reference_and_flake_dir =
175 if FlakeReference::is_path_type(flake_reference) {
176 let flake_dir_str =
177 shellexpand::full(flake_uri.strip_prefix("path:").unwrap_or(flake_uri))?;
178 let expanded_flake_reference = format!(
179 "{}{}{}",
180 &flake_dir_str,
181 flake_specifier.map(|_| "#").unwrap_or(""),
182 flake_specifier.unwrap_or("")
183 );
184 Some((expanded_flake_reference, flake_dir_str))
185 } else {
186 None
187 };
188
189 Ok(Self {
190 flake_dir: expanded_flake_reference_and_flake_dir
191 .as_ref()
192 .map(|x| PathBuf::from(x.1.to_string())),
193 flake_reference_string: expanded_flake_reference_and_flake_dir
194 .map(|x| x.0)
195 .unwrap_or_else(|| String::from(flake_reference)),
196 })
197 }
198
199 fn is_path_type(flake_reference: &str) -> bool {
200 flake_reference.starts_with("path:")
201 || flake_reference.starts_with('~')
202 || flake_reference.starts_with('/')
203 || flake_reference.starts_with("./")
204 || flake_reference.starts_with("../")
205 }
206}
207
208fn hash_files(filenames: impl AsRef<[PathBuf]>) -> anyhow::Result<String> {
209 let (hasher, no_files) = filenames
210 .as_ref()
211 .iter()
212 .filter(|f| {
213 f.exists()
215 })
216 .try_fold((Sha1::new(), true), |(mut acc, ..), f| {
217 acc.update(fs::read(f)?);
218 anyhow::Result::<(Sha1, bool)>::Ok((acc, false))
219 })?;
220
221 if no_files {
222 return Err(anyhow::anyhow!("No files found to hash"));
223 }
224
225 Ok(format!("{:x}", hasher.finalize()))
226}
227
228fn hash_flake_reference(flake_reference: &str) -> anyhow::Result<String> {
229 let mut hasher = Sha1::new();
230 hasher.update(flake_reference);
231 Ok(format!("{:x}", hasher.finalize()))
232}
233
234fn clean_old_gcroots(cache_dir: &Path, flake_inputs_dir: &Path) -> anyhow::Result<()> {
235 let res = fs::remove_dir_all(cache_dir);
236 if let Err(e) = &res
237 && e.kind() != io::ErrorKind::NotFound
238 {
239 res?;
240 }
241 fs::create_dir_all(flake_inputs_dir)?;
242 Ok(())
243}
244
245fn get_paths_from_doc(doc: &Value) -> Vec<PathBuf> {
246 let mut result = Vec::new();
247
248 if let Some(p) = get_path(doc) {
249 result.push(p);
250 }
251
252 if let Some(inputs) = doc.get("inputs").and_then(|i| i.as_object()) {
253 for (_k, v) in inputs {
254 let sub_paths = get_paths_from_doc(v);
255 result.extend(sub_paths);
256 }
257 }
258
259 result
260}
261
262fn get_path(doc: &Value) -> Option<PathBuf> {
263 doc.get("path")
264 .and_then(|value| value.as_str())
265 .map(|path| {
266 if path.len() > 11 {
267 PathBuf::from(&path[11..])
268 } else {
269 PathBuf::from(path)
270 }
271 })
272}
273
274#[cfg(test)]
275mod tests {
276 use std::{io::Write, path::PathBuf};
277
278 use once_cell::sync::Lazy;
279 use serde_json::json;
280 use tempfile::NamedTempFile;
281
282 use super::{get_path, get_paths_from_doc, hash_files};
283
284 static TEST_FILE: Lazy<NamedTempFile> = Lazy::new(|| {
285 let mut test_file = tempfile::NamedTempFile::new().unwrap();
286 writeln!(test_file.as_file_mut(), r#"echo "1.1.1";"#).unwrap();
287 test_file
288 });
289
290 #[test]
291 fn test_hash_one() {
292 assert_eq!(
293 hash_files([TEST_FILE.path().to_path_buf()]).unwrap(),
294 "6ead949bf4bcae230b9ed9cd11e578e34ce9f9ea"
295 );
296 }
297
298 #[test]
299 fn test_hash_multiple() {
300 assert_eq!(
301 hash_files([
302 TEST_FILE.path().to_path_buf(),
303 TEST_FILE.path().to_path_buf(),
304 ])
305 .unwrap(),
306 "f109b7892a541ed1e3cf39314cd25d21042b984f"
307 );
308 }
309
310 #[test]
311 fn test_hash_filters_nonexistent() {
312 assert_eq!(
313 hash_files([TEST_FILE.path().to_path_buf(), PathBuf::from("FOOBARBAZ"),]).unwrap(),
314 "6ead949bf4bcae230b9ed9cd11e578e34ce9f9ea"
315 );
316 }
317
318 #[test]
319 fn test_get_path_removes_prefix() {
320 let input = json!({
321 "path": "aaaaaaaaaaabbbbb"
322 });
323 let result = get_path(&input);
324 assert_eq!(result, Some(PathBuf::from("bbbbb")));
325 }
326
327 #[test]
328 fn test_get_paths_from_doc() {
329 let input = json!({
330 "path": "aaaaaaaaaaabbbbb",
331 "inputs": {
332 "foo": {
333 "path": "aaaaaaaaaaaccccc",
334 "inputs": {
335 "bar": {
336 "path": "aaaaaaaaaaaddddd",
337 "inputs": {}
338 }
339 }
340 }
341 }
342 });
343 let result = get_paths_from_doc(&input);
344 assert_eq!(
345 result,
346 vec![
347 "bbbbb".to_string(),
348 "ccccc".to_string(),
349 "ddddd".to_string()
350 ]
351 );
352 }
353}