acp/annotate/
writer.rs

1//! @acp:module "Annotation Writer"
2//! @acp:summary "Generates diffs and applies annotation changes to source files"
3//! @acp:domain cli
4//! @acp:layer service
5//! @acp:stability experimental
6//!
7//! # Annotation Writer
8//!
9//! Provides functionality for:
10//! - Generating unified diffs for preview mode
11//! - Applying annotations to source files
12//! - Handling comment syntax for different languages
13//! - Preserving existing documentation
14
15use std::collections::HashSet;
16use std::path::Path;
17
18use similar::TextDiff;
19
20use crate::error::Result;
21
22use super::{AnalysisResult, FileChange, ProvenanceConfig, Suggestion};
23
24/// @acp:summary "Comment style for different languages"
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum CommentStyle {
27    /// JSDoc: /** ... */
28    JsDoc,
29    /// Python docstring: """..."""
30    PyDocstring,
31    /// Rust doc: ///
32    RustDoc,
33    /// Rust module doc: //!
34    RustModuleDoc,
35    /// Go: //
36    GoDoc,
37    /// Javadoc: /** ... */
38    Javadoc,
39}
40
41impl CommentStyle {
42    /// @acp:summary "Determines comment style from language and context"
43    pub fn from_language(language: &str, is_module_level: bool) -> Self {
44        match language {
45            "typescript" | "javascript" => Self::JsDoc,
46            "python" => Self::PyDocstring,
47            "rust" => {
48                if is_module_level {
49                    Self::RustModuleDoc
50                } else {
51                    Self::RustDoc
52                }
53            }
54            "go" => Self::GoDoc,
55            "java" => Self::Javadoc,
56            _ => Self::JsDoc, // Default to JSDoc style
57        }
58    }
59
60    /// @acp:summary "Formats annotations into a comment block"
61    pub fn format_annotations(&self, annotations: &[Suggestion], indent: &str) -> String {
62        if annotations.is_empty() {
63            return String::new();
64        }
65
66        match self {
67            Self::JsDoc | Self::Javadoc => {
68                let mut lines = vec![format!("{}/**", indent)];
69                for ann in annotations {
70                    lines.push(format!("{} * {}", indent, ann.to_annotation_string()));
71                }
72                lines.push(format!("{} */", indent));
73                lines.join("\n")
74            }
75            Self::PyDocstring => {
76                let mut lines = vec![format!("{}\"\"\"", indent)];
77                for ann in annotations {
78                    lines.push(format!("{}{}", indent, ann.to_annotation_string()));
79                }
80                lines.push(format!("{}\"\"\"", indent));
81                lines.join("\n")
82            }
83            Self::RustDoc => annotations
84                .iter()
85                .map(|ann| format!("{}/// {}", indent, ann.to_annotation_string()))
86                .collect::<Vec<_>>()
87                .join("\n"),
88            Self::RustModuleDoc => annotations
89                .iter()
90                .map(|ann| format!("{}//! {}", indent, ann.to_annotation_string()))
91                .collect::<Vec<_>>()
92                .join("\n"),
93            Self::GoDoc => annotations
94                .iter()
95                .map(|ann| format!("{}// {}", indent, ann.to_annotation_string()))
96                .collect::<Vec<_>>()
97                .join("\n"),
98        }
99    }
100
101    /// @acp:summary "Formats annotations for insertion into existing doc comment"
102    /// Places ACP annotations at the beginning of the comment.
103    pub fn format_for_insertion(&self, annotations: &[Suggestion], indent: &str) -> Vec<String> {
104        match self {
105            Self::JsDoc | Self::Javadoc => annotations
106                .iter()
107                .map(|ann| format!("{} * {}", indent, ann.to_annotation_string()))
108                .collect(),
109            Self::PyDocstring => annotations
110                .iter()
111                .map(|ann| format!("{}{}", indent, ann.to_annotation_string()))
112                .collect(),
113            Self::RustDoc => annotations
114                .iter()
115                .map(|ann| format!("{}/// {}", indent, ann.to_annotation_string()))
116                .collect(),
117            Self::RustModuleDoc => annotations
118                .iter()
119                .map(|ann| format!("{}//! {}", indent, ann.to_annotation_string()))
120                .collect(),
121            Self::GoDoc => annotations
122                .iter()
123                .map(|ann| format!("{}// {}", indent, ann.to_annotation_string()))
124                .collect(),
125        }
126    }
127
128    /// @acp:summary "Formats annotations with RFC-0003 provenance markers"
129    pub fn format_annotations_with_provenance(
130        &self,
131        annotations: &[Suggestion],
132        indent: &str,
133        config: &ProvenanceConfig,
134    ) -> String {
135        if annotations.is_empty() {
136            return String::new();
137        }
138
139        // Collect all annotation lines (main + provenance markers)
140        let all_lines: Vec<String> = annotations
141            .iter()
142            .flat_map(|ann| ann.to_annotation_strings_with_provenance(config))
143            .collect();
144
145        match self {
146            Self::JsDoc | Self::Javadoc => {
147                let mut lines = vec![format!("{}/**", indent)];
148                for line in all_lines {
149                    lines.push(format!("{} * {}", indent, line));
150                }
151                lines.push(format!("{} */", indent));
152                lines.join("\n")
153            }
154            Self::PyDocstring => {
155                let mut lines = vec![format!("{}\"\"\"", indent)];
156                for line in all_lines {
157                    lines.push(format!("{}{}", indent, line));
158                }
159                lines.push(format!("{}\"\"\"", indent));
160                lines.join("\n")
161            }
162            Self::RustDoc => all_lines
163                .iter()
164                .map(|line| format!("{}/// {}", indent, line))
165                .collect::<Vec<_>>()
166                .join("\n"),
167            Self::RustModuleDoc => all_lines
168                .iter()
169                .map(|line| format!("{}//! {}", indent, line))
170                .collect::<Vec<_>>()
171                .join("\n"),
172            Self::GoDoc => all_lines
173                .iter()
174                .map(|line| format!("{}// {}", indent, line))
175                .collect::<Vec<_>>()
176                .join("\n"),
177        }
178    }
179
180    /// @acp:summary "Formats annotations for insertion with RFC-0003 provenance markers"
181    pub fn format_for_insertion_with_provenance(
182        &self,
183        annotations: &[Suggestion],
184        indent: &str,
185        config: &ProvenanceConfig,
186    ) -> Vec<String> {
187        // Collect all annotation lines (main + provenance markers)
188        let all_lines: Vec<String> = annotations
189            .iter()
190            .flat_map(|ann| ann.to_annotation_strings_with_provenance(config))
191            .collect();
192
193        match self {
194            Self::JsDoc | Self::Javadoc => all_lines
195                .iter()
196                .map(|line| format!("{} * {}", indent, line))
197                .collect(),
198            Self::PyDocstring => all_lines
199                .iter()
200                .map(|line| format!("{}{}", indent, line))
201                .collect(),
202            Self::RustDoc => all_lines
203                .iter()
204                .map(|line| format!("{}/// {}", indent, line))
205                .collect(),
206            Self::RustModuleDoc => all_lines
207                .iter()
208                .map(|line| format!("{}//! {}", indent, line))
209                .collect(),
210            Self::GoDoc => all_lines
211                .iter()
212                .map(|line| format!("{}// {}", indent, line))
213                .collect(),
214        }
215    }
216}
217
218/// @acp:summary "Writes annotations to files and generates diffs"
219/// @acp:lock normal
220pub struct Writer {
221    /// Whether to preserve existing documentation
222    preserve_existing: bool,
223    /// RFC-0003: Provenance configuration (None = no provenance markers)
224    provenance_config: Option<ProvenanceConfig>,
225}
226
227impl Writer {
228    /// @acp:summary "Creates a new writer"
229    pub fn new() -> Self {
230        Self {
231            preserve_existing: true,
232            provenance_config: None,
233        }
234    }
235
236    /// @acp:summary "Sets whether to preserve existing documentation"
237    pub fn with_preserve_existing(mut self, preserve: bool) -> Self {
238        self.preserve_existing = preserve;
239        self
240    }
241
242    /// @acp:summary "Sets RFC-0003 provenance configuration"
243    pub fn with_provenance(mut self, config: ProvenanceConfig) -> Self {
244        self.provenance_config = Some(config);
245        self
246    }
247
248    /// @acp:summary "Plans changes to apply to a file"
249    ///
250    /// Groups suggestions by target and line, creating FileChange entries
251    /// that can be used for diff generation or application.
252    pub fn plan_changes(
253        &self,
254        file_path: &Path,
255        suggestions: &[Suggestion],
256        analysis: &AnalysisResult,
257    ) -> Result<Vec<FileChange>> {
258        let mut changes: Vec<FileChange> = Vec::new();
259        let path_str = file_path.to_string_lossy().to_string();
260
261        // Group suggestions by target
262        let mut by_target: std::collections::HashMap<String, Vec<&Suggestion>> =
263            std::collections::HashMap::new();
264
265        for suggestion in suggestions {
266            by_target
267                .entry(suggestion.target.clone())
268                .or_default()
269                .push(suggestion);
270        }
271
272        // Create FileChange for each target
273        for (target, target_suggestions) in by_target {
274            if target_suggestions.is_empty() {
275                continue;
276            }
277
278            let line = target_suggestions[0].line;
279            let is_file_level = target_suggestions[0].is_file_level();
280
281            let mut change = FileChange::new(&path_str, line);
282
283            if !is_file_level {
284                change = change.with_symbol(&target);
285            }
286
287            // Find existing doc comment for this target
288            if let Some(gap) = analysis.gaps.iter().find(|g| g.target == target) {
289                if gap.doc_comment.is_some() {
290                    if let Some((start, end)) = gap.doc_comment_range {
291                        // Use the actual doc comment line range
292                        change = change.with_existing_doc(start, end);
293                    } else if line > 1 {
294                        // Fallback: assume doc comment is just the line before symbol
295                        change = change.with_existing_doc(line - 1, line - 1);
296                    }
297                }
298            }
299
300            // Add all suggestions
301            for suggestion in target_suggestions {
302                change.add_annotation(suggestion.clone());
303            }
304
305            changes.push(change);
306        }
307
308        // Sort by line number (descending for bottom-up application)
309        changes.sort_by(|a, b| b.line.cmp(&a.line));
310
311        Ok(changes)
312    }
313
314    /// @acp:summary "Generates a unified diff for preview"
315    pub fn generate_diff(&self, file_path: &Path, changes: &[FileChange]) -> Result<String> {
316        let original = std::fs::read_to_string(file_path)?;
317        let modified =
318            self.apply_to_content(&original, changes, &self.detect_language(file_path))?;
319
320        let diff = generate_unified_diff(&file_path.to_string_lossy(), &original, &modified);
321
322        Ok(diff)
323    }
324
325    /// @acp:summary "Applies changes to file content"
326    fn apply_to_content(
327        &self,
328        content: &str,
329        changes: &[FileChange],
330        language: &str,
331    ) -> Result<String> {
332        let mut lines: Vec<String> = content.lines().map(|s| s.to_string()).collect();
333
334        // Sort changes by line (descending) to apply from bottom to top
335        let mut sorted_changes = changes.to_vec();
336        sorted_changes.sort_by(|a, b| b.line.cmp(&a.line));
337
338        for change in &sorted_changes {
339            let is_module_level = change.symbol_name.is_none();
340            let style = CommentStyle::from_language(language, is_module_level);
341
342            // Detect indentation from the target line
343            let indent = if change.line > 0 && change.line <= lines.len() {
344                let target_line = &lines[change.line - 1];
345                let trimmed = target_line.trim_start();
346                &target_line[..target_line.len() - trimmed.len()]
347            } else {
348                ""
349            };
350
351            if change.existing_doc_start.is_some() {
352                // Insert into existing doc comment
353                // Place ACP annotations after the opening line
354                let insert_line = change.existing_doc_start.unwrap();
355                let doc_end = change.existing_doc_end.unwrap_or(insert_line + 20);
356
357                // Check for existing @acp: annotations in the doc comment range
358                let existing_in_range: HashSet<String> = lines
359                    [insert_line.saturating_sub(1)..doc_end.min(lines.len())]
360                    .iter()
361                    .filter_map(|line| {
362                        if line.contains("@acp:") {
363                            // Extract the annotation type and value for comparison
364                            // e.g., "@acp:summary \"something\"" -> "@acp:summary"
365                            let trimmed = line.trim();
366                            if let Some(start) = trimmed.find("@acp:") {
367                                let ann_part = &trimmed[start..];
368                                // Get just the annotation type (e.g., "@acp:summary")
369                                let type_end = ann_part
370                                    .find(|c: char| c.is_whitespace() || c == '"')
371                                    .unwrap_or(ann_part.len());
372                                Some(ann_part[..type_end].to_string())
373                            } else {
374                                None
375                            }
376                        } else {
377                            None
378                        }
379                    })
380                    .collect();
381
382                // Filter out annotations that already exist (by type)
383                let new_annotations: Vec<_> = change
384                    .annotations
385                    .iter()
386                    .filter(|ann| {
387                        let ann_type = format!("@acp:{}", ann.annotation_type.namespace());
388                        !existing_in_range.contains(&ann_type)
389                    })
390                    .cloned()
391                    .collect();
392
393                if new_annotations.is_empty() {
394                    continue; // Nothing new to add, skip this change
395                }
396
397                // Use provenance-aware formatting if configured (RFC-0003)
398                let annotation_lines = if let Some(ref config) = self.provenance_config {
399                    style.format_for_insertion_with_provenance(&new_annotations, indent, config)
400                } else {
401                    style.format_for_insertion(&new_annotations, indent)
402                };
403
404                for (i, ann_line) in annotation_lines.into_iter().enumerate() {
405                    let insert_at = insert_line + i; // After the opening line
406                    if insert_at <= lines.len() {
407                        lines.insert(insert_at, ann_line);
408                    }
409                }
410            } else {
411                // Create new doc comment before the target line
412                // Use provenance-aware formatting if configured (RFC-0003)
413                let comment_block = if let Some(ref config) = self.provenance_config {
414                    style.format_annotations_with_provenance(&change.annotations, indent, config)
415                } else {
416                    style.format_annotations(&change.annotations, indent)
417                };
418
419                if !comment_block.is_empty() {
420                    let insert_at = if change.line > 0 { change.line - 1 } else { 0 };
421
422                    // Insert comment block lines
423                    for (i, line) in comment_block.lines().enumerate() {
424                        lines.insert(insert_at + i, line.to_string());
425                    }
426                }
427            }
428        }
429
430        Ok(lines.join("\n"))
431    }
432
433    /// @acp:summary "Applies changes to a file on disk"
434    pub fn apply_changes(&self, file_path: &Path, changes: &[FileChange]) -> Result<()> {
435        let content = std::fs::read_to_string(file_path)?;
436        let language = self.detect_language(file_path);
437        let modified = self.apply_to_content(&content, changes, &language)?;
438
439        std::fs::write(file_path, modified)?;
440        Ok(())
441    }
442
443    /// @acp:summary "Detects language from file extension"
444    fn detect_language(&self, path: &Path) -> String {
445        path.extension()
446            .and_then(|ext| ext.to_str())
447            .map(|ext| match ext {
448                "ts" | "tsx" => "typescript",
449                "js" | "jsx" | "mjs" | "cjs" => "javascript",
450                "py" | "pyi" => "python",
451                "rs" => "rust",
452                "go" => "go",
453                "java" => "java",
454                _ => "unknown",
455            })
456            .unwrap_or("unknown")
457            .to_string()
458    }
459}
460
461impl Default for Writer {
462    fn default() -> Self {
463        Self::new()
464    }
465}
466
467/// @acp:summary "Generates a unified diff between original and modified content"
468pub fn generate_unified_diff(file_path: &str, original: &str, modified: &str) -> String {
469    let diff = TextDiff::from_lines(original, modified);
470
471    // Use the built-in unified diff formatter
472    diff.unified_diff()
473        .context_radius(3)
474        .header(&format!("a/{}", file_path), &format!("b/{}", file_path))
475        .to_string()
476}
477
478#[cfg(test)]
479mod tests {
480    use super::*;
481    use crate::annotate::SuggestionSource;
482
483    #[test]
484    fn test_comment_style_from_language() {
485        assert_eq!(
486            CommentStyle::from_language("typescript", false),
487            CommentStyle::JsDoc
488        );
489        assert_eq!(
490            CommentStyle::from_language("python", false),
491            CommentStyle::PyDocstring
492        );
493        assert_eq!(
494            CommentStyle::from_language("rust", false),
495            CommentStyle::RustDoc
496        );
497        assert_eq!(
498            CommentStyle::from_language("rust", true),
499            CommentStyle::RustModuleDoc
500        );
501    }
502
503    #[test]
504    fn test_format_annotations_jsdoc() {
505        let annotations = vec![
506            Suggestion::summary("test", 1, "Test summary", SuggestionSource::Heuristic),
507            Suggestion::domain("test", 1, "authentication", SuggestionSource::Heuristic),
508        ];
509
510        let formatted = CommentStyle::JsDoc.format_annotations(&annotations, "");
511
512        assert!(formatted.contains("/**"));
513        assert!(formatted.contains("@acp:summary \"Test summary\""));
514        assert!(formatted.contains("@acp:domain authentication"));
515        assert!(formatted.contains(" */"));
516    }
517
518    #[test]
519    fn test_format_annotations_rust() {
520        let annotations = vec![Suggestion::summary(
521            "test",
522            1,
523            "Test summary",
524            SuggestionSource::Heuristic,
525        )];
526
527        let formatted = CommentStyle::RustDoc.format_annotations(&annotations, "");
528        assert!(formatted.contains("/// @acp:summary \"Test summary\""));
529
530        let formatted_module = CommentStyle::RustModuleDoc.format_annotations(&annotations, "");
531        assert!(formatted_module.contains("//! @acp:summary \"Test summary\""));
532    }
533
534    #[test]
535    fn test_generate_unified_diff() {
536        let original = "line 1\nline 2\nline 3";
537        let modified = "line 1\nnew line\nline 2\nline 3";
538
539        let diff = generate_unified_diff("test.txt", original, modified);
540
541        assert!(diff.contains("--- a/test.txt"));
542        assert!(diff.contains("+++ b/test.txt"));
543        assert!(diff.contains("+new line"));
544    }
545}