Skip to main content

sheetkit_core/
comment.rs

1//! Comment management utilities.
2//!
3//! Provides functions for adding, querying, and removing cell comments.
4
5use sheetkit_xml::comments::{Authors, Comment, CommentList, CommentRun, CommentText, Comments};
6use sheetkit_xml::namespaces;
7
8/// Configuration for a cell comment.
9#[derive(Debug, Clone, PartialEq)]
10pub struct CommentConfig {
11    /// The cell reference (e.g. "A1").
12    pub cell: String,
13    /// The author of the comment.
14    pub author: String,
15    /// The plain text of the comment.
16    pub text: String,
17}
18
19/// Add a comment to a sheet's comments collection.
20///
21/// If `comments` is `None`, a new `Comments` structure is created.
22pub fn add_comment(comments: &mut Option<Comments>, config: &CommentConfig) {
23    let c = comments.get_or_insert_with(|| Comments {
24        xmlns: namespaces::SPREADSHEET_ML.to_string(),
25        authors: Authors {
26            authors: Vec::new(),
27        },
28        comment_list: CommentList {
29            comments: Vec::new(),
30        },
31    });
32
33    // Find or add the author.
34    let author_id = match c.authors.authors.iter().position(|a| a == &config.author) {
35        Some(idx) => idx as u32,
36        None => {
37            c.authors.authors.push(config.author.clone());
38            (c.authors.authors.len() - 1) as u32
39        }
40    };
41
42    // Remove existing comment on the same cell if any.
43    c.comment_list
44        .comments
45        .retain(|comment| comment.r#ref != config.cell);
46
47    // Add the new comment.
48    c.comment_list.comments.push(Comment {
49        r#ref: config.cell.clone(),
50        author_id,
51        text: CommentText {
52            runs: vec![CommentRun {
53                rpr: None,
54                t: config.text.clone(),
55            }],
56        },
57    });
58}
59
60/// Get the comment for a specific cell.
61///
62/// Returns `None` if there is no comment on the cell.
63pub fn get_comment(comments: &Option<Comments>, cell: &str) -> Option<CommentConfig> {
64    let c = comments.as_ref()?;
65    let comment = c.comment_list.comments.iter().find(|cm| cm.r#ref == cell)?;
66
67    let author = c
68        .authors
69        .authors
70        .get(comment.author_id as usize)
71        .cloned()
72        .unwrap_or_default();
73
74    let text = comment
75        .text
76        .runs
77        .iter()
78        .map(|r| r.t.as_str())
79        .collect::<Vec<_>>()
80        .join("");
81
82    Some(CommentConfig {
83        cell: cell.to_string(),
84        author,
85        text,
86    })
87}
88
89/// Remove a comment from a specific cell.
90///
91/// Returns `true` if a comment was found and removed.
92pub fn remove_comment(comments: &mut Option<Comments>, cell: &str) -> bool {
93    if let Some(ref mut c) = comments {
94        let before = c.comment_list.comments.len();
95        c.comment_list
96            .comments
97            .retain(|comment| comment.r#ref != cell);
98        let removed = c.comment_list.comments.len() < before;
99
100        // Clean up if no comments remain.
101        if c.comment_list.comments.is_empty() {
102            *comments = None;
103        }
104
105        removed
106    } else {
107        false
108    }
109}
110
111/// Get all comments from a sheet's comments collection.
112pub fn get_all_comments(comments: &Option<Comments>) -> Vec<CommentConfig> {
113    match comments.as_ref() {
114        Some(c) => c
115            .comment_list
116            .comments
117            .iter()
118            .map(|comment| {
119                let author = c
120                    .authors
121                    .authors
122                    .get(comment.author_id as usize)
123                    .cloned()
124                    .unwrap_or_default();
125                let text = comment
126                    .text
127                    .runs
128                    .iter()
129                    .map(|r| r.t.as_str())
130                    .collect::<Vec<_>>()
131                    .join("");
132                CommentConfig {
133                    cell: comment.r#ref.clone(),
134                    author,
135                    text,
136                }
137            })
138            .collect(),
139        None => Vec::new(),
140    }
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146
147    #[test]
148    fn test_add_comment() {
149        let mut comments = None;
150        let config = CommentConfig {
151            cell: "A1".to_string(),
152            author: "Alice".to_string(),
153            text: "Hello comment".to_string(),
154        };
155        add_comment(&mut comments, &config);
156
157        assert!(comments.is_some());
158        let c = comments.as_ref().unwrap();
159        assert_eq!(c.authors.authors.len(), 1);
160        assert_eq!(c.authors.authors[0], "Alice");
161        assert_eq!(c.comment_list.comments.len(), 1);
162        assert_eq!(c.comment_list.comments[0].r#ref, "A1");
163    }
164
165    #[test]
166    fn test_get_comment() {
167        let mut comments = None;
168        let config = CommentConfig {
169            cell: "A1".to_string(),
170            author: "Alice".to_string(),
171            text: "Test comment".to_string(),
172        };
173        add_comment(&mut comments, &config);
174
175        let result = get_comment(&comments, "A1");
176        assert!(result.is_some());
177        let c = result.unwrap();
178        assert_eq!(c.cell, "A1");
179        assert_eq!(c.author, "Alice");
180        assert_eq!(c.text, "Test comment");
181    }
182
183    #[test]
184    fn test_get_comment_nonexistent() {
185        let comments: Option<Comments> = None;
186        assert!(get_comment(&comments, "A1").is_none());
187    }
188
189    #[test]
190    fn test_get_comment_wrong_cell() {
191        let mut comments = None;
192        let config = CommentConfig {
193            cell: "A1".to_string(),
194            author: "Alice".to_string(),
195            text: "Test".to_string(),
196        };
197        add_comment(&mut comments, &config);
198        assert!(get_comment(&comments, "B1").is_none());
199    }
200
201    #[test]
202    fn test_remove_comment() {
203        let mut comments = None;
204        let config = CommentConfig {
205            cell: "A1".to_string(),
206            author: "Alice".to_string(),
207            text: "Test".to_string(),
208        };
209        add_comment(&mut comments, &config);
210        assert!(remove_comment(&mut comments, "A1"));
211        assert!(comments.is_none());
212    }
213
214    #[test]
215    fn test_remove_nonexistent_comment() {
216        let mut comments: Option<Comments> = None;
217        assert!(!remove_comment(&mut comments, "A1"));
218    }
219
220    #[test]
221    fn test_multiple_comments_different_cells() {
222        let mut comments = None;
223        add_comment(
224            &mut comments,
225            &CommentConfig {
226                cell: "A1".to_string(),
227                author: "Alice".to_string(),
228                text: "Comment 1".to_string(),
229            },
230        );
231        add_comment(
232            &mut comments,
233            &CommentConfig {
234                cell: "B2".to_string(),
235                author: "Bob".to_string(),
236                text: "Comment 2".to_string(),
237            },
238        );
239        add_comment(
240            &mut comments,
241            &CommentConfig {
242                cell: "C3".to_string(),
243                author: "Alice".to_string(),
244                text: "Comment 3".to_string(),
245            },
246        );
247
248        let all = get_all_comments(&comments);
249        assert_eq!(all.len(), 3);
250
251        // Verify authors are deduplicated
252        let c = comments.as_ref().unwrap();
253        assert_eq!(c.authors.authors.len(), 2); // Alice and Bob
254
255        // Verify individual lookups
256        let c1 = get_comment(&comments, "A1").unwrap();
257        assert_eq!(c1.text, "Comment 1");
258        assert_eq!(c1.author, "Alice");
259
260        let c2 = get_comment(&comments, "B2").unwrap();
261        assert_eq!(c2.text, "Comment 2");
262        assert_eq!(c2.author, "Bob");
263    }
264
265    #[test]
266    fn test_overwrite_comment_on_same_cell() {
267        let mut comments = None;
268        add_comment(
269            &mut comments,
270            &CommentConfig {
271                cell: "A1".to_string(),
272                author: "Alice".to_string(),
273                text: "Original".to_string(),
274            },
275        );
276        add_comment(
277            &mut comments,
278            &CommentConfig {
279                cell: "A1".to_string(),
280                author: "Bob".to_string(),
281                text: "Updated".to_string(),
282            },
283        );
284
285        let all = get_all_comments(&comments);
286        assert_eq!(all.len(), 1);
287        assert_eq!(all[0].text, "Updated");
288        assert_eq!(all[0].author, "Bob");
289    }
290
291    #[test]
292    fn test_remove_one_of_multiple_comments() {
293        let mut comments = None;
294        add_comment(
295            &mut comments,
296            &CommentConfig {
297                cell: "A1".to_string(),
298                author: "Alice".to_string(),
299                text: "First".to_string(),
300            },
301        );
302        add_comment(
303            &mut comments,
304            &CommentConfig {
305                cell: "B2".to_string(),
306                author: "Bob".to_string(),
307                text: "Second".to_string(),
308            },
309        );
310
311        assert!(remove_comment(&mut comments, "A1"));
312        assert!(comments.is_some()); // Still has B2
313
314        let all = get_all_comments(&comments);
315        assert_eq!(all.len(), 1);
316        assert_eq!(all[0].cell, "B2");
317    }
318
319    #[test]
320    fn test_get_all_comments_empty() {
321        let comments: Option<Comments> = None;
322        let all = get_all_comments(&comments);
323        assert!(all.is_empty());
324    }
325
326    #[test]
327    fn test_comments_xml_roundtrip() {
328        let mut comments = None;
329        add_comment(
330            &mut comments,
331            &CommentConfig {
332                cell: "A1".to_string(),
333                author: "Author".to_string(),
334                text: "A test comment".to_string(),
335            },
336        );
337
338        let c = comments.as_ref().unwrap();
339        let xml = quick_xml::se::to_string(c).unwrap();
340        let parsed: Comments = quick_xml::de::from_str(&xml).unwrap();
341
342        assert_eq!(parsed.authors.authors.len(), 1);
343        assert_eq!(parsed.comment_list.comments.len(), 1);
344        assert_eq!(parsed.comment_list.comments[0].r#ref, "A1");
345        assert_eq!(
346            parsed.comment_list.comments[0].text.runs[0].t,
347            "A test comment"
348        );
349    }
350}