1use std::collections::HashMap;
9use std::fs;
10use std::path::{Path, PathBuf};
11
12use console::style;
13use dialoguer::Confirm;
14use regex::Regex;
15
16use crate::cache::Cache;
17use crate::error::Result;
18
19#[derive(Debug, Clone)]
21pub struct MigrateOptions {
22 pub paths: Vec<PathBuf>,
23 pub dry_run: bool,
24 pub interactive: bool,
25 pub backup: bool,
26}
27
28impl Default for MigrateOptions {
29 fn default() -> Self {
30 Self {
31 paths: vec![],
32 dry_run: false,
33 interactive: false,
34 backup: true,
35 }
36 }
37}
38
39#[derive(Debug, Clone)]
41pub struct AnnotationMigration {
42 pub file: PathBuf,
43 pub line: usize,
44 pub original: String,
45 pub migrated: String,
46 pub annotation_type: String,
47 pub annotation_value: String,
48}
49
50pub struct DirectiveDefaults {
52 defaults: HashMap<(String, String), String>,
53 type_defaults: HashMap<String, String>,
54}
55
56impl DirectiveDefaults {
57 pub fn new() -> Self {
58 let mut defaults = HashMap::new();
59 let mut type_defaults = HashMap::new();
60
61 defaults.insert(
63 ("lock".to_string(), "frozen".to_string()),
64 "MUST NOT modify this code under any circumstances".to_string(),
65 );
66 defaults.insert(
67 ("lock".to_string(), "restricted".to_string()),
68 "Explain proposed changes and wait for explicit approval".to_string(),
69 );
70 defaults.insert(
71 ("lock".to_string(), "approval-required".to_string()),
72 "Propose changes and request confirmation before applying".to_string(),
73 );
74 defaults.insert(
75 ("lock".to_string(), "tests-required".to_string()),
76 "All changes must include corresponding tests".to_string(),
77 );
78 defaults.insert(
79 ("lock".to_string(), "docs-required".to_string()),
80 "All changes must update documentation".to_string(),
81 );
82 defaults.insert(
83 ("lock".to_string(), "review-required".to_string()),
84 "Changes require code review before merging".to_string(),
85 );
86 defaults.insert(
87 ("lock".to_string(), "normal".to_string()),
88 "Safe to modify following project conventions".to_string(),
89 );
90 defaults.insert(
91 ("lock".to_string(), "experimental".to_string()),
92 "Experimental code - changes welcome but may be unstable".to_string(),
93 );
94
95 type_defaults.insert(
97 "hack".to_string(),
98 "Temporary workaround - check expiry before modifying".to_string(),
99 );
100 type_defaults.insert(
101 "deprecated".to_string(),
102 "Do not use or extend - see replacement annotation".to_string(),
103 );
104 type_defaults.insert(
105 "todo".to_string(),
106 "Pending work item - address before release".to_string(),
107 );
108 type_defaults.insert(
109 "fixme".to_string(),
110 "Known issue requiring fix - prioritize resolution".to_string(),
111 );
112 type_defaults.insert(
113 "critical".to_string(),
114 "Critical section - changes require extra review".to_string(),
115 );
116 type_defaults.insert(
117 "perf".to_string(),
118 "Performance-sensitive code - benchmark any changes".to_string(),
119 );
120
121 Self {
122 defaults,
123 type_defaults,
124 }
125 }
126
127 pub fn get(&self, annotation_type: &str, value: &str) -> Option<String> {
129 if let Some(directive) = self
131 .defaults
132 .get(&(annotation_type.to_string(), value.to_string()))
133 {
134 return Some(directive.clone());
135 }
136
137 if let Some(directive) = self.type_defaults.get(annotation_type) {
139 return Some(directive.clone());
140 }
141
142 if annotation_type == "ref" {
144 return Some(format!("Consult {} before making changes", value));
145 }
146
147 None
148 }
149}
150
151impl Default for DirectiveDefaults {
152 fn default() -> Self {
153 Self::new()
154 }
155}
156
157pub struct MigrationScanner {
159 pattern: Regex,
162 defaults: DirectiveDefaults,
163}
164
165impl MigrationScanner {
166 pub fn new() -> Self {
167 let pattern = Regex::new(r"@acp:([\w-]+)(?:\s+(.+))?").expect("Invalid regex pattern");
170
171 Self {
172 pattern,
173 defaults: DirectiveDefaults::new(),
174 }
175 }
176
177 pub fn scan_file(&self, file_path: &Path) -> Result<Vec<AnnotationMigration>> {
179 let content = fs::read_to_string(file_path)?;
180 let mut migrations = vec![];
181
182 for (line_num, line) in content.lines().enumerate() {
183 if line.contains(" - ") && line.contains("@acp:") {
185 continue;
186 }
187
188 if let Some(cap) = self.pattern.captures(line) {
189 let annotation_type = cap.get(1).unwrap().as_str().to_string();
190 let annotation_value = cap
191 .get(2)
192 .map(|m| m.as_str().trim().to_string())
193 .unwrap_or_default();
194
195 if let Some(directive) = self.defaults.get(&annotation_type, &annotation_value) {
197 let original = line.to_string();
198
199 let migrated = if annotation_value.is_empty() {
201 line.replace(
202 &format!("@acp:{}", annotation_type),
203 &format!("@acp:{} - {}", annotation_type, directive),
204 )
205 } else {
206 let full_match = cap.get(0).unwrap().as_str();
208 let replacement = format!(
209 "@acp:{} {} - {}",
210 annotation_type,
211 annotation_value.trim(),
212 directive
213 );
214 line.replace(full_match.trim(), &replacement)
215 };
216
217 migrations.push(AnnotationMigration {
218 file: file_path.to_path_buf(),
219 line: line_num + 1,
220 original,
221 migrated,
222 annotation_type,
223 annotation_value,
224 });
225 }
226 }
227 }
228
229 Ok(migrations)
230 }
231
232 pub fn scan_cache(
234 &self,
235 cache: &Cache,
236 filter_paths: &[PathBuf],
237 ) -> Result<Vec<AnnotationMigration>> {
238 let mut all_migrations = vec![];
239
240 for path in cache.files.keys() {
241 let file_path = PathBuf::from(path);
242
243 if !filter_paths.is_empty() {
245 let matches = filter_paths.iter().any(|p| {
246 file_path.starts_with(p) || path.starts_with(p.to_string_lossy().as_ref())
247 });
248 if !matches {
249 continue;
250 }
251 }
252
253 if !file_path.exists() {
255 continue;
256 }
257
258 match self.scan_file(&file_path) {
259 Ok(migrations) => all_migrations.extend(migrations),
260 Err(e) => {
261 eprintln!("Warning: Could not scan {}: {}", path, e);
262 }
263 }
264 }
265
266 all_migrations.sort_by(|a, b| a.file.cmp(&b.file).then(a.line.cmp(&b.line)));
268
269 Ok(all_migrations)
270 }
271}
272
273impl Default for MigrationScanner {
274 fn default() -> Self {
275 Self::new()
276 }
277}
278
279pub struct MigrationWriter {
281 backup_dir: PathBuf,
282}
283
284impl MigrationWriter {
285 pub fn new() -> Self {
286 Self {
287 backup_dir: PathBuf::from(".acp/backups"),
288 }
289 }
290
291 fn backup_file(&self, file_path: &Path) -> Result<()> {
293 fs::create_dir_all(&self.backup_dir)?;
294
295 let backup_name = format!(
296 "{}-{}",
297 chrono::Utc::now().format("%Y%m%d-%H%M%S"),
298 file_path.file_name().unwrap().to_string_lossy()
299 );
300 let backup_path = self.backup_dir.join(backup_name);
301
302 fs::copy(file_path, backup_path)?;
303 Ok(())
304 }
305
306 pub fn apply_migrations(
308 &self,
309 file_path: &Path,
310 migrations: &[&AnnotationMigration],
311 backup: bool,
312 ) -> Result<()> {
313 if migrations.is_empty() {
314 return Ok(());
315 }
316
317 if backup {
319 self.backup_file(file_path)?;
320 }
321
322 let content = fs::read_to_string(file_path)?;
324 let mut lines: Vec<String> = content.lines().map(|s| s.to_string()).collect();
325
326 let mut sorted_migrations: Vec<_> = migrations.iter().collect();
328 sorted_migrations.sort_by(|a, b| b.line.cmp(&a.line));
329
330 for migration in sorted_migrations {
331 let line_idx = migration.line - 1;
332 if line_idx < lines.len() {
333 lines[line_idx] = migration.migrated.clone();
334 }
335 }
336
337 let new_content = lines.join("\n");
339 fs::write(file_path, new_content)?;
340
341 Ok(())
342 }
343}
344
345impl Default for MigrationWriter {
346 fn default() -> Self {
347 Self::new()
348 }
349}
350
351pub fn print_migration_preview(migrations: &[AnnotationMigration]) {
353 if migrations.is_empty() {
354 println!("{}", style("No annotations need migration.").green());
355 return;
356 }
357
358 println!(
359 "Would update {} annotations:\n",
360 style(migrations.len()).bold()
361 );
362
363 let mut current_file: Option<&PathBuf> = None;
364
365 for migration in migrations {
366 if current_file != Some(&migration.file) {
368 if current_file.is_some() {
369 println!();
370 }
371 println!(
372 " {}:{}",
373 style(migration.file.display()).cyan(),
374 migration.line
375 );
376 current_file = Some(&migration.file);
377 } else {
378 println!(
379 " {}:{}",
380 style(migration.file.display()).cyan(),
381 migration.line
382 );
383 }
384
385 println!(" {} {}", style("-").red(), migration.original.trim());
387 println!(" {} {}", style("+").green(), migration.migrated.trim());
388 println!();
389 }
390
391 println!("{}", style("Run without --dry-run to apply changes.").dim());
392}
393
394pub fn execute_migrate(cache: &Cache, options: MigrateOptions) -> Result<()> {
396 let scanner = MigrationScanner::new();
397 let migrations = scanner.scan_cache(cache, &options.paths)?;
398
399 if options.dry_run {
400 print_migration_preview(&migrations);
401 return Ok(());
402 }
403
404 if migrations.is_empty() {
405 println!("{}", style("No annotations need migration.").green());
406 return Ok(());
407 }
408
409 let mut by_file: HashMap<PathBuf, Vec<&AnnotationMigration>> = HashMap::new();
411 for migration in &migrations {
412 by_file
413 .entry(migration.file.clone())
414 .or_default()
415 .push(migration);
416 }
417
418 let writer = MigrationWriter::new();
419 let mut applied_count = 0;
420 let mut skipped_count = 0;
421
422 for (file_path, file_migrations) in &by_file {
423 if options.interactive {
425 println!(
426 "\n{} ({} annotations):",
427 style(file_path.display()).cyan().bold(),
428 file_migrations.len()
429 );
430
431 for m in file_migrations.iter() {
432 println!(
433 " Line {}: @acp:{} {}",
434 m.line, m.annotation_type, m.annotation_value
435 );
436 }
437
438 let confirmed = Confirm::new()
439 .with_prompt("Apply these migrations?")
440 .default(true)
441 .interact()
442 .map_err(|e| std::io::Error::other(e.to_string()))?;
443
444 if !confirmed {
445 skipped_count += file_migrations.len();
446 continue;
447 }
448 }
449
450 match writer.apply_migrations(file_path, file_migrations, options.backup) {
452 Ok(()) => {
453 applied_count += file_migrations.len();
454 println!(
455 "{} Updated {} ({} annotations)",
456 style("✓").green(),
457 file_path.display(),
458 file_migrations.len()
459 );
460 }
461 Err(e) => {
462 eprintln!(
463 "{} Failed to update {}: {}",
464 style("✗").red(),
465 file_path.display(),
466 e
467 );
468 skipped_count += file_migrations.len();
469 }
470 }
471 }
472
473 println!();
474 println!(
475 "{} Applied {} migrations, skipped {}",
476 style("Done.").bold(),
477 style(applied_count).green(),
478 style(skipped_count).yellow()
479 );
480
481 Ok(())
482}
483
484#[cfg(test)]
485mod tests {
486 use super::*;
487
488 #[test]
489 fn test_directive_defaults() {
490 let defaults = DirectiveDefaults::new();
491
492 assert_eq!(
493 defaults.get("lock", "frozen"),
494 Some("MUST NOT modify this code under any circumstances".to_string())
495 );
496
497 assert_eq!(
498 defaults.get("hack", ""),
499 Some("Temporary workaround - check expiry before modifying".to_string())
500 );
501
502 assert_eq!(
503 defaults.get("ref", "https://docs.example.com"),
504 Some("Consult https://docs.example.com before making changes".to_string())
505 );
506 }
507
508 #[test]
509 fn test_migration_scanner_pattern() {
510 let scanner = MigrationScanner::new();
511
512 assert!(scanner.pattern.is_match("// @acp:lock frozen"));
514 assert!(scanner.pattern.is_match("// @acp:hack"));
515
516 let cap = scanner.pattern.captures("// @acp:lock frozen").unwrap();
518 assert_eq!(cap.get(1).unwrap().as_str(), "lock");
519 assert_eq!(cap.get(2).unwrap().as_str(), "frozen");
520 }
521}