tally-todo 0.9.0

Make TODO management a little more automatic
use crate::models::{
    changes::{Change, Log, Release},
    common::{Priority, Version},
};
use anyhow::Result;
use chrono::{DateTime, NaiveDate, Utc};
use std::collections::BTreeMap;

pub fn to_markdown(changelog: &Log) -> String {
    let mut output = String::new();

    output.push_str(&format!("# Changelog — {}\n\n", changelog.project_name));
    output.push_str(&format!(
        "*Generated on {}*\n\n",
        changelog.generated_at.format("%Y-%m-%d")
    ));

    for release in &changelog.releases {
        output.push_str(&release_to_markdown(release));
        output.push('\n');
    }

    output
}

pub fn from_markdown(content: &str) -> Result<Log> {
    let mut project_name = "Untitled".to_string();
    let mut generated_at = Utc::now();
    let mut releases: Vec<Release> = Vec::new();

    let mut current_version: Option<Version> = None;
    let mut current_date = Utc::now();
    let mut current_priority = Priority::Medium;
    let mut current_changes: Vec<Change> = Vec::new();

    for line in content.lines() {
        let trimmed = line.trim();

        if let Some(rest) = trimmed.strip_prefix("# Changelog —") {
            let name = rest.trim();
            if !name.is_empty() {
                project_name = name.to_string();
            }
            continue;
        }

        if let Some(date_str) = trimmed
            .strip_prefix("*Generated on ")
            .and_then(|s| s.strip_suffix('*'))
        {
            if let Some(dt) = parse_date(date_str.trim()) {
                generated_at = dt;
            }
            continue;
        }

        if let Some(release) = parse_release_header(trimmed) {
            if let Some(version) = current_version.take() {
                let refs: Vec<&Change> = current_changes.iter().collect();
                releases.push(Release::from_changes(version, current_date, refs));
                current_changes.clear();
            }
            current_version = Some(release.0);
            current_date = release.1;
            current_priority = Priority::Medium;
            continue;
        }

        if let Some(priority) = parse_priority_header(trimmed) {
            current_priority = priority;
            continue;
        }

        if let Some(change) = parse_bullet_change(trimmed, current_priority)
            && current_version.is_some()
        {
            current_changes.push(change);
        }
    }

    if let Some(version) = current_version.take() {
        let refs: Vec<&Change> = current_changes.iter().collect();
        releases.push(Release::from_changes(version, current_date, refs));
    }

    Ok(Log {
        project_name,
        releases,
        generated_at,
    })
}

fn release_to_markdown(release: &Release) -> String {
    let mut output = String::new();

    output.push_str(&format!(
        "## {}{}\n\n",
        release.version,
        release.date.format("%Y-%m-%d")
    ));

    for (priority, section_name) in [
        (Priority::High, "High Priority"),
        (Priority::Medium, "Changes"),
        (Priority::Low, "Minor Changes"),
    ] {
        if let Some(changes) = release.changes_by_priority.get(&priority)
            && !changes.is_empty()
        {
            output.push_str(&format!("### {}\n\n", section_name));

            for change in changes {
                let tags = if change.tags.is_empty() {
                    String::new()
                } else {
                    format!(" `{}`", change.tags.join("`, `"))
                };

                let commit = change
                    .commit
                    .as_ref()
                    .map(|c| format!(" ([`{}`])", &c[..7.min(c.len())]))
                    .unwrap_or_default();

                output.push_str(&format!("- {}{}{}\n", change.description, tags, commit));
            }

            output.push('\n');
        }
    }

    output
}

fn parse_release_header(line: &str) -> Option<(Version, DateTime<Utc>)> {
    let body = line.strip_prefix("## ")?;
    let mut parts = body.split('');
    let version_part = parts.next()?.trim();
    let date_part = parts.next().map(str::trim).unwrap_or_default();

    let version = Version::parse(version_part).ok()?;
    let date = parse_date(date_part).unwrap_or_else(Utc::now);
    Some((version, date))
}

fn parse_priority_header(line: &str) -> Option<Priority> {
    match line {
        "### High Priority" => Some(Priority::High),
        "### Changes" => Some(Priority::Medium),
        "### Minor Changes" => Some(Priority::Low),
        _ => None,
    }
}

fn parse_bullet_change(line: &str, priority: Priority) -> Option<Change> {
    let body = line.strip_prefix("- ")?.trim();
    if body.is_empty() {
        return None;
    }

    let (without_commit, commit) = extract_commit(body);
    let (description, tags) = extract_tags(without_commit);

    Some(Change {
        description,
        priority,
        tags,
        commit,
        completed_at: Utc::now(),
    })
}

fn extract_commit(text: &str) -> (&str, Option<String>) {
    if let Some(start) = text.rfind(" ([`")
        && let Some(end) = text[start + 4..].find("`])")
    {
        let hash = &text[start + 4..start + 4 + end];
        return (text[..start].trim_end(), Some(hash.to_string()));
    }
    (text, None)
}

fn extract_tags(text: &str) -> (String, Vec<String>) {
    let mut description = text.trim().to_string();
    let mut tags = Vec::new();

    loop {
        if !description.ends_with('`') {
            break;
        }

        let Some(start) = description[..description.len() - 1].rfind(" `") else {
            break;
        };

        let candidate = &description[start + 2..description.len() - 1];
        if candidate.is_empty() || !candidate.split(", ").all(|t| !t.is_empty()) {
            break;
        }

        tags = candidate.split(", ").map(|s| s.to_string()).collect();
        description = description[..start].trim_end().to_string();
        break;
    }

    (description, tags)
}

fn parse_date(s: &str) -> Option<DateTime<Utc>> {
    let date = NaiveDate::parse_from_str(s, "%Y-%m-%d").ok()?;
    let dt = date.and_hms_opt(0, 0, 0)?;
    Some(DateTime::<Utc>::from_naive_utc_and_offset(dt, Utc))
}

pub fn empty_log(project_name: &str) -> Log {
    Log {
        project_name: project_name.to_string(),
        releases: Vec::new(),
        generated_at: Utc::now(),
    }
}

pub fn normalize(log: &mut Log) {
    let mut merged: BTreeMap<Version, Vec<Change>> = BTreeMap::new();
    let mut date_map: BTreeMap<Version, DateTime<Utc>> = BTreeMap::new();

    for release in &log.releases {
        let mut changes = Vec::new();
        for group in release.changes_by_priority.values() {
            for change in group {
                changes.push(change.clone());
            }
        }

        merged
            .entry(release.version.clone())
            .or_default()
            .extend(changes);

        let entry = date_map.entry(release.version.clone()).or_insert(release.date);
        if release.date > *entry {
            *entry = release.date;
        }
    }

    log.releases = merged
        .into_iter()
        .map(|(version, changes)| {
            let refs: Vec<&Change> = changes.iter().collect();
            Release::from_changes(version.clone(), *date_map.get(&version).unwrap_or(&Utc::now()), refs)
        })
        .collect();

    log.releases.sort_by(|a, b| b.version.cmp(&a.version));
}

#[cfg(test)]
mod tests {
    use super::*;
    use chrono::{TimeZone, Utc};

    fn change(
        description: &str,
        priority: Priority,
        tags: &[&str],
        commit: Option<&str>,
    ) -> Change {
        Change {
            description: description.to_string(),
            priority,
            tags: tags.iter().map(|tag| (*tag).to_string()).collect(),
            commit: commit.map(str::to_string),
            completed_at: Utc.with_ymd_and_hms(2026, 2, 20, 10, 30, 0).unwrap(),
        }
    }

    #[test]
    fn to_markdown_renders_sections_tags_and_short_commits() {
        let mut by_priority = BTreeMap::new();
        by_priority.insert(
            Priority::High,
            vec![change(
                "Fix crash when history is empty",
                Priority::High,
                &["bug"],
                Some("1234567890abcdef"),
            )],
        );
        by_priority.insert(
            Priority::Low,
            vec![change("Polish docs", Priority::Low, &[], None)],
        );

        let release = Release {
            version: Version::new(1, 2, 3, false),
            date: Utc.with_ymd_and_hms(2026, 2, 21, 0, 0, 0).unwrap(),
            changes_by_priority: by_priority,
            changes_by_tag: BTreeMap::new(),
        };

        let log = Log {
            project_name: "tally".to_string(),
            releases: vec![release],
            generated_at: Utc.with_ymd_and_hms(2026, 2, 23, 0, 0, 0).unwrap(),
        };

        let markdown = to_markdown(&log);

        assert!(markdown.contains("# Changelog — tally"));
        assert!(markdown.contains("*Generated on 2026-02-23*"));
        assert!(markdown.contains("## 1.2.3 — 2026-02-21"));
        assert!(markdown.contains("### High Priority"));
        assert!(markdown.contains("### Minor Changes"));
        assert!(!markdown.contains("### Changes"));
        assert!(markdown.contains("- Fix crash when history is empty `bug` ([`1234567`])"));
        assert!(markdown.contains("- Polish docs"));
    }

    #[test]
    fn serde_json_emits_valid_payload() {
        let release = Release {
            version: Version::new(0, 5, 0, false),
            date: Utc.with_ymd_and_hms(2026, 2, 2, 0, 0, 0).unwrap(),
            changes_by_priority: BTreeMap::new(),
            changes_by_tag: BTreeMap::new(),
        };

        let log = Log {
            project_name: "tally".to_string(),
            releases: vec![release],
            generated_at: Utc.with_ymd_and_hms(2026, 2, 23, 0, 0, 0).unwrap(),
        };

        let json = serde_json::to_string_pretty(&log).unwrap();
        let value: serde_json::Value = serde_json::from_str(&json).unwrap();

        assert_eq!(value["project_name"], "tally");
        assert_eq!(value["releases"].as_array().unwrap().len(), 1);
    }
}