acp/commands/
migrate.rs

1//! @acp:module "Migrate Command"
2//! @acp:summary "Add directive suffixes to existing annotations (RFC-001)"
3//! @acp:domain cli
4//! @acp:layer service
5//!
6//! Implements `acp migrate --add-directives` command for upgrading annotations.
7
8use 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/// Options for the migrate command
20#[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/// A single annotation migration
40#[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
50/// RFC-001 default directives for annotation types
51pub 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        // Lock level defaults (RFC-001)
62        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-only defaults (for annotations without specific values)
96        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    /// Get the default directive for an annotation type and value
128    pub fn get(&self, annotation_type: &str, value: &str) -> Option<String> {
129        // Try exact match first
130        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        // Try type-only default
138        if let Some(directive) = self.type_defaults.get(annotation_type) {
139            return Some(directive.clone());
140        }
141
142        // Special case for ref - include the URL
143        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
157/// Scanner for finding annotations without directives
158pub struct MigrationScanner {
159    /// Pattern to match @acp: annotations without directive suffix
160    /// Captures: 1=type, 2=value (no trailing ` - `)
161    pattern: Regex,
162    defaults: DirectiveDefaults,
163}
164
165impl MigrationScanner {
166    pub fn new() -> Self {
167        // Match @acp:type with optional value
168        // The directive check ` - ` is done separately in scan_file
169        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    /// Scan a file for annotations needing migration
178    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            // Skip if already has directive suffix
184            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                // Get default directive
196                if let Some(directive) = self.defaults.get(&annotation_type, &annotation_value) {
197                    let original = line.to_string();
198
199                    // Build migrated line by inserting directive
200                    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                        // Insert ` - directive` after the value
207                        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    /// Scan all files in the cache
233    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            // Apply path filter if specified
244            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            // Skip if file doesn't exist (cache might be stale)
254            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        // Sort by file and line
267        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
279/// Apply migrations to source files
280pub 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    /// Create backup of a file before modification
292    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    /// Apply a set of migrations to a single file
307    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        // Create backup if requested
318        if backup {
319            self.backup_file(file_path)?;
320        }
321
322        // Read file content
323        let content = fs::read_to_string(file_path)?;
324        let mut lines: Vec<String> = content.lines().map(|s| s.to_string()).collect();
325
326        // Apply migrations in reverse order (to preserve line numbers)
327        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        // Write back
338        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
351/// Print migration preview (dry-run output)
352pub 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        // Print file header when file changes
367        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        // Print diff
386        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
394/// Execute the migrate command
395pub 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    // Group migrations by file
410    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        // Interactive confirmation
424        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        // Apply migrations
451        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        // These should match (no directive)
513        assert!(scanner.pattern.is_match("// @acp:lock frozen"));
514        assert!(scanner.pattern.is_match("// @acp:hack"));
515
516        // Verify capture groups
517        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}