use crate::commands::{CmdMessage, CmdResult};
use crate::error::Result;
use crate::index::{DisplayIndex, DisplayPad, PadSelector};
use crate::model::{Scope, TodoStatus};
use crate::store::{Bucket, DataStore};
use uuid::Uuid;
use super::helpers::{indexed_pads, resolve_selectors};
pub fn complete<S: DataStore>(
store: &mut S,
scope: Scope,
selectors: &[PadSelector],
) -> Result<CmdResult> {
set_status(store, scope, selectors, TodoStatus::Done)
}
pub fn reopen<S: DataStore>(
store: &mut S,
scope: Scope,
selectors: &[PadSelector],
) -> Result<CmdResult> {
set_status(store, scope, selectors, TodoStatus::Planned)
}
fn set_status<S: DataStore>(
store: &mut S,
scope: Scope,
selectors: &[PadSelector],
new_status: TodoStatus,
) -> Result<CmdResult> {
let resolved = resolve_selectors(store, scope, selectors, false)?;
let mut result = CmdResult::default();
let mut affected_uuids: Vec<Uuid> = Vec::new();
for (display_index, uuid) in resolved {
let mut pad = store.get_pad(&uuid, scope, Bucket::Active)?;
let old_status = pad.metadata.status;
if old_status == new_status {
let status_name = match new_status {
TodoStatus::Done => "done",
TodoStatus::Planned => "planned",
TodoStatus::InProgress => "in progress",
};
result.add_message(CmdMessage::info(format!(
"Pad {} is already {}",
super::helpers::fmt_path(&display_index),
status_name
)));
} else {
pad.metadata.status = new_status;
pad.metadata.updated_at = chrono::Utc::now();
let parent_id = pad.metadata.parent_id;
store.save_pad(&pad, scope, Bucket::Active)?;
crate::todos::propagate_status_change(store, scope, parent_id)?;
}
affected_uuids.push(uuid);
}
let indexed = indexed_pads(store, scope)?;
for uuid in affected_uuids {
let index_filter =
|idx: &DisplayIndex| matches!(idx, DisplayIndex::Regular(_) | DisplayIndex::Pinned(_));
if let Some(dp) = super::helpers::find_pad_by_uuid(&indexed, uuid, index_filter) {
result.affected_pads.push(DisplayPad {
pad: dp.pad.clone(),
index: dp.index.clone(),
matches: None,
children: Vec::new(),
});
}
}
Ok(result)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::commands::{create, get};
use crate::index::DisplayIndex;
use crate::model::Scope;
use crate::store::bucketed::BucketedStore;
use crate::store::mem_backend::MemBackend;
use std::slice;
#[test]
fn complete_marks_pad_as_done() {
let mut store = BucketedStore::new(
MemBackend::new(),
MemBackend::new(),
MemBackend::new(),
MemBackend::new(),
);
create::run(&mut store, Scope::Project, "Task".into(), "".into(), None).unwrap();
let sel = PadSelector::Path(vec![DisplayIndex::Regular(1)]);
let result = complete(&mut store, Scope::Project, slice::from_ref(&sel)).unwrap();
assert!(result.messages.is_empty());
assert_eq!(result.affected_pads.len(), 1);
let pads = get::run(&store, Scope::Project, get::PadFilter::default(), &[]).unwrap();
assert_eq!(pads.listed_pads[0].pad.metadata.status, TodoStatus::Done);
}
#[test]
fn reopen_sets_pad_to_planned() {
let mut store = BucketedStore::new(
MemBackend::new(),
MemBackend::new(),
MemBackend::new(),
MemBackend::new(),
);
create::run(&mut store, Scope::Project, "Task".into(), "".into(), None).unwrap();
let sel = PadSelector::Path(vec![DisplayIndex::Regular(1)]);
complete(&mut store, Scope::Project, slice::from_ref(&sel)).unwrap();
let result = reopen(&mut store, Scope::Project, slice::from_ref(&sel)).unwrap();
assert!(result.messages.is_empty());
assert_eq!(result.affected_pads.len(), 1);
let pads = get::run(&store, Scope::Project, get::PadFilter::default(), &[]).unwrap();
assert_eq!(pads.listed_pads[0].pad.metadata.status, TodoStatus::Planned);
}
#[test]
fn complete_already_done_is_idempotent() {
let mut store = BucketedStore::new(
MemBackend::new(),
MemBackend::new(),
MemBackend::new(),
MemBackend::new(),
);
create::run(&mut store, Scope::Project, "Task".into(), "".into(), None).unwrap();
let sel = PadSelector::Path(vec![DisplayIndex::Regular(1)]);
complete(&mut store, Scope::Project, slice::from_ref(&sel)).unwrap();
let result = complete(&mut store, Scope::Project, slice::from_ref(&sel)).unwrap();
assert!(matches!(
result.messages[0].level,
crate::commands::MessageLevel::Info
));
assert!(result.messages[0].content.contains("already done"));
}
#[test]
fn reopen_already_planned_is_idempotent() {
let mut store = BucketedStore::new(
MemBackend::new(),
MemBackend::new(),
MemBackend::new(),
MemBackend::new(),
);
create::run(&mut store, Scope::Project, "Task".into(), "".into(), None).unwrap();
let sel = PadSelector::Path(vec![DisplayIndex::Regular(1)]);
let result = reopen(&mut store, Scope::Project, slice::from_ref(&sel)).unwrap();
assert!(matches!(
result.messages[0].level,
crate::commands::MessageLevel::Info
));
assert!(result.messages[0].content.contains("already planned"));
}
#[test]
fn complete_batch() {
let mut store = BucketedStore::new(
MemBackend::new(),
MemBackend::new(),
MemBackend::new(),
MemBackend::new(),
);
create::run(&mut store, Scope::Project, "A".into(), "".into(), None).unwrap();
create::run(&mut store, Scope::Project, "B".into(), "".into(), None).unwrap();
let selectors = vec![
PadSelector::Path(vec![DisplayIndex::Regular(1)]),
PadSelector::Path(vec![DisplayIndex::Regular(2)]),
];
let result = complete(&mut store, Scope::Project, &selectors).unwrap();
assert!(result.messages.is_empty());
assert_eq!(result.affected_pads.len(), 2);
let pads = get::run(&store, Scope::Project, get::PadFilter::default(), &[]).unwrap();
assert!(pads
.listed_pads
.iter()
.all(|dp| dp.pad.metadata.status == TodoStatus::Done));
}
#[test]
fn complete_propagates_to_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();
let sel = PadSelector::Path(vec![DisplayIndex::Regular(1), DisplayIndex::Regular(1)]);
complete(&mut store, Scope::Project, slice::from_ref(&sel)).unwrap();
let pads = store.list_pads(Scope::Project, Bucket::Active).unwrap();
let parent = pads.iter().find(|p| p.metadata.title == "Parent").unwrap();
assert_eq!(parent.metadata.status, TodoStatus::Done);
}
#[test]
fn reopen_propagates_to_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();
let sel = PadSelector::Path(vec![DisplayIndex::Regular(1), DisplayIndex::Regular(1)]);
complete(&mut store, Scope::Project, slice::from_ref(&sel)).unwrap();
let pads = store.list_pads(Scope::Project, Bucket::Active).unwrap();
let parent = pads.iter().find(|p| p.metadata.title == "Parent").unwrap();
assert_eq!(parent.metadata.status, TodoStatus::Done);
reopen(&mut store, Scope::Project, slice::from_ref(&sel)).unwrap();
let pads_after = store.list_pads(Scope::Project, Bucket::Active).unwrap();
let parent_after = pads_after
.iter()
.find(|p| p.metadata.title == "Parent")
.unwrap();
assert_eq!(parent_after.metadata.status, TodoStatus::Planned);
}
}