use std::path::{Path, PathBuf};
use crate::error::Result;
use crate::types::{Area, Project, Task};
use crate::Taskdn;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum FileChangeKind {
Created,
Modified,
Deleted,
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub enum VaultEvent {
TaskCreated(Task),
TaskUpdated(Task),
TaskDeleted {
path: PathBuf,
},
ProjectCreated(Project),
ProjectUpdated(Project),
ProjectDeleted {
path: PathBuf,
},
AreaCreated(Area),
AreaUpdated(Area),
AreaDeleted {
path: PathBuf,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum EntityType {
Task,
Project,
Area,
}
impl Taskdn {
pub fn process_file_change(
&self,
path: impl AsRef<Path>,
kind: FileChangeKind,
) -> Result<Option<VaultEvent>> {
let path = path.as_ref();
if !Self::is_markdown_file(path) {
return Ok(None);
}
let Some(entity_type) = self.classify_path(path) else {
return Ok(None); };
match kind {
FileChangeKind::Created => self.process_create(path, entity_type),
FileChangeKind::Modified => self.process_modify(path, entity_type),
FileChangeKind::Deleted => Ok(Some(Self::process_delete(path, entity_type))),
}
}
#[must_use]
pub fn watched_paths(&self) -> Vec<PathBuf> {
vec![
self.config.tasks_dir.clone(),
self.config.projects_dir.clone(),
self.config.areas_dir.clone(),
]
}
fn is_markdown_file(path: &Path) -> bool {
path.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("md"))
}
fn classify_path(&self, path: &Path) -> Option<EntityType> {
let path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
if let Ok(tasks_dir) = self.config.tasks_dir.canonicalize() {
if path.starts_with(&tasks_dir) {
return Some(EntityType::Task);
}
}
if path.starts_with(&self.config.tasks_dir) {
return Some(EntityType::Task);
}
if let Ok(projects_dir) = self.config.projects_dir.canonicalize() {
if path.starts_with(&projects_dir) {
return Some(EntityType::Project);
}
}
if path.starts_with(&self.config.projects_dir) {
return Some(EntityType::Project);
}
if let Ok(areas_dir) = self.config.areas_dir.canonicalize() {
if path.starts_with(&areas_dir) {
return Some(EntityType::Area);
}
}
if path.starts_with(&self.config.areas_dir) {
return Some(EntityType::Area);
}
None
}
fn process_create(&self, path: &Path, entity_type: EntityType) -> Result<Option<VaultEvent>> {
match entity_type {
EntityType::Task => {
let task = self.get_task(path)?;
Ok(Some(VaultEvent::TaskCreated(task)))
}
EntityType::Project => {
let project = self.get_project(path)?;
Ok(Some(VaultEvent::ProjectCreated(project)))
}
EntityType::Area => {
let area = self.get_area(path)?;
Ok(Some(VaultEvent::AreaCreated(area)))
}
}
}
fn process_modify(&self, path: &Path, entity_type: EntityType) -> Result<Option<VaultEvent>> {
match entity_type {
EntityType::Task => {
let task = self.get_task(path)?;
Ok(Some(VaultEvent::TaskUpdated(task)))
}
EntityType::Project => {
let project = self.get_project(path)?;
Ok(Some(VaultEvent::ProjectUpdated(project)))
}
EntityType::Area => {
let area = self.get_area(path)?;
Ok(Some(VaultEvent::AreaUpdated(area)))
}
}
}
fn process_delete(path: &Path, entity_type: EntityType) -> VaultEvent {
let path = path.to_path_buf();
match entity_type {
EntityType::Task => VaultEvent::TaskDeleted { path },
EntityType::Project => VaultEvent::ProjectDeleted { path },
EntityType::Area => VaultEvent::AreaDeleted { path },
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::TaskdnConfig;
use std::fs;
use tempfile::TempDir;
fn setup_test_vault() -> (TempDir, Taskdn) {
let temp_dir = TempDir::new().unwrap();
let tasks_dir = temp_dir.path().join("tasks");
let projects_dir = temp_dir.path().join("projects");
let areas_dir = temp_dir.path().join("areas");
fs::create_dir_all(&tasks_dir).unwrap();
fs::create_dir_all(&projects_dir).unwrap();
fs::create_dir_all(&areas_dir).unwrap();
let config = TaskdnConfig::new(tasks_dir, projects_dir, areas_dir);
let taskdn = Taskdn::new(config).unwrap();
(temp_dir, taskdn)
}
fn create_task_file(dir: &Path, filename: &str, title: &str) -> PathBuf {
let path = dir.join(filename);
let content = format!(
r#"---
title: {title}
status: inbox
created-at: 2025-01-01
updated-at: 2025-01-01
---
Task body
"#
);
fs::write(&path, content).unwrap();
path
}
fn create_project_file(dir: &Path, filename: &str, title: &str) -> PathBuf {
let path = dir.join(filename);
let content = format!(
r#"---
title: {title}
---
Project body
"#
);
fs::write(&path, content).unwrap();
path
}
fn create_area_file(dir: &Path, filename: &str, title: &str) -> PathBuf {
let path = dir.join(filename);
let content = format!(
r#"---
title: {title}
---
Area body
"#
);
fs::write(&path, content).unwrap();
path
}
#[test]
fn watched_paths_returns_all_directories() {
let (_temp, taskdn) = setup_test_vault();
let paths = taskdn.watched_paths();
assert_eq!(paths.len(), 3);
assert!(paths.contains(&taskdn.config().tasks_dir));
assert!(paths.contains(&taskdn.config().projects_dir));
assert!(paths.contains(&taskdn.config().areas_dir));
}
#[test]
fn process_file_change_ignores_non_markdown_files() {
let (_temp, taskdn) = setup_test_vault();
let path = taskdn.config().tasks_dir.join("file.txt");
fs::write(&path, "some content").unwrap();
let result = taskdn.process_file_change(&path, FileChangeKind::Created);
assert!(result.is_ok());
assert!(result.unwrap().is_none());
}
#[test]
fn process_file_change_ignores_files_outside_watched_dirs() {
let (temp, taskdn) = setup_test_vault();
let path = temp.path().join("random.md");
fs::write(&path, "---\ntitle: Random\n---\n").unwrap();
let result = taskdn.process_file_change(&path, FileChangeKind::Created);
assert!(result.is_ok());
assert!(result.unwrap().is_none());
}
#[test]
fn process_task_created() {
let (_temp, taskdn) = setup_test_vault();
let path = create_task_file(&taskdn.config().tasks_dir, "test.md", "Test Task");
let result = taskdn.process_file_change(&path, FileChangeKind::Created);
assert!(result.is_ok());
match result.unwrap() {
Some(VaultEvent::TaskCreated(task)) => {
assert_eq!(task.title, "Test Task");
}
other => panic!("Expected TaskCreated, got {:?}", other),
}
}
#[test]
fn process_task_modified() {
let (_temp, taskdn) = setup_test_vault();
let path = create_task_file(&taskdn.config().tasks_dir, "test.md", "Test Task");
let result = taskdn.process_file_change(&path, FileChangeKind::Modified);
assert!(result.is_ok());
match result.unwrap() {
Some(VaultEvent::TaskUpdated(task)) => {
assert_eq!(task.title, "Test Task");
}
other => panic!("Expected TaskUpdated, got {:?}", other),
}
}
#[test]
fn process_task_deleted() {
let (_temp, taskdn) = setup_test_vault();
let path = create_task_file(&taskdn.config().tasks_dir, "test.md", "Test Task");
fs::remove_file(&path).unwrap();
let result = taskdn.process_file_change(&path, FileChangeKind::Deleted);
assert!(result.is_ok());
match result.unwrap() {
Some(VaultEvent::TaskDeleted { path: deleted_path }) => {
assert_eq!(deleted_path, path);
}
other => panic!("Expected TaskDeleted, got {:?}", other),
}
}
#[test]
fn process_project_created() {
let (_temp, taskdn) = setup_test_vault();
let path = create_project_file(&taskdn.config().projects_dir, "project.md", "Test Project");
let result = taskdn.process_file_change(&path, FileChangeKind::Created);
assert!(result.is_ok());
match result.unwrap() {
Some(VaultEvent::ProjectCreated(project)) => {
assert_eq!(project.title, "Test Project");
}
other => panic!("Expected ProjectCreated, got {:?}", other),
}
}
#[test]
fn process_area_created() {
let (_temp, taskdn) = setup_test_vault();
let path = create_area_file(&taskdn.config().areas_dir, "area.md", "Test Area");
let result = taskdn.process_file_change(&path, FileChangeKind::Created);
assert!(result.is_ok());
match result.unwrap() {
Some(VaultEvent::AreaCreated(area)) => {
assert_eq!(area.title, "Test Area");
}
other => panic!("Expected AreaCreated, got {:?}", other),
}
}
#[test]
fn process_invalid_task_returns_error() {
let (_temp, taskdn) = setup_test_vault();
let path = taskdn.config().tasks_dir.join("invalid.md");
fs::write(&path, "---\nrandom: field\n---\n").unwrap();
let result = taskdn.process_file_change(&path, FileChangeKind::Created);
assert!(result.is_err());
}
#[test]
fn process_task_in_archive_subdirectory() {
let (_temp, taskdn) = setup_test_vault();
let archive_dir = taskdn.config().tasks_dir.join("archive");
fs::create_dir_all(&archive_dir).unwrap();
let path = create_task_file(&archive_dir, "archived.md", "Archived Task");
let result = taskdn.process_file_change(&path, FileChangeKind::Created);
assert!(result.is_ok());
match result.unwrap() {
Some(VaultEvent::TaskCreated(task)) => {
assert_eq!(task.title, "Archived Task");
assert!(task.is_archived());
}
other => panic!("Expected TaskCreated, got {:?}", other),
}
}
#[test]
fn file_change_kind_traits() {
assert_eq!(format!("{:?}", FileChangeKind::Created), "Created");
let kind = FileChangeKind::Modified;
let cloned = kind;
assert_eq!(kind, cloned);
assert_eq!(FileChangeKind::Created, FileChangeKind::Created);
assert_ne!(FileChangeKind::Created, FileChangeKind::Deleted);
use std::collections::HashSet;
let mut set = HashSet::new();
set.insert(FileChangeKind::Created);
set.insert(FileChangeKind::Modified);
assert!(set.contains(&FileChangeKind::Created));
}
}