oh-my-todo 0.2.0

Local-first terminal task manager with a mouse-first TUI and CLI.
Documentation
use serde::{Deserialize, Serialize};
use std::fmt;
use thiserror::Error;
use ulid::Ulid;

pub const MIN_SHORT_ID_SUFFIX_LEN: usize = 8;
const ULID_LENGTH: usize = 26;

#[derive(Debug, Error, Clone, PartialEq, Eq)]
pub enum IdError {
    #[error("invalid {kind} id `{value}`: expected prefix `{expected_prefix}_`")]
    InvalidPrefix {
        kind: &'static str,
        value: String,
        expected_prefix: &'static str,
    },
    #[error("invalid {kind} id `{value}`: suffix must be a ULID")]
    InvalidUlid { kind: &'static str, value: String },
    #[error(
        "invalid {kind} reference `{value}`: id suffix must be between {min} and {max} characters"
    )]
    InvalidReferenceLength {
        kind: &'static str,
        value: String,
        min: usize,
        max: usize,
    },
}

#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct SpaceId(String);

#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct TaskId(String);

impl SpaceId {
    pub const PREFIX: &'static str = "spc";

    pub fn new() -> Self {
        Self(format!("{}_{}", Self::PREFIX, Ulid::new()))
    }

    pub fn as_str(&self) -> &str {
        &self.0
    }

    pub fn short_id(&self) -> String {
        short_id(self.as_str(), Self::PREFIX)
    }

    pub fn matches_ref(&self, value: &str) -> Result<bool, IdError> {
        matches_ref(self.as_str(), Self::PREFIX, "space", value)
    }
}

impl TaskId {
    pub const PREFIX: &'static str = "tsk";

    pub fn new() -> Self {
        Self(format!("{}_{}", Self::PREFIX, Ulid::new()))
    }

    pub fn as_str(&self) -> &str {
        &self.0
    }

    pub fn short_id(&self) -> String {
        short_id(self.as_str(), Self::PREFIX)
    }

    pub fn matches_ref(&self, value: &str) -> Result<bool, IdError> {
        matches_ref(self.as_str(), Self::PREFIX, "task", value)
    }
}

impl Default for SpaceId {
    fn default() -> Self {
        Self::new()
    }
}

impl Default for TaskId {
    fn default() -> Self {
        Self::new()
    }
}

impl fmt::Display for SpaceId {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(self.as_str())
    }
}

impl fmt::Display for TaskId {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(self.as_str())
    }
}

impl TryFrom<&str> for SpaceId {
    type Error = IdError;

    fn try_from(value: &str) -> Result<Self, Self::Error> {
        validate_long_id(value, Self::PREFIX, "space")?;
        Ok(Self(value.to_owned()))
    }
}

impl TryFrom<String> for SpaceId {
    type Error = IdError;

    fn try_from(value: String) -> Result<Self, Self::Error> {
        Self::try_from(value.as_str())
    }
}

impl TryFrom<&str> for TaskId {
    type Error = IdError;

    fn try_from(value: &str) -> Result<Self, Self::Error> {
        validate_long_id(value, Self::PREFIX, "task")?;
        Ok(Self(value.to_owned()))
    }
}

impl TryFrom<String> for TaskId {
    type Error = IdError;

    fn try_from(value: String) -> Result<Self, Self::Error> {
        Self::try_from(value.as_str())
    }
}

fn short_id(value: &str, prefix: &str) -> String {
    let suffix = value
        .split_once('_')
        .map(|(_, suffix)| suffix)
        .unwrap_or(value);

    format!("{}_{}", prefix, &suffix[..MIN_SHORT_ID_SUFFIX_LEN])
}

fn validate_long_id(
    value: &str,
    expected_prefix: &'static str,
    kind: &'static str,
) -> Result<(), IdError> {
    let (prefix, suffix) = value
        .split_once('_')
        .ok_or_else(|| IdError::InvalidPrefix {
            kind,
            value: value.to_owned(),
            expected_prefix,
        })?;

    if prefix != expected_prefix {
        return Err(IdError::InvalidPrefix {
            kind,
            value: value.to_owned(),
            expected_prefix,
        });
    }

    if suffix.len() != ULID_LENGTH || Ulid::from_string(suffix).is_err() {
        return Err(IdError::InvalidUlid {
            kind,
            value: value.to_owned(),
        });
    }

    Ok(())
}

fn matches_ref(
    full_id: &str,
    expected_prefix: &'static str,
    kind: &'static str,
    reference: &str,
) -> Result<bool, IdError> {
    if reference == full_id {
        return Ok(true);
    }

    let (prefix, suffix) = reference
        .split_once('_')
        .ok_or_else(|| IdError::InvalidPrefix {
            kind,
            value: reference.to_owned(),
            expected_prefix,
        })?;

    if prefix != expected_prefix {
        return Err(IdError::InvalidPrefix {
            kind,
            value: reference.to_owned(),
            expected_prefix,
        });
    }

    if !(MIN_SHORT_ID_SUFFIX_LEN..=ULID_LENGTH).contains(&suffix.len()) {
        return Err(IdError::InvalidReferenceLength {
            kind,
            value: reference.to_owned(),
            min: MIN_SHORT_ID_SUFFIX_LEN,
            max: ULID_LENGTH,
        });
    }

    let (_, full_suffix) = full_id.split_once('_').expect("long ids are well formed");
    Ok(full_suffix.starts_with(suffix))
}