Skip to main content

ljos_cli/
tree_cleanup.rs

1//! Deletes inside the working tree that lose no work: a build output
2//! directory, a scratch directory at the top of the tree, or a `find`
3//! cleanup of files a tool writes again. The static guard allows these, as
4//! `ljos-policyd` does, so a stock seat with no rules of its own does not
5//! stop `rm -rf target/` or `find . -name '*.pyc' -delete`. The lists and
6//! the reading are the ones in ljos-policyd's `src/lib.rs`; keep the two in
7//! step.
8
9/// Directories a build, a package manager or a test run writes and can
10/// write again.
11const BUILD_OUTPUT_DIRS: &[&str] = &[
12    "node_modules",
13    "target",
14    "dist",
15    "build",
16    "_build",
17    ".venv",
18    "venv",
19    "__pycache__",
20    ".pytest_cache",
21    ".mypy_cache",
22    ".ruff_cache",
23    ".tox",
24    ".nox",
25    ".next",
26    ".nuxt",
27    ".svelte-kit",
28    ".turbo",
29    ".parcel-cache",
30    ".angular",
31    ".gradle",
32    "coverage",
33    "htmlcov",
34];
35
36/// Scratch directories a project keeps at its top for throwaway output.
37const SCRATCH_DIRS: &[&str] = &["tmp", ".tmp", "temp"];
38
39/// Names a compiler, an interpreter, an editor or a desktop writes and
40/// writes again.
41const REGENERABLE_NAMES: &[&str] = &[
42    "*.pyc",
43    "*.pyo",
44    "__pycache__",
45    ".pytest_cache",
46    ".mypy_cache",
47    ".ruff_cache",
48    "*.o",
49    "*.obj",
50    "*.class",
51    "*.gcda",
52    "*.gcno",
53    "*.orig",
54    "*.rej",
55    "*.swp",
56    "*.swo",
57    "*~",
58    ".DS_Store",
59    "._*",
60    "Thumbs.db",
61    "desktop.ini",
62];
63
64/// A path written out in plain characters that stays below the working
65/// directory: not absolute, no `~`, no `$`, no glob, no `..` and no `.`
66/// part. A leading `./` and a trailing `/` are allowed.
67fn plain_relative(p: &str) -> bool {
68    let mut rest = p;
69    while let Some(r) = rest.strip_prefix("./") {
70        rest = r;
71    }
72    let rest = rest.trim_end_matches('/');
73    !rest.is_empty()
74        && !rest.starts_with('-')
75        && rest
76            .chars()
77            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-' | '+' | '@' | '/'))
78        && rest
79            .split('/')
80            .all(|part| !part.is_empty() && part != "." && part != "..")
81}
82
83/// A plain relative path inside a build output directory, or the
84/// directory itself: `target/`, `./build`, `web/node_modules`. A path
85/// through `.git` is not one.
86#[must_use]
87pub fn build_output(p: &str) -> bool {
88    plain_relative(p)
89        && !p.split('/').any(|part| part == ".git")
90        && p.split('/').any(|part| BUILD_OUTPUT_DIRS.contains(&part))
91}
92
93/// A plain relative path under a scratch directory at the top of the tree,
94/// or the directory itself: `tmp/`, `./tmp/test-output`. A scratch name
95/// deeper down (`src/tmp`) says nothing about what the path holds.
96#[must_use]
97pub fn scratch(p: &str) -> bool {
98    let mut rest = p;
99    while let Some(r) = rest.strip_prefix("./") {
100        rest = r;
101    }
102    plain_relative(p)
103        && rest
104            .split('/')
105            .next()
106            .is_some_and(|first| SCRATCH_DIRS.contains(&first))
107        && !rest.split('/').any(|part| part == ".git")
108}
109
110/// A `cd`, `pushd` or `popd` that can leave the working tree: an absolute
111/// target, `~`, `-`, `..`, a variable, or none at all (home). `words` is
112/// one command, wrappers already stripped.
113#[must_use]
114pub fn leaves_tree(words: &[String]) -> bool {
115    let Some(first) = words.first() else {
116        return false;
117    };
118    let name = first.rsplit('/').next().unwrap_or(first);
119    if name == "popd" {
120        return true;
121    }
122    if name != "cd" && name != "pushd" {
123        return false;
124    }
125    match words[1..].iter().find(|a| !a.starts_with('-') || *a == "-") {
126        Some(t) => !plain_relative(t),
127        None => true,
128    }
129}
130
131/// A `find` cleanup inside the working tree: every starting point is `.`
132/// or a plain relative path outside `.git`, and every name test names a
133/// file in [`REGENERABLE_NAMES`], with at least one. An `-o`, a `!`, a
134/// `-not` or a group can turn a name test around, so none passes.
135/// `find . -delete` has no name test and `find . -name '*.rs' -delete`
136/// names source, so both stay refused.
137#[must_use]
138pub fn regenerable_cleanup(starts: &[&str], expr: &[&str]) -> bool {
139    let in_tree = |p: &&str| {
140        let p = p.trim_end_matches('/');
141        (p == "." || plain_relative(p)) && !p.split('/').any(|part| part == ".git")
142    };
143    if !starts.iter().all(in_tree) {
144        return false;
145    }
146    if expr.iter().any(|a| {
147        matches!(
148            *a,
149            "-o" | "-or" | "!" | "-not" | "," | "(" | ")" | "\\(" | "\\)"
150        )
151    }) {
152        return false;
153    }
154    let mut named = 0;
155    for w in expr.windows(2) {
156        match w[0] {
157            "-name" | "-iname" => {
158                if !REGENERABLE_NAMES.contains(&w[1]) {
159                    return false;
160                }
161                named += 1;
162            }
163            "-path" | "-ipath" | "-wholename" => {
164                let parts: Vec<&str> = w[1].split('/').filter(|p| *p != "*" && *p != ".").collect();
165                if parts.is_empty() || !parts.iter().all(|p| REGENERABLE_NAMES.contains(p)) {
166                    return false;
167                }
168                named += 1;
169            }
170            "-regex" | "-iregex" | "-lname" | "-ilname" => return false,
171            _ => {}
172        }
173    }
174    named > 0
175}