1use std::cmp::Ordering;
16
17use chrono::{DateTime, SecondsFormat, TimeDelta, Utc};
18use onetaskgraph_plugin_api::{Label, Repository, asset_sha256};
19use serde::Serialize;
20
21pub const SETTLED: TimeDelta = TimeDelta::seconds(60);
30
31#[must_use]
34pub fn settled(issue: DateTime<Utc>, item: DateTime<Utc>, served: DateTime<Utc>) -> bool {
35 served - issue.max(item) > SETTLED
36}
37
38pub const PREFIX: &str = "gh3|";
48
49#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct Parts {
52 pub issue: DateTime<Utc>,
54 pub item: DateTime<Utc>,
56 pub comments: u64,
58 pub relationships: u64,
61 pub facets: FacetsDigest,
63 pub repository: Repository,
66}
67
68#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
76pub struct Facets {
77 kind: FacetKind,
81 fields: Vec<(String, String)>,
83 labels: Vec<(String, String, Option<String>)>,
85}
86
87#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
89#[serde(rename_all = "lowercase")]
90pub enum FacetKind {
91 Task,
93 Project,
95 Document,
97}
98
99#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
102#[serde(try_from = "String", into = "String")]
103pub struct FacetsDigest(String);
104
105impl schemars::JsonSchema for FacetsDigest {
107 fn inline_schema() -> bool {
108 true
109 }
110
111 fn schema_name() -> std::borrow::Cow<'static, str> {
112 String::schema_name()
113 }
114
115 fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
116 String::json_schema(generator)
117 }
118}
119
120impl TryFrom<String> for FacetsDigest {
121 type Error = String;
122
123 fn try_from(digest: String) -> Result<Self, Self::Error> {
124 Self::parse(&digest).ok_or_else(|| format!("{digest:?} is not a lowercase hex SHA-256"))
125 }
126}
127
128impl From<FacetsDigest> for String {
129 fn from(digest: FacetsDigest) -> Self {
130 digest.0
131 }
132}
133
134impl FacetsDigest {
135 #[must_use]
137 pub fn parse(digest: &str) -> Option<Self> {
138 is_digest(digest).then(|| Self(digest.to_owned()))
139 }
140
141 #[must_use]
143 pub fn as_str(&self) -> &str {
144 &self.0
145 }
146}
147
148impl Facets {
149 #[must_use]
151 pub fn new(kind: FacetKind, mut fields: Vec<(String, String)>, labels: &[Label]) -> Self {
152 fields.sort();
153 let mut labels: Vec<(String, String, Option<String>)> = labels
154 .iter()
155 .map(|label| (label.id.0.clone(), label.name.clone(), label.color.clone()))
156 .collect();
157 labels.sort();
158 Self {
159 kind,
160 fields,
161 labels,
162 }
163 }
164
165 #[must_use]
167 pub fn digest(&self) -> FacetsDigest {
168 FacetsDigest(asset_sha256(
171 serde_json::to_string(self).unwrap_or_default().as_bytes(),
172 ))
173 }
174}
175
176#[must_use]
178pub fn spell(parts: &Parts) -> String {
179 format!(
180 "{PREFIX}{}|{}|{}|{}|{}|{}",
181 parts.issue.to_rfc3339_opts(SecondsFormat::AutoSi, true),
182 parts.item.to_rfc3339_opts(SecondsFormat::AutoSi, true),
183 parts.comments,
184 parts.relationships,
185 parts.facets.as_str(),
186 parts.repository.as_str()
187 )
188}
189
190fn is_digest(facets: &str) -> bool {
192 facets.len() == 64
193 && facets
194 .bytes()
195 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
196}
197
198fn parts(token: &str) -> Option<Parts> {
200 let mut parts = token.strip_prefix(PREFIX)?.splitn(6, '|');
201 let issue = DateTime::parse_from_rfc3339(parts.next()?).ok()?;
202 let item = DateTime::parse_from_rfc3339(parts.next()?).ok()?;
203 let comments = parts.next()?.parse().ok()?;
204 let relationships = parts.next()?.parse().ok()?;
205 let facets = FacetsDigest::parse(parts.next()?)?;
206 let repository = Repository::try_from(parts.next()?.to_owned()).ok()?;
207 Some(Parts {
208 issue: issue.with_timezone(&Utc),
209 item: item.with_timezone(&Utc),
210 comments,
211 relationships,
212 facets,
213 repository,
214 })
215}
216
217#[must_use]
231pub fn order(a: &str, b: &str) -> Option<Ordering> {
232 let (a, b) = (parts(a)?, parts(b)?);
233 if a.repository != b.repository || a.facets != b.facets || a.relationships != b.relationships {
234 return None;
235 }
236 let forward = [a.issue.cmp(&b.issue), a.item.cmp(&b.item)];
237 if forward.iter().all(|part| part.is_eq()) {
238 Some(b.comments.cmp(&a.comments))
239 } else if forward.iter().all(|part| part.is_le()) {
240 Some(Ordering::Less)
241 } else if forward.iter().all(|part| part.is_ge()) {
242 Some(Ordering::Greater)
243 } else {
244 None
245 }
246}
247
248#[cfg(test)]
249mod tests {
250 use super::*;
251 use chrono::TimeZone as _;
252 use onetaskgraph_plugin_api::NativeId;
253
254 fn at(second: u32) -> DateTime<Utc> {
255 Utc.with_ymd_and_hms(2026, 10, 1, 0, 0, second).unwrap()
256 }
257
258 fn label(id: &str, name: &str, color: &str) -> Label {
259 Label {
260 id: NativeId::from(id),
261 name: name.to_owned(),
262 color: Some(color.to_owned()),
263 }
264 }
265
266 fn facets() -> FacetsDigest {
268 Facets::new(
269 FacetKind::Task,
270 vec![("Status".to_owned(), "Todo".to_owned())],
271 &[label("L_1", "bug", "d73a4a")],
272 )
273 .digest()
274 }
275
276 fn token(issue: u32, item: u32, comments: u64, relationships: u64) -> String {
277 token_with(issue, item, comments, relationships, &facets())
278 }
279
280 fn token_with(
281 issue: u32,
282 item: u32,
283 comments: u64,
284 relationships: u64,
285 facets: &FacetsDigest,
286 ) -> String {
287 spell(&Parts {
288 issue: at(issue),
289 item: at(item),
290 comments,
291 relationships,
292 facets: facets.clone(),
293 repository: repository("github.com/acme/widgets"),
294 })
295 }
296
297 fn repository(origin: &str) -> Repository {
298 Repository::try_from(origin.to_owned()).expect("a repository origin")
299 }
300
301 #[test]
302 fn the_facets_are_the_digest_of_their_canonical_json_whatever_order_they_arrive_in() {
303 let one = Facets::new(
304 FacetKind::Project,
305 vec![
306 ("Status".to_owned(), "Todo".to_owned()),
307 ("Priority".to_owned(), "P1".to_owned()),
308 ],
309 &[label("L_2", "ui", "ffffff"), label("L_1", "bug", "d73a4a")],
310 );
311 let other = Facets::new(
312 FacetKind::Project,
313 vec![
314 ("Priority".to_owned(), "P1".to_owned()),
315 ("Status".to_owned(), "Todo".to_owned()),
316 ],
317 &[label("L_1", "bug", "d73a4a"), label("L_2", "ui", "ffffff")],
318 );
319 assert_eq!(one, other);
320 assert_eq!(
321 serde_json::to_string(&one).unwrap(),
322 r#"{"kind":"project","fields":[["Priority","P1"],["Status","Todo"]],"labels":[["L_1","bug","d73a4a"],["L_2","ui","ffffff"]]}"#
323 );
324 assert_eq!(
325 one.digest().as_str(),
326 asset_sha256(serde_json::to_string(&one).unwrap().as_bytes())
327 );
328 }
329
330 #[test]
331 fn every_change_kind_the_times_carry_orders_the_later_version_after_the_earlier() {
332 let before = token(1, 1, 2, 3);
333 for after in [
334 token(2, 1, 3, 3), token(2, 1, 2, 3), token(1, 1, 1, 3), token(1, 2, 2, 3), ] {
339 assert_eq!(order(&after, &before), Some(Ordering::Greater), "{after}");
340 assert_eq!(order(&before, &after), Some(Ordering::Less), "{after}");
341 }
342 assert_eq!(order(&before, &before), Some(Ordering::Equal));
343 }
344
345 #[test]
346 fn another_facet_another_relationship_count_or_another_repository_is_never_ordered() {
347 let before = token(1, 1, 2, 3);
348 let renamed = Facets::new(
349 FacetKind::Task,
350 vec![("Status".to_owned(), "Todo".to_owned())],
351 &[label("L_1", "defect", "d73a4a")],
352 )
353 .digest();
354 let recoloured = Facets::new(
355 FacetKind::Task,
356 vec![("Status".to_owned(), "Todo".to_owned())],
357 &[label("L_1", "bug", "000000")],
358 )
359 .digest();
360 let option_renamed = Facets::new(
361 FacetKind::Task,
362 vec![("Status".to_owned(), "To do".to_owned())],
363 &[label("L_1", "bug", "d73a4a")],
364 )
365 .digest();
366 let became_a_project = Facets::new(
367 FacetKind::Project,
368 vec![("Status".to_owned(), "Todo".to_owned())],
369 &[label("L_1", "bug", "d73a4a")],
370 )
371 .digest();
372 for (change, facets) in [
373 ("a label renamed", &renamed),
374 ("a label recoloured", &recoloured),
375 ("a field option renamed", &option_renamed),
376 ("its own kind changed", &became_a_project),
377 ] {
378 for after in [
380 token_with(1, 1, 2, 3, facets),
381 token_with(2, 2, 2, 3, facets),
382 ] {
383 assert_eq!(order(&after, &before), None, "{change}");
384 assert_eq!(order(&before, &after), None, "{change}");
385 }
386 }
387 for relationships in [2, 4, 5] {
388 assert_eq!(
389 order(&token(2, 2, 2, relationships), &before),
390 None,
391 "a relationship count of {relationships} against 3: a far end deleted lowers it"
392 );
393 }
394 let transferred = spell(&Parts {
395 issue: at(1),
396 item: at(1),
397 comments: 2,
398 relationships: 3,
399 facets: facets(),
400 repository: repository("github.com/acme/gadgets"),
401 });
402 assert_eq!(order(&transferred, &before), None);
403 }
404
405 #[test]
406 fn a_token_is_given_only_once_the_later_time_is_more_than_the_margin_behind_the_read() {
407 let served = at(0) + SETTLED + TimeDelta::seconds(1);
408 assert!(settled(at(0), at(0), served));
409 assert!(
410 !settled(at(0), at(1), served),
411 "the board item's later time counts"
412 );
413 assert!(!settled(at(1), at(0), served), "and so does the issue's");
414 assert!(
415 !settled(at(0), at(0), at(0) + SETTLED),
416 "exactly the margin is not past it"
417 );
418 assert!(
419 !settled(at(5), at(5), at(5)),
420 "read in the second of the change"
421 );
422 }
423
424 #[test]
425 fn crossed_times_and_foreign_or_older_values_are_not_ordered() {
426 assert_eq!(order(&token(2, 1, 0, 0), &token(1, 2, 0, 0)), None);
427 assert_eq!(order("md1|abc", &token(1, 1, 0, 0)), None);
428 let digest = facets().as_str().to_owned();
429 for spelled in ["not-a-digest", &digest.to_uppercase(), &digest[1..]] {
430 assert_eq!(FacetsDigest::parse(spelled), None, "{spelled}");
431 assert!(
432 serde_json::from_value::<FacetsDigest>(serde_json::json!(spelled)).is_err(),
433 "a context record holding {spelled} is refused"
434 );
435 }
436 assert_eq!(
437 serde_json::from_value::<FacetsDigest>(serde_json::json!(digest)).ok(),
438 Some(facets())
439 );
440 for malformed in [
441 format!("gh3|not-a-time|x|1|0|{digest}|github.com/a/b"),
442 format!("gh3|2026-10-01T00:00:01Z|2026-10-01T00:00:01Z|0|0|{digest}|"),
443 format!("gh3|2026-10-01T00:00:01Z|2026-10-01T00:00:01Z|0|0|{digest}|a/b"),
444 "gh3|2026-10-01T00:00:01Z|2026-10-01T00:00:01Z|0|0|not-a-digest|github.com/a/b"
445 .to_owned(),
446 ] {
447 assert_eq!(order(&malformed, &malformed), None, "{malformed}");
448 }
449 for older in [
450 "gh2|2026-10-01T00:00:01Z|2026-10-01T00:00:01Z|0|0|github.com/a/b",
451 "gh1|2026-10-01T00:00:01Z|2026-10-01T00:00:01Z|0",
452 ] {
453 assert_eq!(
454 order(older, older),
455 None,
456 "a token from before the facets is not ordered, even against itself"
457 );
458 }
459 assert_eq!(
460 order(&format!("{}|extra", token(1, 1, 0, 0)), &token(1, 1, 0, 0)),
461 None,
462 "anything after the repository is part of it, and names another"
463 );
464 }
465}