note-to-self-lib 0.1.0

Shared data model, crypto, and sync types for note-to-self.
Documentation
use chrono::{DateTime, NaiveDate, Utc};
use serde::{Deserialize, Serialize};
use std::cmp::Ordering;
use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
use std::str::FromStr;
use uuid::Uuid;

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Entry {
    pub id: Uuid,
    pub created_at: DateTime<Utc>,
    pub modified_at: DateTime<Utc>,
    pub hlc: Hlc,
    pub body: String,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub attachments: Vec<Attachment>,
    pub tags: Vec<String>,
    pub starred: bool,
    pub kind: EntryKind,
    pub deleted: bool,
    pub version: u64,
}

impl Entry {
    pub fn new_journal(body: String, explicit_tags: &[String], starred: bool, hlc: Hlc) -> Self {
        Self::new_journal_with_attachments(body, explicit_tags, starred, Vec::new(), hlc)
    }

    pub fn new_journal_with_attachments(
        body: String,
        explicit_tags: &[String],
        starred: bool,
        attachments: Vec<Attachment>,
        hlc: Hlc,
    ) -> Self {
        let now = Utc::now();
        Self {
            id: Uuid::now_v7(),
            created_at: now,
            modified_at: now,
            hlc,
            tags: merged_tags(&body, explicit_tags),
            body,
            attachments,
            starred,
            kind: EntryKind::Journal,
            deleted: false,
            version: 1,
        }
    }

    pub fn new_todo(
        body: String,
        explicit_tags: &[String],
        priority: Priority,
        due: Option<NaiveDate>,
        hlc: Hlc,
    ) -> Self {
        let now = Utc::now();
        Self {
            id: Uuid::now_v7(),
            created_at: now,
            modified_at: now,
            hlc,
            tags: merged_tags(&body, explicit_tags),
            body,
            attachments: Vec::new(),
            starred: false,
            kind: EntryKind::Todo(TodoMeta {
                completed: false,
                completed_at: None,
                priority,
                due,
            }),
            deleted: false,
            version: 1,
        }
    }

    pub fn touch(&mut self, hlc: Hlc) {
        self.modified_at = Utc::now();
        self.hlc = hlc;
        self.version = self.version.saturating_add(1);
        self.tags = merged_tags(&self.body, &[]);
    }

    pub fn short_id(&self) -> String {
        self.id.to_string().chars().take(8).collect()
    }
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Attachment {
    pub id: String,
    pub file_name: String,
    pub media_type: String,
    pub data: String,
    pub size: u64,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "type", content = "meta", rename_all = "snake_case")]
pub enum EntryKind {
    Journal,
    Todo(TodoMeta),
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct TodoMeta {
    pub completed: bool,
    pub completed_at: Option<DateTime<Utc>>,
    pub priority: Priority,
    pub due: Option<NaiveDate>,
}

#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[serde(rename_all = "snake_case")]
pub enum Priority {
    Low,
    Medium,
    High,
    Urgent,
}

impl Default for Priority {
    fn default() -> Self {
        Self::Medium
    }
}

impl fmt::Display for Priority {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        let value = match self {
            Self::Low => "low",
            Self::Medium => "medium",
            Self::High => "high",
            Self::Urgent => "urgent",
        };
        f.write_str(value)
    }
}

impl FromStr for Priority {
    type Err = String;

    fn from_str(value: &str) -> Result<Self, Self::Err> {
        match value.to_ascii_lowercase().as_str() {
            "low" | "l" => Ok(Self::Low),
            "medium" | "med" | "m" => Ok(Self::Medium),
            "high" | "h" => Ok(Self::High),
            "urgent" | "u" => Ok(Self::Urgent),
            other => Err(format!("unknown priority: {other}")),
        }
    }
}

#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
pub struct Hlc {
    pub wall_ms: u64,
    pub counter: u32,
    pub node_id: u64,
}

impl Hlc {
    pub fn new(node_id: u64) -> Self {
        Self {
            wall_ms: now_ms(),
            counter: 0,
            node_id,
        }
    }

    pub fn tick(self) -> Self {
        let now = now_ms();
        if now > self.wall_ms {
            Self {
                wall_ms: now,
                counter: 0,
                node_id: self.node_id,
            }
        } else {
            Self {
                wall_ms: self.wall_ms,
                counter: self.counter.saturating_add(1),
                node_id: self.node_id,
            }
        }
    }

    pub fn observe(self, remote: Hlc) -> Self {
        let now = now_ms();
        let max_wall = self.wall_ms.max(remote.wall_ms).max(now);
        let counter = if max_wall == self.wall_ms && max_wall == remote.wall_ms {
            self.counter.max(remote.counter).saturating_add(1)
        } else if max_wall == self.wall_ms {
            self.counter.saturating_add(1)
        } else if max_wall == remote.wall_ms {
            remote.counter.saturating_add(1)
        } else {
            0
        };

        Self {
            wall_ms: max_wall,
            counter,
            node_id: self.node_id,
        }
    }
}

impl Ord for Hlc {
    fn cmp(&self, other: &Self) -> Ordering {
        (self.wall_ms, self.counter, self.node_id).cmp(&(
            other.wall_ms,
            other.counter,
            other.node_id,
        ))
    }
}

impl PartialOrd for Hlc {
    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
        Some(self.cmp(other))
    }
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct HlcState {
    pub current: Hlc,
}

impl HlcState {
    pub fn new(node_id: u64) -> Self {
        Self {
            current: Hlc::new(node_id),
        }
    }

    pub fn next(&mut self) -> Hlc {
        self.current = self.current.tick();
        self.current
    }

    pub fn observe(&mut self, remote: Hlc) {
        self.current = self.current.observe(remote);
    }
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ManifestEntry {
    pub version: u64,
    pub hlc: Hlc,
    pub checksum: String,
}

pub type Manifest = BTreeMap<Uuid, ManifestEntry>;

pub fn extract_tags(body: &str) -> Vec<String> {
    let mut tags = BTreeSet::new();
    for token in
        body.split(|c: char| c.is_whitespace() || c == ',' || c == ';' || c == ')' || c == '(')
    {
        let Some(stripped) = token.strip_prefix('#') else {
            continue;
        };
        let tag: String = stripped
            .chars()
            .take_while(|c| c.is_ascii_alphanumeric() || *c == '_' || *c == '-')
            .collect::<String>()
            .to_ascii_lowercase();
        if !tag.is_empty() {
            tags.insert(tag);
        }
    }
    tags.into_iter().collect()
}

pub fn normalize_tag(tag: &str) -> Option<String> {
    let tag = tag.trim().trim_start_matches('#').to_ascii_lowercase();
    if tag.is_empty() {
        None
    } else {
        Some(tag)
    }
}

fn merged_tags(body: &str, explicit_tags: &[String]) -> Vec<String> {
    let mut tags: BTreeSet<String> = extract_tags(body).into_iter().collect();
    for tag in explicit_tags {
        if let Some(tag) = normalize_tag(tag) {
            tags.insert(tag);
        }
    }
    tags.into_iter().collect()
}

fn now_ms() -> u64 {
    Utc::now().timestamp_millis().max(0) as u64
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn hlc_orders_by_wall_counter_node() {
        let a = Hlc {
            wall_ms: 10,
            counter: 1,
            node_id: 1,
        };
        let b = Hlc {
            wall_ms: 10,
            counter: 2,
            node_id: 0,
        };
        let c = Hlc {
            wall_ms: 10,
            counter: 2,
            node_id: 9,
        };
        assert!(a < b);
        assert!(b < c);
    }

    #[test]
    fn extracts_hashtags_stably() {
        assert_eq!(
            extract_tags("hello #Work and #work, #rust-lang."),
            vec!["rust-lang".to_string(), "work".to_string()]
        );
    }
}