Skip to main content

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}