use super::{DateTimeValue, FileReference};
use crate::validation::ValidationWarning;
use chrono::NaiveDate;
use std::collections::HashMap;
use std::path::PathBuf;
use std::str::FromStr;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
#[non_exhaustive]
pub enum TaskStatus {
#[default]
Inbox,
Icebox,
Ready,
InProgress,
Blocked,
Dropped,
Done,
}
impl TaskStatus {
#[must_use]
pub fn is_completed(&self) -> bool {
matches!(self, Self::Done | Self::Dropped)
}
#[must_use]
pub fn is_active(&self) -> bool {
matches!(self, Self::Ready | Self::InProgress | Self::Blocked)
}
#[must_use]
pub fn as_str(&self) -> &'static str {
match self {
Self::Inbox => "inbox",
Self::Icebox => "icebox",
Self::Ready => "ready",
Self::InProgress => "in-progress",
Self::Blocked => "blocked",
Self::Dropped => "dropped",
Self::Done => "done",
}
}
}
impl FromStr for TaskStatus {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().replace('_', "-").as_str() {
"inbox" => Ok(Self::Inbox),
"icebox" => Ok(Self::Icebox),
"ready" => Ok(Self::Ready),
"in-progress" => Ok(Self::InProgress),
"blocked" => Ok(Self::Blocked),
"dropped" => Ok(Self::Dropped),
"done" => Ok(Self::Done),
_ => Err(format!("invalid task status: {s}")),
}
}
}
impl std::fmt::Display for TaskStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.as_str())
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Task {
pub path: PathBuf,
pub title: String,
pub status: TaskStatus,
pub created_at: DateTimeValue,
pub updated_at: DateTimeValue,
pub completed_at: Option<DateTimeValue>,
pub due: Option<DateTimeValue>,
pub scheduled: Option<NaiveDate>,
pub defer_until: Option<NaiveDate>,
pub project: Option<FileReference>,
pub area: Option<FileReference>,
pub body: String,
pub extra: HashMap<String, serde_yaml::Value>,
pub projects_count: Option<usize>,
}
impl Task {
#[must_use]
pub fn filename(&self) -> &str {
self.path.file_name().and_then(|n| n.to_str()).unwrap_or("")
}
#[must_use]
pub fn is_archived(&self) -> bool {
self.path.components().any(|c| c.as_os_str() == "archive")
}
#[must_use]
pub fn is_active(&self) -> bool {
!self.status.is_completed() && !self.is_archived()
}
#[must_use]
pub fn validate(&self) -> Vec<ValidationWarning> {
let mut warnings = Vec::new();
if let Some(count) = self.projects_count {
if count > 1 {
warnings.push(ValidationWarning::MultipleProjects { count });
}
}
if self.status.is_completed() && self.completed_at.is_none() {
warnings.push(ValidationWarning::MissingCompletedAt);
}
warnings
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct ParsedTask {
pub title: String,
pub status: TaskStatus,
pub created_at: DateTimeValue,
pub updated_at: DateTimeValue,
pub completed_at: Option<DateTimeValue>,
pub due: Option<DateTimeValue>,
pub scheduled: Option<NaiveDate>,
pub defer_until: Option<NaiveDate>,
pub project: Option<FileReference>,
pub area: Option<FileReference>,
pub body: String,
pub extra: HashMap<String, serde_yaml::Value>,
pub projects_count: Option<usize>,
}
impl ParsedTask {
#[must_use]
pub fn with_path(self, path: impl Into<PathBuf>) -> Task {
Task {
path: path.into(),
title: self.title,
status: self.status,
created_at: self.created_at,
updated_at: self.updated_at,
completed_at: self.completed_at,
due: self.due,
scheduled: self.scheduled,
defer_until: self.defer_until,
project: self.project,
area: self.area,
body: self.body,
extra: self.extra,
projects_count: self.projects_count,
}
}
#[must_use]
pub fn validate(&self) -> Vec<ValidationWarning> {
let mut warnings = Vec::new();
if let Some(count) = self.projects_count {
if count > 1 {
warnings.push(ValidationWarning::MultipleProjects { count });
}
}
if self.status.is_completed() && self.completed_at.is_none() {
warnings.push(ValidationWarning::MissingCompletedAt);
}
warnings
}
}
#[derive(Debug, Clone, Default)]
pub struct NewTask {
pub title: String,
pub status: TaskStatus,
pub filename: Option<String>,
pub due: Option<DateTimeValue>,
pub scheduled: Option<NaiveDate>,
pub defer_until: Option<NaiveDate>,
pub project: Option<FileReference>,
pub area: Option<FileReference>,
pub body: String,
pub extra: HashMap<String, serde_yaml::Value>,
}
impl NewTask {
#[must_use]
pub fn new(title: impl Into<String>) -> Self {
Self {
title: title.into(),
status: TaskStatus::Inbox,
..Default::default()
}
}
#[must_use]
pub fn with_status(mut self, status: TaskStatus) -> Self {
self.status = status;
self
}
#[must_use]
pub fn with_filename(mut self, filename: impl Into<String>) -> Self {
self.filename = Some(filename.into());
self
}
#[must_use]
pub fn with_due(mut self, due: impl Into<DateTimeValue>) -> Self {
self.due = Some(due.into());
self
}
#[must_use]
pub fn with_scheduled(mut self, scheduled: NaiveDate) -> Self {
self.scheduled = Some(scheduled);
self
}
#[must_use]
pub fn with_defer_until(mut self, defer_until: NaiveDate) -> Self {
self.defer_until = Some(defer_until);
self
}
#[must_use]
pub fn in_project(mut self, project: impl Into<FileReference>) -> Self {
self.project = Some(project.into());
self
}
#[must_use]
pub fn in_area(mut self, area: impl Into<FileReference>) -> Self {
self.area = Some(area.into());
self
}
#[must_use]
pub fn with_body(mut self, body: impl Into<String>) -> Self {
self.body = body.into();
self
}
}
#[derive(Debug, Clone, Default)]
pub struct TaskUpdates {
pub title: Option<String>,
pub status: Option<TaskStatus>,
pub due: Option<Option<DateTimeValue>>,
pub scheduled: Option<Option<NaiveDate>>,
pub defer_until: Option<Option<NaiveDate>>,
pub project: Option<Option<FileReference>>,
pub area: Option<Option<FileReference>>,
}
impl TaskUpdates {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn title(mut self, title: impl Into<String>) -> Self {
self.title = Some(title.into());
self
}
#[must_use]
pub fn status(mut self, status: TaskStatus) -> Self {
self.status = Some(status);
self
}
#[must_use]
pub fn due(mut self, due: impl Into<DateTimeValue>) -> Self {
self.due = Some(Some(due.into()));
self
}
#[must_use]
pub fn clear_due(mut self) -> Self {
self.due = Some(None);
self
}
#[must_use]
pub fn scheduled(mut self, scheduled: NaiveDate) -> Self {
self.scheduled = Some(Some(scheduled));
self
}
#[must_use]
pub fn clear_scheduled(mut self) -> Self {
self.scheduled = Some(None);
self
}
#[must_use]
pub fn defer_until(mut self, defer_until: NaiveDate) -> Self {
self.defer_until = Some(Some(defer_until));
self
}
#[must_use]
pub fn clear_defer_until(mut self) -> Self {
self.defer_until = Some(None);
self
}
#[must_use]
pub fn project(mut self, project: impl Into<FileReference>) -> Self {
self.project = Some(Some(project.into()));
self
}
#[must_use]
pub fn clear_project(mut self) -> Self {
self.project = Some(None);
self
}
#[must_use]
pub fn area(mut self, area: impl Into<FileReference>) -> Self {
self.area = Some(Some(area.into()));
self
}
#[must_use]
pub fn clear_area(mut self) -> Self {
self.area = Some(None);
self
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.title.is_none()
&& self.status.is_none()
&& self.due.is_none()
&& self.scheduled.is_none()
&& self.defer_until.is_none()
&& self.project.is_none()
&& self.area.is_none()
}
}
#[cfg(test)]
mod tests {
use super::*;
mod task_status {
use super::*;
#[test]
fn parse_lowercase() {
assert_eq!("inbox".parse::<TaskStatus>().unwrap(), TaskStatus::Inbox);
assert_eq!("ready".parse::<TaskStatus>().unwrap(), TaskStatus::Ready);
assert_eq!(
"in-progress".parse::<TaskStatus>().unwrap(),
TaskStatus::InProgress
);
}
#[test]
fn parse_case_insensitive() {
assert_eq!("INBOX".parse::<TaskStatus>().unwrap(), TaskStatus::Inbox);
assert_eq!("Ready".parse::<TaskStatus>().unwrap(), TaskStatus::Ready);
assert_eq!(
"IN-PROGRESS".parse::<TaskStatus>().unwrap(),
TaskStatus::InProgress
);
}
#[test]
fn parse_underscore_variant() {
assert_eq!(
"in_progress".parse::<TaskStatus>().unwrap(),
TaskStatus::InProgress
);
}
#[test]
fn parse_invalid() {
assert!("invalid".parse::<TaskStatus>().is_err());
}
#[test]
fn as_str_returns_canonical() {
assert_eq!(TaskStatus::Inbox.as_str(), "inbox");
assert_eq!(TaskStatus::InProgress.as_str(), "in-progress");
}
#[test]
fn is_completed() {
assert!(TaskStatus::Done.is_completed());
assert!(TaskStatus::Dropped.is_completed());
assert!(!TaskStatus::Ready.is_completed());
assert!(!TaskStatus::Inbox.is_completed());
}
#[test]
fn is_active() {
assert!(TaskStatus::Ready.is_active());
assert!(TaskStatus::InProgress.is_active());
assert!(TaskStatus::Blocked.is_active());
assert!(!TaskStatus::Inbox.is_active());
assert!(!TaskStatus::Done.is_active());
}
}
mod task {
use super::*;
use std::path::Path;
fn sample_task(path: impl AsRef<Path>) -> Task {
Task {
path: path.as_ref().to_path_buf(),
title: "Test Task".to_string(),
status: TaskStatus::Ready,
created_at: "2025-01-01".parse().unwrap(),
updated_at: "2025-01-01".parse().unwrap(),
completed_at: None,
due: None,
scheduled: None,
defer_until: None,
project: None,
area: None,
body: String::new(),
extra: HashMap::new(),
projects_count: None,
}
}
#[test]
fn filename_extracts_correctly() {
let task = sample_task("/path/to/tasks/my-task.md");
assert_eq!(task.filename(), "my-task.md");
}
#[test]
fn is_archived_detects_archive_path() {
let archived = sample_task("/path/to/tasks/archive/old-task.md");
assert!(archived.is_archived());
let not_archived = sample_task("/path/to/tasks/my-task.md");
assert!(!not_archived.is_archived());
}
#[test]
fn is_active_considers_status_and_archive() {
let active = sample_task("/path/to/tasks/my-task.md");
assert!(active.is_active());
let mut done = sample_task("/path/to/tasks/done-task.md");
done.status = TaskStatus::Done;
assert!(!done.is_active());
let archived = sample_task("/path/to/tasks/archive/old-task.md");
assert!(!archived.is_active());
}
}
mod new_task {
use super::*;
#[test]
fn new_defaults_to_inbox() {
let task = NewTask::new("Test");
assert_eq!(task.status, TaskStatus::Inbox);
}
#[test]
fn builder_pattern() {
let task = NewTask::new("Test")
.with_status(TaskStatus::Ready)
.in_project("[[My Project]]")
.with_body("Some content");
assert_eq!(task.status, TaskStatus::Ready);
assert!(task.project.is_some());
assert_eq!(task.body, "Some content");
}
}
mod task_updates {
use super::*;
#[test]
fn empty_updates() {
let updates = TaskUpdates::new();
assert!(updates.is_empty());
}
#[test]
fn with_title() {
let updates = TaskUpdates::new().title("New Title");
assert!(!updates.is_empty());
assert_eq!(updates.title, Some("New Title".to_string()));
}
#[test]
fn clear_due() {
let updates = TaskUpdates::new().clear_due();
assert_eq!(updates.due, Some(None));
}
#[test]
fn set_due() {
let due: DateTimeValue = "2025-06-01".parse().unwrap();
let updates = TaskUpdates::new().due(due.clone());
assert_eq!(updates.due, Some(Some(due)));
}
}
mod validation {
use super::*;
use std::path::Path;
fn sample_task(path: impl AsRef<Path>) -> Task {
Task {
path: path.as_ref().to_path_buf(),
title: "Test Task".to_string(),
status: TaskStatus::Ready,
created_at: "2025-01-01".parse().unwrap(),
updated_at: "2025-01-01".parse().unwrap(),
completed_at: None,
due: None,
scheduled: None,
defer_until: None,
project: None,
area: None,
body: String::new(),
extra: HashMap::new(),
projects_count: None,
}
}
#[test]
fn valid_task_has_no_warnings() {
let task = sample_task("/test/task.md");
assert!(task.validate().is_empty());
}
#[test]
fn single_project_has_no_warning() {
let mut task = sample_task("/test/task.md");
task.projects_count = Some(1);
assert!(task.validate().is_empty());
}
#[test]
fn multiple_projects_warns() {
let mut task = sample_task("/test/task.md");
task.projects_count = Some(3);
let warnings = task.validate();
assert_eq!(warnings.len(), 1);
assert!(matches!(
warnings[0],
ValidationWarning::MultipleProjects { count: 3 }
));
}
#[test]
fn done_task_without_completed_at_warns() {
let mut task = sample_task("/test/task.md");
task.status = TaskStatus::Done;
let warnings = task.validate();
assert_eq!(warnings.len(), 1);
assert!(matches!(warnings[0], ValidationWarning::MissingCompletedAt));
}
#[test]
fn dropped_task_without_completed_at_warns() {
let mut task = sample_task("/test/task.md");
task.status = TaskStatus::Dropped;
let warnings = task.validate();
assert_eq!(warnings.len(), 1);
assert!(matches!(warnings[0], ValidationWarning::MissingCompletedAt));
}
#[test]
fn done_task_with_completed_at_has_no_warning() {
let mut task = sample_task("/test/task.md");
task.status = TaskStatus::Done;
task.completed_at = Some("2025-01-15".parse().unwrap());
assert!(task.validate().is_empty());
}
#[test]
fn multiple_warnings_accumulated() {
let mut task = sample_task("/test/task.md");
task.status = TaskStatus::Done;
task.projects_count = Some(2);
let warnings = task.validate();
assert_eq!(warnings.len(), 2);
}
#[test]
fn parsed_task_validate_works() {
let parsed = ParsedTask {
title: "Test".to_string(),
status: TaskStatus::Done,
created_at: "2025-01-01".parse().unwrap(),
updated_at: "2025-01-01".parse().unwrap(),
completed_at: None,
due: None,
scheduled: None,
defer_until: None,
project: None,
area: None,
body: String::new(),
extra: HashMap::new(),
projects_count: Some(2),
};
let warnings = parsed.validate();
assert_eq!(warnings.len(), 2);
}
}
}