onetaskgraph_github_projects/
change_token.rs1use std::cmp::Ordering;
13
14use chrono::{DateTime, SecondsFormat, TimeDelta, Utc};
15use onetaskgraph_plugin_api::Repository;
16
17pub const SETTLED: TimeDelta = TimeDelta::seconds(60);
26
27#[must_use]
30pub fn settled(issue: DateTime<Utc>, item: DateTime<Utc>, served: DateTime<Utc>) -> bool {
31 served - issue.max(item) > SETTLED
32}
33
34pub const PREFIX: &str = "gh2|";
42
43#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct Parts {
46 pub issue: DateTime<Utc>,
48 pub item: DateTime<Utc>,
50 pub comments: u64,
52 pub relationships: u64,
55 pub repository: Repository,
58}
59
60#[must_use]
62pub fn spell(parts: &Parts) -> String {
63 format!(
64 "{PREFIX}{}|{}|{}|{}|{}",
65 parts.issue.to_rfc3339_opts(SecondsFormat::AutoSi, true),
66 parts.item.to_rfc3339_opts(SecondsFormat::AutoSi, true),
67 parts.comments,
68 parts.relationships,
69 parts.repository.as_str()
70 )
71}
72
73fn parts(token: &str) -> Option<Parts> {
75 let mut parts = token.strip_prefix(PREFIX)?.splitn(5, '|');
76 let issue = DateTime::parse_from_rfc3339(parts.next()?).ok()?;
77 let item = DateTime::parse_from_rfc3339(parts.next()?).ok()?;
78 let comments = parts.next()?.parse().ok()?;
79 let relationships = parts.next()?.parse().ok()?;
80 let repository = Repository::try_from(parts.next()?.to_owned()).ok()?;
81 Some(Parts {
82 issue: issue.with_timezone(&Utc),
83 item: item.with_timezone(&Utc),
84 comments,
85 relationships,
86 repository,
87 })
88}
89
90#[must_use]
103pub fn order(a: &str, b: &str) -> Option<Ordering> {
104 let (a, b) = (parts(a)?, parts(b)?);
105 if a.repository != b.repository {
106 return None;
107 }
108 let forward = [
109 a.issue.cmp(&b.issue),
110 a.item.cmp(&b.item),
111 a.relationships.cmp(&b.relationships),
112 ];
113 if forward.iter().all(|part| part.is_eq()) {
114 Some(b.comments.cmp(&a.comments))
115 } else if forward.iter().all(|part| part.is_le()) {
116 Some(Ordering::Less)
117 } else if forward.iter().all(|part| part.is_ge()) {
118 Some(Ordering::Greater)
119 } else {
120 None
121 }
122}
123
124#[cfg(test)]
125mod tests {
126 use super::*;
127 use chrono::TimeZone as _;
128
129 fn at(second: u32) -> DateTime<Utc> {
130 Utc.with_ymd_and_hms(2026, 10, 1, 0, 0, second).unwrap()
131 }
132
133 fn token(issue: u32, item: u32, comments: u64, relationships: u64) -> String {
134 spell(&Parts {
135 issue: at(issue),
136 item: at(item),
137 comments,
138 relationships,
139 repository: repository("github.com/acme/widgets"),
140 })
141 }
142
143 fn repository(origin: &str) -> Repository {
144 Repository::try_from(origin.to_owned()).expect("a repository origin")
145 }
146
147 #[test]
148 fn every_change_kind_orders_the_later_version_after_the_earlier() {
149 let before = token(1, 1, 2, 3);
150 for after in [
151 token(2, 1, 3, 3), token(2, 1, 2, 3), token(1, 1, 1, 3), token(1, 2, 2, 3), token(1, 1, 2, 4), token(1, 1, 2, 5), ] {
158 assert_eq!(order(&after, &before), Some(Ordering::Greater), "{after}");
159 assert_eq!(order(&before, &after), Some(Ordering::Less), "{after}");
160 }
161 assert_eq!(order(&before, &before), Some(Ordering::Equal));
162 }
163
164 #[test]
165 fn a_token_is_given_only_once_the_later_time_is_more_than_the_margin_behind_the_read() {
166 let served = at(0) + SETTLED + TimeDelta::seconds(1);
167 assert!(settled(at(0), at(0), served));
168 assert!(
169 !settled(at(0), at(1), served),
170 "the board item's later time counts"
171 );
172 assert!(!settled(at(1), at(0), served), "and so does the issue's");
173 assert!(
174 !settled(at(0), at(0), at(0) + SETTLED),
175 "exactly the margin is not past it"
176 );
177 assert!(
178 !settled(at(5), at(5), at(5)),
179 "read in the second of the change"
180 );
181 }
182
183 #[test]
184 fn crossed_parts_another_repository_and_foreign_values_are_not_ordered() {
185 assert_eq!(order(&token(2, 1, 0, 0), &token(1, 2, 0, 0)), None);
186 assert_eq!(
187 order(&token(2, 1, 0, 0), &token(1, 1, 0, 1)),
188 None,
189 "a later time beside fewer relationship events is no version read after the other"
190 );
191 let transferred = spell(&Parts {
192 issue: at(1),
193 item: at(1),
194 comments: 0,
195 relationships: 0,
196 repository: repository("github.com/acme/gadgets"),
197 });
198 assert_eq!(
199 order(&transferred, &token(1, 1, 0, 0)),
200 None,
201 "a token naming another repository is not equal, and not ordered"
202 );
203 assert_eq!(order("md1|abc", &token(1, 1, 0, 0)), None);
204 assert_eq!(
205 order(
206 "gh2|not-a-time|x|1|0|github.com/a/b",
207 "gh2|not-a-time|x|1|0|github.com/a/b"
208 ),
209 None
210 );
211 assert_eq!(
212 order(
213 "gh2|2026-10-01T00:00:01Z|2026-10-01T00:00:01Z|0|0|",
214 "gh2|2026-10-01T00:00:01Z|2026-10-01T00:00:01Z|0|0|"
215 ),
216 None,
217 "a token naming no repository"
218 );
219 assert_eq!(
220 order(
221 "gh2|2026-10-01T00:00:01Z|2026-10-01T00:00:01Z|0|0|a/b",
222 "gh2|2026-10-01T00:00:01Z|2026-10-01T00:00:01Z|0|0|a/b"
223 ),
224 None,
225 "a token naming something that is no repository origin"
226 );
227 assert_eq!(
228 order(
229 "gh1|2026-10-01T00:00:01Z|2026-10-01T00:00:01Z|0",
230 "gh1|2026-10-01T00:00:01Z|2026-10-01T00:00:01Z|0"
231 ),
232 None,
233 "a token from before the relationship count is not ordered, even against itself"
234 );
235 assert_eq!(
236 order(&format!("{}|extra", token(1, 1, 0, 0)), &token(1, 1, 0, 0)),
237 None,
238 "anything after the repository is part of it, and names another"
239 );
240 }
241}