1use super::{
4 digest, validate_digest_field, validate_id, ResearchContractError, RESEARCH_MAX_TEXT_BYTES,
5};
6use serde::{Deserialize, Serialize};
7
8pub const RESEARCH_CITATION_SCHEMA_V1: &str = "a3s.code.research-citation.v1";
9const RESEARCH_CITATION_DIGEST_DOMAIN: &str = "a3s.code.research-citation.identity.v1";
10
11#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
16#[serde(rename_all = "camelCase", deny_unknown_fields)]
17pub struct ResearchCitationV1 {
18 pub schema: String,
19 pub citation_id: String,
20 pub project_id: String,
21 pub run_id: String,
22 pub claim_id: String,
23 pub source_digest: String,
24 pub source_span_digest: String,
25 #[serde(default, skip_serializing_if = "Option::is_none")]
26 pub locator: Option<String>,
27 pub observed_at_ms: u64,
28 pub citation_digest: String,
29}
30
31impl ResearchCitationV1 {
32 #[allow(clippy::too_many_arguments)]
33 pub fn new(
34 citation_id: impl Into<String>,
35 project_id: impl Into<String>,
36 run_id: impl Into<String>,
37 claim_id: impl Into<String>,
38 source_digest: impl Into<String>,
39 source_span_digest: impl Into<String>,
40 locator: Option<String>,
41 observed_at_ms: u64,
42 ) -> Result<Self, ResearchContractError> {
43 let mut citation = Self {
44 schema: RESEARCH_CITATION_SCHEMA_V1.to_owned(),
45 citation_id: citation_id.into(),
46 project_id: project_id.into(),
47 run_id: run_id.into(),
48 claim_id: claim_id.into(),
49 source_digest: source_digest.into(),
50 source_span_digest: source_span_digest.into(),
51 locator,
52 observed_at_ms,
53 citation_digest: String::new(),
54 };
55 citation.validate_without_digest()?;
56 citation.citation_digest = citation.expected_digest()?;
57 Ok(citation)
58 }
59
60 pub fn validate_for_run(
61 &self,
62 run: &crate::research::ResearchRunV1,
63 ) -> Result<(), ResearchContractError> {
64 self.validate()?;
65 if self.project_id != run.project_id {
66 return Err(ResearchContractError::InvalidField("projectId"));
67 }
68 if self.run_id != run.run_id {
69 return Err(ResearchContractError::InvalidField("runId"));
70 }
71 Ok(())
72 }
73
74 pub fn validate(&self) -> Result<(), ResearchContractError> {
75 self.validate_without_digest()?;
76 validate_digest_field("citationDigest", &self.citation_digest)?;
77 if self.citation_digest != self.expected_digest()? {
78 return Err(ResearchContractError::DigestMismatch("citationDigest"));
79 }
80 Ok(())
81 }
82
83 pub fn from_slice(bytes: &[u8]) -> Result<Self, ResearchContractError> {
84 let citation: Self = super::decode_json_slice(bytes)?;
85 citation.validate()?;
86 Ok(citation)
87 }
88
89 pub fn to_vec(&self) -> Result<Vec<u8>, ResearchContractError> {
90 self.validate()?;
91 super::encode_json(self)
92 }
93
94 fn validate_without_digest(&self) -> Result<(), ResearchContractError> {
95 if self.schema != RESEARCH_CITATION_SCHEMA_V1 {
96 return Err(ResearchContractError::UnsupportedSchema);
97 }
98 validate_id("citationId", &self.citation_id)?;
99 validate_id("projectId", &self.project_id)?;
100 validate_id("runId", &self.run_id)?;
101 validate_id("claimId", &self.claim_id)?;
102 validate_digest_field("sourceDigest", &self.source_digest)?;
103 validate_digest_field("sourceSpanDigest", &self.source_span_digest)?;
104 if let Some(locator) = &self.locator {
105 super::validate_text("locator", locator, RESEARCH_MAX_TEXT_BYTES)?;
106 }
107 if self.observed_at_ms == 0 {
108 return Err(ResearchContractError::InvalidField("observedAtMs"));
109 }
110 Ok(())
111 }
112
113 fn expected_digest(&self) -> Result<String, ResearchContractError> {
114 #[derive(Serialize)]
115 struct Identity<'a> {
116 schema: &'a str,
117 citation_id: &'a str,
118 project_id: &'a str,
119 run_id: &'a str,
120 claim_id: &'a str,
121 source_digest: &'a str,
122 source_span_digest: &'a str,
123 locator: Option<&'a str>,
124 observed_at_ms: u64,
125 }
126 digest(
127 RESEARCH_CITATION_DIGEST_DOMAIN,
128 &Identity {
129 schema: &self.schema,
130 citation_id: &self.citation_id,
131 project_id: &self.project_id,
132 run_id: &self.run_id,
133 claim_id: &self.claim_id,
134 source_digest: &self.source_digest,
135 source_span_digest: &self.source_span_digest,
136 locator: self.locator.as_deref(),
137 observed_at_ms: self.observed_at_ms,
138 },
139 )
140 }
141}
142
143#[cfg(test)]
144mod tests {
145 use super::*;
146
147 fn digest(ch: char) -> String {
148 format!("sha256:{}", ch.to_string().repeat(64))
149 }
150
151 #[test]
152 fn citation_is_digest_bound_and_rejects_multiline_locator() {
153 let citation = ResearchCitationV1::new(
154 "cite-1",
155 "project-1",
156 "run-1",
157 "claim-1",
158 digest('a'),
159 digest('b'),
160 Some("page-3".to_owned()),
161 1,
162 )
163 .unwrap();
164 let encoded = citation.to_vec().unwrap();
165 assert_eq!(ResearchCitationV1::from_slice(&encoded).unwrap(), citation);
166 assert!(matches!(
167 ResearchCitationV1::new(
168 "cite-1",
169 "project-1",
170 "run-1",
171 "claim-1",
172 digest('a'),
173 digest('b'),
174 Some("page-3\n".to_owned()),
175 1,
176 ),
177 Err(ResearchContractError::InvalidField("locator"))
178 ));
179 }
180}