rusty-todo-md 1.2.1

A multi-language TODO comment extractor for source code files.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
use crate::todo_extractor::MarkedItem;
use log::{debug, info};
use std::collections::HashMap;
use std::path::PathBuf;

// TODO: generalize in maker collection
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TodoCollection {
    // Maps a file path to a list of TODO items found in that file.
    pub todos: HashMap<PathBuf, Vec<MarkedItem>>,
}

impl TodoCollection {
    /// Creates and returns a new, empty TodoCollection instance.
    pub fn new() -> Self {
        info!("Creating a new TodoCollection");
        TodoCollection {
            todos: HashMap::new(),
        }
    }

    /// Adds a MarkedItem to the collection. If the file already has associated TODO items,
    /// the new item is appended to the existing list.
    pub fn add_item(&mut self, item: MarkedItem) {
        info!("Adding item to collection: {item:?}");
        self.todos
            .entry(item.file_path.clone())
            .or_default()
            .push(item);
    }

    /// Merges a new TodoCollection (representing the latest scan results) into the
    /// existing collection, updating only those files that were scanned and removing
    /// entries for deleted files.
    ///
    /// Merge Logic:
    ///     For each file in the provided scanned_files, remove any existing TODO items.
    ///     For each file in the new collection, insert the new TODO items (which replaces any previous
    ///         entries for that file).
    ///     For each file listed in deleted_files, remove its entries from the collection.
    ///     Files not included in scanned_files remain unchanged.
    pub fn merge(
        &mut self,
        new: TodoCollection,
        scanned_files: Vec<PathBuf>,
        deleted_files: Vec<PathBuf>,
    ) {
        info!("Merging new TodoCollection into existing one");

        // For each file that was scanned, remove its previous entries.
        for file in scanned_files {
            self.todos.remove(&file);
        }

        // Insert new todos for files that were scanned.
        for (file, new_items) in new.todos {
            debug!("Updating todos for file: {file:?}");
            self.todos.insert(file, new_items);
        }

        // Remove entries for files that have been deleted.
        for file in deleted_files {
            debug!("Removing todos for deleted file: {file:?}");
            self.todos.remove(&file);
        }
    }

    /// Returns a vector containing all MarkedItem entries sorted first lexicographically by
    /// file path and then in ascending order by line number.
    pub fn to_sorted_vec(&self) -> Vec<MarkedItem> {
        info!("Converting TodoCollection to a sorted vector");
        let mut all_items: Vec<_> = self.todos.values().flat_map(|v| v.clone()).collect();
        all_items.sort_by(|a, b| {
            a.file_path
                .cmp(&b.file_path)
                .then_with(|| a.line_number.cmp(&b.line_number))
        });
        all_items
    }
}

impl Default for TodoCollection {
    fn default() -> Self {
        Self::new()
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::todo_extractor::MarkedItem;
    use log::LevelFilter;
    use std::io::Write;
    use std::path::PathBuf;
    use std::sync::Once;

    static INIT: Once = Once::new();

    fn init_logger() {
        INIT.call_once(|| {
            env_logger::Builder::from_default_env()
                .format(|buf, record| {
                    writeln!(
                        buf,
                        "{level}: {args}",
                        level = record.level(),
                        args = record.args()
                    )
                })
                .filter_level(LevelFilter::Debug)
                .is_test(true)
                .try_init()
                .ok();
        });
    }

    #[test]
    fn test_add_item() {
        init_logger();
        let mut collection = TodoCollection::new();
        let item = MarkedItem {
            file_path: PathBuf::from("src/test.rs"),
            line_number: 42,
            message: "Test TODO".to_string(),
            marker: "TODO".to_string(),
        };
        collection.add_item(item.clone());
        assert!(collection.todos.contains_key(&PathBuf::from("src/test.rs")));
        let items = collection.todos.get(&PathBuf::from("src/test.rs")).unwrap();
        assert_eq!(items.len(), 1);
        assert_eq!(items[0], item);
    }

    // Test that missing items from the new collection are added to the existing collection.
    #[test]
    fn test_merge_adds_missing_items() {
        init_logger();
        let mut col1 = TodoCollection::new();
        let item1 = MarkedItem {
            file_path: PathBuf::from("src/foo.rs"),
            line_number: 10,
            message: "Fix bug".to_string(),
            marker: "TODO".to_string(),
        };
        col1.add_item(item1.clone());

        let mut col2 = TodoCollection::new();
        let item2 = MarkedItem {
            file_path: PathBuf::from("src/foo.rs"),
            line_number: 20,
            message: "Implement new feature".to_string(),
            marker: "TODO".to_string(),
        };
        col2.add_item(item1.clone());
        col2.add_item(item2.clone());

        // Updated merge call with an empty deleted_files list.
        col1.merge(col2, vec![], vec![]);

        let foo_items = col1.todos.get(&PathBuf::from("src/foo.rs")).unwrap();
        assert_eq!(foo_items.len(), 2, "Expected two items for src/foo.rs");
        assert!(foo_items.contains(&item1));
        assert!(foo_items.contains(&item2));
    }

    // Test that merging collections does not duplicate items when the same item exists.
    #[test]
    fn test_merge_no_duplicates() {
        init_logger();
        let mut col1 = TodoCollection::new();
        let item = MarkedItem {
            file_path: PathBuf::from("src/bar.rs"),
            line_number: 15,
            message: "Refactor code".to_string(),
            marker: "TODO".to_string(),
        };
        col1.add_item(item.clone());

        let mut col2 = TodoCollection::new();
        // Add the same item in the second collection.
        col2.add_item(item.clone());

        col1.merge(col2, vec![], vec![]);

        let bar_items = col1.todos.get(&PathBuf::from("src/bar.rs")).unwrap();
        assert_eq!(bar_items.len(), 1, "Expected no duplicates for src/bar.rs");
        assert_eq!(bar_items[0], item);
    }

    // Test that merging an empty collection leaves the existing collection unchanged.
    #[test]
    fn test_merge_keeps_existing_items_when_new_empty() {
        init_logger();
        let mut col1 = TodoCollection::new();
        let item = MarkedItem {
            file_path: PathBuf::from("src/baz.rs"),
            line_number: 25,
            message: "Optimize performance".to_string(),
            marker: "TODO".to_string(),
        };
        col1.add_item(item.clone());

        let col2 = TodoCollection::new(); // empty collection

        col1.merge(col2, vec![], vec![]);

        let baz_items = col1.todos.get(&PathBuf::from("src/baz.rs")).unwrap();
        assert_eq!(baz_items.len(), 1, "Existing item should not be removed");
        assert_eq!(baz_items[0], item);
    }

    // Test merging collections across different files.
    #[test]
    fn test_merge_multiple_files() {
        init_logger();
        let mut col1 = TodoCollection::new();
        let item1 = MarkedItem {
            file_path: PathBuf::from("src/a.rs"),
            line_number: 5,
            message: "Improve variable naming".to_string(),
            marker: "TODO".to_string(),
        };
        col1.add_item(item1.clone());

        let mut col2 = TodoCollection::new();
        let item2 = MarkedItem {
            file_path: PathBuf::from("src/b.rs"),
            line_number: 10,
            message: "Add unit tests".to_string(),
            marker: "TODO".to_string(),
        };
        col2.add_item(item2.clone());

        col1.merge(col2, vec![], vec![]);

        // Both files should be present with their respective items.
        assert!(col1.todos.contains_key(&PathBuf::from("src/a.rs")));
        assert!(col1.todos.contains_key(&PathBuf::from("src/b.rs")));
        let a_items = col1.todos.get(&PathBuf::from("src/a.rs")).unwrap();
        let b_items = col1.todos.get(&PathBuf::from("src/b.rs")).unwrap();
        assert_eq!(a_items.len(), 1);
        assert_eq!(b_items.len(), 1);
        assert_eq!(a_items[0], item1);
        assert_eq!(b_items[0], item2);
    }

    // Test that the sorted vector output is in the expected order across multiple files.
    #[test]
    fn test_merge_sorting_order() {
        init_logger();
        let mut collection = TodoCollection::new();
        let item1 = MarkedItem {
            file_path: PathBuf::from("src/z.rs"),
            line_number: 50,
            message: "Last item".to_string(),
            marker: "TODO".to_string(),
        };
        let item2 = MarkedItem {
            file_path: PathBuf::from("src/a.rs"),
            line_number: 10,
            message: "First item".to_string(),
            marker: "TODO".to_string(),
        };
        let item3 = MarkedItem {
            file_path: PathBuf::from("src/a.rs"),
            line_number: 20,
            message: "Second item".to_string(),
            marker: "TODO".to_string(),
        };
        // Add items in non-sorted order.
        collection.add_item(item1.clone());
        collection.add_item(item3.clone());
        collection.add_item(item2.clone());

        let sorted = collection.to_sorted_vec();
        // Expected order: items from src/a.rs (line 10, then 20) followed by src/z.rs.
        assert_eq!(sorted.len(), 3);
        assert_eq!(sorted[0], item2);
        assert_eq!(sorted[1], item3);
        assert_eq!(sorted[2], item1);
    }

    #[test]
    fn test_merge_collections() {
        init_logger();
        let mut col1 = TodoCollection::new();
        let item1 = MarkedItem {
            file_path: PathBuf::from("src/foo.rs"),
            line_number: 10,
            message: "Fix bug".to_string(),
            marker: "TODO".to_string(),
        };
        col1.add_item(item1.clone());

        let mut col2 = TodoCollection::new();
        let item2 = MarkedItem {
            file_path: PathBuf::from("src/bar.rs"),
            line_number: 20,
            message: "Implement feature".to_string(),
            marker: "TODO".to_string(),
        };
        let item3 = MarkedItem {
            file_path: PathBuf::from("src/foo.rs"),
            line_number: 30,
            message: "Add tests".to_string(),
            marker: "TODO".to_string(),
        };
        col2.add_item(item2.clone());
        col2.add_item(item3.clone());

        // Merge col2 into col1
        col1.merge(col2, vec![], vec![]);

        // Expect col1 to contain both items for src/foo.rs and one for src_bar.rs.
        assert!(col1.todos.contains_key(&PathBuf::from("src/foo.rs")));
        assert!(col1.todos.contains_key(&PathBuf::from("src/bar.rs")));
        let foo_items = col1.todos.get(&PathBuf::from("src/foo.rs")).unwrap();
        assert_eq!(foo_items.len(), 1);
        let bar_items = col1.todos.get(&PathBuf::from("src/bar.rs")).unwrap();
        assert_eq!(bar_items.len(), 1);
    }

    #[test]
    fn test_to_sorted_vec() {
        init_logger();
        let mut collection = TodoCollection::new();
        let item1 = MarkedItem {
            file_path: PathBuf::from("src/z.rs"),
            line_number: 50,
            message: "Last item".to_string(),
            marker: "TODO".to_string(),
        };
        let item2 = MarkedItem {
            file_path: PathBuf::from("src/a.rs"),
            line_number: 10,
            message: "First item".to_string(),
            marker: "TODO".to_string(),
        };
        let item3 = MarkedItem {
            file_path: PathBuf::from("src/a.rs"),
            line_number: 20,
            message: "Second item".to_string(),
            marker: "TODO".to_string(),
        };
        collection.add_item(item1.clone());
        collection.add_item(item2.clone());
        collection.add_item(item3.clone());

        let sorted = collection.to_sorted_vec();
        // Expected order: items from src/a.rs (line 10, then 20) followed by src/z.rs.
        assert_eq!(sorted.len(), 3);
        assert_eq!(sorted[0], item2);
        assert_eq!(sorted[1], item3);
        assert_eq!(sorted[2], item1);
    }

    #[test]
    fn test_merge_replaces_existing_items() {
        init_logger();
        let mut col1 = TodoCollection::new();
        let item_old = MarkedItem {
            file_path: PathBuf::from("src/foo.rs"),
            line_number: 10,
            message: "Fix bug".to_string(),
            marker: "TODO".to_string(),
        };
        let item_stale = MarkedItem {
            file_path: PathBuf::from("src/foo.rs"),
            line_number: 15,
            message: "Old note".to_string(),
            marker: "TODO".to_string(),
        };
        col1.add_item(item_old);
        col1.add_item(item_stale);

        let mut col2 = TodoCollection::new();
        let item_new = MarkedItem {
            file_path: PathBuf::from("src/foo.rs"),
            line_number: 20,
            message: "Implement feature".to_string(),
            marker: "TODO".to_string(),
        };
        col2.add_item(item_new.clone());

        // Updated merge call.
        col1.merge(col2, vec![], vec![]);

        let foo_items = col1.todos.get(&PathBuf::from("src/foo.rs")).unwrap();
        // We expect that the stale items have been replaced and only the new one remains.
        assert_eq!(
            foo_items.len(),
            1,
            "Expected old items to be replaced by the new list"
        );
        assert_eq!(foo_items[0], item_new);
    }

    #[test]
    fn test_merge_complex_replacement() {
        init_logger();
        let mut col1 = TodoCollection::new();
        // File A: initially two items.
        let a_item1 = MarkedItem {
            file_path: PathBuf::from("src/a.rs"),
            line_number: 5,
            message: "A: initial task".to_string(),
            marker: "TODO".to_string(),
        };
        let a_item2 = MarkedItem {
            file_path: PathBuf::from("src/a.rs"),
            line_number: 15,
            message: "A: old task".to_string(),
            marker: "TODO".to_string(),
        };
        col1.add_item(a_item1);
        col1.add_item(a_item2);

        // File B: initially one item.
        let b_item1 = MarkedItem {
            file_path: PathBuf::from("src/b.rs"),
            line_number: 10,
            message: "B: fix issue".to_string(),
            marker: "TODO".to_string(),
        };
        col1.add_item(b_item1.clone());

        // File C: exists only in col1.
        let c_item1 = MarkedItem {
            file_path: PathBuf::from("src/c.rs"),
            line_number: 20,
            message: "C: temporary note".to_string(),
            marker: "TODO".to_string(),
        };
        col1.add_item(c_item1);

        // Create col2 with updated items.
        let mut col2 = TodoCollection::new();
        // For File A, new list with one updated item.
        let a_item_new = MarkedItem {
            file_path: PathBuf::from("src/a.rs"),
            line_number: 7,
            message: "A: new task".to_string(),
            marker: "TODO".to_string(),
        };
        col2.add_item(a_item_new.clone());

        // For File B, new list with an additional item.
        let b_item2 = MarkedItem {
            file_path: PathBuf::from("src/b.rs"),
            line_number: 12,
            message: "B: additional improvement".to_string(),
            marker: "TODO".to_string(),
        };
        // Note: Even though b_item1 is already in col1, intended behavior is to replace the list.
        col2.add_item(b_item1.clone());
        col2.add_item(b_item2.clone());

        // For File D, a new file not in col1.
        let d_item1 = MarkedItem {
            file_path: PathBuf::from("src/d.rs"),
            line_number: 1,
            message: "D: start here".to_string(),
            marker: "TODO".to_string(),
        };
        col2.add_item(d_item1.clone());

        // Simulate that File C has been deleted by passing its path in the deleted_files list.
        let deleted_files = vec![PathBuf::from("src/c.rs")];

        // Updated merge call with deleted_files.
        col1.merge(col2, vec![], deleted_files);

        // File A should now have only the new item.
        let a_items = col1.todos.get(&PathBuf::from("src/a.rs")).unwrap();
        assert_eq!(a_items.len(), 1, "File A's items should have been replaced");
        assert_eq!(a_items[0], a_item_new);

        // File B should have exactly the two items from col2.
        let b_items = col1.todos.get(&PathBuf::from("src/b.rs")).unwrap();
        assert_eq!(
            b_items.len(),
            2,
            "File B should have been replaced with two items"
        );
        assert!(b_items.contains(&b_item1));
        assert!(b_items.contains(&b_item2));

        // File D should be newly added.
        let d_items = col1.todos.get(&PathBuf::from("src/d.rs")).unwrap();
        assert_eq!(d_items.len(), 1);
        assert_eq!(d_items[0], d_item1);

        // File C should have been removed.
        assert!(
            !col1.todos.contains_key(&PathBuf::from("src/c.rs")),
            "File C should have been removed"
        );
    }

    #[test]
    fn test_merge_scanned_file_removal() {
        // Initialize a collection with a TODO for a file.
        let mut original = TodoCollection::new();
        let item = MarkedItem {
            file_path: PathBuf::from("src/old.rs"),
            line_number: 100,
            message: "Obsolete TODO".to_string(),
            marker: "TODO".to_string(),
        };
        original.add_item(item);

        // Create an empty new collection (simulating that no new TODO was found for that file).
        let new_collection = TodoCollection::new();

        // Call merge with scanned_files containing "src/old.rs" and no deleted_files.
        original.merge(new_collection, vec![PathBuf::from("src/old.rs")], vec![]);

        // Assert that "src/old.rs" has been removed from the collection.
        assert!(
            !original.todos.contains_key(&PathBuf::from("src/old.rs")),
            "Expected 'src/old.rs' to be removed when no new TODOs are provided."
        );
    }
}