tally-todo 0.7.0

Make TODO management a little more automatic
use anyhow::{Result, anyhow};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};

use crate::utils::project_paths::global_registry_file;

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
struct ProjectRegistry {
    projects: Vec<String>,
}

pub struct ProjectRegistryStorage {
    registry: ProjectRegistry,
    registry_file: PathBuf,
}

impl ProjectRegistryStorage {
    pub fn new() -> Result<Self> {
        let mut storage = Self {
            registry: ProjectRegistry::default(),
            registry_file: global_registry_file()?,
        };
        storage.load()?;
        Ok(storage)
    }

    fn load(&mut self) -> Result<()> {
        if !self.registry_file.exists() {
            self.save()?;
            return Ok(());
        }

        let raw = fs::read_to_string(&self.registry_file)?;
        if raw.trim().is_empty() {
            self.registry = ProjectRegistry::default();
            return Ok(());
        }

        self.registry = match serde_json::from_str::<ProjectRegistry>(&raw) {
            Ok(registry) => registry,
            Err(_) => {
                let legacy = serde_json::from_str::<Vec<String>>(&raw)
                    .map_err(|e| anyhow!("Failed to parse project registry: {}", e))?;
                ProjectRegistry { projects: legacy }
            }
        };

        self.normalize();
        Ok(())
    }

    fn save(&self) -> Result<()> {
        if let Some(parent) = self.registry_file.parent() {
            fs::create_dir_all(parent)?;
        }

        let contents = serde_json::to_string_pretty(&self.registry)?;
        fs::write(&self.registry_file, contents)?;
        Ok(())
    }

    fn normalize(&mut self) {
        let mut deduped = Vec::new();
        for project in &self.registry.projects {
            if !deduped.contains(project) {
                deduped.push(project.clone());
            }
        }
        self.registry.projects = deduped;
    }

    fn normalize_path(path: &Path) -> PathBuf {
        fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
    }

    pub fn add_project(&mut self, project_root: &Path) -> Result<bool> {
        let normalized = Self::normalize_path(project_root)
            .to_string_lossy()
            .to_string();

        if self.registry.projects.contains(&normalized) {
            return Ok(false);
        }

        self.registry.projects.push(normalized);
        self.save()?;
        Ok(true)
    }

    pub fn projects(&self) -> Vec<PathBuf> {
        self.registry.projects.iter().map(PathBuf::from).collect()
    }

    pub fn prune_missing(&mut self) -> Result<usize> {
        let before = self.registry.projects.len();
        self.registry.projects.retain(|path| {
            let root = PathBuf::from(path);
            root.join(".tally").is_dir() && root.join("TODO.md").is_file()
        });

        let removed = before.saturating_sub(self.registry.projects.len());
        if removed > 0 {
            self.save()?;
        }
        Ok(removed)
    }
}