use crate::todo_extractor::MarkedItem;
use log::{debug, info};
use std::collections::HashMap;
use std::path::PathBuf;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TodoCollection {
pub todos: HashMap<PathBuf, Vec<MarkedItem>>,
}
impl TodoCollection {
pub fn new() -> Self {
info!("Creating a new TodoCollection");
TodoCollection {
todos: HashMap::new(),
}
}
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);
}
pub fn merge(
&mut self,
new: TodoCollection,
scanned_files: Vec<PathBuf>,
deleted_files: Vec<PathBuf>,
) {
info!("Merging new TodoCollection into existing one");
for file in scanned_files {
self.todos.remove(&file);
}
for (file, new_items) in new.todos {
debug!("Updating todos for file: {file:?}");
self.todos.insert(file, new_items);
}
for file in deleted_files {
debug!("Removing todos for deleted file: {file:?}");
self.todos.remove(&file);
}
}
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]
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());
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]
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();
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]
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();
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]
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![]);
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]
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(),
};
collection.add_item(item1.clone());
collection.add_item(item3.clone());
collection.add_item(item2.clone());
let sorted = collection.to_sorted_vec();
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());
col1.merge(col2, vec![], vec![]);
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();
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());
col1.merge(col2, vec![], vec![]);
let foo_items = col1.todos.get(&PathBuf::from("src/foo.rs")).unwrap();
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();
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);
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());
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);
let mut col2 = TodoCollection::new();
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());
let b_item2 = MarkedItem {
file_path: PathBuf::from("src/b.rs"),
line_number: 12,
message: "B: additional improvement".to_string(),
marker: "TODO".to_string(),
};
col2.add_item(b_item1.clone());
col2.add_item(b_item2.clone());
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());
let deleted_files = vec![PathBuf::from("src/c.rs")];
col1.merge(col2, vec![], deleted_files);
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);
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));
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);
assert!(
!col1.todos.contains_key(&PathBuf::from("src/c.rs")),
"File C should have been removed"
);
}
#[test]
fn test_merge_scanned_file_removal() {
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);
let new_collection = TodoCollection::new();
original.merge(new_collection, vec![PathBuf::from("src/old.rs")], vec![]);
assert!(
!original.todos.contains_key(&PathBuf::from("src/old.rs")),
"Expected 'src/old.rs' to be removed when no new TODOs are provided."
);
}
}