oh-my-todo 0.2.0

Local-first terminal task manager with a mouse-first TUI and CLI.
Documentation
use crate::domain::{AppConfig, AppState, Space, SpaceId, Task, TaskId};
use crate::storage::paths::DataPaths;
use crate::storage::serializer::{read_ron_file, write_ron_file};
use std::fs;
use std::path::{Path, PathBuf};
use thiserror::Error;

#[derive(Debug, Error)]
pub enum StorageError {
    #[error("I/O error at `{path}`: {source}")]
    Io {
        path: PathBuf,
        source: std::io::Error,
    },
    #[error("failed to serialize RON: {source}")]
    RonSerialize { source: ron::error::Error },
    #[error("failed to deserialize RON: {source}")]
    RonDeserialize { source: ron::error::SpannedError },
    #[error("failed to resolve data path: {0}")]
    PathResolution(String),
    #[error("task `{0}` was not found")]
    TaskNotFound(String),
    #[error("space `{0}` was not found")]
    SpaceNotFound(String),
}

#[derive(Debug, Clone)]
pub struct RepositorySnapshot {
    pub config: AppConfig,
    pub state: AppState,
    pub spaces: Vec<Space>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TaskBucket {
    Todo,
    Archive,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StoredTaskRecord {
    pub task: Task,
    pub bucket: TaskBucket,
}

pub trait AppRepository: Send + Sync {
    fn paths(&self) -> &DataPaths;
    fn initialize(&self) -> Result<RepositorySnapshot, StorageError>;
    fn load_config(&self) -> Result<AppConfig, StorageError>;
    fn save_config(&self, config: &AppConfig) -> Result<(), StorageError>;
    fn load_state(&self) -> Result<AppState, StorageError>;
    fn save_state(&self, state: &AppState) -> Result<(), StorageError>;
    fn list_spaces(&self) -> Result<Vec<Space>, StorageError>;
    fn load_space(&self, space_id: &SpaceId) -> Result<Space, StorageError>;
    fn save_space(&self, space: &Space) -> Result<(), StorageError>;
    fn list_task_records_in_space(
        &self,
        space_id: &SpaceId,
    ) -> Result<Vec<StoredTaskRecord>, StorageError>;
    fn list_all_task_records(&self) -> Result<Vec<StoredTaskRecord>, StorageError>;
    fn list_tasks_in_space(&self, space_id: &SpaceId) -> Result<Vec<Task>, StorageError>;
    fn list_all_tasks(&self) -> Result<Vec<Task>, StorageError>;
    fn load_task(&self, task_id: &TaskId) -> Result<Task, StorageError>;
    fn save_task(&self, task: &Task) -> Result<(), StorageError>;
    fn delete_task(&self, task_id: &TaskId) -> Result<(), StorageError>;
    fn delete_space(&self, space_id: &SpaceId) -> Result<(), StorageError>;
}

#[derive(Debug, Clone)]
pub struct FilesystemRepository {
    paths: DataPaths,
}

impl FilesystemRepository {
    pub fn new(paths: DataPaths) -> Self {
        Self { paths }
    }

    fn ensure_layout(&self) -> Result<(), StorageError> {
        for dir in [
            self.paths.root(),
            &self.paths.config_dir(),
            &self.paths.spaces_dir(),
        ] {
            fs::create_dir_all(dir).map_err(|source| StorageError::Io {
                path: dir.to_path_buf(),
                source,
            })?;
        }

        if !self.paths.config_file().exists() {
            self.save_config(&AppConfig::default())?;
        }

        if !self.paths.state_file().exists() {
            self.save_state(&AppState::default())?;
        }

        Ok(())
    }

    fn ensure_space_layout(&self, space_id: &SpaceId) -> Result<(), StorageError> {
        for dir in [
            self.paths.space_dir(space_id),
            self.paths.space_todo_dir(space_id),
            self.paths.space_archive_dir(space_id),
        ] {
            fs::create_dir_all(&dir).map_err(|source| StorageError::Io { path: dir, source })?;
        }

        Ok(())
    }
}

impl AppRepository for FilesystemRepository {
    fn paths(&self) -> &DataPaths {
        &self.paths
    }

    fn initialize(&self) -> Result<RepositorySnapshot, StorageError> {
        self.ensure_layout()?;
        Ok(RepositorySnapshot {
            config: self.load_config()?,
            state: self.load_state()?,
            spaces: self.list_spaces()?,
        })
    }

    fn load_config(&self) -> Result<AppConfig, StorageError> {
        read_ron_file(&self.paths.config_file())
    }

    fn save_config(&self, config: &AppConfig) -> Result<(), StorageError> {
        write_ron_file(&self.paths.config_file(), config)
    }

    fn load_state(&self) -> Result<AppState, StorageError> {
        read_ron_file(&self.paths.state_file())
    }

    fn save_state(&self, state: &AppState) -> Result<(), StorageError> {
        write_ron_file(&self.paths.state_file(), state)
    }

    fn list_spaces(&self) -> Result<Vec<Space>, StorageError> {
        if !self.paths.spaces_dir().exists() {
            return Ok(Vec::new());
        }

        let mut spaces = Vec::new();
        for entry in read_dir_sorted(&self.paths.spaces_dir())? {
            let path = entry.path();
            if !path.is_dir() {
                continue;
            }

            let space_file = path.join("space.ron");
            if space_file.exists() {
                spaces.push(read_ron_file(&space_file)?);
            }
        }

        Ok(spaces)
    }

    fn load_space(&self, space_id: &SpaceId) -> Result<Space, StorageError> {
        let path = self.paths.space_file(space_id);
        if !path.exists() {
            return Err(StorageError::SpaceNotFound(space_id.as_str().to_owned()));
        }

        read_ron_file(&path)
    }

    fn save_space(&self, space: &Space) -> Result<(), StorageError> {
        self.ensure_layout()?;
        self.ensure_space_layout(&space.id)?;
        write_ron_file(&self.paths.space_file(&space.id), space)
    }

    fn list_task_records_in_space(
        &self,
        space_id: &SpaceId,
    ) -> Result<Vec<StoredTaskRecord>, StorageError> {
        self.ensure_layout()?;
        let mut tasks =
            load_task_records_from_dir(&self.paths.space_todo_dir(space_id), TaskBucket::Todo)?;
        tasks.extend(load_task_records_from_dir(
            &self.paths.space_archive_dir(space_id),
            TaskBucket::Archive,
        )?);
        Ok(tasks)
    }

    fn list_all_task_records(&self) -> Result<Vec<StoredTaskRecord>, StorageError> {
        let mut tasks = Vec::new();
        for space in self.list_spaces()? {
            tasks.extend(self.list_task_records_in_space(&space.id)?);
        }
        Ok(tasks)
    }

    fn list_tasks_in_space(&self, space_id: &SpaceId) -> Result<Vec<Task>, StorageError> {
        self.list_task_records_in_space(space_id)
            .map(|records| records.into_iter().map(|record| record.task).collect())
    }

    fn list_all_tasks(&self) -> Result<Vec<Task>, StorageError> {
        self.list_all_task_records()
            .map(|records| records.into_iter().map(|record| record.task).collect())
    }

    fn load_task(&self, task_id: &TaskId) -> Result<Task, StorageError> {
        self.list_all_tasks()?
            .into_iter()
            .find(|task| &task.id == task_id)
            .ok_or_else(|| StorageError::TaskNotFound(task_id.as_str().to_owned()))
    }

    fn save_task(&self, task: &Task) -> Result<(), StorageError> {
        self.ensure_layout()?;
        self.ensure_space_layout(&task.space_id)?;

        let target_path = self.paths.task_path(task);

        write_ron_file(&target_path, task)?;

        for space in self.list_spaces()? {
            for bucket in ["todo", "archive"] {
                let candidate_path = self.paths.task_path_for_id(&space.id, &task.id, bucket);
                if candidate_path != target_path && candidate_path.exists() {
                    fs::remove_file(&candidate_path).map_err(|source| StorageError::Io {
                        path: candidate_path,
                        source,
                    })?;
                }
            }
        }

        Ok(())
    }

    fn delete_task(&self, task_id: &TaskId) -> Result<(), StorageError> {
        for space in self.list_spaces()? {
            for bucket in [TaskBucket::Todo, TaskBucket::Archive] {
                let candidate_path =
                    self.paths
                        .task_path_for_id(&space.id, task_id, bucket.as_str());
                if candidate_path.exists() {
                    fs::remove_file(&candidate_path).map_err(|source| StorageError::Io {
                        path: candidate_path,
                        source,
                    })?;
                }
            }
        }

        Ok(())
    }

    fn delete_space(&self, space_id: &SpaceId) -> Result<(), StorageError> {
        let path = self.paths.space_dir(space_id);
        if path.exists() {
            fs::remove_dir_all(&path).map_err(|source| StorageError::Io { path, source })?;
        }

        Ok(())
    }
}

fn load_task_records_from_dir(
    dir: &Path,
    bucket: TaskBucket,
) -> Result<Vec<StoredTaskRecord>, StorageError> {
    if !dir.exists() {
        return Ok(Vec::new());
    }

    let mut tasks = Vec::new();
    for entry in read_dir_sorted(dir)? {
        let path = entry.path();
        if path.extension().and_then(|value| value.to_str()) != Some("ron") {
            continue;
        }

        tasks.push(StoredTaskRecord {
            task: read_ron_file(&path)?,
            bucket,
        });
    }

    Ok(tasks)
}

fn read_dir_sorted(dir: &Path) -> Result<Vec<fs::DirEntry>, StorageError> {
    let mut entries = fs::read_dir(dir)
        .map_err(|source| StorageError::Io {
            path: dir.to_path_buf(),
            source,
        })?
        .collect::<Result<Vec<_>, _>>()
        .map_err(|source| StorageError::Io {
            path: dir.to_path_buf(),
            source,
        })?;

    entries.sort_by_key(|entry| entry.path());
    Ok(entries)
}

impl TaskBucket {
    pub fn as_str(self) -> &'static str {
        match self {
            Self::Todo => "todo",
            Self::Archive => "archive",
        }
    }
}