use crate::attributes::{AttrFilter, AttrValue};
use crate::commands::CmdResult;
use crate::error::{PadzError, Result};
use crate::index::{DisplayIndex, DisplayPad, MatchSegment, PadSelector, SearchMatch};
use crate::model::{Scope, TodoStatus};
use crate::store::DataStore;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PadStatusFilter {
All,
Active,
Archived,
Deleted,
Pinned,
}
#[derive(Debug, Clone)]
pub struct PadFilter {
pub status: PadStatusFilter,
pub search_term: Option<String>,
pub todo_status: Option<TodoStatus>,
pub tags: Option<Vec<String>>,
}
impl Default for PadFilter {
fn default() -> Self {
Self {
status: PadStatusFilter::Active,
search_term: None,
todo_status: None, tags: None, }
}
}
fn filter_tree(pads: Vec<DisplayPad>, status: PadStatusFilter) -> Vec<DisplayPad> {
pads.into_iter()
.filter_map(|dp| filter_pad_recursive(dp, status))
.collect()
}
fn filter_pad_recursive(mut dp: DisplayPad, status: PadStatusFilter) -> Option<DisplayPad> {
let dominated = matches_status(&dp.index, status);
if !dominated {
return None;
}
if status == PadStatusFilter::Deleted {
dp.children = dp
.children
.into_iter()
.map(filter_children_under_deleted)
.collect();
} else {
dp.children = dp
.children
.into_iter()
.filter_map(|child| filter_pad_recursive(child, status))
.collect();
}
Some(dp)
}
fn filter_children_under_deleted(mut dp: DisplayPad) -> DisplayPad {
dp.children = dp
.children
.into_iter()
.map(filter_children_under_deleted)
.collect();
dp
}
fn apply_attr_filters(pads: Vec<DisplayPad>, filters: &[AttrFilter]) -> Vec<DisplayPad> {
if filters.is_empty() {
return pads;
}
pads.into_iter()
.filter_map(|dp| filter_pad_by_attrs(dp, filters))
.collect()
}
fn filter_pad_by_attrs(mut dp: DisplayPad, filters: &[AttrFilter]) -> Option<DisplayPad> {
dp.children = dp
.children
.into_iter()
.filter_map(|child| filter_pad_by_attrs(child, filters))
.collect();
let matches_all = filters.iter().all(|f| f.matches(&dp.pad.metadata));
if matches_all {
Some(dp)
} else {
None
}
}
fn filter_by_selectors(
pads: Vec<DisplayPad>,
selectors: &[PadSelector],
) -> Result<Vec<DisplayPad>> {
let linearized = linearize_for_filter(&pads);
let mut matched = Vec::new();
for selector in selectors {
match selector {
PadSelector::Path(path) => {
if let Some(dp) = find_by_path(&linearized, path) {
if !matched
.iter()
.any(|m: &DisplayPad| m.pad.metadata.id == dp.pad.metadata.id)
{
matched.push(dp.clone());
}
} else {
let s: Vec<String> = path.iter().map(|idx| idx.to_string()).collect();
return Err(PadzError::Api(format!(
"Index {} not found in current scope",
s.join(".")
)));
}
}
PadSelector::Range(start_path, end_path) => {
let start_idx = linearized
.iter()
.position(|(p, _)| p == start_path)
.ok_or_else(|| {
let s: Vec<String> = start_path.iter().map(|idx| idx.to_string()).collect();
PadzError::Api(format!("Range start {} not found", s.join(".")))
})?;
let end_idx = linearized
.iter()
.position(|(p, _)| p == end_path)
.ok_or_else(|| {
let s: Vec<String> = end_path.iter().map(|idx| idx.to_string()).collect();
PadzError::Api(format!("Range end {} not found", s.join(".")))
})?;
if start_idx > end_idx {
return Err(PadzError::Api(
"Invalid range: start appears after end".into(),
));
}
for (_, dp) in linearized.iter().take(end_idx + 1).skip(start_idx) {
if !matched
.iter()
.any(|m: &DisplayPad| m.pad.metadata.id == dp.pad.metadata.id)
{
matched.push((*dp).clone());
}
}
}
PadSelector::Title(term) => {
let term_lower = term.to_lowercase();
let matches: Vec<&DisplayPad> = linearized
.iter()
.filter(|(_, dp)| dp.pad.metadata.title.to_lowercase().contains(&term_lower))
.map(|(_, dp)| *dp)
.collect();
if matches.is_empty() {
return Err(PadzError::Api(format!(
"No pad found matching \"{}\"",
term
)));
}
for dp in matches {
if !matched
.iter()
.any(|m: &DisplayPad| m.pad.metadata.id == dp.pad.metadata.id)
{
matched.push(dp.clone());
}
}
}
}
}
Ok(matched)
}
fn linearize_for_filter(roots: &[DisplayPad]) -> Vec<(Vec<DisplayIndex>, &DisplayPad)> {
let mut result = Vec::new();
for pad in roots {
linearize_recursive(pad, Vec::new(), &mut result);
}
result
}
fn linearize_recursive<'a>(
pad: &'a DisplayPad,
parent_path: Vec<DisplayIndex>,
result: &mut Vec<(Vec<DisplayIndex>, &'a DisplayPad)>,
) {
let mut current_path = parent_path;
current_path.push(pad.index.clone());
result.push((current_path.clone(), pad));
for child in &pad.children {
linearize_recursive(child, current_path.clone(), result);
}
}
fn find_by_path<'a>(
linearized: &[(Vec<DisplayIndex>, &'a DisplayPad)],
path: &[DisplayIndex],
) -> Option<&'a DisplayPad> {
linearized
.iter()
.find(|(p, _)| p == path)
.map(|(_, dp)| *dp)
}
fn matches_status(index: &DisplayIndex, status: PadStatusFilter) -> bool {
match status {
PadStatusFilter::All => true,
PadStatusFilter::Active => {
matches!(index, DisplayIndex::Pinned(_) | DisplayIndex::Regular(_))
}
PadStatusFilter::Archived => matches!(index, DisplayIndex::Archived(_)),
PadStatusFilter::Deleted => matches!(index, DisplayIndex::Deleted(_)),
PadStatusFilter::Pinned => matches!(index, DisplayIndex::Pinned(_)),
}
}
pub fn run<S: DataStore>(
store: &S,
scope: Scope,
filter: PadFilter,
selectors: &[PadSelector],
) -> Result<CmdResult> {
let indexed = super::helpers::indexed_pads(store, scope)?;
let indexed = if selectors.is_empty() {
indexed
} else {
filter_by_selectors(indexed, selectors)?
};
let mut filtered: Vec<DisplayPad> = filter_tree(indexed, filter.status);
let mut attr_filters: Vec<AttrFilter> = Vec::new();
if let Some(todo_status) = filter.todo_status {
let status_str = format!("{:?}", todo_status);
attr_filters.push(AttrFilter::eq("status", AttrValue::Enum(status_str)));
}
if let Some(ref tags) = filter.tags {
if !tags.is_empty() {
attr_filters.push(AttrFilter::contains_all("tags", tags.clone()));
}
}
filtered = apply_attr_filters(filtered, &attr_filters);
if let Some(term) = &filter.search_term {
let term_lower = term.to_lowercase();
let mut matches: Vec<(DisplayPad, u8)> = filtered
.into_iter()
.filter_map(|mut dp| {
let mut search_matches = Vec::new();
let mut score = 0;
let title_lower = dp.pad.metadata.title.to_lowercase();
if title_lower.contains(&term_lower) {
score += 10;
search_matches.push(SearchMatch {
line_number: 0,
segments: highlight_matches(&dp.pad.metadata.title, &term_lower),
});
}
for (idx, line) in dp.pad.content.lines().enumerate() {
if idx == 0 {
continue;
}
let line_lower = line.to_lowercase();
if line_lower.contains(&term_lower) {
score += 5;
if search_matches.len() < 4 {
let segments = extract_context(line, &term_lower, 3);
search_matches.push(SearchMatch {
line_number: idx + 1, segments,
});
}
}
}
if score > 0 {
dp.matches = Some(search_matches);
Some((dp, score))
} else {
None
}
})
.collect();
matches.sort_by(
|(a, score_a), (b, score_b)| match score_a.cmp(score_b).reverse() {
std::cmp::Ordering::Equal => {
let len_a = a.pad.metadata.title.len();
let len_b = b.pad.metadata.title.len();
match len_a.cmp(&len_b) {
std::cmp::Ordering::Equal => {
a.pad.metadata.created_at.cmp(&b.pad.metadata.created_at)
}
ord => ord,
}
}
ord => ord,
},
);
filtered = matches.into_iter().map(|(dp, _)| dp).collect();
}
Ok(CmdResult::default().with_listed_pads(filtered))
}
fn highlight_matches(text: &str, term_lower: &str) -> Vec<MatchSegment> {
let mut segments = Vec::new();
let text_lower = text.to_lowercase();
let term_len = term_lower.len();
let mut last_idx = 0;
for (start_idx, _) in text_lower.match_indices(term_lower) {
if start_idx > last_idx {
segments.push(MatchSegment::Plain(text[last_idx..start_idx].to_string()));
}
segments.push(MatchSegment::Match(
text[start_idx..start_idx + term_len].to_string(),
));
last_idx = start_idx + term_len;
}
if last_idx < text.len() {
segments.push(MatchSegment::Plain(text[last_idx..].to_string()));
}
segments
}
fn extract_context(line: &str, term_lower: &str, context_words: usize) -> Vec<MatchSegment> {
let line_lower = line.to_lowercase();
let start_idx = match line_lower.find(term_lower) {
Some(idx) => idx,
None => return vec![MatchSegment::Plain(line.to_string())], };
let term_len = term_lower.len();
let end_idx = start_idx + term_len;
let is_separator = |c: char| c.is_whitespace() || c == '.';
let pre_match = &line[..start_idx];
let mut start_context_idx = 0;
let mut words_found = 0;
let mut in_word = false;
for (idx, c) in pre_match.char_indices().rev() {
let is_sep = is_separator(c);
if !is_sep && !in_word {
words_found += 1;
in_word = true;
} else if is_sep && in_word {
in_word = false;
}
if words_found > context_words {
start_context_idx = idx + c.len_utf8();
break;
}
}
let post_match = &line[end_idx..];
let mut end_context_idx = line.len();
words_found = 0;
in_word = false;
for (idx, c) in post_match.char_indices() {
let is_sep = is_separator(c);
if !is_sep && !in_word {
words_found += 1;
in_word = true;
} else if is_sep && in_word {
in_word = false;
}
if words_found > context_words {
end_context_idx = end_idx + idx;
break;
}
}
let mut segments = Vec::new();
if start_context_idx > 0 {
segments.push(MatchSegment::Plain("...".to_string()));
}
if start_idx > start_context_idx {
segments.push(MatchSegment::Plain(
line[start_context_idx..start_idx].to_string(),
));
}
segments.push(MatchSegment::Match(line[start_idx..end_idx].to_string()));
if end_context_idx > end_idx {
segments.push(MatchSegment::Plain(
line[end_idx..end_context_idx].to_string(),
));
}
if end_context_idx < line.len() {
segments.push(MatchSegment::Plain("...".to_string()));
}
segments
}
#[cfg(test)]
mod tests {
use super::*;
use crate::commands::{create, delete, tagging, tags};
use crate::index::PadSelector;
use crate::model::Scope;
use crate::store::bucketed::BucketedStore;
use crate::store::mem_backend::MemBackend;
use crate::store::Bucket;
#[test]
fn test_filters() {
let mut store = BucketedStore::new(
MemBackend::new(),
MemBackend::new(),
MemBackend::new(),
MemBackend::new(),
);
create::run(&mut store, Scope::Project, "Active".into(), "".into(), None).unwrap();
create::run(
&mut store,
Scope::Project,
"Deleted".into(),
"".into(),
None,
)
.unwrap();
delete::run(
&mut store,
Scope::Project,
&[PadSelector::Path(vec![DisplayIndex::Regular(1)])],
)
.unwrap();
let res = run(
&store,
Scope::Project,
PadFilter {
status: PadStatusFilter::Active,
search_term: None,
todo_status: None,
tags: None,
},
&[],
)
.unwrap();
assert_eq!(res.listed_pads.len(), 1);
assert_eq!(res.listed_pads[0].pad.metadata.title, "Active");
let res = run(
&store,
Scope::Project,
PadFilter {
status: PadStatusFilter::Deleted,
search_term: None,
todo_status: None,
tags: None,
},
&[],
)
.unwrap();
assert_eq!(res.listed_pads.len(), 1);
assert_eq!(res.listed_pads[0].pad.metadata.title, "Deleted");
let res = run(
&store,
Scope::Project,
PadFilter {
status: PadStatusFilter::All,
search_term: None,
todo_status: None,
tags: None,
},
&[],
)
.unwrap();
assert_eq!(res.listed_pads.len(), 2);
}
#[test]
fn test_search() {
let mut store = BucketedStore::new(
MemBackend::new(),
MemBackend::new(),
MemBackend::new(),
MemBackend::new(),
);
create::run(&mut store, Scope::Project, "Foo".into(), "".into(), None).unwrap();
create::run(
&mut store,
Scope::Project,
"Bar".into(),
"contains foo".into(),
None,
)
.unwrap();
let res = run(
&store,
Scope::Project,
PadFilter {
status: PadStatusFilter::Active,
search_term: Some("foo".into()),
todo_status: None,
tags: None,
},
&[],
)
.unwrap();
assert_eq!(res.listed_pads.len(), 2);
assert_eq!(res.listed_pads[0].pad.metadata.title, "Foo");
let matches_0 = res.listed_pads[0].matches.as_ref().unwrap();
assert!(matches_0.iter().any(|m| m.line_number == 0));
let matches_1 = res.listed_pads[1].matches.as_ref().unwrap();
assert!(matches_1.iter().any(|m| m.line_number == 3)); }
#[test]
fn test_highlight_matches() {
let text = "Hello World";
let segments = highlight_matches(text, "world");
assert_eq!(segments.len(), 2);
assert_eq!(segments[0], MatchSegment::Plain("Hello ".to_string()));
assert_eq!(segments[1], MatchSegment::Match("World".to_string()));
}
#[test]
fn test_extract_context() {
let line = "One two three four match five six seven eight";
let segments = extract_context(line, "match", 3);
assert!(segments.len() >= 3);
let joined: String = segments
.iter()
.map(|s| match s {
MatchSegment::Plain(t) => t.as_str(),
MatchSegment::Match(t) => t.as_str(),
})
.collect();
assert!(joined.contains("match"));
assert!(joined.contains("two three four"));
assert!(joined.contains("five six seven"));
assert!(!joined.contains("One")); assert!(!joined.contains("eight")); }
#[test]
fn test_active_filter_shows_nested_children() {
let mut store = BucketedStore::new(
MemBackend::new(),
MemBackend::new(),
MemBackend::new(),
MemBackend::new(),
);
create::run(&mut store, Scope::Project, "Parent".into(), "".into(), None).unwrap();
create::run(
&mut store,
Scope::Project,
"Child".into(),
"".into(),
Some(PadSelector::Path(vec![DisplayIndex::Regular(1)])),
)
.unwrap();
let res = run(&store, Scope::Project, PadFilter::default(), &[]).unwrap();
assert_eq!(res.listed_pads.len(), 1);
assert_eq!(res.listed_pads[0].pad.metadata.title, "Parent");
assert_eq!(res.listed_pads[0].children.len(), 1);
assert_eq!(res.listed_pads[0].children[0].pad.metadata.title, "Child");
}
#[test]
fn test_active_filter_hides_deleted_child() {
let mut store = BucketedStore::new(
MemBackend::new(),
MemBackend::new(),
MemBackend::new(),
MemBackend::new(),
);
create::run(&mut store, Scope::Project, "Parent".into(), "".into(), None).unwrap();
create::run(
&mut store,
Scope::Project,
"Child1".into(),
"".into(),
Some(PadSelector::Path(vec![DisplayIndex::Regular(1)])),
)
.unwrap();
create::run(
&mut store,
Scope::Project,
"Child2".into(),
"".into(),
Some(PadSelector::Path(vec![DisplayIndex::Regular(1)])),
)
.unwrap();
delete::run(
&mut store,
Scope::Project,
&[PadSelector::Path(vec![
DisplayIndex::Regular(1),
DisplayIndex::Regular(1),
])],
)
.unwrap();
let res = run(&store, Scope::Project, PadFilter::default(), &[]).unwrap();
assert_eq!(res.listed_pads.len(), 1);
assert_eq!(res.listed_pads[0].children.len(), 1);
assert_eq!(res.listed_pads[0].children[0].pad.metadata.title, "Child1");
}
#[test]
fn test_deleted_filter_shows_parent_with_children() {
let mut store = BucketedStore::new(
MemBackend::new(),
MemBackend::new(),
MemBackend::new(),
MemBackend::new(),
);
create::run(&mut store, Scope::Project, "Parent".into(), "".into(), None).unwrap();
create::run(
&mut store,
Scope::Project,
"Child".into(),
"".into(),
Some(PadSelector::Path(vec![DisplayIndex::Regular(1)])),
)
.unwrap();
delete::run(
&mut store,
Scope::Project,
&[PadSelector::Path(vec![DisplayIndex::Regular(1)])],
)
.unwrap();
let res = run(
&store,
Scope::Project,
PadFilter {
status: PadStatusFilter::Deleted,
search_term: None,
todo_status: None,
tags: None,
},
&[],
)
.unwrap();
assert_eq!(res.listed_pads.len(), 1);
assert_eq!(res.listed_pads[0].pad.metadata.title, "Parent");
assert_eq!(res.listed_pads[0].children.len(), 1);
assert_eq!(res.listed_pads[0].children[0].pad.metadata.title, "Child");
}
#[test]
fn test_active_filter_hides_children_of_deleted_parent() {
let mut store = BucketedStore::new(
MemBackend::new(),
MemBackend::new(),
MemBackend::new(),
MemBackend::new(),
);
create::run(&mut store, Scope::Project, "Parent".into(), "".into(), None).unwrap();
create::run(
&mut store,
Scope::Project,
"Child".into(),
"".into(),
Some(PadSelector::Path(vec![DisplayIndex::Regular(1)])),
)
.unwrap();
delete::run(
&mut store,
Scope::Project,
&[PadSelector::Path(vec![DisplayIndex::Regular(1)])],
)
.unwrap();
let res = run(&store, Scope::Project, PadFilter::default(), &[]).unwrap();
assert_eq!(res.listed_pads.len(), 0);
}
#[test]
fn test_todo_status_filter_planned() {
let mut store = BucketedStore::new(
MemBackend::new(),
MemBackend::new(),
MemBackend::new(),
MemBackend::new(),
);
create::run(
&mut store,
Scope::Project,
"Planned1".into(),
"".into(),
None,
)
.unwrap();
create::run(
&mut store,
Scope::Project,
"Planned2".into(),
"".into(),
None,
)
.unwrap();
let pads = store.list_pads(Scope::Project, Bucket::Active).unwrap();
let mut pad = pads
.iter()
.find(|p| p.metadata.title == "Planned1")
.unwrap()
.clone();
pad.metadata.status = TodoStatus::Done;
store
.save_pad(&pad, Scope::Project, Bucket::Active)
.unwrap();
let res = run(
&store,
Scope::Project,
PadFilter {
status: PadStatusFilter::Active,
search_term: None,
todo_status: Some(TodoStatus::Planned),
tags: None,
},
&[],
)
.unwrap();
assert_eq!(res.listed_pads.len(), 1);
assert_eq!(res.listed_pads[0].pad.metadata.title, "Planned2");
}
#[test]
fn test_todo_status_filter_done() {
let mut store = BucketedStore::new(
MemBackend::new(),
MemBackend::new(),
MemBackend::new(),
MemBackend::new(),
);
create::run(&mut store, Scope::Project, "Todo1".into(), "".into(), None).unwrap();
create::run(&mut store, Scope::Project, "Todo2".into(), "".into(), None).unwrap();
create::run(&mut store, Scope::Project, "Todo3".into(), "".into(), None).unwrap();
let pads = store.list_pads(Scope::Project, Bucket::Active).unwrap();
for title in ["Todo1", "Todo2"] {
let mut pad = pads
.iter()
.find(|p| p.metadata.title == title)
.unwrap()
.clone();
pad.metadata.status = TodoStatus::Done;
store
.save_pad(&pad, Scope::Project, Bucket::Active)
.unwrap();
}
let res = run(
&store,
Scope::Project,
PadFilter {
status: PadStatusFilter::Active,
search_term: None,
todo_status: Some(TodoStatus::Done),
tags: None,
},
&[],
)
.unwrap();
assert_eq!(res.listed_pads.len(), 2);
let titles: Vec<_> = res
.listed_pads
.iter()
.map(|dp| dp.pad.metadata.title.as_str())
.collect();
assert!(titles.contains(&"Todo1"));
assert!(titles.contains(&"Todo2"));
}
#[test]
fn test_todo_status_filter_in_progress() {
let mut store = BucketedStore::new(
MemBackend::new(),
MemBackend::new(),
MemBackend::new(),
MemBackend::new(),
);
create::run(&mut store, Scope::Project, "Task1".into(), "".into(), None).unwrap();
create::run(&mut store, Scope::Project, "Task2".into(), "".into(), None).unwrap();
let pads = store.list_pads(Scope::Project, Bucket::Active).unwrap();
let mut pad = pads
.iter()
.find(|p| p.metadata.title == "Task1")
.unwrap()
.clone();
pad.metadata.status = TodoStatus::InProgress;
store
.save_pad(&pad, Scope::Project, Bucket::Active)
.unwrap();
let res = run(
&store,
Scope::Project,
PadFilter {
status: PadStatusFilter::Active,
search_term: None,
todo_status: Some(TodoStatus::InProgress),
tags: None,
},
&[],
)
.unwrap();
assert_eq!(res.listed_pads.len(), 1);
assert_eq!(res.listed_pads[0].pad.metadata.title, "Task1");
}
#[test]
fn test_todo_status_filter_none_shows_all() {
let mut store = BucketedStore::new(
MemBackend::new(),
MemBackend::new(),
MemBackend::new(),
MemBackend::new(),
);
create::run(
&mut store,
Scope::Project,
"Planned".into(),
"".into(),
None,
)
.unwrap();
create::run(&mut store, Scope::Project, "Done".into(), "".into(), None).unwrap();
create::run(
&mut store,
Scope::Project,
"InProgress".into(),
"".into(),
None,
)
.unwrap();
let pads = store.list_pads(Scope::Project, Bucket::Active).unwrap();
let mut done_pad = pads
.iter()
.find(|p| p.metadata.title == "Done")
.unwrap()
.clone();
done_pad.metadata.status = TodoStatus::Done;
store
.save_pad(&done_pad, Scope::Project, Bucket::Active)
.unwrap();
let mut ip_pad = pads
.iter()
.find(|p| p.metadata.title == "InProgress")
.unwrap()
.clone();
ip_pad.metadata.status = TodoStatus::InProgress;
store
.save_pad(&ip_pad, Scope::Project, Bucket::Active)
.unwrap();
let res = run(
&store,
Scope::Project,
PadFilter {
status: PadStatusFilter::Active,
search_term: None,
todo_status: None,
tags: None,
},
&[],
)
.unwrap();
assert_eq!(res.listed_pads.len(), 3);
}
#[test]
fn test_todo_status_filter_preserves_index() {
let mut store = BucketedStore::new(
MemBackend::new(),
MemBackend::new(),
MemBackend::new(),
MemBackend::new(),
);
create::run(&mut store, Scope::Project, "First".into(), "".into(), None).unwrap();
create::run(&mut store, Scope::Project, "Second".into(), "".into(), None).unwrap();
create::run(&mut store, Scope::Project, "Third".into(), "".into(), None).unwrap();
let pads = store.list_pads(Scope::Project, Bucket::Active).unwrap();
let mut pad = pads
.iter()
.find(|p| p.metadata.title == "Second")
.unwrap()
.clone();
pad.metadata.status = TodoStatus::Done;
store
.save_pad(&pad, Scope::Project, Bucket::Active)
.unwrap();
let res = run(
&store,
Scope::Project,
PadFilter {
status: PadStatusFilter::Active,
search_term: None,
todo_status: Some(TodoStatus::Planned),
tags: None,
},
&[],
)
.unwrap();
assert_eq!(res.listed_pads.len(), 2);
let third = res
.listed_pads
.iter()
.find(|dp| dp.pad.metadata.title == "Third")
.unwrap();
assert!(matches!(third.index, DisplayIndex::Regular(1)));
let first = res
.listed_pads
.iter()
.find(|dp| dp.pad.metadata.title == "First")
.unwrap();
assert!(matches!(first.index, DisplayIndex::Regular(3)));
}
#[test]
fn test_todo_status_filter_with_nested_pads() {
let mut store = BucketedStore::new(
MemBackend::new(),
MemBackend::new(),
MemBackend::new(),
MemBackend::new(),
);
create::run(&mut store, Scope::Project, "Parent".into(), "".into(), None).unwrap();
create::run(
&mut store,
Scope::Project,
"Child1".into(),
"".into(),
Some(PadSelector::Path(vec![DisplayIndex::Regular(1)])),
)
.unwrap();
create::run(
&mut store,
Scope::Project,
"Child2".into(),
"".into(),
Some(PadSelector::Path(vec![DisplayIndex::Regular(1)])),
)
.unwrap();
let pads = store.list_pads(Scope::Project, Bucket::Active).unwrap();
let mut child1 = pads
.iter()
.find(|p| p.metadata.title == "Child1")
.unwrap()
.clone();
child1.metadata.status = TodoStatus::Done;
store
.save_pad(&child1, Scope::Project, Bucket::Active)
.unwrap();
let res = run(
&store,
Scope::Project,
PadFilter {
status: PadStatusFilter::Active,
search_term: None,
todo_status: Some(TodoStatus::Planned),
tags: None,
},
&[],
)
.unwrap();
assert_eq!(res.listed_pads.len(), 1);
assert_eq!(res.listed_pads[0].pad.metadata.title, "Parent");
assert_eq!(res.listed_pads[0].children.len(), 1);
assert_eq!(res.listed_pads[0].children[0].pad.metadata.title, "Child2");
}
#[test]
fn test_tag_filter_single_tag() {
let mut store = BucketedStore::new(
MemBackend::new(),
MemBackend::new(),
MemBackend::new(),
MemBackend::new(),
);
tags::create_tag(&mut store, Scope::Project, "work").unwrap();
tags::create_tag(&mut store, Scope::Project, "rust").unwrap();
create::run(&mut store, Scope::Project, "Pad1".into(), "".into(), None).unwrap();
create::run(&mut store, Scope::Project, "Pad2".into(), "".into(), None).unwrap();
create::run(&mut store, Scope::Project, "Pad3".into(), "".into(), None).unwrap();
tagging::add_tags(
&mut store,
Scope::Project,
&[PadSelector::Path(vec![DisplayIndex::Regular(3)])],
&["work".to_string()],
)
.unwrap();
tagging::add_tags(
&mut store,
Scope::Project,
&[PadSelector::Path(vec![DisplayIndex::Regular(2)])],
&["rust".to_string()],
)
.unwrap();
let res = run(
&store,
Scope::Project,
PadFilter {
status: PadStatusFilter::Active,
search_term: None,
todo_status: None,
tags: Some(vec!["work".to_string()]),
},
&[],
)
.unwrap();
assert_eq!(res.listed_pads.len(), 1);
assert_eq!(res.listed_pads[0].pad.metadata.title, "Pad1");
}
#[test]
fn test_tag_filter_multiple_tags_and_logic() {
let mut store = BucketedStore::new(
MemBackend::new(),
MemBackend::new(),
MemBackend::new(),
MemBackend::new(),
);
tags::create_tag(&mut store, Scope::Project, "work").unwrap();
tags::create_tag(&mut store, Scope::Project, "rust").unwrap();
create::run(&mut store, Scope::Project, "Pad1".into(), "".into(), None).unwrap();
create::run(&mut store, Scope::Project, "Pad2".into(), "".into(), None).unwrap();
tagging::add_tags(
&mut store,
Scope::Project,
&[PadSelector::Path(vec![DisplayIndex::Regular(2)])],
&["work".to_string(), "rust".to_string()],
)
.unwrap();
tagging::add_tags(
&mut store,
Scope::Project,
&[PadSelector::Path(vec![DisplayIndex::Regular(1)])],
&["work".to_string()],
)
.unwrap();
let res = run(
&store,
Scope::Project,
PadFilter {
status: PadStatusFilter::Active,
search_term: None,
todo_status: None,
tags: Some(vec!["work".to_string(), "rust".to_string()]),
},
&[],
)
.unwrap();
assert_eq!(res.listed_pads.len(), 1);
assert_eq!(res.listed_pads[0].pad.metadata.title, "Pad1");
}
#[test]
fn test_tag_filter_no_matches() {
let mut store = BucketedStore::new(
MemBackend::new(),
MemBackend::new(),
MemBackend::new(),
MemBackend::new(),
);
tags::create_tag(&mut store, Scope::Project, "work").unwrap();
create::run(&mut store, Scope::Project, "Pad1".into(), "".into(), None).unwrap();
let res = run(
&store,
Scope::Project,
PadFilter {
status: PadStatusFilter::Active,
search_term: None,
todo_status: None,
tags: Some(vec!["work".to_string()]),
},
&[],
)
.unwrap();
assert_eq!(res.listed_pads.len(), 0);
}
#[test]
fn test_tag_filter_empty_tags_shows_all() {
let mut store = BucketedStore::new(
MemBackend::new(),
MemBackend::new(),
MemBackend::new(),
MemBackend::new(),
);
create::run(&mut store, Scope::Project, "Pad1".into(), "".into(), None).unwrap();
create::run(&mut store, Scope::Project, "Pad2".into(), "".into(), None).unwrap();
let res = run(
&store,
Scope::Project,
PadFilter {
status: PadStatusFilter::Active,
search_term: None,
todo_status: None,
tags: Some(vec![]),
},
&[],
)
.unwrap();
assert_eq!(res.listed_pads.len(), 2);
}
#[test]
fn test_tag_filter_preserves_index() {
let mut store = BucketedStore::new(
MemBackend::new(),
MemBackend::new(),
MemBackend::new(),
MemBackend::new(),
);
tags::create_tag(&mut store, Scope::Project, "work").unwrap();
create::run(&mut store, Scope::Project, "First".into(), "".into(), None).unwrap();
create::run(&mut store, Scope::Project, "Second".into(), "".into(), None).unwrap();
create::run(&mut store, Scope::Project, "Third".into(), "".into(), None).unwrap();
tagging::add_tags(
&mut store,
Scope::Project,
&[PadSelector::Path(vec![DisplayIndex::Regular(3)])],
&["work".to_string()],
)
.unwrap();
tagging::add_tags(
&mut store,
Scope::Project,
&[PadSelector::Path(vec![DisplayIndex::Regular(1)])],
&["work".to_string()],
)
.unwrap();
let res = run(
&store,
Scope::Project,
PadFilter {
status: PadStatusFilter::Active,
search_term: None,
todo_status: None,
tags: Some(vec!["work".to_string()]),
},
&[],
)
.unwrap();
assert_eq!(res.listed_pads.len(), 2);
let third = res
.listed_pads
.iter()
.find(|dp| dp.pad.metadata.title == "Third")
.unwrap();
assert!(matches!(third.index, DisplayIndex::Regular(1)));
let first = res
.listed_pads
.iter()
.find(|dp| dp.pad.metadata.title == "First")
.unwrap();
assert!(matches!(first.index, DisplayIndex::Regular(3)));
}
#[test]
fn test_tag_filter_with_nested_pads() {
let mut store = BucketedStore::new(
MemBackend::new(),
MemBackend::new(),
MemBackend::new(),
MemBackend::new(),
);
tags::create_tag(&mut store, Scope::Project, "work").unwrap();
create::run(&mut store, Scope::Project, "Parent".into(), "".into(), None).unwrap();
create::run(
&mut store,
Scope::Project,
"Child1".into(),
"".into(),
Some(PadSelector::Path(vec![DisplayIndex::Regular(1)])),
)
.unwrap();
create::run(
&mut store,
Scope::Project,
"Child2".into(),
"".into(),
Some(PadSelector::Path(vec![DisplayIndex::Regular(1)])),
)
.unwrap();
tagging::add_tags(
&mut store,
Scope::Project,
&[PadSelector::Path(vec![DisplayIndex::Regular(1)])],
&["work".to_string()],
)
.unwrap();
tagging::add_tags(
&mut store,
Scope::Project,
&[PadSelector::Path(vec![
DisplayIndex::Regular(1),
DisplayIndex::Regular(2),
])],
&["work".to_string()],
)
.unwrap();
let res = run(
&store,
Scope::Project,
PadFilter {
status: PadStatusFilter::Active,
search_term: None,
todo_status: None,
tags: Some(vec!["work".to_string()]),
},
&[],
)
.unwrap();
assert_eq!(res.listed_pads.len(), 1);
assert_eq!(res.listed_pads[0].pad.metadata.title, "Parent");
assert_eq!(res.listed_pads[0].children.len(), 1);
assert_eq!(res.listed_pads[0].children[0].pad.metadata.title, "Child1");
}
#[test]
fn test_tag_filter_combined_with_search() {
let mut store = BucketedStore::new(
MemBackend::new(),
MemBackend::new(),
MemBackend::new(),
MemBackend::new(),
);
tags::create_tag(&mut store, Scope::Project, "work").unwrap();
create::run(
&mut store,
Scope::Project,
"Rust Guide".into(),
"".into(),
None,
)
.unwrap();
create::run(
&mut store,
Scope::Project,
"Python Guide".into(),
"".into(),
None,
)
.unwrap();
create::run(
&mut store,
Scope::Project,
"Rust Notes".into(),
"".into(),
None,
)
.unwrap();
tagging::add_tags(
&mut store,
Scope::Project,
&[PadSelector::Path(vec![DisplayIndex::Regular(3)])],
&["work".to_string()],
)
.unwrap();
let res = run(
&store,
Scope::Project,
PadFilter {
status: PadStatusFilter::Active,
search_term: Some("rust".into()),
todo_status: None,
tags: Some(vec!["work".to_string()]),
},
&[],
)
.unwrap();
assert_eq!(res.listed_pads.len(), 1);
assert_eq!(res.listed_pads[0].pad.metadata.title, "Rust Guide");
}
#[test]
fn test_id_selector_single_pad() {
let mut store = BucketedStore::new(
MemBackend::new(),
MemBackend::new(),
MemBackend::new(),
MemBackend::new(),
);
create::run(&mut store, Scope::Project, "First".into(), "".into(), None).unwrap();
create::run(&mut store, Scope::Project, "Second".into(), "".into(), None).unwrap();
create::run(&mut store, Scope::Project, "Third".into(), "".into(), None).unwrap();
let res = run(
&store,
Scope::Project,
PadFilter::default(),
&[PadSelector::Path(vec![DisplayIndex::Regular(2)])],
)
.unwrap();
assert_eq!(res.listed_pads.len(), 1);
assert_eq!(res.listed_pads[0].pad.metadata.title, "Second");
}
#[test]
fn test_id_selector_multiple_pads() {
let mut store = BucketedStore::new(
MemBackend::new(),
MemBackend::new(),
MemBackend::new(),
MemBackend::new(),
);
create::run(&mut store, Scope::Project, "First".into(), "".into(), None).unwrap();
create::run(&mut store, Scope::Project, "Second".into(), "".into(), None).unwrap();
create::run(&mut store, Scope::Project, "Third".into(), "".into(), None).unwrap();
let res = run(
&store,
Scope::Project,
PadFilter::default(),
&[
PadSelector::Path(vec![DisplayIndex::Regular(1)]),
PadSelector::Path(vec![DisplayIndex::Regular(3)]),
],
)
.unwrap();
assert_eq!(res.listed_pads.len(), 2);
let titles: Vec<_> = res
.listed_pads
.iter()
.map(|dp| dp.pad.metadata.title.as_str())
.collect();
assert!(titles.contains(&"Third"));
assert!(titles.contains(&"First"));
}
#[test]
fn test_id_selector_with_children() {
let mut store = BucketedStore::new(
MemBackend::new(),
MemBackend::new(),
MemBackend::new(),
MemBackend::new(),
);
create::run(
&mut store,
Scope::Project,
"Parent1".into(),
"".into(),
None,
)
.unwrap();
create::run(
&mut store,
Scope::Project,
"Parent2".into(),
"".into(),
None,
)
.unwrap();
create::run(
&mut store,
Scope::Project,
"Child1".into(),
"".into(),
Some(PadSelector::Path(vec![DisplayIndex::Regular(2)])),
)
.unwrap();
create::run(
&mut store,
Scope::Project,
"Child2".into(),
"".into(),
Some(PadSelector::Path(vec![DisplayIndex::Regular(2)])),
)
.unwrap();
let res = run(
&store,
Scope::Project,
PadFilter::default(),
&[PadSelector::Path(vec![DisplayIndex::Regular(2)])],
)
.unwrap();
assert_eq!(res.listed_pads.len(), 1);
assert_eq!(res.listed_pads[0].pad.metadata.title, "Parent1");
assert_eq!(res.listed_pads[0].children.len(), 2);
}
#[test]
fn test_id_selector_range() {
let mut store = BucketedStore::new(
MemBackend::new(),
MemBackend::new(),
MemBackend::new(),
MemBackend::new(),
);
create::run(&mut store, Scope::Project, "First".into(), "".into(), None).unwrap();
create::run(&mut store, Scope::Project, "Second".into(), "".into(), None).unwrap();
create::run(&mut store, Scope::Project, "Third".into(), "".into(), None).unwrap();
create::run(&mut store, Scope::Project, "Fourth".into(), "".into(), None).unwrap();
let res = run(
&store,
Scope::Project,
PadFilter::default(),
&[PadSelector::Range(
vec![DisplayIndex::Regular(2)],
vec![DisplayIndex::Regular(3)],
)],
)
.unwrap();
assert_eq!(res.listed_pads.len(), 2);
}
#[test]
fn test_id_selector_not_found() {
let mut store = BucketedStore::new(
MemBackend::new(),
MemBackend::new(),
MemBackend::new(),
MemBackend::new(),
);
create::run(&mut store, Scope::Project, "Only".into(), "".into(), None).unwrap();
let res = run(
&store,
Scope::Project,
PadFilter::default(),
&[PadSelector::Path(vec![DisplayIndex::Regular(5)])],
);
assert!(res.is_err());
}
#[test]
fn test_id_selector_preserves_index() {
let mut store = BucketedStore::new(
MemBackend::new(),
MemBackend::new(),
MemBackend::new(),
MemBackend::new(),
);
create::run(&mut store, Scope::Project, "First".into(), "".into(), None).unwrap();
create::run(&mut store, Scope::Project, "Second".into(), "".into(), None).unwrap();
create::run(&mut store, Scope::Project, "Third".into(), "".into(), None).unwrap();
let res = run(
&store,
Scope::Project,
PadFilter::default(),
&[PadSelector::Path(vec![DisplayIndex::Regular(3)])],
)
.unwrap();
assert_eq!(res.listed_pads.len(), 1);
assert!(matches!(res.listed_pads[0].index, DisplayIndex::Regular(3)));
}
}