use std::io::Read;
use crate::cli::BodyCommand;
use crate::error::{Error, Result};
use crate::storage;
use super::task::active_library_root;
pub fn run(command: BodyCommand) -> Result<()> {
match command {
BodyCommand::Append { id, text, after, stdin, quiet } => {
let text = resolve_text(text, stdin)?;
append(&id, &text, after.as_deref(), quiet)
}
BodyCommand::Replace { id, old, new, stdin, quiet } => {
let new = resolve_text(new, stdin)?;
replace(&id, &old, &new, quiet)
}
BodyCommand::Delete { id, prefix, quiet } => delete(&id, &prefix, quiet),
}
}
fn resolve_text(args: Vec<String>, stdin: bool) -> Result<String> {
if stdin {
let mut buf = String::new();
std::io::stdin().read_to_string(&mut buf)?;
Ok(buf.trim_end_matches('\n').to_string())
} else if args.is_empty() {
Err(Error::InvalidTaskFile("missing text argument (or use --stdin)".into()))
} else {
Ok(args.join(" "))
}
}
fn find_unique_contains(lines: &[&str], needle: &str) -> Result<usize> {
let hits: Vec<usize> = lines
.iter()
.enumerate()
.filter(|(_, l)| l.contains(needle))
.map(|(i, _)| i)
.collect();
match hits.len() {
0 => Err(Error::InvalidTaskFile(format!("no line contains '{needle}'"))),
1 => Ok(hits[0]),
n => Err(Error::InvalidTaskFile(format!(
"'{needle}' matches {n} lines; be more specific"
))),
}
}
fn find_unique_starts_with(lines: &[&str], prefix: &str) -> Result<usize> {
let hits: Vec<usize> = lines
.iter()
.enumerate()
.filter(|(_, l)| l.trim_start().starts_with(prefix))
.map(|(i, _)| i)
.collect();
match hits.len() {
0 => Err(Error::InvalidTaskFile(format!("no line starts with '{prefix}'"))),
1 => Ok(hits[0]),
n => Err(Error::InvalidTaskFile(format!(
"'{prefix}' matches {n} lines; be more specific"
))),
}
}
fn save_and_print(_root: &std::path::Path, st: &mut storage::StoredTask, quiet: bool) -> Result<()> {
st.task.steps = crate::checkbox::parse_checkboxes(&st.task.body);
storage::write_task(&st.path, &st.task)?;
storage::read_task(&st.path)?;
if !quiet {
print!("{}", st.task.body);
if !st.task.body.ends_with('\n') {
println!();
}
}
Ok(())
}
fn append(id: &str, text: &str, after: Option<&str>, quiet: bool) -> Result<()> {
let root = active_library_root()?;
let mut st = storage::resolve_task(&root, id)?;
let mut lines: Vec<String> = st.task.body.lines().map(String::from).collect();
if let Some(pattern) = after {
let refs: Vec<&str> = lines.iter().map(|s| s.as_str()).collect();
let idx = find_unique_contains(&refs, pattern)?;
lines.insert(idx + 1, text.to_string());
} else {
if !lines.is_empty() && lines.last().is_some_and(|l| !l.is_empty()) {
}
lines.push(text.to_string());
}
st.task.body = lines.join("\n");
if !st.task.body.ends_with('\n') {
st.task.body.push('\n');
}
save_and_print(&root, &mut st, quiet)
}
fn replace(id: &str, old: &str, new: &str, quiet: bool) -> Result<()> {
let root = active_library_root()?;
let mut st = storage::resolve_task(&root, id)?;
let mut lines: Vec<String> = st.task.body.lines().map(String::from).collect();
let refs: Vec<&str> = lines.iter().map(|s| s.as_str()).collect();
let idx = find_unique_contains(&refs, old)?;
lines[idx] = new.to_string();
st.task.body = lines.join("\n");
if !st.task.body.ends_with('\n') {
st.task.body.push('\n');
}
save_and_print(&root, &mut st, quiet)
}
fn delete(id: &str, prefix: &str, quiet: bool) -> Result<()> {
let root = active_library_root()?;
let mut st = storage::resolve_task(&root, id)?;
let mut lines: Vec<String> = st.task.body.lines().map(String::from).collect();
let refs: Vec<&str> = lines.iter().map(|s| s.as_str()).collect();
let idx = find_unique_starts_with(&refs, prefix)?;
lines.remove(idx);
st.task.body = lines.join("\n");
if !st.task.body.is_empty() && !st.task.body.ends_with('\n') {
st.task.body.push('\n');
}
save_and_print(&root, &mut st, quiet)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn contains_unique_match() {
let lines = vec!["hello world", "foo bar", "baz"];
assert_eq!(find_unique_contains(&lines, "foo").unwrap(), 1);
assert_eq!(find_unique_contains(&lines, "hello").unwrap(), 0);
}
#[test]
fn contains_no_match() {
let lines = vec!["hello", "world"];
assert!(find_unique_contains(&lines, "nope").is_err());
}
#[test]
fn contains_multiple_matches() {
let lines = vec!["dup x", "other", "dup y"];
let err = find_unique_contains(&lines, "dup").unwrap_err();
assert!(err.to_string().contains("2 lines"));
}
#[test]
fn starts_with_unique() {
let lines = vec!["## Notes", "content", "## Other"];
assert_eq!(find_unique_starts_with(&lines, "## Notes").unwrap(), 0);
assert_eq!(find_unique_starts_with(&lines, "## Other").unwrap(), 2);
}
#[test]
fn starts_with_ignores_indentation() {
let lines = vec![" - [ ] task", "text"];
assert_eq!(find_unique_starts_with(&lines, "- [ ]").unwrap(), 0);
}
#[test]
fn starts_with_no_match() {
let lines = vec!["hello"];
assert!(find_unique_starts_with(&lines, "##").is_err());
}
#[test]
fn starts_with_multiple() {
let lines = vec!["- item a", "- item b"];
let err = find_unique_starts_with(&lines, "- item").unwrap_err();
assert!(err.to_string().contains("2 lines"));
}
#[test]
fn resolve_text_from_args() {
assert_eq!(resolve_text(vec!["hi".into()], false).unwrap(), "hi");
assert_eq!(
resolve_text(vec!["多".into(), "个".into(), "词".into()], false).unwrap(),
"多 个 词"
);
assert!(resolve_text(vec![], false).is_err());
}
}