Skip to main content

dev_prune/adapters/
cabal.rs

1// Copyright 2026 VKrishna04
2// SPDX-License-Identifier: Apache-2.0
3
4// Haskell Cabal build tree adapter.
5//
6// `cabal build` keeps everything it compiles for a project in `dist-newstyle/` beside
7// `cabal.project`: compiled modules, per-GHC artifacts, the build plan. The directory
8// is written only by cabal-install and regenerates in full from `cabal build`, which is
9// why the standard Haskell `.gitignore` excludes it.
10//
11// Detection is on `cabal.project`, not on a lone `*.cabal` file: the project file is a
12// fixed name at a fixed place, marks the root the way `stack.yaml` does for Stack, and
13// a Stack project wrapping the same `.cabal` files is claimed by the stack adapter
14// instead. A repository driving cabal directly off one `.cabal` file with no project
15// file is left alone — narrower is safer than clever.
16//
17// Opt-in, and held to `build_idle_days`: getting `dist-newstyle/` back means
18// recompiling the project.
19
20use super::{BloatDir, EnforcePolicy, PackageManager, dir_size};
21use anyhow::{Result, anyhow};
22use std::fs;
23use std::path::Path;
24
25/// The project file at a Cabal project's root.
26const MANIFEST: &str = "cabal.project";
27
28/// cabal-install's per-project build tree.
29const CACHE_DIRS: &[&str] = &["dist-newstyle"];
30
31/// Haskell Cabal build tree adapter. Opt-in; see the module comment.
32pub struct Cabal;
33
34impl PackageManager for Cabal {
35    fn name(&self) -> &'static str {
36        "cabal"
37    }
38
39    fn detect(&self, path: &Path) -> bool {
40        path.join(MANIFEST).is_file()
41    }
42
43    fn bloat_dirs(&self, path: &Path) -> Vec<BloatDir> {
44        CACHE_DIRS
45            .iter()
46            .map(|name| path.join(name))
47            .filter(|dir| dir.is_dir())
48            .map(|dir| BloatDir {
49                name: dir
50                    .file_name()
51                    .unwrap_or_default()
52                    .to_string_lossy()
53                    .into_owned(),
54                size_bytes: dir_size(&dir),
55                path: dir,
56                shared_bytes: 0,
57            })
58            .collect()
59    }
60
61    /// What is checked is that `cabal.project` still declares its packages, because
62    /// `packages:` is the one field every project file carries and the one `cabal
63    /// build` rebuilds from. A file without it is whatever it is, not a project
64    /// cabal-install can rebuild. Running `cabal build` here to find out would start a
65    /// compile in the middle of a delete pass.
66    fn enforce_lockfile(&self, path: &Path, _policy: EnforcePolicy) -> Result<()> {
67        let manifest = path.join(MANIFEST);
68        let content = fs::read_to_string(&manifest).map_err(|e| {
69            anyhow!(
70                "`{MANIFEST}` could not be read ({e}): nothing to rebuild `dist-newstyle` from."
71            )
72        })?;
73        if !content.contains("packages") {
74            return Err(anyhow!(
75                "`{MANIFEST}` declares no `packages:`: refusing to treat the build tree \
76                 as regenerable from it."
77            ));
78        }
79        Ok(())
80    }
81
82    fn restore(&self, _path: &Path, _timeout: std::time::Duration) -> Result<()> {
83        println!("Cabal's build tree regenerates on the next `cabal 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 `cabal.project`.
103    fn project(dir: &Path) -> PathBuf {
104        fs::write(dir.join(MANIFEST), "packages: .\n").unwrap();
105        dir.to_path_buf()
106    }
107
108    fn claimed(project: &Path) -> Vec<String> {
109        Cabal
110            .bloat_dirs(project)
111            .into_iter()
112            .map(|b| b.name)
113            .collect()
114    }
115
116    #[test]
117    fn detects_on_the_project_file() {
118        let dir = tempdir().unwrap();
119        assert!(!Cabal.detect(dir.path()));
120        project(dir.path());
121        assert!(Cabal.detect(dir.path()));
122    }
123
124    #[test]
125    fn the_build_tree_is_claimed_and_nothing_else() {
126        let dir = tempdir().unwrap();
127        let root = project(dir.path());
128        fs::create_dir(root.join("dist-newstyle")).unwrap();
129        fs::create_dir(root.join("src")).unwrap();
130
131        assert_eq!(claimed(&root), vec!["dist-newstyle"]);
132    }
133
134    #[test]
135    fn a_missing_or_packageless_project_file_is_refused() {
136        let dir = tempdir().unwrap();
137        let policy = EnforcePolicy::default();
138        assert!(Cabal.enforce_lockfile(dir.path(), policy).is_err());
139        fs::write(dir.path().join(MANIFEST), "-- just a comment\n").unwrap();
140        assert!(Cabal.enforce_lockfile(dir.path(), policy).is_err());
141        project(dir.path());
142        assert!(Cabal.enforce_lockfile(dir.path(), policy).is_ok());
143    }
144
145    #[test]
146    fn cabal_is_opt_in() {
147        assert!(Cabal.opt_in());
148    }
149}