Skip to main content

null_e/git/
protection.rs

1//! Git protection - safety guards for cleaning operations
2
3use crate::core::{Artifact, Project};
4use crate::error::Result;
5
6/// Protection level for cleaning operations
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
8pub enum ProtectionLevel {
9    /// No protection - delete anything (dangerous!)
10    None,
11    /// Warn but allow cleaning projects with uncommitted changes
12    #[default]
13    Warn,
14    /// Block cleaning projects with uncommitted changes
15    Block,
16    /// Paranoid: require explicit confirmation for everything
17    Paranoid,
18}
19
20impl ProtectionLevel {
21    /// Parse from string
22    pub fn from_str(s: &str) -> Option<Self> {
23        match s.to_lowercase().as_str() {
24            "none" => Some(Self::None),
25            "warn" => Some(Self::Warn),
26            "block" => Some(Self::Block),
27            "paranoid" => Some(Self::Paranoid),
28            _ => None,
29        }
30    }
31
32    /// Convert to string
33    pub fn as_str(&self) -> &'static str {
34        match self {
35            Self::None => "none",
36            Self::Warn => "warn",
37            Self::Block => "block",
38            Self::Paranoid => "paranoid",
39        }
40    }
41}
42
43/// Result of a protection check
44#[derive(Debug, Clone)]
45pub struct ProtectionResult {
46    /// Whether cleaning is allowed
47    pub allowed: bool,
48    /// Warnings to show the user
49    pub warnings: Vec<String>,
50    /// Reason if blocked
51    pub blocked_reason: Option<String>,
52    /// Suggested action
53    pub suggestion: Option<String>,
54}
55
56impl ProtectionResult {
57    /// Create an allowed result
58    pub fn allowed() -> Self {
59        Self {
60            allowed: true,
61            warnings: Vec::new(),
62            blocked_reason: None,
63            suggestion: None,
64        }
65    }
66
67    /// Create a blocked result
68    pub fn blocked(reason: impl Into<String>) -> Self {
69        Self {
70            allowed: false,
71            warnings: Vec::new(),
72            blocked_reason: Some(reason.into()),
73            suggestion: None,
74        }
75    }
76
77    /// Add a warning
78    pub fn with_warning(mut self, warning: impl Into<String>) -> Self {
79        self.warnings.push(warning.into());
80        self
81    }
82
83    /// Add a suggestion
84    pub fn with_suggestion(mut self, suggestion: impl Into<String>) -> Self {
85        self.suggestion = Some(suggestion.into());
86        self
87    }
88
89    /// Check if there are any warnings
90    pub fn has_warnings(&self) -> bool {
91        !self.warnings.is_empty()
92    }
93}
94
95/// Check if it's safe to clean a project
96pub fn check_project_protection(
97    project: &Project,
98    level: ProtectionLevel,
99) -> ProtectionResult {
100    if level == ProtectionLevel::None {
101        return ProtectionResult::allowed();
102    }
103
104    let mut result = ProtectionResult::allowed();
105
106    // Check git status
107    match &project.git_status {
108        Some(status) if status.has_uncommitted => {
109            let msg = format!(
110                "Project '{}' has uncommitted changes ({} files)",
111                project.name,
112                status.dirty_paths.len()
113            );
114
115            match level {
116                ProtectionLevel::Warn => {
117                    result = result.with_warning(msg);
118                    result = result.with_suggestion("Commit or stash changes first");
119                }
120                ProtectionLevel::Block | ProtectionLevel::Paranoid => {
121                    return ProtectionResult::blocked(msg)
122                        .with_suggestion("Use --force to override or commit changes first");
123                }
124                _ => {}
125            }
126        }
127        Some(status) if status.has_untracked => {
128            let msg = format!("Project '{}' has untracked files", project.name);
129            result = result.with_warning(msg);
130        }
131        None => {
132            let msg = format!(
133                "Project '{}' is not a git repository - cannot verify safety",
134                project.name
135            );
136
137            match level {
138                ProtectionLevel::Paranoid => {
139                    return ProtectionResult::blocked(msg)
140                        .with_suggestion("Initialize a git repo or use --force");
141                }
142                _ => {
143                    result = result.with_warning(msg);
144                }
145            }
146        }
147        _ => {}
148    }
149
150    // Check if recently modified
151    if let Some(modified) = project.last_modified {
152        if let Ok(age) = modified.elapsed() {
153            let days = age.as_secs() / 86400;
154            if days < 7 {
155                let msg = format!(
156                    "Project '{}' was modified recently ({} days ago)",
157                    project.name, days
158                );
159
160                match level {
161                    ProtectionLevel::Paranoid => {
162                        return ProtectionResult::blocked(msg);
163                    }
164                    _ => {
165                        result = result.with_warning(msg);
166                    }
167                }
168            }
169        }
170    }
171
172    result
173}
174
175/// Check if a specific artifact is safe to clean
176pub fn check_artifact_protection(
177    artifact: &Artifact,
178    project: &Project,
179    level: ProtectionLevel,
180) -> ProtectionResult {
181    if level == ProtectionLevel::None {
182        return ProtectionResult::allowed();
183    }
184
185    let mut result = ProtectionResult::allowed();
186
187    // Check if artifact path contains uncommitted changes
188    if let Some(status) = &project.git_status {
189        for dirty_path in &status.dirty_paths {
190            // Check if dirty path is inside artifact path
191            if dirty_path.starts_with(&artifact.path) || artifact.path.starts_with(dirty_path) {
192                let msg = format!(
193                    "Artifact '{}' contains uncommitted changes",
194                    artifact.path.display()
195                );
196
197                match level {
198                    ProtectionLevel::Warn => {
199                        result = result.with_warning(msg);
200                    }
201                    ProtectionLevel::Block | ProtectionLevel::Paranoid => {
202                        return ProtectionResult::blocked(msg);
203                    }
204                    _ => {}
205                }
206            }
207        }
208    }
209
210    // Check artifact safety level
211    match artifact.kind.default_safety() {
212        crate::core::ArtifactSafety::NeverAuto => {
213            return ProtectionResult::blocked(format!(
214                "Artifact '{}' should never be auto-deleted",
215                artifact.name()
216            ));
217        }
218        crate::core::ArtifactSafety::RequiresConfirmation
219            if level == ProtectionLevel::Paranoid =>
220        {
221            return ProtectionResult::blocked(format!(
222                "Artifact '{}' requires explicit confirmation",
223                artifact.name()
224            ));
225        }
226        crate::core::ArtifactSafety::SafeWithLockfile if artifact.metadata.lockfile.is_none() => {
227            result = result.with_warning(format!(
228                "No lockfile found for '{}' - reinstallation may change versions",
229                artifact.name()
230            ));
231        }
232        _ => {}
233    }
234
235    result
236}
237
238/// Add git status to all projects in a list
239pub fn enrich_with_git_status(projects: &mut [Project]) -> Result<()> {
240    use rayon::prelude::*;
241
242    projects.par_iter_mut().for_each(|project| {
243        if let Ok(status) = super::get_git_status(&project.root) {
244            project.git_status = status;
245        }
246    });
247
248    Ok(())
249}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254    use crate::core::{GitStatus, ProjectKind};
255    use std::path::PathBuf;
256
257    fn create_test_project(has_uncommitted: bool) -> Project {
258        let mut project = Project::new(ProjectKind::NodeNpm, PathBuf::from("/test"));
259
260        if has_uncommitted {
261            project.git_status = Some(GitStatus {
262                is_repo: true,
263                has_uncommitted: true,
264                dirty_paths: vec![PathBuf::from("src/index.js")],
265                ..Default::default()
266            });
267        } else {
268            project.git_status = Some(GitStatus {
269                is_repo: true,
270                has_uncommitted: false,
271                ..Default::default()
272            });
273        }
274
275        project
276    }
277
278    #[test]
279    fn test_protection_none_allows_everything() {
280        let project = create_test_project(true);
281        let result = check_project_protection(&project, ProtectionLevel::None);
282        assert!(result.allowed);
283        assert!(result.warnings.is_empty());
284    }
285
286    #[test]
287    fn test_protection_warn_uncommitted() {
288        let project = create_test_project(true);
289        let result = check_project_protection(&project, ProtectionLevel::Warn);
290        assert!(result.allowed);
291        assert!(result.has_warnings());
292    }
293
294    #[test]
295    fn test_protection_block_uncommitted() {
296        let project = create_test_project(true);
297        let result = check_project_protection(&project, ProtectionLevel::Block);
298        assert!(!result.allowed);
299        assert!(result.blocked_reason.is_some());
300    }
301
302    #[test]
303    fn test_protection_clean_repo_allowed() {
304        let project = create_test_project(false);
305        let result = check_project_protection(&project, ProtectionLevel::Block);
306        assert!(result.allowed);
307    }
308
309    #[test]
310    fn test_protection_no_git_repo() {
311        let mut project = Project::new(ProjectKind::NodeNpm, PathBuf::from("/test"));
312        project.git_status = None;
313
314        let result = check_project_protection(&project, ProtectionLevel::Warn);
315        assert!(result.allowed);
316        assert!(result.has_warnings());
317
318        let result = check_project_protection(&project, ProtectionLevel::Paranoid);
319        assert!(!result.allowed);
320    }
321
322    #[test]
323    fn test_protection_level_from_str() {
324        assert_eq!(ProtectionLevel::from_str("none"), Some(ProtectionLevel::None));
325        assert_eq!(ProtectionLevel::from_str("WARN"), Some(ProtectionLevel::Warn));
326        assert_eq!(ProtectionLevel::from_str("Block"), Some(ProtectionLevel::Block));
327        assert_eq!(ProtectionLevel::from_str("invalid"), None);
328    }
329}