Skip to main content

cairn_mod/pds_admin/
types.rs

1//! Shared types for the PDS-admin outbound bridge (§F23, #84,
2//! v1.7).
3//!
4//! v1.7 introduces one new shared type: [`Subject`], the
5//! identity carried by [`crate::pds_admin::PdsAdminBackend`]'s
6//! label methods (`apply_label` / `negate_label`). The other
7//! trait methods (account-level actions like `takedown_account`)
8//! take a bare `did: &str` because the backend's wire shape
9//! identifies accounts by DID alone; only the record-level
10//! label surface needs the (DID, AT-URI, CID) triple.
11//!
12//! # Why a new type rather than reusing
13//! [`crate::labels::emission::ActionForEmission`]
14//!
15//! `ActionForEmission` is the v1.5 label-emission projection —
16//! it carries action-stream context (`action_type`,
17//! `expires_at`, `reason_codes`) that doesn't belong on a
18//! label-target identity. The [`PdsAdminBackend`] trait is
19//! decoupled from cairn-mod's recordAction stream; backends can
20//! be exercised by future direct-CLI tools (#99) and
21//! cross-cutting tests, not just by the recorder. A minimal
22//! pds_admin-local [`Subject`] keeps the trait's contract
23//! independent of any one caller's projection.
24//!
25//! [`PdsAdminBackend`]: crate::pds_admin::PdsAdminBackend
26
27use serde::{Deserialize, Serialize};
28
29/// Subject of a label apply / negate call.
30///
31/// ATProto subjects have three coordinates:
32/// - **DID** (always required): the account's DID. For
33///   account-level labels this is the only relevant field.
34/// - **AT-URI** (optional): present when the label targets a
35///   specific record (e.g., `at://did:plc:.../app.bsky.feed.post/abc`).
36///   Absent for account-level labels.
37/// - **CID** (optional): present when the label pins a specific
38///   record version. Absent when the label targets all
39///   versions of a record OR the account.
40///
41/// The convention mirrors v1.5's
42/// [`crate::labels::emission::ActionForEmission`]'s embedded
43/// (subject_did, subject_uri, cid) triple, but without the
44/// action-stream context fields. v1.7 trait callers project
45/// from whatever domain shape they have (the recorder's
46/// `RecordActionRequest`, an `ActionForEmission`, or operator
47/// CLI input) into this minimal identity.
48///
49/// # Validation
50///
51/// Construction is unconditional — the type doesn't enforce
52/// that `did.starts_with("did:")` or that `at_uri` parses as
53/// AT-URI. v1.7 trait implementations validate at the wire
54/// boundary (the backend's HTTP request layer in #86–#90);
55/// pre-validation lives in the recorder's
56/// [`crate::writer`]'s `route_subject` helper for the
57/// recordAction path. The trait callers may call this type's
58/// constructors with already-validated inputs.
59#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
60pub struct Subject {
61    /// Account DID. Always present. Account-level labels target
62    /// this DID directly; record-level labels still attribute
63    /// to the parent account via this field for strike-state
64    /// rollup (the same convention `subject_actions.subject_did`
65    /// uses in v1.4-v1.6).
66    pub did: String,
67    /// AT-URI for record-level labels. `None` for account-level
68    /// labels.
69    pub at_uri: Option<String>,
70    /// CID for record-version-pinned labels. `None` for
71    /// "all versions of this record" or for account-level
72    /// labels.
73    pub cid: Option<String>,
74}
75
76impl Subject {
77    /// Construct a [`Subject`] from a DID with no record-level
78    /// or version-level qualification. Use this for
79    /// account-level label targets.
80    pub fn account(did: impl Into<String>) -> Self {
81        Self {
82            did: did.into(),
83            at_uri: None,
84            cid: None,
85        }
86    }
87
88    /// Construct a [`Subject`] for a record (versioned or not).
89    /// `at_uri` should be a full `at://did:.../collection/rkey`
90    /// URI; the parent repo DID is supplied separately so
91    /// callers don't have to re-parse the URI to recover it.
92    /// Pass `cid = None` to label all versions; pass
93    /// `cid = Some(...)` to pin a specific record version.
94    pub fn record(did: impl Into<String>, at_uri: impl Into<String>, cid: Option<String>) -> Self {
95        Self {
96            did: did.into(),
97            at_uri: Some(at_uri.into()),
98            cid,
99        }
100    }
101
102    /// `true` when the subject is account-level (no AT-URI).
103    /// Convenience for trait implementations that branch on
104    /// account-vs-record path.
105    pub fn is_account_level(&self) -> bool {
106        self.at_uri.is_none()
107    }
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113
114    #[test]
115    fn account_constructor_omits_uri_and_cid() {
116        let s = Subject::account("did:plc:abc");
117        assert_eq!(s.did, "did:plc:abc");
118        assert!(s.at_uri.is_none());
119        assert!(s.cid.is_none());
120        assert!(s.is_account_level());
121    }
122
123    #[test]
124    fn record_constructor_with_cid_pins_version() {
125        let s = Subject::record(
126            "did:plc:abc",
127            "at://did:plc:abc/app.bsky.feed.post/xyz",
128            Some("bafy123".to_string()),
129        );
130        assert_eq!(s.did, "did:plc:abc");
131        assert_eq!(
132            s.at_uri.as_deref(),
133            Some("at://did:plc:abc/app.bsky.feed.post/xyz")
134        );
135        assert_eq!(s.cid.as_deref(), Some("bafy123"));
136        assert!(!s.is_account_level());
137    }
138
139    #[test]
140    fn record_constructor_without_cid_targets_all_versions() {
141        let s = Subject::record(
142            "did:plc:abc",
143            "at://did:plc:abc/app.bsky.feed.post/xyz",
144            None,
145        );
146        assert!(s.cid.is_none());
147        assert!(s.at_uri.is_some());
148        assert!(!s.is_account_level());
149    }
150
151    #[test]
152    fn subject_serde_roundtrips() {
153        let s = Subject::record(
154            "did:plc:abc",
155            "at://did:plc:abc/app.bsky.feed.post/xyz",
156            Some("bafy123".into()),
157        );
158        let json = serde_json::to_string(&s).unwrap();
159        let back: Subject = serde_json::from_str(&json).unwrap();
160        assert_eq!(s, back);
161    }
162}