devclean/model.rs
1use std::fmt;
2use std::path::PathBuf;
3
4use clap::ValueEnum;
5use serde::{Deserialize, Serialize};
6
7/// A class of rebuildable development data.
8#[derive(
9 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, ValueEnum,
10)]
11#[serde(rename_all = "kebab-case")]
12pub enum Category {
13 /// Cargo compilation outputs with Rust-specific markers.
14 RustTarget,
15 /// JavaScript dependency installations.
16 NodeModules,
17 /// Framework caches such as `.next` and `.svelte-kit`.
18 FrameworkCache,
19 /// Build directories underneath a recognized project manifest.
20 BuildOutput,
21 /// Test, mutation, type-checker, and lint caches.
22 TestCache,
23 /// Python bytecode, test-runner environments, and other reproducible interpreter caches.
24 PythonCache,
25 /// Project-local Python virtual environments with a direct dependency manifest.
26 PythonEnvironment,
27 /// Downloaded package-manager or tool caches.
28 GlobalCache,
29 /// Large runtimes or model caches that are expensive to restore.
30 ExpensiveGlobalCache,
31}
32
33/// Confidence assigned to a filesystem observation.
34#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
35#[serde(rename_all = "kebab-case")]
36pub enum Confidence {
37 /// A known rebuildable artifact with filesystem-verifiable evidence.
38 #[default]
39 Safe,
40 /// A cache-like directory that must be reviewed before a cleanup rule is added.
41 Review,
42 /// A directory protected from cleanup because it may contain source or user data.
43 Protected,
44}
45
46/// A narrowly-scoped cleanup rule that Learning Mode can propose for explicit approval.
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
48#[serde(rename_all = "kebab-case")]
49pub enum ReviewRule {
50 /// A `.build` directory directly beside a Swift Package manifest.
51 SwiftPackageBuild,
52 /// A `DerivedData` directory directly beside an Xcode project or workspace.
53 XcodeDerivedData,
54 /// A `.gradle` directory directly beside a Gradle build or settings script.
55 GradleBuild,
56 /// A `Pods` directory directly beside both a `CocoaPods` `Podfile` and `Podfile.lock`.
57 CocoaPods,
58}
59
60/// A declarative, config-defined cleanup rule with exact names and direct project markers.
61#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
62#[serde(deny_unknown_fields)]
63pub struct CustomRule {
64 /// Stable rule name shown in reports.
65 pub name: String,
66 /// Existing cleanup category used for filtering and presentation.
67 pub category: Category,
68 /// Exact directory names this rule may classify.
69 pub directory_names: Vec<String>,
70 /// Files that must exist directly beside the candidate.
71 pub required_markers: Vec<String>,
72 /// Human-readable evidence shown in cleanup plans.
73 pub reason: String,
74}
75
76impl ReviewRule {
77 /// Returns every rule Learning Mode can suggest, in suggestion order.
78 #[must_use]
79 pub fn all() -> [Self; 4] {
80 [
81 Self::SwiftPackageBuild,
82 Self::XcodeDerivedData,
83 Self::GradleBuild,
84 Self::CocoaPods,
85 ]
86 }
87}
88
89impl Category {
90 /// Returns all categories discovered by a comprehensive scan.
91 #[must_use]
92 pub fn all() -> [Self; 9] {
93 [
94 Self::RustTarget,
95 Self::NodeModules,
96 Self::FrameworkCache,
97 Self::BuildOutput,
98 Self::TestCache,
99 Self::PythonCache,
100 Self::PythonEnvironment,
101 Self::GlobalCache,
102 Self::ExpensiveGlobalCache,
103 ]
104 }
105
106 /// Returns conservative categories used by `clean` unless overridden.
107 #[must_use]
108 pub fn safe_defaults() -> [Self; 5] {
109 [
110 Self::RustTarget,
111 Self::NodeModules,
112 Self::FrameworkCache,
113 Self::PythonCache,
114 Self::PythonEnvironment,
115 ]
116 }
117}
118
119impl fmt::Display for Category {
120 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
121 let value = match self {
122 Self::RustTarget => "rust-target",
123 Self::NodeModules => "node-modules",
124 Self::FrameworkCache => "framework-cache",
125 Self::BuildOutput => "build-output",
126 Self::TestCache => "test-cache",
127 Self::PythonCache => "python-cache",
128 Self::PythonEnvironment => "python-environment",
129 Self::GlobalCache => "global-cache",
130 Self::ExpensiveGlobalCache => "expensive-global-cache",
131 };
132 formatter.pad(value)
133 }
134}
135
136/// A directory that can be rebuilt or downloaded again.
137#[derive(Debug, Clone, Serialize, Deserialize)]
138pub struct Candidate {
139 /// Artifact category.
140 pub category: Category,
141 /// Absolute or user-supplied path to the artifact.
142 pub path: PathBuf,
143 /// Estimated allocated bytes on disk.
144 pub bytes: u64,
145 /// Evidence used to classify the directory.
146 pub reason: String,
147 /// Latest observed modification time as seconds since the Unix epoch.
148 pub modified_at_unix: Option<u64>,
149 /// Safety confidence assigned by the scanner.
150 #[serde(default)]
151 pub confidence: Confidence,
152 /// Explicit learned rule that authorized this candidate, if any.
153 #[serde(default, skip_serializing_if = "Option::is_none")]
154 pub approved_rule: Option<ReviewRule>,
155 /// Declarative rule that classified this candidate, if configured by the user or team.
156 #[serde(default, skip_serializing_if = "Option::is_none")]
157 pub custom_rule: Option<CustomRule>,
158}
159
160/// A large cache-like directory observed by Learning Mode but never selected for cleanup.
161#[derive(Debug, Clone, Serialize, Deserialize)]
162pub struct ReviewCandidate {
163 /// Absolute path retained only in the local report.
164 pub path: PathBuf,
165 /// Estimated allocated bytes on disk.
166 pub bytes: u64,
167 /// Evidence that made the directory interesting to Learning Mode.
168 pub reason: String,
169 /// Latest observed modification time as seconds since the Unix epoch.
170 pub modified_at_unix: Option<u64>,
171 /// Review candidates are never promoted to safe without an explicit product rule.
172 pub confidence: Confidence,
173 /// Product rule that can safely constrain an approval for this path.
174 #[serde(default, skip_serializing_if = "Option::is_none")]
175 pub suggested_rule: Option<ReviewRule>,
176 /// Project root to which the suggested rule would be scoped.
177 #[serde(default, skip_serializing_if = "Option::is_none")]
178 pub project_root: Option<PathBuf>,
179 /// Whether the user has approved the suggested rule for this exact path.
180 #[serde(default)]
181 pub approved: bool,
182}
183
184/// One local-only Learning Mode measurement independent of cleanup eligibility filters.
185#[derive(Debug, Clone, Serialize, Deserialize)]
186pub struct LearningObservation {
187 /// Observed artifact path. Remote telemetry must never include this value.
188 pub path: PathBuf,
189 /// Known category when the scanner can classify the artifact safely.
190 pub category: Option<Category>,
191 /// Estimated allocated bytes on disk.
192 pub bytes: u64,
193 /// Filesystem evidence behind the observation.
194 pub reason: String,
195 /// Latest observed modification time as seconds since the Unix epoch.
196 pub modified_at_unix: Option<u64>,
197 /// Safety confidence independent of cleanup age and size filters.
198 pub confidence: Confidence,
199}
200
201/// Result of scanning one or more roots.
202#[derive(Debug, Clone, Serialize, Deserialize)]
203pub struct ScanReport {
204 /// Roots traversed by the scanner.
205 pub roots: Vec<PathBuf>,
206 /// Rebuildable directories found.
207 pub candidates: Vec<Candidate>,
208 /// Cache-like directories that require review and cannot be cleaned yet.
209 #[serde(default)]
210 pub review_candidates: Vec<ReviewCandidate>,
211 /// Local measurements used for growth history, including active artifacts filtered from cleanup.
212 #[serde(default)]
213 pub learning_observations: Vec<LearningObservation>,
214 /// Non-fatal traversal or metadata errors.
215 pub warnings: Vec<String>,
216 /// Total estimated allocated bytes for all candidates.
217 pub total_bytes: u64,
218 /// Total allocated bytes represented by review-only observations.
219 #[serde(default)]
220 pub review_total_bytes: u64,
221 /// Total allocated bytes represented by Learning Mode measurements.
222 #[serde(default)]
223 pub observed_total_bytes: u64,
224 /// Whether cleanup must repeat the Git tracked-file guard.
225 #[serde(default = "default_true")]
226 pub protect_git_tracked: bool,
227}
228
229const fn default_true() -> bool {
230 true
231}
232
233/// Human- or machine-readable report format.
234#[derive(Debug, Clone, Copy, ValueEnum)]
235pub enum OutputFormat {
236 /// Compact terminal table.
237 Table,
238 /// Structured JSON.
239 Json,
240 /// One JSON object per line for streaming automation.
241 Jsonl,
242 /// Standalone HTML document.
243 Html,
244}
245
246/// Controls presentation without changing the underlying scan result.
247#[derive(Debug, Clone, Copy, Default)]
248pub struct RenderOptions {
249 /// Replace absolute paths with stable root-relative placeholders.
250 pub redact_paths: bool,
251}
252
253#[cfg(test)]
254mod tests {
255 use super::*;
256
257 #[test]
258 fn review_rule_wire_names_should_match_the_macos_app_contract() {
259 let encoded: Vec<String> = ReviewRule::all()
260 .iter()
261 .map(|rule| serde_json::to_string(rule).expect("rule serializes"))
262 .collect();
263
264 assert_eq!(
265 encoded,
266 [
267 r#""swift-package-build""#,
268 r#""xcode-derived-data""#,
269 r#""gradle-build""#,
270 r#""cocoa-pods""#,
271 ]
272 );
273 }
274}