tasks-cli-rs 0.10.0

Markdown-based TODO task management CLI: each task is a Markdown file with YAML front matter
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};

use serde::{Deserialize, Serialize};

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

#[derive(Debug, Default, Serialize, Deserialize, PartialEq)]
pub struct Registry {
    #[serde(default)]
    pub current: Option<String>,
    #[serde(default)]
    pub libraries: BTreeMap<String, PathBuf>,
}

impl Registry {
    pub fn path() -> Result<PathBuf> {
        Ok(crate::config::base_dir()?.join("libraries.toml"))
    }

    pub fn load() -> Result<Self> {
        Self::load_from(&Self::path()?)
    }

    pub fn load_from(path: &Path) -> Result<Self> {
        if !path.exists() {
            return Ok(Self::default());
        }
        Ok(toml::from_str(&std::fs::read_to_string(path)?)?)
    }

    pub fn save(&self) -> Result<()> {
        self.save_to(&Self::path()?)
    }

    pub fn save_to(&self, path: &Path) -> Result<()> {
        if let Some(parent) = path.parent() {
            std::fs::create_dir_all(parent)?;
        }
        let content =
            toml::to_string_pretty(self).map_err(|e| Error::InvalidTaskFile(e.to_string()))?;
        std::fs::write(path, content)?;
        Ok(())
    }

    pub fn add(&mut self, name: &str, path: PathBuf) -> Result<()> {
        if self.libraries.contains_key(name) {
            return Err(Error::LibraryExists(name.to_string()));
        }
        std::fs::create_dir_all(&path)?;
        crate::storage::LibraryMeta::default_init(&path)?;
        self.libraries.insert(name.to_string(), path);
        if self.current.is_none() {
            self.current = Some(name.to_string());
        }
        Ok(())
    }

    pub fn remove(&mut self, name: &str) -> Result<()> {
        if self.libraries.remove(name).is_none() {
            return Err(Error::LibraryNotFound(name.to_string()));
        }
        if self.current.as_deref() == Some(name) {
            self.current = self.libraries.keys().next().cloned();
        }
        Ok(())
    }

    pub fn use_library(&mut self, name: &str) -> Result<()> {
        if !self.libraries.contains_key(name) {
            return Err(Error::LibraryNotFound(name.to_string()));
        }
        self.current = Some(name.to_string());
        Ok(())
    }

    /// Returns (name, root path) of the active library.
    pub fn current_library(&self) -> Result<(&str, &Path)> {
        let name = self.current.as_deref().ok_or(Error::NoActiveLibrary)?;
        let path = self
            .libraries
            .get(name)
            .ok_or_else(|| Error::LibraryNotFound(name.to_string()))?;
        Ok((name, path))
    }
}

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

    fn temp_dir() -> PathBuf {
        let dir = std::env::temp_dir().join(format!("tasks-lib-test-{}", uuid::Uuid::new_v4()));
        std::fs::create_dir_all(&dir).unwrap();
        dir
    }

    #[test]
    fn add_use_remove_flow() {
        let dir = temp_dir();
        let mut reg = Registry::default();
        reg.add("personal", dir.join("personal")).unwrap();
        reg.add("work", dir.join("work")).unwrap();

        // first added library becomes current
        assert_eq!(reg.current.as_deref(), Some("personal"));
        assert!(dir.join("personal/.tasks-meta.toml").exists());

        reg.use_library("work").unwrap();
        assert_eq!(reg.current_library().unwrap().0, "work");

        assert!(reg.use_library("nope").is_err());
        assert!(reg.add("work", dir.join("dup")).is_err());

        // removing current falls back to another library, files stay on disk
        reg.remove("work").unwrap();
        assert_eq!(reg.current.as_deref(), Some("personal"));
        assert!(dir.join("work").exists());

        reg.remove("personal").unwrap();
        assert!(reg.current.is_none());
        assert!(reg.current_library().is_err());

        std::fs::remove_dir_all(&dir).unwrap();
    }

    #[test]
    fn save_load_round_trip() {
        let dir = temp_dir();
        let mut reg = Registry::default();
        reg.add("a", dir.join("a")).unwrap();
        let file = dir.join("libraries.toml");
        reg.save_to(&file).unwrap();
        let loaded = Registry::load_from(&file).unwrap();
        assert_eq!(loaded, reg);
        std::fs::remove_dir_all(&dir).unwrap();
    }

    #[test]
    fn missing_registry_file_is_empty() {
        let reg = Registry::load_from(Path::new("/nonexistent/libraries.toml")).unwrap();
        assert!(reg.libraries.is_empty());
        assert!(reg.current.is_none());
    }
}