tally-todo 0.12.0

Make TODO management a little more automatic
use crate::models::common::{Priority, Version};
use crate::models::tasks::Task;
use crate::services::storage::changelog_storage::ChangelogStorage;
use crate::services::storage::task_storage::ListStorage;
use crate::utils::project_paths::ProjectPaths;
use anyhow::Result;
use serde::Serialize;

pub fn cmd_list(
    tags: Option<Vec<String>>,
    priority: Option<Priority>,
    done: bool,
    released: Option<String>,
    json: bool,
) -> Result<()> {
    let paths = ProjectPaths::get_paths()?;
    let storage = ListStorage::new(&paths.todo_file)?;
    if let Some(released_version_str) = released {
        let released_version = Version::parse(&released_version_str)?;
        return cmd_list_released(
            &paths.changelog_file,
            storage.project_name(),
            tags,
            priority,
            released_version,
            json,
        );
    }

    let tasks = filter_tasks(storage.tasks(), tags.as_deref(), priority.as_ref(), done);

    if json {
        let task_list: Vec<_> = tasks.iter().map(|(_, task)| task).collect();
        println!("{}", serde_json::to_string_pretty(&task_list)?);
    } else {
        if tasks.is_empty() {
            println!("No tasks found.");
            return Ok(());
        }

        for (i, task) in tasks {
            let checkbox = if task.completed { "x" } else { " " };
            let priority_str = match task.priority {
                Priority::High => " (high)",
                Priority::Medium => "",
                Priority::Low => " (low)",
            };
            let tags_str = if task.tags.is_empty() {
                String::new()
            } else {
                format!(
                    " {}",
                    task.tags
                        .iter()
                        .map(|t| format!("#{}", t))
                        .collect::<Vec<_>>()
                        .join(" ")
                )
            };

            println!(
                "{}. [{}] {}{}{}",
                i + 1,
                checkbox,
                task.description,
                priority_str,
                tags_str
            );

            if task.completed {
                if let Some(ref commit) = task.completed_at_commit {
                    println!("      @commit {}", commit);
                }
                if let Some(ref version) = task.completed_at_version {
                    println!("      @version {}", version);
                }
            }
        }
    }

    Ok(())
}

#[derive(Serialize)]
struct ReleasedEntry {
    version: String,
    description: String,
    priority: Priority,
    tags: Vec<String>,
    commit: Option<String>,
}

fn cmd_list_released(
    changelog_file: &std::path::Path,
    project_name: &str,
    tags: Option<Vec<String>>,
    priority: Option<Priority>,
    released_version: Version,
    json: bool,
) -> Result<()> {
    let changelog = ChangelogStorage::new(changelog_file, project_name)?;
    let mut entries: Vec<ReleasedEntry> = Vec::new();

    for release in changelog.log().releases.iter().rev() {
        if release.version != released_version {
            continue;
        }
        for group in release.changes_by_priority.values() {
            for change in group {
                if let Some(filter_tags) = tags.as_ref()
                    && !filter_tags.iter().any(|tag| change.tags.contains(tag))
                {
                    continue;
                }
                if let Some(filter_priority) = priority.as_ref()
                    && &change.priority != filter_priority
                {
                    continue;
                }

                entries.push(ReleasedEntry {
                    version: release.version.to_string(),
                    description: change.description.clone(),
                    priority: change.priority,
                    tags: change.tags.clone(),
                    commit: change.commit.clone(),
                });
            }
        }
    }

    if json {
        println!("{}", serde_json::to_string_pretty(&entries)?);
        return Ok(());
    }

    if entries.is_empty() {
        println!("No released tasks found.");
        return Ok(());
    }

    for (i, entry) in entries.iter().enumerate() {
        let priority_str = match entry.priority {
            Priority::High => " (high)",
            Priority::Medium => "",
            Priority::Low => " (low)",
        };
        let tags_str = if entry.tags.is_empty() {
            String::new()
        } else {
            format!(
                " {}",
                entry.tags
                    .iter()
                    .map(|t| format!("#{}", t))
                    .collect::<Vec<_>>()
                    .join(" ")
            )
        };
        println!(
            "{}. {}{}{} @version {}",
            i + 1,
            entry.description,
            priority_str,
            tags_str,
            entry.version
        );
        if let Some(commit) = &entry.commit {
            println!("      @commit {}", commit);
        }
    }

    Ok(())
}

fn filter_tasks<'a>(
    tasks: &'a [Task],
    tags: Option<&[String]>,
    priority: Option<&Priority>,
    done: bool,
) -> Vec<(usize, &'a Task)> {
    let mut tasks: Vec<_> = tasks.iter().enumerate().collect();

    if let Some(filter_tags) = tags {
        tasks.retain(|(_, task)| filter_tags.iter().any(|tag| task.tags.contains(tag)));
    }

    if let Some(filter_priority) = priority {
        tasks.retain(|(_, task)| &task.priority == filter_priority);
    }

    if done {
        tasks.retain(|(_, task)| task.completed);
    }

    tasks
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::models::{
        common::{Priority, Version},
        tasks::Task,
    };
    use chrono::{TimeZone, Utc};

    fn task(
        description: &str,
        priority: Priority,
        tags: &[&str],
        completed: bool,
        completed_version: Option<Version>,
    ) -> Task {
        Task {
            description: description.to_string(),
            priority,
            tags: tags.iter().map(|tag| tag.to_string()).collect(),
            completed,
            created_at_time: Utc.with_ymd_and_hms(2026, 4, 1, 12, 0, 0).unwrap(),
            created_at_version: None,
            created_at_commit: None,
            completed_at_time: completed
                .then(|| Utc.with_ymd_and_hms(2026, 4, 2, 12, 0, 0).unwrap()),
            completed_at_version: completed_version,
            completed_at_commit: None,
        }
    }

    #[test]
    fn filter_tasks_done_only_returns_completed_tasks() {
        let tasks = vec![
            task("unfinished", Priority::Medium, &["feature"], false, None),
            task(
                "finished",
                Priority::High,
                &["feature"],
                true,
                Some(Version::new(0, 6, 0, false)),
            ),
        ];

        let filtered = filter_tasks(&tasks, None, None, true);

        assert_eq!(filtered.len(), 1);
        assert_eq!(filtered[0].1.description, "finished");
    }

    #[test]
    fn filter_tasks_combines_filters_as_intersection() {
        let tasks = vec![
            task(
                "matching task",
                Priority::High,
                &["feature", "ux"],
                true,
                Some(Version::new(0, 6, 0, false)),
            ),
            task(
                "wrong priority",
                Priority::Medium,
                &["feature", "ux"],
                true,
                Some(Version::new(0, 6, 0, false)),
            ),
            task(
                "wrong tag",
                Priority::High,
                &["backend"],
                true,
                Some(Version::new(0, 6, 0, false)),
            ),
        ];
        let tags = vec!["feature".to_string(), "ux".to_string()];

        let filtered = filter_tasks(&tasks, Some(&tags), Some(&Priority::High), true);

        assert_eq!(filtered.len(), 1);
        assert_eq!(filtered[0].1.description, "matching task");
    }
}