automorph 0.2.0

Derive macros for bidirectional Automerge-Rust struct synchronization
Documentation
//! Module for saving/loading `chrono::DateTime<Utc>` as ISO 8601 strings.
//!
//! This module provides full support for the `#[automorph(with = "...")]` attribute,
//! including save, load, diff, update, and cursor operations.
//!
//! # Example
//!
//! ```rust
//! # use automorph::Automorph;
//! # use automerge::AutoCommit;
//! # use chrono::{DateTime, Utc};
//! #[derive(Automorph)]
//! struct Event {
//!     name: String,
//!
//!     #[automorph(with = "automorph::chrono_iso8601")]
//!     timestamp: DateTime<Utc>,
//! }
//!
//! # fn example() {
//! # let mut doc = AutoCommit::new();
//! # let event = Event { name: "test".to_string(), timestamp: Utc::now() };
//! # event.save(&mut doc, automerge::ROOT, "event").unwrap();
//! # }
//! ```
//!
//! The timestamp is stored as an ISO 8601 string like `"2024-01-15T10:30:00Z"`.

use crate::{ChangeReport, Error, FieldCursor, Result};
use automerge::{ChangeHash, ObjId, Prop, ReadDoc, transaction::Transactable};
use chrono::{DateTime, Utc};
use std::borrow::Cow;

/// Change report for `DateTime<Utc>` fields.
///
/// Tracks whether a datetime value changed. This type is used by the derive macro
/// when `#[automorph(with = "automorph::chrono_iso8601")]` is specified.
///
/// # Example
///
/// ```rust
/// # use automorph::{Automorph, ChangeReport};
/// # use automerge::{AutoCommit, ROOT};
/// # use chrono::{DateTime, Utc, TimeZone};
/// # #[derive(Automorph)]
/// # struct Event {
/// #     name: String,
/// #     #[automorph(with = "automorph::chrono_iso8601")]
/// #     timestamp: DateTime<Utc>,
/// # }
/// # fn example() -> Result<(), Box<dyn std::error::Error>> {
/// # let mut doc = AutoCommit::new();
/// let event1 = Event { name: "test".to_string(), timestamp: Utc.with_ymd_and_hms(2024, 1, 15, 10, 0, 0).unwrap() };
/// event1.save(&mut doc, &ROOT, "event")?;
///
/// let event2 = Event { name: "test".to_string(), timestamp: Utc.with_ymd_and_hms(2024, 1, 16, 10, 0, 0).unwrap() };
/// let changes = event2.diff(&doc, &ROOT, "event")?;
///
/// if changes.timestamp.as_ref().map_or(false, |c| c.changed) {
///     println!("Timestamp changed!");
/// }
/// # Ok(())
/// # }
/// ```
#[derive(Debug, Clone, Default)]
pub struct Changes {
    /// Whether the datetime value changed
    pub changed: bool,
}

impl ChangeReport for Changes {
    fn any(&self) -> bool {
        self.changed
    }

    fn paths(&self) -> Vec<Vec<Cow<'static, str>>> {
        if self.changed { vec![vec![]] } else { vec![] }
    }

    fn leaf_paths(&self) -> Vec<Vec<Cow<'static, str>>> {
        self.paths()
    }
}

/// Cursor for tracking `DateTime<Utc>` changes efficiently.
///
/// This cursor stores the Automerge ObjId of the datetime field, enabling O(1)
/// change detection by comparing cached vs current ObjIds.
///
/// Used internally by `Tracked<T>` for efficient change tracking.
#[derive(Debug, Clone, Default)]
pub struct Cursor {
    /// Cached ObjId for detecting changes via object identity.
    /// When the value changes, Automerge assigns a new ObjId.
    pub obj_id: Option<ObjId>,
}

impl FieldCursor for Cursor {
    type Changes = Changes;

    fn diff<D: ReadDoc>(&self, _doc: &D, obj: &ObjId) -> Result<Self::Changes> {
        // Compare cached ObjId with current to detect changes
        let changed = match &self.obj_id {
            Some(cached) => cached != obj,
            None => true,
        };
        Ok(Changes { changed })
    }

    fn refresh<D: ReadDoc>(&mut self, _doc: &D, obj: &ObjId) -> Result<()> {
        self.obj_id = Some(obj.clone());
        Ok(())
    }
}

/// Save a `DateTime<Utc>` as an ISO 8601 string.
///
/// The value is converted to RFC 3339 format and stored as a string in the document.
///
/// # Errors
///
/// Returns an error if the Automerge operation fails.
pub fn save<D: Transactable + ReadDoc>(
    value: &DateTime<Utc>,
    doc: &mut D,
    obj: impl AsRef<ObjId>,
    prop: impl Into<Prop>,
) -> Result<()> {
    let iso_string = value.to_rfc3339();
    crate::Automorph::save(&iso_string, doc, obj, prop)
}

/// Load a `DateTime<Utc>` from an ISO 8601 string.
///
/// Parses the stored string as RFC 3339 and converts to UTC.
///
/// # Errors
///
/// Returns an error if the value is missing or has an invalid format.
pub fn load<D: ReadDoc>(
    doc: &D,
    obj: impl AsRef<ObjId>,
    prop: impl Into<Prop>,
) -> Result<DateTime<Utc>> {
    let iso_string: String = crate::Automorph::load(doc, obj, prop)?;
    DateTime::parse_from_rfc3339(&iso_string)
        .map(|dt| dt.with_timezone(&Utc))
        .map_err(|e| Error::invalid_value(format!("invalid ISO 8601 datetime: {}", e)))
}

/// Load a `DateTime<Utc>` from a specific version.
///
/// Parses the stored string at the given document version as RFC 3339.
///
/// # Errors
///
/// Returns an error if the value is missing or has an invalid format.
pub fn load_at<D: ReadDoc>(
    doc: &D,
    obj: impl AsRef<ObjId>,
    prop: impl Into<Prop>,
    heads: &[ChangeHash],
) -> Result<DateTime<Utc>> {
    let iso_string: String = crate::Automorph::load_at(doc, obj, prop, heads)?;
    DateTime::parse_from_rfc3339(&iso_string)
        .map(|dt| dt.with_timezone(&Utc))
        .map_err(|e| Error::invalid_value(format!("invalid ISO 8601 datetime: {}", e)))
}

/// Compare a `DateTime<Utc>` against the document.
///
/// Returns a `Changes` report indicating whether the value differs from
/// what's stored in the document.
///
/// # Errors
///
/// Returns an error only if the document read fails (not if the value is missing).
pub fn diff<D: ReadDoc>(
    value: &DateTime<Utc>,
    doc: &D,
    obj: impl AsRef<ObjId>,
    prop: impl Into<Prop>,
) -> Result<Changes> {
    let stored: Result<DateTime<Utc>> = load(doc, obj, prop);
    match stored {
        Ok(stored) => Ok(Changes { changed: *value != stored }),
        Err(_) => Ok(Changes { changed: true }),
    }
}

/// Compare a `DateTime<Utc>` against a specific document version.
///
/// Returns a `Changes` report indicating whether the value differs from
/// what's stored in the document at the given version.
///
/// # Errors
///
/// Returns an error only if the document read fails.
pub fn diff_at<D: ReadDoc>(
    value: &DateTime<Utc>,
    doc: &D,
    obj: impl AsRef<ObjId>,
    prop: impl Into<Prop>,
    heads: &[ChangeHash],
) -> Result<Changes> {
    let stored: Result<DateTime<Utc>> = load_at(doc, obj, prop, heads);
    match stored {
        Ok(stored) => Ok(Changes { changed: *value != stored }),
        Err(_) => Ok(Changes { changed: true }),
    }
}

/// Update a `DateTime<Utc>` value from the document.
///
/// Loads the current value from the document and updates the provided value,
/// returning whether a change occurred.
///
/// # Errors
///
/// Returns an error if the value cannot be loaded from the document.
pub fn update<D: ReadDoc>(
    value: &mut DateTime<Utc>,
    doc: &D,
    obj: impl AsRef<ObjId>,
    prop: impl Into<Prop>,
) -> Result<Changes> {
    let loaded = load(doc, obj, prop)?;
    let changed = *value != loaded;
    *value = loaded;
    Ok(Changes { changed })
}

/// Update a `DateTime<Utc>` value from a specific document version.
///
/// Loads the value at the given version from the document and updates
/// the provided value, returning whether a change occurred.
///
/// # Errors
///
/// Returns an error if the value cannot be loaded from the document.
pub fn update_at<D: ReadDoc>(
    value: &mut DateTime<Utc>,
    doc: &D,
    obj: impl AsRef<ObjId>,
    prop: impl Into<Prop>,
    heads: &[ChangeHash],
) -> Result<Changes> {
    let loaded = load_at(doc, obj, prop, heads)?;
    let changed = *value != loaded;
    *value = loaded;
    Ok(Changes { changed })
}

#[cfg(test)]
mod tests {
    use super::*;
    use automerge::{AutoCommit, ROOT};
    use chrono::TimeZone;

    #[test]
    fn test_datetime_roundtrip() {
        let mut doc = AutoCommit::new();
        let original = Utc.with_ymd_and_hms(2024, 1, 15, 10, 30, 0).unwrap();

        save(&original, &mut doc, &ROOT, "timestamp").unwrap();
        let loaded = load(&doc, &ROOT, "timestamp").unwrap();

        assert_eq!(original, loaded);
    }

    #[test]
    fn test_datetime_format() {
        let mut doc = AutoCommit::new();
        let dt = Utc.with_ymd_and_hms(2024, 1, 15, 10, 30, 0).unwrap();

        save(&dt, &mut doc, &ROOT, "timestamp").unwrap();

        // Verify it's stored as a string
        let stored: String = crate::Automorph::load(&doc, &ROOT, "timestamp").unwrap();
        assert!(stored.contains("2024-01-15"));
        assert!(stored.contains("10:30:00"));
    }

    #[test]
    fn test_datetime_diff_no_change() {
        let mut doc = AutoCommit::new();
        let dt = Utc.with_ymd_and_hms(2024, 1, 15, 10, 30, 0).unwrap();

        save(&dt, &mut doc, &ROOT, "timestamp").unwrap();
        let changes = diff(&dt, &doc, &ROOT, "timestamp").unwrap();

        assert!(!changes.any());
    }

    #[test]
    fn test_datetime_diff_with_change() {
        let mut doc = AutoCommit::new();
        let dt1 = Utc.with_ymd_and_hms(2024, 1, 15, 10, 30, 0).unwrap();
        let dt2 = Utc.with_ymd_and_hms(2024, 1, 16, 14, 0, 0).unwrap();

        save(&dt1, &mut doc, &ROOT, "timestamp").unwrap();
        let changes = diff(&dt2, &doc, &ROOT, "timestamp").unwrap();

        assert!(changes.any());
    }

    #[test]
    fn test_datetime_update() {
        let mut doc = AutoCommit::new();
        let dt1 = Utc.with_ymd_and_hms(2024, 1, 15, 10, 30, 0).unwrap();
        let dt2 = Utc.with_ymd_and_hms(2024, 1, 16, 14, 0, 0).unwrap();

        save(&dt1, &mut doc, &ROOT, "timestamp").unwrap();

        let mut current = dt1;
        let changes = update(&mut current, &doc, &ROOT, "timestamp").unwrap();
        assert!(!changes.any());
        assert_eq!(current, dt1);

        // Now change the document
        save(&dt2, &mut doc, &ROOT, "timestamp").unwrap();

        let changes = update(&mut current, &doc, &ROOT, "timestamp").unwrap();
        assert!(changes.any());
        assert_eq!(current, dt2);
    }

    #[test]
    fn test_datetime_cursor() {
        let mut doc = AutoCommit::new();
        let dt = Utc.with_ymd_and_hms(2024, 1, 15, 10, 30, 0).unwrap();

        save(&dt, &mut doc, &ROOT, "timestamp").unwrap();

        let mut cursor = Cursor::default();
        let (_, obj_id) = doc.get(&ROOT, "timestamp").unwrap().unwrap();
        cursor.refresh(&doc, &obj_id).unwrap();

        // No change yet
        let changes = cursor.diff(&doc, &obj_id).unwrap();
        assert!(!changes.any());

        // Change the value
        let dt2 = Utc.with_ymd_and_hms(2024, 1, 16, 14, 0, 0).unwrap();
        save(&dt2, &mut doc, &ROOT, "timestamp").unwrap();

        let (_, new_obj_id) = doc.get(&ROOT, "timestamp").unwrap().unwrap();
        let changes = cursor.diff(&doc, &new_obj_id).unwrap();
        assert!(changes.any());
    }
}