aven-core 0.1.12

Core library for the Aven local-first task manager
use crate::ids::WorkspaceId;
use anyhow::{Result, anyhow, ensure};
use serde_json::{Value, json};

use crate::choices::{TaskPriority, TaskStatus};
use crate::types::Project;
use crate::types::Task;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TaskField {
    Title,
    Description,
    Project,
    Status,
    Priority,
    AvailableAt,
    DueOn,
    Deleted,
    IsEpic,
}

impl TaskField {
    pub const VERSIONED: [TaskField; 9] = [
        TaskField::Title,
        TaskField::Description,
        TaskField::Project,
        TaskField::Status,
        TaskField::Priority,
        TaskField::AvailableAt,
        TaskField::Deleted,
        TaskField::IsEpic,
        TaskField::DueOn,
    ];

    pub fn parse(field: &str) -> Option<Self> {
        match field {
            "title" => Some(Self::Title),
            "description" => Some(Self::Description),
            "project" => Some(Self::Project),
            "status" => Some(Self::Status),
            "priority" => Some(Self::Priority),
            "available_at" => Some(Self::AvailableAt),
            "due_on" => Some(Self::DueOn),
            "deleted" => Some(Self::Deleted),
            "is_epic" => Some(Self::IsEpic),
            _ => None,
        }
    }

    pub fn is_project(self) -> bool {
        matches!(self, Self::Project)
    }

    pub fn is_scalar(self) -> bool {
        !self.is_project()
    }

    pub fn parse_or_unknown(field: &str) -> Result<Self> {
        Self::parse(field).ok_or_else(|| anyhow!("error unknown-field field={field}"))
    }

    pub fn parse_for_sync(field: &str) -> Result<Self> {
        Self::parse(field).ok_or_else(|| anyhow!("error invalid-sync-change field={field}"))
    }

    pub fn as_str(self) -> &'static str {
        match self {
            Self::Title => "title",
            Self::Description => "description",
            Self::Project => "project",
            Self::Status => "status",
            Self::Priority => "priority",
            Self::AvailableAt => "available_at",
            Self::DueOn => "due_on",
            Self::Deleted => "deleted",
            Self::IsEpic => "is_epic",
        }
    }

    pub fn validate_value(self, value: &str) -> Result<()> {
        match self {
            Self::Status => TaskStatus::parse(value).map(|_| ()).map_err(Into::into),
            Self::Priority => TaskPriority::parse(value).map(|_| ()).map_err(Into::into),
            Self::AvailableAt => crate::time_validation::validate_available_at_value(value),
            Self::DueOn => crate::time_validation::validate_due_on_value(value),
            Self::Deleted if matches!(value, "0" | "1") => Ok(()),
            Self::Deleted => anyhow::bail!("error invalid-deleted value={value}"),
            Self::IsEpic if matches!(value, "0" | "1") => Ok(()),
            Self::IsEpic => anyhow::bail!("error invalid-is-epic value={value}"),
            _ => Ok(()),
        }
    }

    pub fn updates_queue_activity(self) -> bool {
        matches!(self, Self::Status | Self::Priority)
    }

    pub fn scalar_payload(
        self,
        workspace_id: &WorkspaceId,
        workspace_key: &str,
        value: &str,
    ) -> Result<Value> {
        ensure!(self.is_scalar(), "error project-update-requires-project-id");
        Ok(json!({
            "workspace_id": workspace_id,
            "workspace_key": workspace_key,
            "value": value,
        }))
    }

    pub fn project_payload(
        workspace_id: &WorkspaceId,
        workspace_key: &str,
        project: &Project,
    ) -> Value {
        json!({
            "workspace_id": workspace_id,
            "workspace_key": workspace_key,
            "value": &project.id,
            "project_id": &project.id,
            "project_key": &project.key,
            "project_name": &project.name,
            "project_prefix": &project.prefix,
        })
    }

    pub fn current_value(self, task: &Task) -> String {
        match self {
            Self::Title => task.title.clone(),
            Self::Description => task.description.clone(),
            Self::Project => task.project_id.to_string(),
            Self::Status => task.status.as_str().to_string(),
            Self::Priority => task.priority.as_str().to_string(),
            Self::AvailableAt => task.available_at.clone().unwrap_or_default(),
            Self::DueOn => task.due_on.clone().unwrap_or_default(),
            Self::Deleted => {
                if task.deleted {
                    "1".to_string()
                } else {
                    "0".to_string()
                }
            }
            Self::IsEpic => {
                if task.is_epic {
                    "1".to_string()
                } else {
                    "0".to_string()
                }
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::TaskField;
    use crate::ids::WorkspaceId;
    use crate::types::Project;

    #[test]
    fn versioned_fields_keep_protocol_order() {
        let fields: Vec<_> = TaskField::VERSIONED
            .into_iter()
            .map(TaskField::as_str)
            .collect();
        assert_eq!(
            fields,
            [
                "title",
                "description",
                "project",
                "status",
                "priority",
                "available_at",
                "deleted",
                "is_epic",
                "due_on"
            ]
        );
    }

    #[test]
    fn parse_unknown_field_errors_are_canonicalized() {
        assert_eq!(
            TaskField::parse_or_unknown("missing")
                .unwrap_err()
                .to_string(),
            "error unknown-field field=missing"
        );
        assert_eq!(
            TaskField::parse_for_sync("missing")
                .unwrap_err()
                .to_string(),
            "error invalid-sync-change field=missing"
        );
    }

    #[test]
    fn deleted_validation() {
        assert!(TaskField::Deleted.validate_value("0").is_ok());
        assert!(TaskField::Deleted.validate_value("1").is_ok());
        assert_eq!(
            TaskField::Deleted
                .validate_value("x")
                .unwrap_err()
                .to_string(),
            "error invalid-deleted value=x"
        );
    }

    #[test]
    fn scalar_payload_shape() {
        let workspace_id: WorkspaceId = "0000000000000001".parse().unwrap();
        let payload = TaskField::Title
            .scalar_payload(&workspace_id, "key", "v")
            .expect("valid scalar field");
        assert_eq!(
            payload,
            serde_json::json!({
                "workspace_id": "0000000000000001",
                "workspace_key": "key",
                "value": "v",
            })
        );
        assert!(
            TaskField::Project
                .scalar_payload(&workspace_id, "key", "v")
                .is_err()
        );
    }

    #[test]
    fn project_payload_shape() {
        let workspace_id: WorkspaceId = "0000000000000001".parse().unwrap();
        let project = Project {
            id: "0000000000000001".parse().unwrap(),
            workspace_id: workspace_id.clone(),
            key: "proj".to_string(),
            name: "Project".to_string(),
            prefix: "pp".to_string(),
        };
        assert_eq!(
            TaskField::project_payload(&workspace_id, "key", &project),
            serde_json::json!({
                "workspace_id": "0000000000000001",
                "workspace_key": "key",
                "value": "0000000000000001",
                "project_id": "0000000000000001",
                "project_key": "proj",
                "project_name": "Project",
                "project_prefix": "pp",
            })
        );
    }
}