Skip to main content

dev_prune/adapters/
mix.rs

1// Copyright 2026 VKrishna04
2// SPDX-License-Identifier: Apache-2.0
3
4// Mix adapter for Elixir and Erlang projects.
5//
6// `deps/` only. `_build/` is compiled output — beam files this tool has no lockfile
7// proof for — and dev-prune does not delete compiled output outside the two adapters
8// that ask for it by name. Deleting `deps/` alone is safe with `_build/` left in place:
9// the next `mix deps.get` refetches the sources and Mix recompiles only what changed.
10//
11// The proof is offline, for the same reason as CocoaPods: `mix deps.get` fixes drift by
12// fetching rather than reporting it, so what is checked is that `mix.lock` exists, is a
13// lockfile rather than a fragment, and is not older than the `mix.exs` it came from.
14
15use super::{
16    BloatDir, EnforcePolicy, PackageManager, dir_size, refuse_if_manifest_stale,
17    run_command_with_timeout,
18};
19use anyhow::{Result, anyhow};
20use std::fs;
21use std::path::Path;
22
23/// Mix adapter.
24pub struct Mix;
25
26impl PackageManager for Mix {
27    fn name(&self) -> &'static str {
28        "mix"
29    }
30
31    fn detect(&self, path: &Path) -> bool {
32        path.join("mix.exs").exists()
33    }
34
35    fn bloat_dirs(&self, path: &Path) -> Vec<BloatDir> {
36        let deps = path.join("deps");
37        if !deps.is_dir() {
38            return Vec::new();
39        }
40        vec![BloatDir {
41            name: "deps".to_string(),
42            path: deps.clone(),
43            size_bytes: dir_size(&deps),
44            shared_bytes: 0,
45        }]
46    }
47
48    fn enforce_lockfile(&self, path: &Path, _policy: EnforcePolicy) -> Result<()> {
49        let lock = path.join("mix.lock");
50        let content = fs::read_to_string(&lock).map_err(|e| {
51            anyhow!(
52                "`mix.lock` could not be read ({e}) — without it `mix deps.get` resolves \
53                 afresh instead of restoring the versions being deleted."
54            )
55        })?;
56        // A `mix.lock` is a single Elixir map literal: `%{"dep" => {:hex, ...}}`. A file
57        // that does not open one is a fragment or a merge conflict, not a lockfile.
58        if !content.contains("%{") {
59            return Err(anyhow!(
60                "`mix.lock` is not an Elixir map literal — it is not a complete Mix \
61                 lockfile, so `deps/` cannot be proven rebuildable from it."
62            ));
63        }
64        refuse_if_manifest_stale(&path.join("mix.exs"), &lock, "mix deps.get")
65    }
66
67    fn restore(&self, path: &Path, timeout: std::time::Duration) -> Result<()> {
68        run_command_with_timeout("mix", &["deps.get"], path, timeout)
69    }
70
71    fn lockfiles(&self) -> &'static [&'static str] {
72        &["mix.lock"]
73    }
74}
75
76#[cfg(test)]
77mod tests {
78    use super::*;
79    use tempfile::tempdir;
80
81    #[test]
82    fn detects_on_the_mix_manifest() {
83        let dir = tempdir().unwrap();
84        assert!(!Mix.detect(dir.path()));
85        fs::write(dir.path().join("mix.exs"), "defmodule X.MixProject do end").unwrap();
86        assert!(Mix.detect(dir.path()));
87    }
88
89    #[test]
90    fn claims_deps_and_never_the_build_tree() {
91        let dir = tempdir().unwrap();
92        fs::create_dir(dir.path().join("deps")).unwrap();
93        fs::create_dir(dir.path().join("_build")).unwrap();
94        let names: Vec<String> = Mix
95            .bloat_dirs(dir.path())
96            .into_iter()
97            .map(|b| b.name)
98            .collect();
99        assert_eq!(names, vec!["deps"]);
100    }
101
102    #[test]
103    fn a_missing_or_malformed_lockfile_is_refused() {
104        let dir = tempdir().unwrap();
105        assert!(
106            Mix.enforce_lockfile(dir.path(), EnforcePolicy::default())
107                .is_err()
108        );
109        fs::write(dir.path().join("mix.lock"), "<<<<<<< HEAD\n").unwrap();
110        assert!(
111            Mix.enforce_lockfile(dir.path(), EnforcePolicy::default())
112                .is_err()
113        );
114        fs::write(dir.path().join("mix.lock"), "%{\n  \"jason\": {:hex},\n}\n").unwrap();
115        assert!(
116            Mix.enforce_lockfile(dir.path(), EnforcePolicy::default())
117                .is_ok()
118        );
119    }
120}