1use serde::{Deserialize, Serialize};
10use time::Date;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
15#[serde(rename_all = "snake_case")]
16pub enum Attribution {
17 Foundation,
19 Partner,
21 ThirdParty,
23 Community,
25 #[default]
27 Unknown,
28}
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
33#[serde(rename_all = "snake_case")]
34pub enum ContentType {
35 Doc,
37 Tutorial,
39 Reference,
41 Example,
43 ContractSource,
45 SdkSource,
47 Test,
49 Readme,
51 #[default]
53 #[serde(other)]
54 Other,
55}
56
57#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
60pub struct LanguageTarget {
61 pub name: String,
63 #[serde(default, skip_serializing_if = "Option::is_none")]
65 pub version_constraint: Option<String>,
66}
67
68#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
70pub struct SdkDependency {
71 pub kind: String,
73 pub name: String,
75 #[serde(default, skip_serializing_if = "Option::is_none")]
77 pub version_constraint: Option<String>,
78}
79
80#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
83pub struct Deprecation {
84 pub is_deprecated: bool,
86 #[serde(default, skip_serializing_if = "Option::is_none")]
88 pub since: Option<Date>,
89 #[serde(default, skip_serializing_if = "Option::is_none")]
91 pub reason: Option<String>,
92}
93
94#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
100pub struct Provenance {
101 #[serde(default)]
103 pub attribution: Attribution,
104
105 #[serde(default)]
108 pub verified: bool,
109 #[serde(default, skip_serializing_if = "Option::is_none")]
111 pub verified_by: Option<String>,
112 #[serde(default, skip_serializing_if = "Option::is_none")]
114 pub verified_at: Option<Date>,
115 #[serde(default, skip_serializing_if = "Option::is_none")]
117 pub verification_notes: Option<String>,
118
119 #[serde(default, skip_serializing_if = "Vec::is_empty")]
121 pub language_targets: Vec<LanguageTarget>,
122
123 #[serde(default, skip_serializing_if = "Vec::is_empty")]
125 pub sdk_dependencies: Vec<SdkDependency>,
126
127 #[serde(default)]
129 pub deprecation: Deprecation,
130
131 #[serde(default, skip_serializing_if = "Vec::is_empty")]
133 pub tags: Vec<String>,
134
135 #[serde(default)]
137 pub content_type: ContentType,
138}
139
140impl Provenance {
141 #[must_use]
144 pub fn attributed_to(attribution: Attribution) -> Self {
145 Self { attribution, ..Self::default() }
146 }
147}
148
149#[cfg(test)]
150mod tests {
151 use super::*;
152
153 #[test]
154 fn default_is_safe() {
155 let p = Provenance::default();
156 assert_eq!(p.attribution, Attribution::Unknown);
157 assert!(!p.verified);
158 assert!(p.tags.is_empty());
159 }
160
161 #[test]
162 fn round_trips_full_shape() {
163 let p = Provenance {
164 attribution: Attribution::Foundation,
165 verified: true,
166 verified_by: Some("midnight-foundation".into()),
167 verified_at: Date::from_calendar_date(2026, time::Month::April, 1).ok(),
168 verification_notes: None,
169 language_targets: vec![LanguageTarget {
170 name: "compact".into(),
171 version_constraint: Some(">=0.23".into()),
172 }],
173 sdk_dependencies: vec![SdkDependency {
174 kind: "npm".into(),
175 name: "@midnight-ntwrk/midnight-js".into(),
176 version_constraint: Some("^1.4.0".into()),
177 }],
178 deprecation: Deprecation::default(),
179 tags: vec!["quickstart".into(), "tutorial".into()],
180 content_type: ContentType::Tutorial,
181 };
182 let v = serde_json::to_value(&p).unwrap();
183 let back: Provenance = serde_json::from_value(v).unwrap();
184 assert_eq!(p, back);
185 }
186
187 #[test]
188 fn empty_collections_elided() {
189 let v = serde_json::to_value(Provenance::default()).unwrap();
190 assert!(v.get("language_targets").is_none());
191 assert!(v.get("sdk_dependencies").is_none());
192 assert!(v.get("tags").is_none());
193 }
194
195 #[test]
196 fn tolerates_unknown_attribution_via_default() {
197 let v = serde_json::json!({});
202 let p: Provenance = serde_json::from_value(v).unwrap();
203 assert_eq!(p.attribution, Attribution::Unknown);
204 }
205
206 #[test]
207 fn unknown_content_type_falls_back_to_other() {
208 let v = serde_json::json!({ "content_type": "blog_post_2026_meta" });
209 let p: Provenance = serde_json::from_value(v).unwrap();
210 assert_eq!(p.content_type, ContentType::Other);
211 }
212
213 #[test]
214 fn attribution_serializes_snake_case() {
215 let v = serde_json::to_value(Attribution::ThirdParty).unwrap();
216 assert_eq!(v, serde_json::Value::String("third_party".into()));
217 }
218}