techne-sdk 0.1.0

TechneSDK's logic written once: version ranking (eventState) and offline record verification over the published check, shipped to TypeScript as WebAssembly; with opt-in atproto plumbing (service auth, DID resolution, signed labels, proven record fetches, PDS password sessions) behind the `crypto` and `net` features.
Documentation
//! One commons event or place as its versions (§24 versions and trust): the one definition of how
//! versions rank and compare, used by the rules curator when it composes a curated record and by every
//! app (through the SDK's WebAssembly) when it shows one.
//!
//! A version's rank comes from its author ([`Rank`]: 1 a source the commons root endorses, 2 a verified
//! account or a member of an endorsed collective, 3 everyone else). A field "says something" when
//! [`sets`] holds, and two values say the same thing when their [`comparable`] forms are equal (strings
//! trimmed, a datetime as its instant, recursively).
//!
//! [`event_state`] turns a `pub.techne.draft.commons.getVersions` answer into what a screen shows: each
//! field's value, the version it is from and its conflict count, the byline, and the versions grouped
//! Sources / Verified / Others with the fields where each differs from what is shown. A person may pick
//! another version: the fields then show that version's own values.

use std::collections::BTreeMap;

use serde::{Deserialize, Serialize};
use serde_json::{json, Map, Value};

/// A version's rank (§24 VT1), from its author. Lower speaks first; the order is the derive's.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Rank {
    /// A source the root endorses for the record's collection (a mirror, a rule, a collective).
    Endorsed,
    /// A verified account (a `verification` by an issuer the root endorses for it), or a member of a
    /// collective the root endorses to write membership claims.
    Verified,
    /// Everyone else. Fills blanks or stands alone, never raises a conflict, never merges two
    /// clusters (VT6).
    Other,
}

impl Rank {
    /// The number the provenance's `composition.ranks` and `getVersions` carry (1, 2, 3).
    pub const fn number(self) -> u8 {
        match self {
            Rank::Endorsed => 1,
            Rank::Verified => 2,
            Rank::Other => 3,
        }
    }

    /// Rank 1 or 2: it can raise a conflict and merge clusters.
    pub const fn is_ranked(self) -> bool {
        !matches!(self, Rank::Other)
    }
}

/// How a version is listed, in this order.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Group {
    Sources,
    Verified,
    Others,
}

impl Group {
    /// A version's group from the rank `getVersions` carries: 1 Sources, 2 Verified, else Others.
    pub fn of(rank: Option<u8>) -> Group {
        match rank {
            Some(1) => Group::Sources,
            Some(2) => Group::Verified,
            _ => Group::Others,
        }
    }
}

/// Whether a field value says something (blank strings and empty containers do not).
pub fn sets(v: &Value) -> bool {
    match v {
        Value::Null => false,
        Value::String(s) => !s.trim().is_empty(),
        Value::Array(a) => !a.is_empty(),
        Value::Object(o) => !o.is_empty(),
        _ => true,
    }
}

/// A value as compared: strings trimmed and a datetime as its instant, recursively, so two versions
/// saying the same thing in other words (`18:00:00Z`, `18:00:00.000Z`) are one value.
pub fn comparable(v: &Value) -> Value {
    match v {
        Value::String(s) => {
            let t = s.trim();
            match chrono::DateTime::parse_from_rfc3339(t) {
                Ok(at) => json!(at.timestamp_millis()),
                Err(_) => json!(t),
            }
        }
        Value::Array(a) => Value::Array(a.iter().map(comparable).collect()),
        Value::Object(o) => Value::Object(o.iter().map(|(k, v)| (k.clone(), comparable(v))).collect()),
        other => other.clone(),
    }
}

/// A shown field as a reader compares it: [`comparable`], with an event's places compared by what they
/// say, never by which copy of a place they point at (the curated record names the curated place, a
/// version names its own copy; the curator maps both to the curated place before comparing).
pub fn shown_as(field: &str, v: &Value) -> Value {
    fn drop_record_uris(v: &Value) -> Value {
        match v {
            Value::Array(a) => Value::Array(a.iter().map(drop_record_uris).collect()),
            Value::Object(o) => Value::Object(
                o.iter()
                    .filter(|(k, x)| !(k.as_str() == "uri" && x.as_str().is_some_and(|s| s.starts_with("at://"))))
                    .map(|(k, x)| (k.clone(), drop_record_uris(x)))
                    .collect(),
            ),
            other => other.clone(),
        }
    }
    if field == "locations" {
        comparable(&drop_record_uris(v))
    } else {
        comparable(v)
    }
}

// ── the getVersions answer ───────────────────────────────────────────────────────────────────────

/// `pub.techne.draft.commons.getVersions`'s output, as far as [`event_state`] reads it.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GetVersions {
    pub uri: String,
    pub record: Value,
    #[serde(default)]
    pub composition: Option<Composition>,
    #[serde(default)]
    pub versions: Vec<Version>,
    #[serde(default)]
    pub left_out: u64,
}

/// The provenance's `composition` (VT2).
#[derive(Debug, Clone, Deserialize)]
pub struct Composition {
    #[serde(default)]
    pub byline: Option<String>,
    #[serde(default)]
    pub fields: Vec<FieldChoice>,
}

/// Which version gave one field, and the ranked versions' distinct values for it.
#[derive(Debug, Clone, Deserialize)]
pub struct FieldChoice {
    pub field: String,
    pub from: String,
    #[serde(default)]
    pub conflicts: Option<u64>,
}

/// One version as `getVersions` answers it.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Version {
    pub uri: String,
    pub did: String,
    pub record: Value,
    #[serde(default)]
    pub name: Option<String>,
    #[serde(default)]
    pub handle: Option<String>,
    #[serde(default)]
    pub rank: Option<u8>,
    #[serde(default)]
    pub mirror: bool,
    #[serde(default)]
    pub verification: Option<Value>,
    #[serde(default)]
    pub indexed_at: Option<String>,
}

// ── what a screen draws ──────────────────────────────────────────────────────────────────────────

/// Who a value or the byline is from.
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct VersionRef {
    pub uri: String,
    pub did: String,
    /// Who to show it as (a mirror's source credit, a person's display name).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub name: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub handle: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub rank: Option<u8>,
    pub mirror: bool,
    /// A verification record by an issuer the root endorses is about its author.
    pub verified: bool,
}

/// One field as shown: its value, the version it is from, and the ranked versions' conflict count.
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct FieldState {
    pub value: Value,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub from: Option<VersionRef>,
    /// Distinct values among the ranked versions, when 2 or more (the field-conflict badge's count).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub conflicts: Option<u64>,
}

/// One version as the versions sheet lists it.
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct VersionEntry {
    #[serde(flatten)]
    pub version: VersionRef,
    pub group: Group,
    pub record: Value,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub indexed_at: Option<String>,
    /// The version being shown (the byline of the composition, or the one picked).
    pub showing: bool,
    /// Fields this version sets to something other than what is shown, in field-name order.
    pub differs: Vec<String>,
    /// Fields this version sets to what is shown, in field-name order.
    pub same: Vec<String>,
}

/// What a screen draws.
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EventState {
    pub uri: String,
    /// Every field shown (the composition, or the picked version), without `$type` and `createdAt`.
    pub fields: BTreeMap<String, FieldState>,
    /// Whose version speaks first: the composition's byline, or the version picked.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub byline: Option<VersionRef>,
    pub versions: Vec<VersionEntry>,
    /// The version picked, or `None` for the default composition.
    pub picked: Option<String>,
    /// Versions the server left out for a verdict (spam, obscene).
    pub left_out: u64,
}

/// Neither shown nor compared: the record's type and its own creation time.
fn hidden(field: &str) -> bool {
    field == "$type" || field == "createdAt"
}

fn ref_of(v: &Version) -> VersionRef {
    VersionRef {
        uri: v.uri.clone(),
        did: v.did.clone(),
        name: v.name.clone(),
        handle: v.handle.clone(),
        rank: v.rank,
        mirror: v.mirror,
        verified: v.verification.is_some(),
    }
}

/// The state of `data` with `picked` (a version's URI) shown, or the default composition. A pick whose
/// version is not in `data` shows the composition.
pub fn event_state(data: &GetVersions, picked: Option<&str>) -> EventState {
    let empty = Map::new();
    let by_uri = |uri: &str| data.versions.iter().find(|v| v.uri == uri);
    let picked = picked.and_then(by_uri);
    let shown = picked.map_or(&data.record, |v| &v.record).as_object().unwrap_or(&empty);
    let choices = data.composition.as_ref().map_or(&[][..], |c| &c.fields[..]);
    // With one version and no composition, everything is from that version.
    let only = (data.versions.len() == 1).then(|| data.versions[0].uri.as_str());

    let fields = shown
        .iter()
        .filter(|(name, value)| !hidden(name) && sets(value))
        .map(|(name, value)| {
            let choice = choices.iter().find(|f| &f.field == name);
            let from = match picked {
                Some(v) => Some(v.uri.as_str()),
                None => choice.map(|c| c.from.as_str()).or(only),
            };
            let state = FieldState {
                value: value.clone(),
                from: from.and_then(by_uri).map(ref_of),
                conflicts: choice.and_then(|c| c.conflicts).filter(|n| *n >= 2),
            };
            (name.clone(), state)
        })
        .collect();

    let byline = picked.map(|v| v.uri.as_str()).or(data.composition.as_ref().and_then(|c| c.byline.as_deref())).or(only);
    let mut versions: Vec<VersionEntry> = data
        .versions
        .iter()
        .map(|v| {
            let (mut differs, mut same) = (vec![], vec![]);
            for (name, value) in v.record.as_object().unwrap_or(&empty) {
                if hidden(name) || !sets(value) {
                    continue;
                }
                match shown.get(name) {
                    Some(s) if shown_as(name, s) == shown_as(name, value) => same.push(name.clone()),
                    _ => differs.push(name.clone()),
                }
            }
            VersionEntry {
                version: ref_of(v),
                group: Group::of(v.rank),
                record: v.record.clone(),
                indexed_at: v.indexed_at.clone(),
                showing: Some(v.uri.as_str()) == byline,
                differs,
                same,
            }
        })
        .collect();
    // Stable: within a group, the version shown first, then the server's order.
    versions.sort_by_key(|e| (e.group, !e.showing));

    EventState {
        uri: data.uri.clone(),
        fields,
        byline: byline.and_then(by_uri).map(ref_of),
        versions,
        picked: picked.map(|v| v.uri.clone()),
        left_out: data.left_out,
    }
}