1use crate::govlog::{Evidence, ObligationData, ObligationUpdate, Until};
17use chrono::NaiveDate;
18use serde::{Deserialize, Deserializer};
19
20use crate::enums::{ObligationKind, OwedBy};
21use crate::ids::{CalendarEventId, CommentId, GovernanceLogId, PostId};
22
23#[derive(Debug, Clone, Deserialize)]
25#[serde(deny_unknown_fields)]
26pub struct ObligationRequest {
27 #[serde(default)]
29 pub source: Option<GovernanceLogId>,
30 pub kind: ObligationKind,
31 pub owed_by: OwedBy,
32 #[serde(default)]
34 pub until: Option<UntilRequest>,
35 pub text: String,
36 pub quote: String,
37 pub quote_location: String,
38 pub quote_source: QuoteSource,
40 #[serde(default)]
41 pub thread: Option<PostId>,
42 #[serde(default)]
44 pub review_note: Option<String>,
45}
46
47#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
50#[serde(tag = "when", rename_all = "snake_case", deny_unknown_fields)]
51pub enum UntilRequest {
52 NextSitting {},
57 Sitting { event: CalendarEventId },
59 SittingOn {
63 #[serde(deserialize_with = "full_date")]
64 date: NaiveDate,
65 },
66 Date {
68 #[serde(deserialize_with = "full_date")]
69 date: NaiveDate,
70 },
71 AfterDecision { decision: GovernanceLogId },
73 AwaitingDecision { on: String },
77}
78
79impl UntilRequest {
80 pub fn direct(&self) -> Option<Until> {
84 match self {
85 Self::NextSitting {} => Some(Until::NextSitting),
86 Self::Sitting { event } => Some(Until::Sitting { event: *event }),
87 Self::Date { date } => Some(Until::Date { date: *date }),
88 Self::AfterDecision { decision } => Some(Until::AfterDecision {
89 decision: decision.clone(),
90 }),
91 Self::SittingOn { .. } | Self::AwaitingDecision { .. } => None,
92 }
93 }
94}
95
96#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
98#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
99pub enum QuoteSource {
100 Governance { id: GovernanceLogId },
103 Summary { id: GovernanceLogId },
106 RepoFile { path: String },
109 Post { id: PostId },
111 Comment { id: CommentId },
113 Github { url: String },
115}
116
117impl QuoteSource {
118 pub fn operator_checked(&self) -> bool {
120 matches!(
121 self,
122 Self::Post { .. } | Self::Comment { .. } | Self::Github { .. }
123 )
124 }
125
126 pub fn describe(&self) -> String {
128 match self {
129 Self::Governance { id } => format!("governance entry {id}"),
130 Self::Summary { id } => format!("the generated summary of {id}"),
131 Self::RepoFile { path } => format!("repository file {path}"),
132 Self::Post { id } => format!("post {id}"),
133 Self::Comment { id } => format!("comment {id}"),
134 Self::Github { url } => format!("GitHub {url}"),
135 }
136 }
137}
138
139#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
141pub enum RequestError {
142 #[error("`{0}` must say something")]
143 Blank(&'static str),
144 #[error(transparent)]
145 Quote(#[from] crate::govlog::quote::QuoteError),
146 #[error(
147 "quote_source.path {0:?} must be a relative path inside the repository"
148 )]
149 RepoPath(String),
150 #[error("quote_source.url {0:?} is not an https://github.com/ URL")]
151 GithubUrl(String),
152 #[error("held: the deadline waits on {0}, which is not decided yet")]
153 Held(String),
154}
155
156impl ObligationRequest {
157 pub fn check_shape(&self) -> Result<(), RequestError> {
161 blank(&self.text, "text")?;
162 blank(&self.quote, "quote")?;
163 blank(&self.quote_location, "quote_location")?;
164 if crate::govlog::quote::normalize(&self.quote).is_empty() {
165 return Err(crate::govlog::quote::QuoteError::Empty.into());
166 }
167 if let Some(UntilRequest::AwaitingDecision { on }) = &self.until {
168 return Err(RequestError::Held(on.clone()));
169 }
170 match &self.quote_source {
171 QuoteSource::RepoFile { path } => {
172 let p = std::path::Path::new(path);
173 let inside = !path.is_empty()
174 && p.is_relative()
175 && p.components()
176 .all(|c| matches!(c, std::path::Component::Normal(_)));
177 if !inside {
178 return Err(RequestError::RepoPath(path.clone()));
179 }
180 }
181 QuoteSource::Github { url } => {
182 let ok = url::Url::parse(url).is_ok_and(|u| {
183 u.scheme() == "https" && u.host_str() == Some("github.com")
184 });
185 if !ok {
186 return Err(RequestError::GithubUrl(url.clone()));
187 }
188 }
189 QuoteSource::Governance { .. }
190 | QuoteSource::Summary { .. }
191 | QuoteSource::Post { .. }
192 | QuoteSource::Comment { .. } => {}
193 }
194 Ok(())
195 }
196
197 pub fn obligation(&self, until: Option<Until>) -> ObligationData {
200 let mut o = ObligationData::new(
201 self.kind,
202 self.owed_by,
203 self.text.clone(),
204 self.quote.clone(),
205 self.quote_location.clone(),
206 );
207 o.source = self.source.clone();
208 o.until = until;
209 o.thread = self.thread;
210 o
211 }
212}
213
214#[derive(Debug, Clone, Deserialize)]
216#[serde(deny_unknown_fields)]
217pub struct ObligationUpdateRequest {
218 pub target: GovernanceLogId,
220 pub update: UpdateRequest,
221 #[serde(default)]
224 pub authority: Option<GovernanceLogId>,
225 pub basis: String,
226 pub note: String,
227 #[serde(default)]
228 pub rationale: Option<String>,
229 #[serde(default)]
231 pub review_note: Option<String>,
232}
233
234#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
238#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
239pub enum UpdateRequest {
240 Planned {
241 plan: PostId,
242 },
243 Discharged {
244 evidence: Evidence,
245 },
246 Lapsed {},
248 Rescheduled {
249 #[serde(deserialize_with = "required_option")]
251 until: Option<UntilRequest>,
252 },
253 Withdrawn {},
254}
255
256impl UpdateRequest {
257 pub fn resolve(&self, until: Option<Until>) -> ObligationUpdate {
259 match self {
260 Self::Planned { plan } => ObligationUpdate::Planned { plan: *plan },
261 Self::Discharged { evidence } => ObligationUpdate::Discharged {
262 evidence: evidence.clone(),
263 },
264 Self::Lapsed {} => ObligationUpdate::Lapsed,
265 Self::Rescheduled { .. } => ObligationUpdate::Rescheduled { until },
266 Self::Withdrawn {} => ObligationUpdate::Withdrawn,
267 }
268 }
269
270 pub fn until(&self) -> Option<&UntilRequest> {
272 match self {
273 Self::Rescheduled { until } => until.as_ref(),
274 _ => None,
275 }
276 }
277}
278
279impl ObligationUpdateRequest {
280 pub fn check_shape(&self) -> Result<(), RequestError> {
282 blank(&self.basis, "basis")?;
283 blank(&self.note, "note")?;
284 if let Some(r) = &self.rationale {
285 blank(r, "rationale")?;
286 }
287 if let Some(UntilRequest::AwaitingDecision { on }) = self.update.until()
288 {
289 return Err(RequestError::Held(on.clone()));
290 }
291 Ok(())
292 }
293}
294
295fn blank(s: &str, field: &'static str) -> Result<(), RequestError> {
296 match s.trim().is_empty() {
297 true => Err(RequestError::Blank(field)),
298 false => Ok(()),
299 }
300}
301
302fn required_option<'de, D, T>(d: D) -> Result<Option<T>, D::Error>
305where
306 D: Deserializer<'de>,
307 T: Deserialize<'de>,
308{
309 Option::<T>::deserialize(d)
310}
311
312fn full_date<'de, D: Deserializer<'de>>(d: D) -> Result<NaiveDate, D::Error> {
315 let s = String::deserialize(d)?;
316 let shaped = s.len() == 10
317 && s.char_indices().all(|(i, c)| match i {
318 4 | 7 => c == '-',
319 _ => c.is_ascii_digit(),
320 });
321 shaped
322 .then(|| NaiveDate::parse_from_str(&s, "%Y-%m-%d").ok())
323 .flatten()
324 .ok_or_else(|| {
325 serde::de::Error::custom(format!(
326 "{s:?} is not a date as YYYY-MM-DD"
327 ))
328 })
329}
330
331#[cfg(test)]
332mod tests {
333 use super::*;
334
335 fn request(extra: serde_json::Value) -> serde_json::Value {
336 let mut v = serde_json::json!({
337 "source": "GOV-2026-0001",
338 "kind": "review",
339 "owed_by": "council",
340 "text": "Review it.",
341 "quote": "it should be reviewed",
342 "quote_location": "R2, artist, position",
343 "quote_source": {"kind": "governance", "id": "GOV-2026-0001"}
344 });
345 v.as_object_mut()
346 .unwrap()
347 .extend(extra.as_object().unwrap().clone());
348 v
349 }
350
351 fn parse(
352 v: serde_json::Value,
353 ) -> Result<ObligationRequest, serde_json::Error> {
354 serde_json::from_value(v)
355 }
356
357 #[test]
358 fn a_request_builds_the_obligation_without_its_review_fields() {
359 let r = parse(request(serde_json::json!({
360 "until": {"when": "date", "date": "2026-10-12"},
361 "thread": "91e791c0-9aee-4c53-9fb9-ce9259557ef8",
362 "review_note": "O1"
363 })))
364 .unwrap();
365 r.check_shape().unwrap();
366 let o = r.obligation(r.until.as_ref().and_then(UntilRequest::direct));
367 let data = serde_json::to_value(&o).unwrap();
368 assert_eq!(
369 data["until"],
370 serde_json::json!({"when": "date", "date": "2026-10-12"})
371 );
372 assert!(
373 data.get("review_note").is_none()
374 && data.get("quote_source").is_none()
375 );
376 assert_eq!(data["source"], "GOV-2026-0001");
377 }
378
379 #[test]
380 fn closed_enums_and_unknown_fields_are_refused() {
381 for bad in [
382 serde_json::json!({"kind": "chore"}),
383 serde_json::json!({"owed_by": "everyone"}),
384 serde_json::json!({"until": {"when": "someday"}}),
385 serde_json::json!({"until": {"when": "date", "date": "2026-10-12", "x": 1}}),
386 serde_json::json!({"until": {"when": "date", "date": "+2026-1-2"}}),
387 serde_json::json!({"quote_source": {"kind": "rumour"}}),
388 serde_json::json!({"quote_source": {"kind": "governance", "id": "GOV-2026-0001", "at": "/x"}}),
389 serde_json::json!({"typo": true}),
390 ] {
391 assert!(parse(request(bad.clone())).is_err(), "{bad} parsed");
392 }
393 }
394
395 #[test]
396 fn shape_checks_refuse_what_should_not_be_signed() {
397 for (extra, want) in [
398 (
399 serde_json::json!({"text": " "}),
400 RequestError::Blank("text"),
401 ),
402 (
403 serde_json::json!({"quote": "** `` **"}),
404 RequestError::Quote(crate::govlog::quote::QuoteError::Empty),
405 ),
406 (
407 serde_json::json!({"quote_source": {"kind": "repo_file", "path": "../secrets/x"}}),
408 RequestError::RepoPath("../secrets/x".into()),
409 ),
410 (
411 serde_json::json!({"quote_source": {"kind": "repo_file", "path": "/etc/passwd"}}),
412 RequestError::RepoPath("/etc/passwd".into()),
413 ),
414 (
415 serde_json::json!({"quote_source": {"kind": "github", "url": "http://example.com/x"}}),
416 RequestError::GithubUrl("http://example.com/x".into()),
417 ),
418 (
419 serde_json::json!({"until": {"when": "awaiting_decision", "on": "the 10-17 vote"}}),
420 RequestError::Held("the 10-17 vote".into()),
421 ),
422 ] {
423 assert_eq!(parse(request(extra)).unwrap().check_shape(), Err(want));
424 }
425 }
426
427 #[test]
428 fn a_reschedule_must_name_its_deadline_even_to_drop_it() {
429 let parse = |v| serde_json::from_value::<UpdateRequest>(v);
430 assert!(parse(serde_json::json!({"kind": "rescheduled"})).is_err());
431 assert!(
432 parse(serde_json::json!({"kind": "rescheduled", "untill": null}))
433 .is_err()
434 );
435 assert_eq!(
436 parse(serde_json::json!({"kind": "rescheduled", "until": null}))
437 .unwrap(),
438 UpdateRequest::Rescheduled { until: None }
439 );
440 assert!(
441 parse(serde_json::json!({"kind": "lapsed", "note": "x"})).is_err()
442 );
443 assert_eq!(
444 parse(serde_json::json!({"kind": "withdrawn"}))
445 .unwrap()
446 .resolve(None),
447 ObligationUpdate::Withdrawn
448 );
449 }
450}