use crate::commands::{CmdMessage, CmdResult, PadUpdate};
use crate::error::{PadzError, Result};
use crate::index::{DisplayPad, PadSelector};
use crate::model::{parse_pad_content, Scope};
use crate::store::{Bucket, DataStore};
use chrono::Utc;
use super::helpers::resolve_selectors;
pub fn run<S: DataStore>(store: &mut S, scope: Scope, updates: &[PadUpdate]) -> Result<CmdResult> {
if updates.is_empty() {
return Ok(CmdResult::default());
}
let selectors: Vec<_> = updates
.iter()
.map(|u| {
if let Some(path) = &u.path {
PadSelector::Path(path.clone())
} else {
PadSelector::Path(vec![u.index.clone()])
}
})
.collect();
let resolved = resolve_selectors(store, scope, &selectors, false)?;
let mut result = CmdResult::default();
for ((display_index, uuid), update) in resolved.into_iter().zip(updates.iter()) {
let mut pad = store.get_pad(&uuid, scope, Bucket::Active)?;
let (_, normalized_content) =
crate::model::normalize_pad_content(&update.title, &update.content);
if let Some(status) = update.status {
pad.metadata.status = status;
}
pad.metadata.title = update.title.clone();
pad.metadata.updated_at = Utc::now();
pad.content = normalized_content;
let parent_id = pad.metadata.parent_id;
store.save_pad(&pad, scope, Bucket::Active)?;
crate::todos::propagate_status_change(store, scope, parent_id)?;
result.add_message(CmdMessage::success(format!(
"Pad updated ({}): {}",
super::helpers::fmt_path(&display_index),
pad.metadata.title
)));
let local_index = display_index
.last()
.cloned()
.unwrap_or(update.index.clone());
result.affected_pads.push(DisplayPad {
pad,
index: local_index,
matches: None,
children: Vec::new(),
});
}
Ok(result)
}
pub fn run_from_content<S: DataStore>(
store: &mut S,
scope: Scope,
selectors: &[PadSelector],
raw_content: &str,
) -> Result<CmdResult> {
let (title, full_content) = parse_pad_content(raw_content)
.ok_or_else(|| PadzError::Api("Piped content is empty or invalid".to_string()))?;
let resolved = resolve_selectors(store, scope, selectors, false)?;
let mut result = CmdResult::default();
for (display_index, uuid) in resolved {
let mut pad = store.get_pad(&uuid, scope, Bucket::Active)?;
pad.metadata.title = title.clone();
pad.metadata.updated_at = Utc::now();
pad.content = full_content.clone();
let parent_id = pad.metadata.parent_id;
store.save_pad(&pad, scope, Bucket::Active)?;
crate::todos::propagate_status_change(store, scope, parent_id)?;
result.add_message(CmdMessage::success(format!(
"Updated ({}): {}",
super::helpers::fmt_path(&display_index),
pad.metadata.title
)));
let local_index = display_index
.last()
.cloned()
.unwrap_or(display_index[0].clone());
result.affected_pads.push(DisplayPad {
pad,
index: local_index,
matches: None,
children: Vec::new(),
});
}
Ok(result)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::commands::{create, get, view};
use crate::index::{DisplayIndex, PadSelector};
use crate::model::Scope;
use crate::store::bucketed::BucketedStore;
use crate::store::mem_backend::MemBackend;
#[test]
fn updates_pad_content() {
let mut store = BucketedStore::new(
MemBackend::new(),
MemBackend::new(),
MemBackend::new(),
MemBackend::new(),
);
create::run(
&mut store,
Scope::Project,
"Title".into(),
"Old".into(),
None,
)
.unwrap();
let update = PadUpdate::new(DisplayIndex::Regular(1), "Title".into(), "New".into());
run(&mut store, Scope::Project, &[update]).unwrap();
let pads = view::run(
&store,
Scope::Project,
&[PadSelector::Path(vec![DisplayIndex::Regular(1)])],
)
.unwrap()
.listed_pads;
assert_eq!(pads[0].pad.content, "Title\n\nNew");
}
#[test]
fn update_empty_batch_does_nothing() {
let mut store = BucketedStore::new(
MemBackend::new(),
MemBackend::new(),
MemBackend::new(),
MemBackend::new(),
);
let result = run(&mut store, Scope::Project, &[]).unwrap();
assert!(result.messages.is_empty());
assert!(result.affected_pads.is_empty());
}
#[test]
fn update_renames_title() {
let mut store = BucketedStore::new(
MemBackend::new(),
MemBackend::new(),
MemBackend::new(),
MemBackend::new(),
);
create::run(
&mut store,
Scope::Project,
"Old Title".into(),
"Content".into(),
None,
)
.unwrap();
let update = PadUpdate::new(
DisplayIndex::Regular(1),
"New Title".into(),
"Content".into(),
);
run(&mut store, Scope::Project, &[update]).unwrap();
let pads = view::run(
&store,
Scope::Project,
&[PadSelector::Path(vec![DisplayIndex::Regular(1)])],
)
.unwrap()
.listed_pads;
assert_eq!(pads[0].pad.metadata.title, "New Title");
assert_eq!(pads[0].pad.content, "New Title\n\nContent");
}
#[test]
fn update_batch_multiple_pads() {
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 updates = vec![
PadUpdate::new(
DisplayIndex::Regular(1),
"A Updated".into(),
"Content A".into(),
),
PadUpdate::new(
DisplayIndex::Regular(2),
"B Updated".into(),
"Content B".into(),
),
];
let res = run(&mut store, Scope::Project, &updates).unwrap();
assert_eq!(res.messages.len(), 2);
assert!(res.messages.iter().any(|m| m.content.contains("A Updated")));
assert!(res.messages.iter().any(|m| m.content.contains("B Updated")));
let pads = get::run(&store, Scope::Project, get::PadFilter::default(), &[])
.unwrap()
.listed_pads;
let pad_a = pads
.iter()
.find(|p| p.pad.metadata.title == "A Updated")
.unwrap();
let pad_b = pads
.iter()
.find(|p| p.pad.metadata.title == "B Updated")
.unwrap();
assert_eq!(pad_a.pad.content, "A Updated\n\nContent A");
assert_eq!(pad_b.pad.content, "B Updated\n\nContent B");
}
#[test]
fn update_normalizes_content_structure() {
let mut store = BucketedStore::new(
MemBackend::new(),
MemBackend::new(),
MemBackend::new(),
MemBackend::new(),
);
create::run(&mut store, Scope::Project, "Title".into(), "".into(), None).unwrap();
let update = PadUpdate::new(
DisplayIndex::Regular(1),
"New Title".into(),
"Body content".into(),
);
run(&mut store, Scope::Project, &[update]).unwrap();
let pads = view::run(
&store,
Scope::Project,
&[PadSelector::Path(vec![DisplayIndex::Regular(1)])],
)
.unwrap()
.listed_pads;
assert_eq!(pads[0].pad.metadata.title, "New Title");
assert_eq!(pads[0].pad.content, "New Title\n\nBody content");
}
#[test]
fn update_updates_timestamp() {
let mut store = BucketedStore::new(
MemBackend::new(),
MemBackend::new(),
MemBackend::new(),
MemBackend::new(),
);
create::run(&mut store, Scope::Project, "Title".into(), "".into(), None).unwrap();
let pads_before = store.list_pads(Scope::Project, Bucket::Active).unwrap();
let original_updated_at = pads_before[0].metadata.updated_at;
std::thread::sleep(std::time::Duration::from_millis(10));
let update = PadUpdate::new(
DisplayIndex::Regular(1),
"Title".into(),
"New Content".into(),
);
run(&mut store, Scope::Project, &[update]).unwrap();
let pads_after = store.list_pads(Scope::Project, Bucket::Active).unwrap();
let new_updated_at = pads_after[0].metadata.updated_at;
assert!(new_updated_at > original_updated_at);
}
#[test]
fn update_returns_affected_pads() {
let mut store = BucketedStore::new(
MemBackend::new(),
MemBackend::new(),
MemBackend::new(),
MemBackend::new(),
);
create::run(&mut store, Scope::Project, "Title".into(), "".into(), None).unwrap();
let update = PadUpdate::new(
DisplayIndex::Regular(1),
"Updated Title".into(),
"Content".into(),
);
let result = run(&mut store, Scope::Project, &[update]).unwrap();
assert_eq!(result.affected_pads.len(), 1);
assert_eq!(result.affected_pads[0].pad.metadata.title, "Updated Title");
assert!(matches!(
result.affected_pads[0].index,
DisplayIndex::Regular(1)
));
}
#[test]
fn update_success_message_format() {
let mut store = BucketedStore::new(
MemBackend::new(),
MemBackend::new(),
MemBackend::new(),
MemBackend::new(),
);
create::run(&mut store, Scope::Project, "Title".into(), "".into(), None).unwrap();
let update = PadUpdate::new(
DisplayIndex::Regular(1),
"Updated Title".into(),
"Content".into(),
);
let result = run(&mut store, Scope::Project, &[update]).unwrap();
assert_eq!(result.messages.len(), 1);
assert!(result.messages[0].content.contains("Pad updated"));
assert!(result.messages[0].content.contains("1")); assert!(result.messages[0].content.contains("Updated Title"));
}
#[test]
fn update_nonexistent_pad_fails() {
let mut store = BucketedStore::new(
MemBackend::new(),
MemBackend::new(),
MemBackend::new(),
MemBackend::new(),
);
create::run(&mut store, Scope::Project, "Title".into(), "".into(), None).unwrap();
let update = PadUpdate::new(DisplayIndex::Regular(99), "Title".into(), "Content".into());
let result = run(&mut store, Scope::Project, &[update]);
assert!(result.is_err());
}
#[test]
fn update_preserves_other_metadata() {
let mut store = BucketedStore::new(
MemBackend::new(),
MemBackend::new(),
MemBackend::new(),
MemBackend::new(),
);
create::run(&mut store, Scope::Project, "Title".into(), "".into(), None).unwrap();
let pads_before = store.list_pads(Scope::Project, Bucket::Active).unwrap();
let original_created_at = pads_before[0].metadata.created_at;
let original_id = pads_before[0].metadata.id;
let update = PadUpdate::new(
DisplayIndex::Regular(1),
"New Title".into(),
"Content".into(),
);
run(&mut store, Scope::Project, &[update]).unwrap();
let pads_after = store.list_pads(Scope::Project, Bucket::Active).unwrap();
assert_eq!(pads_after[0].metadata.created_at, original_created_at);
assert_eq!(pads_after[0].metadata.id, original_id);
}
#[test]
fn test_status_propagation_via_update() {
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 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, crate::model::TodoStatus::Planned);
let update = PadUpdate::new(
DisplayIndex::Regular(1), "Child".into(),
"".into(),
)
.with_path(vec![DisplayIndex::Regular(1), DisplayIndex::Regular(1)])
.with_status(crate::model::TodoStatus::Done);
run(&mut store, Scope::Project, &[update]).unwrap();
let pads_after = store.list_pads(Scope::Project, Bucket::Active).unwrap();
let child = pads_after
.iter()
.find(|p| p.metadata.title == "Child")
.unwrap();
assert_eq!(child.metadata.status, crate::model::TodoStatus::Done);
let parent_after = pads_after
.iter()
.find(|p| p.metadata.title == "Parent")
.unwrap();
assert_eq!(parent_after.metadata.status, crate::model::TodoStatus::Done);
}
#[test]
fn run_from_content_updates_single_pad() {
let mut store = BucketedStore::new(
MemBackend::new(),
MemBackend::new(),
MemBackend::new(),
MemBackend::new(),
);
create::run(
&mut store,
Scope::Project,
"Original Title".into(),
"Original body".into(),
None,
)
.unwrap();
let raw_content = "New Title From Pipe\n\nNew body content from piped input.";
let selectors = vec![PadSelector::Path(vec![DisplayIndex::Regular(1)])];
let result = run_from_content(&mut store, Scope::Project, &selectors, raw_content).unwrap();
assert_eq!(result.affected_pads.len(), 1);
assert_eq!(
result.affected_pads[0].pad.metadata.title,
"New Title From Pipe"
);
assert_eq!(
result.affected_pads[0].pad.content,
"New Title From Pipe\n\nNew body content from piped input."
);
}
#[test]
fn run_from_content_updates_multiple_pads() {
let mut store = BucketedStore::new(
MemBackend::new(),
MemBackend::new(),
MemBackend::new(),
MemBackend::new(),
);
create::run(&mut store, Scope::Project, "Pad A".into(), "".into(), None).unwrap();
create::run(&mut store, Scope::Project, "Pad B".into(), "".into(), None).unwrap();
let raw_content = "Shared New Title\n\nShared content for all selected pads.";
let selectors = vec![
PadSelector::Path(vec![DisplayIndex::Regular(1)]),
PadSelector::Path(vec![DisplayIndex::Regular(2)]),
];
let result = run_from_content(&mut store, Scope::Project, &selectors, raw_content).unwrap();
assert_eq!(result.affected_pads.len(), 2);
for dp in &result.affected_pads {
assert_eq!(dp.pad.metadata.title, "Shared New Title");
assert_eq!(
dp.pad.content,
"Shared New Title\n\nShared content for all selected pads."
);
}
}
#[test]
fn run_from_content_empty_content_fails() {
let mut store = BucketedStore::new(
MemBackend::new(),
MemBackend::new(),
MemBackend::new(),
MemBackend::new(),
);
create::run(&mut store, Scope::Project, "Test".into(), "".into(), None).unwrap();
let raw_content = " \n\n "; let selectors = vec![PadSelector::Path(vec![DisplayIndex::Regular(1)])];
let result = run_from_content(&mut store, Scope::Project, &selectors, raw_content);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("empty or invalid"));
}
#[test]
fn run_from_content_preserves_metadata() {
let mut store = BucketedStore::new(
MemBackend::new(),
MemBackend::new(),
MemBackend::new(),
MemBackend::new(),
);
create::run(&mut store, Scope::Project, "Title".into(), "".into(), None).unwrap();
let pads_before = store.list_pads(Scope::Project, Bucket::Active).unwrap();
let original_id = pads_before[0].metadata.id;
let original_created_at = pads_before[0].metadata.created_at;
std::thread::sleep(std::time::Duration::from_millis(10));
let raw_content = "New Title\n\nNew content";
let selectors = vec![PadSelector::Path(vec![DisplayIndex::Regular(1)])];
run_from_content(&mut store, Scope::Project, &selectors, raw_content).unwrap();
let pads_after = store.list_pads(Scope::Project, Bucket::Active).unwrap();
assert_eq!(pads_after[0].metadata.id, original_id);
assert_eq!(pads_after[0].metadata.created_at, original_created_at);
assert!(pads_after[0].metadata.updated_at > original_created_at);
}
#[test]
fn run_from_content_title_only() {
let mut store = BucketedStore::new(
MemBackend::new(),
MemBackend::new(),
MemBackend::new(),
MemBackend::new(),
);
create::run(
&mut store,
Scope::Project,
"Original".into(),
"Original body".into(),
None,
)
.unwrap();
let raw_content = "Title Only";
let selectors = vec![PadSelector::Path(vec![DisplayIndex::Regular(1)])];
let result = run_from_content(&mut store, Scope::Project, &selectors, raw_content).unwrap();
assert_eq!(result.affected_pads[0].pad.metadata.title, "Title Only");
assert_eq!(result.affected_pads[0].pad.content, "Title Only");
}
#[test]
fn run_from_content_nonexistent_pad_fails() {
let mut store = BucketedStore::new(
MemBackend::new(),
MemBackend::new(),
MemBackend::new(),
MemBackend::new(),
);
create::run(&mut store, Scope::Project, "Test".into(), "".into(), None).unwrap();
let raw_content = "New Content\n\nBody";
let selectors = vec![PadSelector::Path(vec![DisplayIndex::Regular(99)])];
let result = run_from_content(&mut store, Scope::Project, &selectors, raw_content);
assert!(result.is_err());
}
}