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 => annotations
76                .iter()
77                .map(|ann| format!("{}# {}", indent, ann.to_annotation_string()))
78                .collect::<Vec<_>>()
79                .join("\n"),
80            Self::RustDoc => annotations
81                .iter()
82                .map(|ann| format!("{}/// {}", indent, ann.to_annotation_string()))
83                .collect::<Vec<_>>()
84                .join("\n"),
85            Self::RustModuleDoc => annotations
86                .iter()
87                .map(|ann| format!("{}//! {}", indent, ann.to_annotation_string()))
88                .collect::<Vec<_>>()
89                .join("\n"),
90            Self::GoDoc => annotations
91                .iter()
92                .map(|ann| format!("{}// {}", indent, ann.to_annotation_string()))
93                .collect::<Vec<_>>()
94                .join("\n"),
95        }
96    }
97
98    /// @acp:summary "Formats annotations for insertion into existing doc comment"
99    /// Places ACP annotations at the beginning of the comment.
100    pub fn format_for_insertion(&self, annotations: &[Suggestion], indent: &str) -> Vec<String> {
101        match self {
102            Self::JsDoc | Self::Javadoc => annotations
103                .iter()
104                .map(|ann| format!("{} * {}", indent, ann.to_annotation_string()))
105                .collect(),
106            Self::PyDocstring => annotations
107                .iter()
108                .map(|ann| format!("{}# {}", indent, ann.to_annotation_string()))
109                .collect(),
110            Self::RustDoc => annotations
111                .iter()
112                .map(|ann| format!("{}/// {}", indent, ann.to_annotation_string()))
113                .collect(),
114            Self::RustModuleDoc => annotations
115                .iter()
116                .map(|ann| format!("{}//! {}", indent, ann.to_annotation_string()))
117                .collect(),
118            Self::GoDoc => annotations
119                .iter()
120                .map(|ann| format!("{}// {}", indent, ann.to_annotation_string()))
121                .collect(),
122        }
123    }
124
125    /// @acp:summary "Formats annotations with RFC-0003 provenance markers"
126    pub fn format_annotations_with_provenance(
127        &self,
128        annotations: &[Suggestion],
129        indent: &str,
130        config: &ProvenanceConfig,
131    ) -> String {
132        if annotations.is_empty() {
133            return String::new();
134        }
135
136        // Collect all annotation lines (main + provenance markers)
137        let all_lines: Vec<String> = annotations
138            .iter()
139            .flat_map(|ann| ann.to_annotation_strings_with_provenance(config))
140            .collect();
141
142        match self {
143            Self::JsDoc | Self::Javadoc => {
144                let mut lines = vec![format!("{}/**", indent)];
145                for line in all_lines {
146                    lines.push(format!("{} * {}", indent, line));
147                }
148                lines.push(format!("{} */", indent));
149                lines.join("\n")
150            }
151            Self::PyDocstring => all_lines
152                .iter()
153                .map(|line| format!("{}# {}", indent, line))
154                .collect::<Vec<_>>()
155                .join("\n"),
156            Self::RustDoc => all_lines
157                .iter()
158                .map(|line| format!("{}/// {}", indent, line))
159                .collect::<Vec<_>>()
160                .join("\n"),
161            Self::RustModuleDoc => all_lines
162                .iter()
163                .map(|line| format!("{}//! {}", indent, line))
164                .collect::<Vec<_>>()
165                .join("\n"),
166            Self::GoDoc => all_lines
167                .iter()
168                .map(|line| format!("{}// {}", indent, line))
169                .collect::<Vec<_>>()
170                .join("\n"),
171        }
172    }
173
174    /// @acp:summary "Formats annotations for insertion with RFC-0003 provenance markers"
175    pub fn format_for_insertion_with_provenance(
176        &self,
177        annotations: &[Suggestion],
178        indent: &str,
179        config: &ProvenanceConfig,
180    ) -> Vec<String> {
181        // Collect all annotation lines (main + provenance markers)
182        let all_lines: Vec<String> = annotations
183            .iter()
184            .flat_map(|ann| ann.to_annotation_strings_with_provenance(config))
185            .collect();
186
187        match self {
188            Self::JsDoc | Self::Javadoc => all_lines
189                .iter()
190                .map(|line| format!("{} * {}", indent, line))
191                .collect(),
192            Self::PyDocstring => all_lines
193                .iter()
194                .map(|line| format!("{}# {}", indent, line))
195                .collect(),
196            Self::RustDoc => all_lines
197                .iter()
198                .map(|line| format!("{}/// {}", indent, line))
199                .collect(),
200            Self::RustModuleDoc => all_lines
201                .iter()
202                .map(|line| format!("{}//! {}", indent, line))
203                .collect(),
204            Self::GoDoc => all_lines
205                .iter()
206                .map(|line| format!("{}// {}", indent, line))
207                .collect(),
208        }
209    }
210}
211
212/// @acp:summary "Writes annotations to files and generates diffs"
213/// @acp:lock normal
214pub struct Writer {
215    /// Whether to preserve existing documentation
216    preserve_existing: bool,
217    /// RFC-0003: Provenance configuration (None = no provenance markers)
218    provenance_config: Option<ProvenanceConfig>,
219}
220
221impl Writer {
222    /// @acp:summary "Creates a new writer"
223    pub fn new() -> Self {
224        Self {
225            preserve_existing: true,
226            provenance_config: None,
227        }
228    }
229
230    /// @acp:summary "Sets whether to preserve existing documentation"
231    pub fn with_preserve_existing(mut self, preserve: bool) -> Self {
232        self.preserve_existing = preserve;
233        self
234    }
235
236    /// @acp:summary "Sets RFC-0003 provenance configuration"
237    pub fn with_provenance(mut self, config: ProvenanceConfig) -> Self {
238        self.provenance_config = Some(config);
239        self
240    }
241
242    /// @acp:summary "Plans changes to apply to a file"
243    ///
244    /// Groups suggestions by target and line, creating FileChange entries
245    /// that can be used for diff generation or application.
246    pub fn plan_changes(
247        &self,
248        file_path: &Path,
249        suggestions: &[Suggestion],
250        analysis: &AnalysisResult,
251    ) -> Result<Vec<FileChange>> {
252        let mut changes: Vec<FileChange> = Vec::new();
253        let path_str = file_path.to_string_lossy().to_string();
254
255        // Group suggestions by target
256        let mut by_target: std::collections::HashMap<String, Vec<&Suggestion>> =
257            std::collections::HashMap::new();
258
259        for suggestion in suggestions {
260            by_target
261                .entry(suggestion.target.clone())
262                .or_default()
263                .push(suggestion);
264        }
265
266        // Create FileChange for each target
267        for (target, target_suggestions) in by_target {
268            if target_suggestions.is_empty() {
269                continue;
270            }
271
272            // Use effective_insertion_line (before decorators/attributes) for placement
273            let insertion_line = target_suggestions[0].effective_insertion_line();
274            let is_file_level = target_suggestions[0].is_file_level();
275
276            let mut change = FileChange::new(&path_str, insertion_line);
277
278            if !is_file_level {
279                change = change.with_symbol(&target);
280            }
281
282            // Find existing doc comment for this target
283            if let Some(gap) = analysis.gaps.iter().find(|g| g.target == target) {
284                if gap.doc_comment.is_some() {
285                    if let Some((start, end)) = gap.doc_comment_range {
286                        // Use the actual doc comment line range
287                        change = change.with_existing_doc(start, end);
288                    } else if insertion_line > 1 {
289                        // Fallback: assume doc comment is just the line before symbol
290                        change = change.with_existing_doc(insertion_line - 1, insertion_line - 1);
291                    }
292                }
293            }
294
295            // Add all suggestions
296            for suggestion in target_suggestions {
297                change.add_annotation(suggestion.clone());
298            }
299
300            changes.push(change);
301        }
302
303        // Sort by line number (descending for bottom-up application)
304        changes.sort_by(|a, b| b.line.cmp(&a.line));
305
306        Ok(changes)
307    }
308
309    /// @acp:summary "Generates a unified diff for preview"
310    pub fn generate_diff(&self, file_path: &Path, changes: &[FileChange]) -> Result<String> {
311        let original = std::fs::read_to_string(file_path)?;
312        let modified =
313            self.apply_to_content(&original, changes, &self.detect_language(file_path))?;
314
315        let diff = generate_unified_diff(&file_path.to_string_lossy(), &original, &modified);
316
317        Ok(diff)
318    }
319
320    /// @acp:summary "Applies changes to file content"
321    fn apply_to_content(
322        &self,
323        content: &str,
324        changes: &[FileChange],
325        language: &str,
326    ) -> Result<String> {
327        let mut lines: Vec<String> = content.lines().map(|s| s.to_string()).collect();
328
329        // Sort changes by line (descending) to apply from bottom to top
330        let mut sorted_changes = changes.to_vec();
331        sorted_changes.sort_by(|a, b| b.line.cmp(&a.line));
332
333        for change in &sorted_changes {
334            let is_module_level = change.symbol_name.is_none();
335            let style = CommentStyle::from_language(language, is_module_level);
336
337            // Detect indentation from the target line
338            let indent = if change.line > 0 && change.line <= lines.len() {
339                let target_line = &lines[change.line - 1];
340                let trimmed = target_line.trim_start();
341                &target_line[..target_line.len() - trimmed.len()]
342            } else {
343                ""
344            };
345
346            // For Python/Go style (# or // comments), ALWAYS insert before the symbol
347            // regardless of existing docstrings (since docstrings are inside the body, not before)
348            let is_line_comment_style =
349                matches!(style, CommentStyle::PyDocstring | CommentStyle::GoDoc);
350
351            if change.existing_doc_start.is_some() && !is_line_comment_style {
352                // Insert into existing doc comment (JSDoc, Javadoc, etc.)
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 inside the doc comment
358                let search_start = insert_line.saturating_sub(1);
359                let search_end = doc_end.min(lines.len());
360
361                // Check for existing @acp: annotations in the appropriate range
362                let existing_in_range: HashSet<String> = lines
363                    [search_start..search_end.min(lines.len())]
364                    .iter()
365                    .filter_map(|line| {
366                        if line.contains("@acp:") {
367                            // Extract the annotation type and value for comparison
368                            // e.g., "@acp:summary \"something\"" -> "@acp:summary"
369                            let trimmed = line.trim();
370                            if let Some(start) = trimmed.find("@acp:") {
371                                let ann_part = &trimmed[start..];
372                                // Get just the annotation type (e.g., "@acp:summary")
373                                let type_end = ann_part
374                                    .find(|c: char| c.is_whitespace() || c == '"')
375                                    .unwrap_or(ann_part.len());
376                                Some(ann_part[..type_end].to_string())
377                            } else {
378                                None
379                            }
380                        } else {
381                            None
382                        }
383                    })
384                    .collect();
385
386                // Filter out annotations that already exist (by type)
387                let new_annotations: Vec<_> = change
388                    .annotations
389                    .iter()
390                    .filter(|ann| {
391                        let ann_type = format!("@acp:{}", ann.annotation_type.namespace());
392                        !existing_in_range.contains(&ann_type)
393                    })
394                    .cloned()
395                    .collect();
396
397                if new_annotations.is_empty() {
398                    continue; // Nothing new to add, skip this change
399                }
400
401                // Use provenance-aware formatting if configured (RFC-0003)
402                let annotation_lines = if let Some(ref config) = self.provenance_config {
403                    style.format_for_insertion_with_provenance(&new_annotations, indent, config)
404                } else {
405                    style.format_for_insertion(&new_annotations, indent)
406                };
407
408                // Insert after the opening line of the doc comment
409                for (i, ann_line) in annotation_lines.into_iter().enumerate() {
410                    let insert_at = insert_line + i;
411                    if insert_at <= lines.len() {
412                        lines.insert(insert_at, ann_line);
413                    }
414                }
415            } else {
416                // Create new doc comment before the target line
417                // Use provenance-aware formatting if configured (RFC-0003)
418                let comment_block = if let Some(ref config) = self.provenance_config {
419                    style.format_annotations_with_provenance(&change.annotations, indent, config)
420                } else {
421                    style.format_annotations(&change.annotations, indent)
422                };
423
424                if !comment_block.is_empty() {
425                    let insert_at = if change.line > 0 { change.line - 1 } else { 0 };
426
427                    // Insert comment block lines
428                    for (i, line) in comment_block.lines().enumerate() {
429                        lines.insert(insert_at + i, line.to_string());
430                    }
431                }
432            }
433        }
434
435        Ok(lines.join("\n"))
436    }
437
438    /// @acp:summary "Applies changes to a file on disk"
439    pub fn apply_changes(&self, file_path: &Path, changes: &[FileChange]) -> Result<()> {
440        let content = std::fs::read_to_string(file_path)?;
441        let language = self.detect_language(file_path);
442        let modified = self.apply_to_content(&content, changes, &language)?;
443
444        std::fs::write(file_path, modified)?;
445        Ok(())
446    }
447
448    /// @acp:summary "Detects language from file extension"
449    fn detect_language(&self, path: &Path) -> String {
450        path.extension()
451            .and_then(|ext| ext.to_str())
452            .map(|ext| match ext {
453                "ts" | "tsx" => "typescript",
454                "js" | "jsx" | "mjs" | "cjs" => "javascript",
455                "py" | "pyi" => "python",
456                "rs" => "rust",
457                "go" => "go",
458                "java" => "java",
459                _ => "unknown",
460            })
461            .unwrap_or("unknown")
462            .to_string()
463    }
464}
465
466impl Default for Writer {
467    fn default() -> Self {
468        Self::new()
469    }
470}
471
472/// @acp:summary "Generates a unified diff between original and modified content"
473pub fn generate_unified_diff(file_path: &str, original: &str, modified: &str) -> String {
474    let diff = TextDiff::from_lines(original, modified);
475
476    // Use the built-in unified diff formatter
477    diff.unified_diff()
478        .context_radius(3)
479        .header(&format!("a/{}", file_path), &format!("b/{}", file_path))
480        .to_string()
481}
482
483#[cfg(test)]
484mod tests {
485    use super::*;
486    use crate::annotate::SuggestionSource;
487
488    #[test]
489    fn test_comment_style_from_language() {
490        assert_eq!(
491            CommentStyle::from_language("typescript", false),
492            CommentStyle::JsDoc
493        );
494        assert_eq!(
495            CommentStyle::from_language("python", false),
496            CommentStyle::PyDocstring
497        );
498        assert_eq!(
499            CommentStyle::from_language("rust", false),
500            CommentStyle::RustDoc
501        );
502        assert_eq!(
503            CommentStyle::from_language("rust", true),
504            CommentStyle::RustModuleDoc
505        );
506    }
507
508    #[test]
509    fn test_format_annotations_jsdoc() {
510        let annotations = vec![
511            Suggestion::summary("test", 1, "Test summary", SuggestionSource::Heuristic),
512            Suggestion::domain("test", 1, "authentication", SuggestionSource::Heuristic),
513        ];
514
515        let formatted = CommentStyle::JsDoc.format_annotations(&annotations, "");
516
517        assert!(formatted.contains("/**"));
518        assert!(formatted.contains("@acp:summary \"Test summary\""));
519        assert!(formatted.contains("@acp:domain authentication"));
520        assert!(formatted.contains(" */"));
521    }
522
523    #[test]
524    fn test_format_annotations_rust() {
525        let annotations = vec![Suggestion::summary(
526            "test",
527            1,
528            "Test summary",
529            SuggestionSource::Heuristic,
530        )];
531
532        let formatted = CommentStyle::RustDoc.format_annotations(&annotations, "");
533        assert!(formatted.contains("/// @acp:summary \"Test summary\""));
534
535        let formatted_module = CommentStyle::RustModuleDoc.format_annotations(&annotations, "");
536        assert!(formatted_module.contains("//! @acp:summary \"Test summary\""));
537    }
538
539    #[test]
540    fn test_generate_unified_diff() {
541        let original = "line 1\nline 2\nline 3";
542        let modified = "line 1\nnew line\nline 2\nline 3";
543
544        let diff = generate_unified_diff("test.txt", original, modified);
545
546        assert!(diff.contains("--- a/test.txt"));
547        assert!(diff.contains("+++ b/test.txt"));
548        assert!(diff.contains("+new line"));
549    }
550
551    #[test]
552    fn test_format_annotations_python() {
553        let annotations = vec![
554            Suggestion::summary("test", 1, "Test summary", SuggestionSource::Heuristic),
555            Suggestion::domain("test", 1, "authentication", SuggestionSource::Heuristic),
556        ];
557
558        let formatted = CommentStyle::PyDocstring.format_annotations(&annotations, "");
559
560        // Python annotations use # comments, not docstrings
561        assert!(formatted.contains("# @acp:summary \"Test summary\""));
562        assert!(formatted.contains("# @acp:domain authentication"));
563        // Should NOT contain docstring markers
564        assert!(!formatted.contains("\"\"\""));
565    }
566
567    #[test]
568    fn test_format_annotations_python_with_indent() {
569        let annotations = vec![Suggestion::summary(
570            "test",
571            1,
572            "Test",
573            SuggestionSource::Heuristic,
574        )];
575
576        let formatted = CommentStyle::PyDocstring.format_annotations(&annotations, "    ");
577
578        assert!(formatted.contains("    # @acp:summary \"Test\""));
579    }
580
581    #[test]
582    fn test_format_for_insertion_python() {
583        let annotations = vec![
584            Suggestion::summary("test", 1, "Test summary", SuggestionSource::Heuristic),
585            Suggestion::domain("test", 1, "core", SuggestionSource::Heuristic),
586        ];
587
588        let lines = CommentStyle::PyDocstring.format_for_insertion(&annotations, "");
589
590        assert_eq!(lines.len(), 2);
591        assert_eq!(lines[0], "# @acp:summary \"Test summary\"");
592        assert_eq!(lines[1], "# @acp:domain core");
593    }
594
595    #[test]
596    fn test_format_annotations_go() {
597        let annotations = vec![Suggestion::summary(
598            "test",
599            1,
600            "Test summary",
601            SuggestionSource::Heuristic,
602        )];
603
604        let formatted = CommentStyle::GoDoc.format_annotations(&annotations, "");
605
606        assert!(formatted.contains("// @acp:summary \"Test summary\""));
607    }
608}