1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum CommentStyle {
27 JsDoc,
29 PyDocstring,
31 RustDoc,
33 RustModuleDoc,
35 GoDoc,
37 Javadoc,
39}
40
41impl CommentStyle {
42 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, }
58 }
59
60 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 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 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 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 pub fn format_for_insertion_with_provenance(
176 &self,
177 annotations: &[Suggestion],
178 indent: &str,
179 config: &ProvenanceConfig,
180 ) -> Vec<String> {
181 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
212pub struct Writer {
215 preserve_existing: bool,
217 provenance_config: Option<ProvenanceConfig>,
219}
220
221impl Writer {
222 pub fn new() -> Self {
224 Self {
225 preserve_existing: true,
226 provenance_config: None,
227 }
228 }
229
230 pub fn with_preserve_existing(mut self, preserve: bool) -> Self {
232 self.preserve_existing = preserve;
233 self
234 }
235
236 pub fn with_provenance(mut self, config: ProvenanceConfig) -> Self {
238 self.provenance_config = Some(config);
239 self
240 }
241
242 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 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 for (target, target_suggestions) in by_target {
268 if target_suggestions.is_empty() {
269 continue;
270 }
271
272 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 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 change = change.with_existing_doc(start, end);
288 } else if insertion_line > 1 {
289 change = change.with_existing_doc(insertion_line - 1, insertion_line - 1);
291 }
292 }
293 }
294
295 for suggestion in target_suggestions {
297 change.add_annotation(suggestion.clone());
298 }
299
300 changes.push(change);
301 }
302
303 changes.sort_by(|a, b| b.line.cmp(&a.line));
305
306 Ok(changes)
307 }
308
309 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 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 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 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 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 let insert_line = change.existing_doc_start.unwrap();
355 let doc_end = change.existing_doc_end.unwrap_or(insert_line + 20);
356
357 let search_start = insert_line.saturating_sub(1);
359 let search_end = doc_end.min(lines.len());
360
361 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 let trimmed = line.trim();
370 if let Some(start) = trimmed.find("@acp:") {
371 let ann_part = &trimmed[start..];
372 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 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; }
400
401 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 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 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 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 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 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
472pub fn generate_unified_diff(file_path: &str, original: &str, modified: &str) -> String {
474 let diff = TextDiff::from_lines(original, modified);
475
476 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 assert!(formatted.contains("# @acp:summary \"Test summary\""));
562 assert!(formatted.contains("# @acp:domain authentication"));
563 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}