///|
pub(all) enum RiskLevel {
Low
Medium
High
} derive(Eq, Debug, ToJson, FromJson)
///|
pub(all) enum RuleCondition {
PathPattern(String)
Extension(String)
MinimumSize(Int64)
OlderThan(Int64)
EmptyDirectory
Any(Array[RuleCondition])
All(Array[RuleCondition])
Not(RuleCondition)
} derive(Eq, Debug, ToJson, FromJson)
///|
pub(all) struct CleanupRule {
id : String
title : String
reason : String
risk : RiskLevel
regenerable : Bool
condition : RuleCondition
} derive(Eq, Debug, ToJson, FromJson)
///|
pub(all) struct RuleMatch {
rule_id : String
rule_title : String
path : String
size : Int64
reason : String
risk : RiskLevel
regenerable : Bool
} derive(Eq, Debug, ToJson, FromJson)
///|
fn normalized_extension(value : String) -> String {
let lower = value.to_lower()
if lower.has_prefix(".") {
lower[1:].to_owned()
} else {
lower
}
}
///|
pub fn condition_matches(
condition : RuleCondition,
entry : Entry,
snapshot : Snapshot,
now : Int64,
) -> Bool {
match condition {
PathPattern(pattern) =>
wildcard_match(normalize_relative_path(pattern), entry.path) ||
wildcard_match(normalize_relative_path(pattern), entry.name())
Extension(extension) =>
entry.is_file() && entry.extension() == normalized_extension(extension)
MinimumSize(bytes) => entry.is_file() && entry.size >= bytes
OlderThan(seconds) =>
entry.is_file() &&
now >= entry.modified_at &&
now - entry.modified_at >= seconds
EmptyDirectory =>
if !entry.is_dir() {
false
} else {
match stat_for_directory(snapshot, entry.path) {
Some(stat) => stat.recursive_files == 0
None => true
}
}
Any(conditions) => {
for item in conditions {
if condition_matches(item, entry, snapshot, now) {
return true
}
}
false
}
All(conditions) => {
for item in conditions {
if !condition_matches(item, entry, snapshot, now) {
return false
}
}
true
}
Not(inner) => !condition_matches(inner, entry, snapshot, now)
}
}
///|
pub fn evaluate_rule(
rule : CleanupRule,
snapshot : Snapshot,
now : Int64,
) -> Array[RuleMatch] {
let result : Array[RuleMatch] = []
for entry in snapshot.entries {
if condition_matches(rule.condition, entry, snapshot, now) {
result.push(RuleMatch::{
rule_id: rule.id,
rule_title: rule.title,
path: entry.path,
size: if entry.is_file() {
entry.size
} else if entry.is_dir() {
match stat_for_directory(snapshot, entry.path) {
Some(stat) => stat.recursive_size
None => 0L
}
} else {
0L
},
reason: rule.reason,
risk: rule.risk,
regenerable: rule.regenerable,
})
}
}
result
}
///|
pub fn evaluate_rules(
rules : Array[CleanupRule],
snapshot : Snapshot,
now : Int64,
) -> Array[RuleMatch] {
let result : Array[RuleMatch] = []
let seen : Map[String, Bool] = Map([])
for rule in rules {
for candidate in evaluate_rule(rule, snapshot, now) {
let key = candidate.rule_id + "\n" + candidate.path
if seen.get(key) is None {
seen[key] = true
result.push(candidate)
}
}
}
result.sort_by(fn(a, b) {
if a.size > b.size {
-1
} else if a.size < b.size {
1
} else if a.path < b.path {
-1
} else {
1
}
})
result
}
///|
pub fn builtin_cleanup_rules() -> Array[CleanupRule] {
[
CleanupRule::{
id: "moonbit-build",
title: "MoonBit build output",
reason: "The _build directory can be regenerated by the MoonBit toolchain.",
risk: Low,
regenerable: true,
condition: Any([PathPattern("_build"), PathPattern("*/_build")]),
},
CleanupRule::{
id: "node-modules",
title: "Node.js dependency directory",
reason: "node_modules can normally be restored from the dependency lockfile.",
risk: Low,
regenerable: true,
condition: Any([
PathPattern("node_modules"),
PathPattern("*/node_modules"),
]),
},
CleanupRule::{
id: "python-cache",
title: "Python bytecode cache",
reason: "Python recreates __pycache__ directories when modules are imported.",
risk: Low,
regenerable: true,
condition: Any([PathPattern("__pycache__"), PathPattern("*/__pycache__")]),
},
CleanupRule::{
id: "temporary-files",
title: "Temporary file",
reason: "The name or extension follows a common temporary-file convention.",
risk: Medium,
regenerable: false,
condition: Any([
Extension("tmp"),
Extension("temp"),
PathPattern("*~"),
PathPattern("*.bak"),
]),
},
CleanupRule::{
id: "old-logs",
title: "Old log file",
reason: "The file is a log and has not been modified for at least 90 days.",
risk: Medium,
regenerable: false,
condition: All([Extension("log"), OlderThan(7776000L)]),
},
CleanupRule::{
id: "empty-directory",
title: "Empty directory",
reason: "The directory contains no regular files in the snapshot.",
risk: Medium,
regenerable: false,
condition: EmptyDirectory,
},
]
}
///|
pub fn risk_name(risk : RiskLevel) -> String {
match risk {
Low => "low"
Medium => "medium"
High => "high"
}
}