1use crate::core::{Artifact, Project};
4use crate::error::Result;
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
8pub enum ProtectionLevel {
9 None,
11 #[default]
13 Warn,
14 Block,
16 Paranoid,
18}
19
20impl ProtectionLevel {
21 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 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#[derive(Debug, Clone)]
45pub struct ProtectionResult {
46 pub allowed: bool,
48 pub warnings: Vec<String>,
50 pub blocked_reason: Option<String>,
52 pub suggestion: Option<String>,
54}
55
56impl ProtectionResult {
57 pub fn allowed() -> Self {
59 Self {
60 allowed: true,
61 warnings: Vec::new(),
62 blocked_reason: None,
63 suggestion: None,
64 }
65 }
66
67 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 pub fn with_warning(mut self, warning: impl Into<String>) -> Self {
79 self.warnings.push(warning.into());
80 self
81 }
82
83 pub fn with_suggestion(mut self, suggestion: impl Into<String>) -> Self {
85 self.suggestion = Some(suggestion.into());
86 self
87 }
88
89 pub fn has_warnings(&self) -> bool {
91 !self.warnings.is_empty()
92 }
93}
94
95pub 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 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 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
175pub 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 if let Some(status) = &project.git_status {
189 for dirty_path in &status.dirty_paths {
190 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 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
238pub 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}