Skip to main content

dev_prune/adapters/
stack.rs

1// Copyright 2026 VKrishna04
2// SPDX-License-Identifier: Apache-2.0
3
4// Haskell Stack build tree adapter.
5//
6// Stack keeps everything it builds for a project in `.stack-work/` beside
7// `stack.yaml`: compiled modules, the project's own packages, per-snapshot GHC
8// artifacts. The directory is written only by Stack and regenerates in full from
9// `stack build`, which is why Stack's own documentation tells people to ignore it in
10// version control. What pins the rebuild is the resolver: `stack.yaml` names a snapshot
11// that fixes GHC and every package version, so the rebuild is reproducible in a way a
12// bare `.cabal` file's version ranges are not.
13//
14// Opt-in, and held to `build_idle_days`: getting `.stack-work/` back means recompiling
15// the project, and Haskell compiles are famously not quick.
16
17use super::{BloatDir, EnforcePolicy, PackageManager, dir_size};
18use anyhow::{Result, anyhow};
19use std::fs;
20use std::path::Path;
21
22/// The project file at every Stack project's root.
23const MANIFEST: &str = "stack.yaml";
24
25/// Stack's per-project build tree.
26const CACHE_DIRS: &[&str] = &[".stack-work"];
27
28/// Haskell Stack build tree adapter. Opt-in; see the module comment.
29pub struct Stack;
30
31impl PackageManager for Stack {
32    fn name(&self) -> &'static str {
33        "stack"
34    }
35
36    fn detect(&self, path: &Path) -> bool {
37        path.join(MANIFEST).is_file()
38    }
39
40    fn bloat_dirs(&self, path: &Path) -> Vec<BloatDir> {
41        CACHE_DIRS
42            .iter()
43            .map(|name| path.join(name))
44            .filter(|dir| dir.is_dir())
45            .map(|dir| BloatDir {
46                name: dir
47                    .file_name()
48                    .unwrap_or_default()
49                    .to_string_lossy()
50                    .into_owned(),
51                size_bytes: dir_size(&dir),
52                path: dir,
53                shared_bytes: 0,
54            })
55            .collect()
56    }
57
58    /// What is checked is that `stack.yaml` still names a snapshot, because the
59    /// snapshot is what makes the rebuild reproducible: it pins GHC and every package
60    /// version the way a lockfile would. `resolver:` is the traditional key,
61    /// `snapshot:` its newer synonym — a file with neither is not a project Stack can
62    /// rebuild deterministically. Running `stack build` here to find out would start a
63    /// compile in the middle of a delete pass.
64    fn enforce_lockfile(&self, path: &Path, _policy: EnforcePolicy) -> Result<()> {
65        let manifest = path.join(MANIFEST);
66        let content = fs::read_to_string(&manifest).map_err(|e| {
67            anyhow!("`{MANIFEST}` could not be read ({e}): nothing to rebuild `.stack-work` from.")
68        })?;
69        let names_snapshot = content.lines().any(|line| {
70            let line = line.trim_start();
71            line.starts_with("resolver:") || line.starts_with("snapshot:")
72        });
73        if !names_snapshot {
74            return Err(anyhow!(
75                "`{MANIFEST}` names no `resolver:` or `snapshot:`: refusing to treat the \
76                 build tree as reproducibly regenerable from it."
77            ));
78        }
79        Ok(())
80    }
81
82    fn restore(&self, _path: &Path, _timeout: std::time::Duration) -> Result<()> {
83        println!("Stack's build tree regenerates on the next `stack build`");
84        Ok(())
85    }
86
87    fn lockfiles(&self) -> &'static [&'static str] {
88        &[MANIFEST]
89    }
90
91    fn opt_in(&self) -> bool {
92        true
93    }
94}
95
96#[cfg(test)]
97mod tests {
98    use super::*;
99    use std::path::PathBuf;
100    use tempfile::tempdir;
101
102    /// A project root holding a plausible `stack.yaml`.
103    fn project(dir: &Path) -> PathBuf {
104        fs::write(
105            dir.join(MANIFEST),
106            "resolver: lts-22.33\n\npackages:\n- .\n",
107        )
108        .unwrap();
109        dir.to_path_buf()
110    }
111
112    fn claimed(project: &Path) -> Vec<String> {
113        Stack
114            .bloat_dirs(project)
115            .into_iter()
116            .map(|b| b.name)
117            .collect()
118    }
119
120    #[test]
121    fn detects_on_the_project_file() {
122        let dir = tempdir().unwrap();
123        assert!(!Stack.detect(dir.path()));
124        project(dir.path());
125        assert!(Stack.detect(dir.path()));
126    }
127
128    #[test]
129    fn the_build_tree_is_claimed_and_nothing_else() {
130        let dir = tempdir().unwrap();
131        let root = project(dir.path());
132        fs::create_dir(root.join(".stack-work")).unwrap();
133        fs::create_dir(root.join("src")).unwrap();
134
135        assert_eq!(claimed(&root), vec![".stack-work"]);
136    }
137
138    #[test]
139    fn the_newer_snapshot_key_also_passes() {
140        let dir = tempdir().unwrap();
141        fs::write(
142            dir.path().join(MANIFEST),
143            "snapshot: nightly-2026-01-01\npackages:\n- .\n",
144        )
145        .unwrap();
146        assert!(
147            Stack
148                .enforce_lockfile(dir.path(), EnforcePolicy::default())
149                .is_ok()
150        );
151    }
152
153    #[test]
154    fn a_missing_or_snapshotless_project_file_is_refused() {
155        let dir = tempdir().unwrap();
156        let policy = EnforcePolicy::default();
157        assert!(Stack.enforce_lockfile(dir.path(), policy).is_err());
158        fs::write(dir.path().join(MANIFEST), "packages:\n- .\n").unwrap();
159        assert!(Stack.enforce_lockfile(dir.path(), policy).is_err());
160        project(dir.path());
161        assert!(Stack.enforce_lockfile(dir.path(), policy).is_ok());
162    }
163
164    #[test]
165    fn stack_is_opt_in() {
166        assert!(Stack.opt_in());
167    }
168}