tasks-cli-rs 0.2.0

Markdown-based TODO task management CLI: each task is a Markdown file with YAML front matter
use crate::cli::StepCommand;
use crate::error::{Error, Result};
use crate::model::{Status, Step, Task};
use crate::storage;

use super::task::{active_library_root, parse_due};

pub fn run(command: StepCommand) -> Result<()> {
    match command {
        StepCommand::Add { id, title, due, depends_on } => add(&id, title, due, depends_on),
        StepCommand::Done { id, step } => set_status(&id, &step, Status::Done),
        StepCommand::Status { id, step, status } => {
            let status = status.parse::<Status>().map_err(Error::InvalidTaskFile)?;
            set_status(&id, &step, status)
        }
        StepCommand::Remove { id, step } => remove(&id, &step),
    }
}

/// Next free id of the form sN.
pub fn next_step_id(task: &Task) -> String {
    let max = task
        .meta
        .steps
        .iter()
        .filter_map(|s| s.id.strip_prefix('s').and_then(|n| n.parse::<u64>().ok()))
        .max()
        .unwrap_or(0);
    format!("s{}", max + 1)
}

fn add(id: &str, title: String, due: Option<String>, depends_on: Option<String>) -> Result<()> {
    let root = active_library_root()?;
    let mut st = storage::resolve_task(&root, id)?;
    let deps: Vec<String> = depends_on
        .map(|d| d.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect())
        .unwrap_or_default();
    for dep in &deps {
        if !st.task.meta.steps.iter().any(|s| &s.id == dep) {
            return Err(Error::InvalidTaskFile(format!("unknown step id in --depends-on: {dep}")));
        }
    }
    let step_id = next_step_id(&st.task);
    st.task.meta.steps.push(Step {
        id: step_id.clone(),
        title,
        status: Status::Todo,
        due_date: due.map(|d| parse_due(&d).map(|dt| dt.date_naive())).transpose()?,
        depends_on: deps,
    });
    storage::write_task(&st.path, &st.task)?;
    println!("added step {step_id} to #{}", st.task.meta.seq);
    print_progress(&st.task);
    Ok(())
}

fn set_status(id: &str, step_id: &str, status: Status) -> Result<()> {
    let root = active_library_root()?;
    let mut st = storage::resolve_task(&root, id)?;
    let step = st
        .task
        .meta
        .steps
        .iter_mut()
        .find(|s| s.id == step_id)
        .ok_or_else(|| Error::TaskNotFound(format!("step {step_id} in #{}", st.task.meta.seq)))?;
    step.status = status;
    storage::write_task(&st.path, &st.task)?;
    println!("step {step_id} -> {status}");
    print_progress(&st.task);
    Ok(())
}

fn remove(id: &str, step_id: &str) -> Result<()> {
    let root = active_library_root()?;
    let mut st = storage::resolve_task(&root, id)?;
    let before = st.task.meta.steps.len();
    st.task.meta.steps.retain(|s| s.id != step_id);
    if st.task.meta.steps.len() == before {
        return Err(Error::TaskNotFound(format!("step {step_id} in #{}", st.task.meta.seq)));
    }
    let dangling: Vec<String> = st
        .task
        .meta
        .steps
        .iter()
        .filter(|s| s.depends_on.iter().any(|d| d == step_id))
        .map(|s| s.id.clone())
        .collect();
    for s in st.task.meta.steps.iter_mut() {
        s.depends_on.retain(|d| d != step_id);
    }
    if !dangling.is_empty() {
        println!("note: removed dependency on {step_id} from: {}", dangling.join(", "));
    }
    storage::write_task(&st.path, &st.task)?;
    println!("removed step {step_id} from #{}", st.task.meta.seq);
    Ok(())
}

fn print_progress(task: &Task) {
    let done = task.meta.steps.iter().filter(|s| s.status == Status::Done).count();
    println!("progress: {done}/{}", task.meta.steps.len());
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn step_ids_increment() {
        let mut task = Task::new(1, "t");
        assert_eq!(next_step_id(&task), "s1");
        task.meta.steps.push(Step {
            id: "s1".into(),
            title: "a".into(),
            status: Status::Todo,
            due_date: None,
            depends_on: vec![],
        });
        task.meta.steps.push(Step {
            id: "s7".into(),
            title: "b".into(),
            status: Status::Todo,
            due_date: None,
            depends_on: vec![],
        });
        assert_eq!(next_step_id(&task), "s8");
    }
}