wisp/screens/plan_review/
feedback.rs1use super::document::PlanDocument;
2use std::fmt::Write;
3
4pub struct ReviewComment {
5 pub line_no: usize,
6 pub body: String,
7}
8
9impl ReviewComment {
10 pub fn new(line_no: usize, body: String) -> Self {
11 Self { line_no, body }
12 }
13}
14
15pub fn compile_feedback(document: &PlanDocument, comments: &[ReviewComment]) -> String {
16 if comments.is_empty() {
17 return "Plan needs changes, but no inline comments were provided.".to_string();
18 }
19
20 let mut output = String::from("# Plan review feedback\n\n");
21 let mut current_section: Option<usize> = None;
22
23 for comment in comments {
24 let Some(line) = document.line_by_no(comment.line_no) else {
25 continue;
26 };
27
28 if line.section_index != current_section {
29 if let Some(section_title) = document.section_title_for(line) {
30 let _ = writeln!(output, "## {section_title}");
31 output.push('\n');
32 }
33 current_section = line.section_index;
34 }
35
36 let _ = writeln!(output, "### Line {}", line.line_no);
37 if !line.text.trim().is_empty() {
38 let _ = writeln!(output, "`{}`", sanitize_line_snippet(&line.text));
39 }
40
41 let mut wrote_point = false;
42 for feedback_line in comment.body.lines().map(str::trim).filter(|line| !line.is_empty()) {
43 let _ = writeln!(output, "- {feedback_line}");
44 wrote_point = true;
45 }
46
47 if !wrote_point {
48 output.push_str("- (no comment text provided)\n");
49 }
50
51 output.push('\n');
52 }
53
54 if output.trim() == "# Plan review feedback" {
55 "Plan needs changes, but no inline comments were provided.".to_string()
56 } else {
57 output.trim().to_string()
58 }
59}
60
61fn sanitize_line_snippet(line: &str) -> String {
62 let mut trimmed = line.trim().replace('`', "\\`");
63 if trimmed.chars().count() > 140 {
64 trimmed = trimmed.chars().take(137).collect::<String>() + "...";
65 }
66 trimmed
67}
68
69#[cfg(test)]
70mod tests {
71 use super::super::document::PlanDocument;
72 use super::*;
73
74 #[test]
75 fn compile_feedback_falls_back_when_no_comments() {
76 let document = PlanDocument::parse("/tmp/plan.md", "# Plan");
77 let feedback = compile_feedback(&document, &[]);
78 assert!(feedback.contains("no inline comments"));
79 }
80
81 #[test]
82 fn compile_feedback_includes_line_numbers_and_comments() {
83 let document = PlanDocument::parse("/tmp/plan.md", "# Overview\nline");
84 let comments = vec![ReviewComment::new(2, "Please expand this".to_string())];
85
86 let feedback = compile_feedback(&document, &comments);
87 assert!(feedback.contains("Line 2"));
88 assert!(feedback.contains("Please expand this"));
89 }
90
91 #[test]
92 fn compile_feedback_groups_by_section() {
93 let document = PlanDocument::parse("/tmp/plan.md", "# Intro\nline1\n## Details\nline3");
94 let comments =
95 vec![ReviewComment::new(2, "fix intro".to_string()), ReviewComment::new(4, "fix details".to_string())];
96
97 let feedback = compile_feedback(&document, &comments);
98 assert!(feedback.contains("## Intro"));
99 assert!(feedback.contains("## Details"));
100 assert!(feedback.contains("fix intro"));
101 assert!(feedback.contains("fix details"));
102 }
103
104 #[test]
105 fn compile_feedback_handles_multiline_comments() {
106 let document = PlanDocument::parse("/tmp/plan.md", "# Top\nline");
107 let comments = vec![ReviewComment::new(2, "First point\nSecond point".to_string())];
108
109 let feedback = compile_feedback(&document, &comments);
110 assert!(feedback.contains("- First point"));
111 assert!(feedback.contains("- Second point"));
112 }
113
114 #[test]
115 fn compile_feedback_sanitizes_backticks_in_snippets() {
116 let document = PlanDocument::parse("/tmp/plan.md", "# Top\nuse `backtick` here");
117 let comments = vec![ReviewComment::new(2, "ok".to_string())];
118
119 let feedback = compile_feedback(&document, &comments);
120 assert!(feedback.contains("\\`backtick\\`"));
121 }
122
123 #[test]
124 fn compile_feedback_truncates_long_snippets() {
125 let long_line = "x".repeat(200);
126 let markdown = format!("# Top\n{long_line}");
127 let document = PlanDocument::parse("/tmp/plan.md", &markdown);
128 let comments = vec![ReviewComment::new(2, "ok".to_string())];
129
130 let feedback = compile_feedback(&document, &comments);
131 assert!(!feedback.contains(&long_line));
132 assert!(feedback.contains("..."));
133 }
134
135 #[test]
136 fn sanitize_handles_empty_line() {
137 assert_eq!(sanitize_line_snippet(""), "");
138 }
139
140 #[test]
141 fn compile_feedback_handles_blank_source_lines() {
142 let document = PlanDocument::parse("/tmp/plan.md", "# Top\n\n\ntext");
143 let comments = vec![ReviewComment::new(2, "blank line above".to_string())];
144
145 let feedback = compile_feedback(&document, &comments);
146 assert!(feedback.contains("Line 2"));
147 assert!(feedback.contains("blank line above"));
148 }
149}