nyado 0.3.1

A Rust todo-list manager with TUI, inspired by meowdo
use crate::todo::Todo;
use std::collections::HashMap;
use std::fs::{self, File};
use std::io::{BufRead, BufReader, Write};
use std::path::PathBuf;
use anyhow::Result;

const MAX_BACKUPS: usize = 5;
const MAX_PROJECTS: usize = 64;

pub fn get_data_dir() -> PathBuf {
    dirs::data_local_dir().unwrap_or_else(|| PathBuf::from(".")).join("nyado")
}

pub fn get_config_dir() -> PathBuf {
    if let Some(mut dir) = dirs::config_dir() {
        dir.push("nyado");
        if dir.exists() && dir.is_dir() {
            return dir;
        }
    }
    if PathBuf::from("config").exists() && PathBuf::from("config").is_dir() {
        return PathBuf::from("config");
    }
    let mut fallback = dirs::config_dir().unwrap_or_else(|| PathBuf::from("."));
    fallback.push("nyado");
    fallback
}

pub fn migrate_old_todos(data_dir: &PathBuf, projects_dir: &PathBuf) -> Result<()> {
    let old_todos = data_dir.join("todos.txt");
    let old_default = projects_dir.join("default.txt");
    if old_todos.exists() {
        let should_migrate = if !old_default.exists() {
            true
        } else {
            let default_size = fs::metadata(&old_default).map(|m| m.len()).unwrap_or(0);
            default_size == 0
        };
        if should_migrate {
            if let Ok(content) = fs::read_to_string(&old_todos) {
                let mut file = File::create(&old_default)?;
                write!(file, "{}", content)?;
            }
            let _ = fs::remove_file(&old_todos);
        }
    }
    Ok(())
}

pub struct Storage {
    pub todos: Vec<Todo>,
    pub search: String,
    pub filter_tag: String,
    pub tags_available: Vec<(String, usize)>,
    pub dirty_tags: bool,
    projects_dir: PathBuf,
    pub current_project: String,
}

impl Storage {
    pub fn new(projects_dir: PathBuf) -> Self {
        Self {
            todos: Vec::new(),
            search: String::new(),
            filter_tag: String::new(),
            tags_available: Vec::new(),
            dirty_tags: true,
            projects_dir,
            current_project: "default".to_string(),
        }
    }

    fn backup_dir(&self) -> PathBuf {
        self.projects_dir.join(".backups").join(&self.current_project)
    }

    pub fn set_project(&mut self, name: &str) -> bool {
        let project_file = self.projects_dir.join(format!("{}.txt", name));
        if !project_file.exists() && name != "default" {
            return false;
        }
        self.current_project = name.to_string();
        self.load_current();
        self.rebuild_tags();
        true
    }

    pub fn load_current(&mut self) {
        let path = self.projects_dir.join(format!("{}.txt", self.current_project));
        if !path.exists() {
            self.todos.clear();
            self.dirty_tags = true;
            self.save();
            return;
        }
        let file = File::open(&path).unwrap();
        let reader = BufReader::new(file);
        self.todos.clear();
        for line in reader.lines() {
            if let Ok(line) = line {
                if let Some(todo) = Todo::from_line(&line) {
                    self.todos.push(todo);
                }
            }
        }
        self.dirty_tags = true;
    }

    pub fn save(&self) {
        self.create_backup();
        let path = self.projects_dir.join(format!("{}.txt", self.current_project));
        let mut file = File::create(&path).unwrap();
        for todo in &self.todos {
            write!(file, "{}", todo.to_line()).unwrap();
        }
    }

    fn create_backup(&self) {
        let path = self.projects_dir.join(format!("{}.txt", self.current_project));
        if !path.exists() {
            return;
        }
        let backup_dir = self.backup_dir();
        let _ = fs::create_dir_all(&backup_dir);
        let backup_name = |n: usize| format!("{:02}.bak", n);
        for i in (0..MAX_BACKUPS-1).rev() {
            let old = backup_dir.join(backup_name(i));
            let new = backup_dir.join(backup_name(i+1));
            if old.exists() {
                let _ = fs::rename(&old, &new);
            }
        }
        let backup_path = backup_dir.join(backup_name(0));
        let _ = fs::copy(&path, &backup_path);
    }

    pub fn list_projects(&self) -> Vec<String> {
        let mut projects = Vec::new();
        if let Ok(entries) = fs::read_dir(&self.projects_dir) {
            for entry in entries.flatten() {
                let path = entry.path();
                if path.is_file() && path.extension().and_then(|e| e.to_str()) == Some("txt") {
                    if let Some(name) = path.file_stem().and_then(|s| s.to_str()) {
                        projects.push(name.to_string());
                    }
                }
            }
        }
        if projects.is_empty() {
            projects.push("default".to_string());
        }
        projects.sort();
        projects
    }

    pub fn create_project(&self, name: &str) -> bool {
        let projects = self.list_projects();
        if projects.len() >= MAX_PROJECTS {
            return false;
        }
        let path = self.projects_dir.join(format!("{}.txt", name));
        if path.exists() {
            return false;
        }
        File::create(&path).ok();
        let backup_dir = self.projects_dir.join(".backups").join(name);
        let _ = fs::create_dir_all(&backup_dir);
        true
    }

    pub fn delete_project(&self, name: &str) -> bool {
        if name == "default" {
            return false;
        }
        let path = self.projects_dir.join(format!("{}.txt", name));
        if path.exists() {
            fs::remove_file(&path).ok();
            let backup_dir = self.projects_dir.join(".backups").join(name);
            let _ = fs::remove_dir_all(&backup_dir);
            true
        } else {
            false
        }
    }

    pub fn rename_project(&mut self, old_name: &str, new_name: &str) -> bool {
        if old_name == "default" || new_name.is_empty() {
            return false;
        }
        let old_path = self.projects_dir.join(format!("{}.txt", old_name));
        let new_path = self.projects_dir.join(format!("{}.txt", new_name));
        if !old_path.exists() || new_path.exists() {
            return false;
        }
        fs::rename(&old_path, &new_path).ok();
        let old_backup = self.projects_dir.join(".backups").join(old_name);
        let new_backup = self.projects_dir.join(".backups").join(new_name);
        if old_backup.exists() {
            let _ = fs::rename(&old_backup, &new_backup);
        }
        if self.current_project == old_name {
            self.current_project = new_name.to_string();
            self.load_current();
            self.rebuild_tags();
        }
        true
    }

    pub fn rebuild_tags(&mut self) {
        if !self.dirty_tags {
            return;
        }
        let mut counts = HashMap::new();
        for todo in &self.todos {
            if !todo.tag.is_empty() {
                *counts.entry(todo.tag.clone()).or_insert(0) += 1;
            }
        }
        let mut tags: Vec<_> = counts.into_iter().collect();
        tags.sort_by(|a, b| b.1.cmp(&a.1));
        self.tags_available = tags;
        self.dirty_tags = false;
    }

    pub fn pending_count(&self) -> usize {
        self.todos.iter().filter(|t| !t.done).count()
    }

    pub fn done_count(&self) -> usize {
        self.todos.iter().filter(|t| t.done).count()
    }

    pub fn pinned_count(&self) -> usize {
        self.todos.iter().filter(|t| t.pinned).count()
    }
}