tasks-cli-rs 0.9.0

Markdown-based TODO task management CLI: each task is a Markdown file with YAML front matter
use std::io::Read;

use crate::error::{Error, Result};
use crate::storage;

use super::task::active_library_root;

pub fn log(id: &str, text: Vec<String>, after: Option<&str>, stdin: bool, quiet: bool) -> Result<()> {
    let text = resolve_text(text, stdin)?;
    append(id, &text, after, quiet)
}

pub fn rewrite(id: &str, old: &str, new: Vec<String>, stdin: bool, quiet: bool) -> Result<()> {
    let new = resolve_text(new, stdin)?;
    replace(id, old, &new, quiet)
}

pub fn strike(id: &str, prefix: &str, quiet: bool) -> Result<()> {
    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)?;
    // Validate the file still parses correctly
    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()) {
            // no extra blank line needed
        }
        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());
    }
}