1use std::collections::BTreeMap;
16
17use crate::entities::{CommitFact, RefFact, StatusChange, StatusKind, Task};
18use crate::resolve::{FactsAncestors, Resolution, ResolvedStatus, Status, effective_status};
19
20pub fn statuses_unanchored(
24 tasks: &[Task],
25 changes: &[StatusChange],
26 project_id: &str,
27) -> BTreeMap<String, ResolvedStatus> {
28 let mut by_task: BTreeMap<&str, Vec<&StatusChange>> = BTreeMap::new();
31 for c in changes.iter().filter(|c| c.project_id == project_id) {
32 by_task.entry(&c.task_id).or_default().push(c);
33 }
34 tasks
35 .iter()
36 .filter(|t| t.project_id == project_id)
37 .map(|t| {
38 let id = t.id.to_string();
39 let mut mine = by_task.get(id.as_str()).cloned().unwrap_or_default();
40 mine.sort_by(|a, b| {
41 (a.created.as_str(), &*a.id.0).cmp(&(b.created.as_str(), &*b.id.0))
42 });
43 let mut status = Status::Open;
44 let mut ever_closed = false;
45 for c in &mine {
46 status = match c.to_status {
47 StatusKind::Closed => {
48 ever_closed = true;
49 Status::Closed
50 }
51 StatusKind::Reopened => Status::Open,
52 };
53 }
54 let resolution = if ever_closed {
56 Resolution::Partial
57 } else {
58 Resolution::Exact
59 };
60 (id, ResolvedStatus { status, resolution })
61 })
62 .collect()
63}
64
65pub fn default_head(refs: &[RefFact], project_id: &str) -> Option<String> {
68 let mine: Vec<&RefFact> = refs.iter().filter(|r| r.project_id == project_id).collect();
69 mine.iter()
70 .find(|r| r.branch == "main")
71 .or_else(|| mine.iter().max_by(|a, b| a.observed.cmp(&b.observed)))
72 .map(|r| r.head.clone())
73}
74
75pub fn statuses_for_project(
79 tasks: &[Task],
80 changes: &[StatusChange],
81 facts: &[CommitFact],
82 project_id: &str,
83 head: Option<&str>,
84) -> BTreeMap<String, ResolvedStatus> {
85 let fact_map: BTreeMap<String, CommitFact> = facts
86 .iter()
87 .filter(|f| f.project_id == project_id)
88 .map(|f| (f.id.to_string(), f.clone()))
89 .collect();
90 let mut sorted: Vec<&StatusChange> = changes
91 .iter()
92 .filter(|c| c.project_id == project_id)
93 .collect();
94 sorted.sort_by(|a, b| (a.created.as_str(), &*a.id.0).cmp(&(b.created.as_str(), &*b.id.0)));
95
96 let mut anc = head.map(|h| FactsAncestors::new(&fact_map, h));
97 tasks
98 .iter()
99 .filter(|t| t.project_id == project_id)
100 .map(|t| {
101 let id = t.id.to_string();
102 let mine: Vec<&StatusChange> = sorted
103 .iter()
104 .copied()
105 .filter(|c| *c.task_id == id)
106 .collect();
107 let resolved = match &mut anc {
108 Some(anc) => effective_status(&mine, anc, Resolution::Facts),
109 None => {
110 let mut none = NoAncestry;
113 effective_status(&mine, &mut none, Resolution::Partial)
114 }
115 };
116 (id, resolved)
117 })
118 .collect()
119}
120
121struct NoAncestry;
123impl crate::resolve::AncestorSet for NoAncestry {
124 fn contains(&mut self, _sha: &str) -> crate::resolve::Ancestry {
125 crate::resolve::Ancestry::Unknown
126 }
127}
128
129#[cfg(test)]
130mod tests {
131 use super::*;
132
133 fn task(id: &str, project: &str) -> Task {
134 Task {
135 id: id.into(),
136 project_id: project.into(),
137 title: format!("task {id}"),
138 body: String::new(),
139 priority: Default::default(),
140 labels: vec![],
141 created_by_dev: "d".into(),
142 created_by_machine: "m".into(),
143 created: "2026-01-01T00:00:00Z".into(),
144 }
145 }
146
147 fn change(id: &str, task: &str, project: &str, to: StatusKind, at: &str) -> StatusChange {
148 StatusChange {
149 id: id.into(),
150 project_id: project.into(),
151 task_id: task.into(),
152 to_status: to,
153 anchor_commit: Some("deadbeef".into()),
154 created: at.into(),
155 by_dev: "d".into(),
156 by_machine: "m".into(),
157 }
158 }
159
160 #[test]
161 fn open_when_no_change() {
162 let tasks = vec![task("t1", "p")];
163 let got = statuses_unanchored(&tasks, &[], "p");
164 assert_eq!(got["t1"].status, Status::Open);
165 assert_eq!(got["t1"].resolution, Resolution::Exact);
166 }
167
168 #[test]
169 fn closed_is_partial_ignoring_anchors() {
170 let tasks = vec![task("t1", "p")];
171 let changes = vec![change(
174 "c1",
175 "t1",
176 "p",
177 StatusKind::Closed,
178 "2026-02-01T00:00:00Z",
179 )];
180 let got = statuses_unanchored(&tasks, &changes, "p");
181 assert_eq!(got["t1"].status, Status::Closed);
182 assert_eq!(got["t1"].resolution, Resolution::Partial);
183 }
184
185 #[test]
186 fn latest_change_wins() {
187 let tasks = vec![task("t1", "p")];
188 let changes = vec![
189 change("c1", "t1", "p", StatusKind::Closed, "2026-02-01T00:00:00Z"),
190 change(
191 "c2",
192 "t1",
193 "p",
194 StatusKind::Reopened,
195 "2026-03-01T00:00:00Z",
196 ),
197 ];
198 let got = statuses_unanchored(&tasks, &changes, "p");
199 assert_eq!(got["t1"].status, Status::Open);
200 assert_eq!(got["t1"].resolution, Resolution::Partial);
202 }
203
204 #[test]
205 fn other_projects_ignored() {
206 let tasks = vec![task("t1", "p"), task("t2", "other")];
207 let got = statuses_unanchored(&tasks, &[], "p");
208 assert!(got.contains_key("t1"));
209 assert!(!got.contains_key("t2"));
210 }
211
212 #[test]
213 fn default_head_prefers_main() {
214 let refs = vec![
215 RefFact {
216 id: "p:feature".into(),
217 project_id: "p".into(),
218 branch: "feature".into(),
219 head: "aaaa".into(),
220 observed: "2026-05-01T00:00:00Z".into(),
221 },
222 RefFact {
223 id: "p:main".into(),
224 project_id: "p".into(),
225 branch: "main".into(),
226 head: "bbbb".into(),
227 observed: "2026-01-01T00:00:00Z".into(),
228 },
229 ];
230 assert_eq!(default_head(&refs, "p").as_deref(), Some("bbbb"));
231 }
232
233 #[test]
234 fn default_head_falls_back_to_latest_observed() {
235 let refs = vec![
236 RefFact {
237 id: "p:a".into(),
238 project_id: "p".into(),
239 branch: "a".into(),
240 head: "aaaa".into(),
241 observed: "2026-05-01T00:00:00Z".into(),
242 },
243 RefFact {
244 id: "p:b".into(),
245 project_id: "p".into(),
246 branch: "b".into(),
247 head: "bbbb".into(),
248 observed: "2026-06-01T00:00:00Z".into(),
249 },
250 ];
251 assert_eq!(default_head(&refs, "p").as_deref(), Some("bbbb"));
252 }
253
254 #[test]
255 fn default_head_none_without_refs() {
256 assert_eq!(default_head(&[], "p"), None);
257 }
258
259 fn commit(id: &str, project: &str, parents: &[&str]) -> CommitFact {
260 CommitFact {
261 id: id.into(),
262 project_id: project.into(),
263 parents: parents.iter().map(|p| p.to_string()).collect(),
264 patch_id: None,
265 }
266 }
267
268 #[test]
269 fn statuses_for_project_resolves_against_head_via_facts() {
270 let tasks = vec![task("t1", "p")];
272 let changes = vec![StatusChange {
273 anchor_commit: Some("c2".into()),
274 ..change("x1", "t1", "p", StatusKind::Closed, "2026-02-01T00:00:00Z")
275 }];
276 let facts = vec![
277 commit("c1", "p", &[]),
278 commit("c2", "p", &["c1"]),
279 commit("c3", "p", &["c2"]),
280 ];
281
282 let at_c3 = statuses_for_project(&tasks, &changes, &facts, "p", Some("c3"));
284 assert_eq!(at_c3["t1"].status, Status::Closed);
285 assert_eq!(at_c3["t1"].resolution, Resolution::Facts);
286
287 let at_c1 = statuses_for_project(&tasks, &changes, &facts, "p", Some("c1"));
289 assert_eq!(at_c1["t1"].status, Status::Open);
290
291 let no_head = statuses_for_project(&tasks, &changes, &facts, "p", None);
293 assert_eq!(no_head["t1"].status, Status::Open);
294 assert_eq!(no_head["t1"].resolution, Resolution::Partial);
295 }
296}