use serde::{Deserialize, Serialize};
use super::subject_id::subject_id;
use crate::error::{Result, ShoreError};
use crate::model::{
EngagementType, JournalId, ReviewTargetRef, RevisionId, TargetRef, TrackId,
engagement_type_of_subject,
};
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EventTarget {
pub journal_id: JournalId,
#[serde(skip_serializing_if = "Option::is_none")]
pub subject_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub track_id: Option<TrackId>,
}
impl EventTarget {
pub fn for_subject(
journal_id: JournalId,
subject: TargetRef,
track_id: Option<TrackId>,
) -> Result<Self> {
Ok(Self {
journal_id,
subject_id: subject_id(&subject)?,
track_id,
})
}
pub fn for_journal(journal_id: JournalId) -> Self {
Self {
journal_id,
subject_id: None,
track_id: None,
}
}
pub fn for_generative_move(
journal_id: JournalId,
engagement_type: EngagementType,
subject: TargetRef,
track_id: Option<TrackId>,
) -> Result<Self> {
match engagement_type_of_subject(&subject) {
Some(subject_domain) if subject_domain == engagement_type => {
Self::for_subject(journal_id, subject, track_id)
}
other => Err(ShoreError::Message(format!(
"generative move domain mismatch: a {engagement_type:?} engagement cannot address a {other:?} subject"
))),
}
}
pub fn for_revision(
journal_id: JournalId,
revision_id: RevisionId,
track_id: Option<TrackId>,
) -> Result<Self> {
Self::for_subject(
journal_id,
TargetRef::Review(ReviewTargetRef::Revision { revision_id }),
track_id,
)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{TaskTargetRef, WorkObjectId};
fn journal_id() -> JournalId {
JournalId::new("journal:default")
}
fn revision_ref() -> ReviewTargetRef {
ReviewTargetRef::Revision {
revision_id: RevisionId::new("rev:sha256:abc"),
}
}
#[test]
fn for_subject_binds_an_opaque_subject_id_not_the_structure() {
let target =
EventTarget::for_subject(journal_id(), TargetRef::Review(revision_ref()), None)
.unwrap();
assert_eq!(target.journal_id, journal_id());
let id = target
.subject_id
.as_deref()
.expect("review subject has an id");
assert!(id.starts_with("subject:sha256:"), "got {id}");
assert!(target.track_id.is_none());
}
#[test]
fn journal_carrier_has_no_subject_id() {
let target = EventTarget::for_journal(journal_id());
assert!(target.subject_id.is_none());
assert!(target.track_id.is_none());
}
#[test]
fn event_target_names_the_container_journal() {
let target = EventTarget::for_journal(JournalId::new("journal:claude:uuid"));
let json = serde_json::to_value(&target).unwrap();
assert!(json.get("journalId").is_some(), "wire key is journalId");
assert!(
json.get("ledgerId").is_none(),
"legacy ledgerId key is gone"
);
assert!(json["journalId"].as_str().unwrap().starts_with("journal:"));
assert!(
json.get("subjectId").is_none(),
"the journal carrier binds no subjectId"
);
}
#[test]
fn for_journal_binds_no_subject_id_and_round_trips() {
let target = EventTarget::for_journal(JournalId::new("journal:fixture"));
let json = serde_json::to_value(&target).unwrap();
assert_eq!(json["journalId"], "journal:fixture");
assert!(json.get("subjectId").is_none());
assert!(
json.get("subject").is_none(),
"no structural subject on the wire"
);
assert!(json.get("trackId").is_none());
let text = json.to_string();
assert!(!text.contains("/Users/"));
assert!(!text.contains("worktreeRoot"));
let parsed: EventTarget = serde_json::from_value(json).unwrap();
assert_eq!(parsed, target);
}
#[test]
fn signed_target_binds_subject_id_not_structure() {
let target =
EventTarget::for_subject(journal_id(), TargetRef::Review(revision_ref()), None)
.unwrap();
let json = serde_json::to_value(&target).unwrap();
assert!(
json["subjectId"]
.as_str()
.unwrap()
.starts_with("subject:sha256:"),
"got {}",
json
);
assert!(json.get("subject").is_none(), "no structural subject");
assert!(json.get("review").is_none());
assert!(json.get("workObjectId").is_none());
assert!(json.get("workObjectType").is_none());
assert!(json.get("domain").is_none());
let parsed: EventTarget = serde_json::from_value(json).unwrap();
assert_eq!(parsed, target);
}
#[test]
fn for_subject_derives_a_task_subject_id_from_the_self_identifying_ref() {
let target = EventTarget::for_subject(
journal_id(),
TargetRef::Task(TaskTargetRef::TaskAttempt {
task_attempt_id: WorkObjectId::new("task-attempt:sha256:abc"),
}),
None,
)
.unwrap();
let id = target
.subject_id
.as_deref()
.expect("task subject has an id");
assert!(id.starts_with("subject:sha256:"), "got {id}");
let other = EventTarget::for_subject(
journal_id(),
TargetRef::Task(TaskTargetRef::TaskAttempt {
task_attempt_id: WorkObjectId::new("task-attempt:sha256:def"),
}),
None,
)
.unwrap();
assert_ne!(target.subject_id, other.subject_id);
}
#[test]
fn a_review_engagement_refuses_a_task_subject() {
let err = EventTarget::for_generative_move(
journal_id(),
EngagementType::Review,
TargetRef::Task(TaskTargetRef::TaskAttempt {
task_attempt_id: WorkObjectId::new("task-attempt:sha256:ta"),
}),
None,
)
.unwrap_err();
assert!(matches!(err, ShoreError::Message(_)));
}
#[test]
fn for_generative_move_accepts_a_matching_domain() {
let target = EventTarget::for_generative_move(
journal_id(),
EngagementType::Review,
TargetRef::Review(revision_ref()),
None,
)
.unwrap();
assert!(target.subject_id.is_some());
}
#[test]
fn for_generative_move_refuses_a_journal_subject() {
let err = EventTarget::for_generative_move(
journal_id(),
EngagementType::Review,
TargetRef::Journal,
None,
)
.unwrap_err();
assert!(matches!(err, ShoreError::Message(_)));
}
#[test]
fn rejects_legacy_envelope_with_no_journal_id() {
let legacy = r#"{"sessionId":"session:default","workUnitId":"work:default"}"#;
let result: Result<EventTarget> = serde_json::from_str(legacy).map_err(Into::into);
assert!(
result.is_err(),
"legacy subject-less envelope must not deserialize, got {:?}",
result.ok()
);
}
#[test]
fn rejects_legacy_envelope_with_review_id() {
let legacy = r#"{"reviewId":"review:default","subject":"ledger"}"#;
let result: Result<EventTarget> = serde_json::from_str(legacy).map_err(Into::into);
assert!(
result.is_err(),
"legacy reviewId envelope must not deserialize, got {:?}",
result.ok()
);
}
}