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 => {
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 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 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 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 pub fn format_for_insertion_with_provenance(
182 &self,
183 annotations: &[Suggestion],
184 indent: &str,
185 config: &ProvenanceConfig,
186 ) -> Vec<String> {
187 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
218pub struct Writer {
221 preserve_existing: bool,
223 provenance_config: Option<ProvenanceConfig>,
225}
226
227impl Writer {
228 pub fn new() -> Self {
230 Self {
231 preserve_existing: true,
232 provenance_config: None,
233 }
234 }
235
236 pub fn with_preserve_existing(mut self, preserve: bool) -> Self {
238 self.preserve_existing = preserve;
239 self
240 }
241
242 pub fn with_provenance(mut self, config: ProvenanceConfig) -> Self {
244 self.provenance_config = Some(config);
245 self
246 }
247
248 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 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 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 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 change = change.with_existing_doc(start, end);
293 } else if line > 1 {
294 change = change.with_existing_doc(line - 1, line - 1);
296 }
297 }
298 }
299
300 for suggestion in target_suggestions {
302 change.add_annotation(suggestion.clone());
303 }
304
305 changes.push(change);
306 }
307
308 changes.sort_by(|a, b| b.line.cmp(&a.line));
310
311 Ok(changes)
312 }
313
314 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 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 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 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 let insert_line = change.existing_doc_start.unwrap();
355 let doc_end = change.existing_doc_end.unwrap_or(insert_line + 20);
356
357 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 let trimmed = line.trim();
366 if let Some(start) = trimmed.find("@acp:") {
367 let ann_part = &trimmed[start..];
368 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 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; }
396
397 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; if insert_at <= lines.len() {
407 lines.insert(insert_at, ann_line);
408 }
409 }
410 } else {
411 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 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 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 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
467pub fn generate_unified_diff(file_path: &str, original: &str, modified: &str) -> String {
469 let diff = TextDiff::from_lines(original, modified);
470
471 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}