auditlog 0.1.0

Audit trail for your data models — an ORM-agnostic core with a pluggable, async sqlx backend (SQLite & Postgres).
Documentation
//! Historical state reconstruction: rebuild a record's prior state from its audit trail.
//!
//! Because this crate does not own your model's persistence, a reconstructed revision is returned
//! as a [`Revision`] — the folded attribute map plus metadata — rather than a live model instance.
//! Apply it to your ORM (or build a fresh model from it) to obtain a usable record. Saving a
//! record built from a `new_record` revision re-inserts a destroyed row.

use crate::action::Action;
use crate::audit::Audit;
use crate::changes::ValueMap;

/// A reconstructed snapshot of an auditable record at a point in its history.
#[derive(Clone, Debug, PartialEq)]
pub struct Revision {
    /// The audited attributes folded up to this revision (column → value).
    pub attributes: ValueMap,
    /// The version this revision corresponds to.
    pub version: i32,
    /// `true` if the record was destroyed at this point — the reconstructed record should be
    /// treated as a new (unsaved) row, and saving it re-inserts the record.
    pub new_record: bool,
}

/// Fold a slice of audits (assumed ascending by version) into `(attributes, last_version,
/// last_action_is_destroy)` by replaying each audit's recorded attribute changes in order.
pub(crate) fn reconstruct(audits: &[Audit]) -> (ValueMap, i32, bool) {
    let mut attrs = ValueMap::new();
    let mut version = 0;
    let mut destroyed = false;
    for audit in audits {
        for (k, v) in audit.new_attributes() {
            attrs.insert(k, v);
        }
        version = audit.version;
        destroyed = audit.action == Action::Destroy;
    }
    (attrs, version, destroyed)
}

/// Build one [`Revision`] per audit at/after `from_version`, seeded from the state implied by
/// earlier audits.
pub(crate) fn revisions(all_ascending: &[Audit], from_version: i32) -> Vec<Revision> {
    if !all_ascending.iter().any(|a| a.version >= from_version) {
        return Vec::new();
    }

    let before: Vec<Audit> = all_ascending
        .iter()
        .filter(|a| a.version < from_version)
        .cloned()
        .collect();
    let (mut attrs, _, _) = reconstruct(&before);

    let mut out = Vec::new();
    for audit in all_ascending.iter().filter(|a| a.version >= from_version) {
        for (k, v) in audit.new_attributes() {
            attrs.insert(k, v);
        }
        out.push(Revision {
            attributes: attrs.clone(),
            version: audit.version,
            new_record: audit.action == Action::Destroy,
        });
    }
    out
}

/// Reconstruct the state at `version`, or `None` if `version` is greater than the latest recorded
/// version.
pub(crate) fn revision_at_version(all_ascending: &[Audit], version: i32) -> Option<Revision> {
    let last = all_ascending.last()?;
    if last.version < version {
        return None;
    }
    let up_to: Vec<Audit> = all_ascending
        .iter()
        .filter(|a| a.version <= version)
        .cloned()
        .collect();
    let (attrs, reconstructed_version, destroyed) = reconstruct(&up_to);
    Some(Revision {
        attributes: attrs,
        version: if reconstructed_version == 0 {
            version
        } else {
            reconstructed_version
        },
        new_record: destroyed,
    })
}

/// Reconstruct the previous revision: the second-most-recent version (or version 1 if there is no
/// earlier audit).
pub(crate) fn revision_previous(all_ascending: &[Audit]) -> Option<Revision> {
    if all_ascending.is_empty() {
        return None;
    }
    // descending offset 1 → the second-most-recent version, else 1.
    let target_version = if all_ascending.len() >= 2 {
        all_ascending[all_ascending.len() - 2].version
    } else {
        1
    };
    revision_at_version(all_ascending, target_version)
}

/// The latest revision at/before a given time. Caller passes the audits already filtered to
/// `created_at <= time`, ascending.
pub(crate) fn revision_up_until(up_until_ascending: &[Audit]) -> Option<Revision> {
    if up_until_ascending.is_empty() {
        return None;
    }
    let (attrs, version, destroyed) = reconstruct(up_until_ascending);
    Some(Revision {
        attributes: attrs,
        version,
        new_record: destroyed,
    })
}