Skip to main content

wisp/screens/plan_review/
feedback.rs

1use 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}