use anyhow::Result;
use chrono::{DateTime, Local, NaiveDate, Utc};
use clap::ValueEnum;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, ValueEnum)]
#[serde(rename_all = "lowercase")]
pub enum Difficulty {
Easy,
#[default]
Medium,
Hard,
}
impl Difficulty {
pub fn label(self) -> &'static str {
match self {
Difficulty::Easy => "Easy",
Difficulty::Medium => "Medium",
Difficulty::Hard => "Hard",
}
}
pub fn next(self) -> Self {
match self {
Difficulty::Easy => Difficulty::Medium,
Difficulty::Medium => Difficulty::Hard,
Difficulty::Hard => Difficulty::Easy,
}
}
pub fn prev(self) -> Self {
match self {
Difficulty::Easy => Difficulty::Hard,
Difficulty::Medium => Difficulty::Easy,
Difficulty::Hard => Difficulty::Medium,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Project {
#[serde(default = "Uuid::new_v4")]
pub uuid: Uuid,
pub name: String,
#[serde(default)]
pub completed: bool,
#[serde(default)]
pub difficulty: Difficulty,
#[serde(default)]
pub tech: Vec<String>,
#[serde(default)]
pub due_date: Option<NaiveDate>,
pub created_at: DateTime<Utc>,
#[serde(default)]
pub completed_at: Option<NaiveDate>,
#[serde(default)]
pub updated_at: Option<DateTime<Utc>>,
#[serde(default)]
pub deleted_at: Option<DateTime<Utc>>,
}
impl Project {
pub fn new(name: String) -> Self {
Self {
uuid: Uuid::new_v4(),
name,
completed: false,
difficulty: Difficulty::Medium,
tech: Vec::new(),
due_date: None,
created_at: Utc::now(),
completed_at: None,
updated_at: Some(Utc::now()),
deleted_at: None,
}
}
pub fn touch(&mut self) {
self.updated_at = Some(Utc::now());
}
pub fn mark_done(&mut self) {
self.completed = true;
self.completed_at = Some(Local::now().naive_local().date());
self.touch();
}
pub fn mark_undone(&mut self) {
self.completed = false;
self.completed_at = None;
self.touch();
}
pub fn soft_delete(&mut self) {
self.deleted_at = Some(Utc::now());
self.touch();
}
pub fn is_deleted(&self) -> bool {
self.deleted_at.is_some()
}
pub fn is_overdue(&self) -> bool {
if let Some(due) = self.due_date {
let today = Local::now().naive_local().date();
due < today && !self.completed
} else {
false
}
}
pub fn resolve_or_create(
storage: &impl crate::storage::Storage,
projects: &[Project],
name: &str,
) -> Result<Uuid> {
if let Some(existing) = projects
.iter()
.find(|p| p.name.to_lowercase() == name.to_lowercase() && !p.is_deleted())
{
return Ok(existing.uuid);
}
let new_project = Project::new(name.to_string());
let uuid = new_project.uuid;
let mut all = projects.to_vec();
all.push(new_project);
storage.save_projects(&all)?;
Ok(uuid)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::storage::{InMemoryStorage, Storage};
#[test]
fn test_resolve_creates_new_project() {
let storage = InMemoryStorage::default();
let projects = storage.load_projects().unwrap();
let uuid = Project::resolve_or_create(&storage, &projects, "Backend").unwrap();
let saved = storage.load_projects().unwrap();
assert_eq!(saved.len(), 1);
assert_eq!(saved[0].uuid, uuid);
assert_eq!(saved[0].name, "Backend");
}
#[test]
fn test_resolve_finds_existing_project() {
let storage = InMemoryStorage::default();
let project = Project::new("Backend".into());
let expected_uuid = project.uuid;
storage.save_projects(&[project]).unwrap();
let projects = storage.load_projects().unwrap();
let uuid = Project::resolve_or_create(&storage, &projects, "Backend").unwrap();
assert_eq!(uuid, expected_uuid);
assert_eq!(storage.load_projects().unwrap().len(), 1);
}
#[test]
fn test_resolve_is_case_insensitive() {
let storage = InMemoryStorage::default();
let project = Project::new("Backend".into());
let expected_uuid = project.uuid;
storage.save_projects(&[project]).unwrap();
let projects = storage.load_projects().unwrap();
let uuid = Project::resolve_or_create(&storage, &projects, "backend").unwrap();
assert_eq!(uuid, expected_uuid);
assert_eq!(storage.load_projects().unwrap().len(), 1);
}
#[test]
fn test_resolve_ignores_deleted_projects() {
let storage = InMemoryStorage::default();
let mut project = Project::new("Backend".into());
project.soft_delete();
storage.save_projects(&[project]).unwrap();
let projects = storage.load_projects().unwrap();
let uuid = Project::resolve_or_create(&storage, &projects, "Backend").unwrap();
let saved = storage.load_projects().unwrap();
assert_eq!(saved.len(), 2);
let new_proj = saved.iter().find(|p| p.uuid == uuid).unwrap();
assert!(!new_proj.is_deleted());
}
#[test]
fn test_resolve_deduplicates_across_calls() {
let storage = InMemoryStorage::default();
let p1 = storage.load_projects().unwrap();
let uuid1 = Project::resolve_or_create(&storage, &p1, "Work").unwrap();
let p2 = storage.load_projects().unwrap();
let uuid2 = Project::resolve_or_create(&storage, &p2, "WORK").unwrap();
assert_eq!(uuid1, uuid2);
assert_eq!(storage.load_projects().unwrap().len(), 1);
}
}