1use std::collections::BTreeMap;
2
3use serde::{Deserialize, Serialize};
4
5#[derive(Debug, Clone, Serialize, Deserialize, Default)]
6pub struct Handoff {
7 #[serde(default)]
8 pub project: Option<String>,
9 #[serde(default)]
10 pub id: Option<String>,
11 #[serde(default)]
12 pub updated: Option<String>,
13 #[serde(default)]
14 pub items: Vec<HandoffItem>,
15 #[serde(default)]
16 pub log: Vec<LogEntry>,
17 #[serde(flatten)]
18 pub extra: BTreeMap<String, serde_yaml::Value>,
19}
20
21#[derive(Debug, Clone, Serialize, Deserialize, Default)]
22pub struct HandoffItem {
23 pub id: String,
24 #[serde(default)]
25 pub doob_uuid: Option<String>,
26 #[serde(default)]
27 pub name: Option<String>,
28 #[serde(default)]
29 pub priority: Option<String>,
30 #[serde(default)]
31 pub status: Option<String>,
32 #[serde(default)]
33 pub title: String,
34 #[serde(default)]
35 pub description: Option<String>,
36 #[serde(default)]
37 pub files: Vec<String>,
38 #[serde(default)]
39 pub completed: Option<String>,
40 #[serde(default)]
41 pub extra: Vec<ExtraEntry>,
42 #[serde(flatten)]
43 pub extra_fields: BTreeMap<String, serde_yaml::Value>,
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize, Default)]
47pub struct ExtraEntry {
48 #[serde(default)]
49 pub date: Option<String>,
50 #[serde(default)]
51 pub r#type: Option<String>,
52 #[serde(default)]
53 pub field: Option<String>,
54 #[serde(default)]
55 pub value: Option<String>,
56 #[serde(default)]
57 pub reviewed: Option<String>,
58 #[serde(default)]
59 pub note: Option<String>,
60 #[serde(flatten)]
61 pub extra_fields: BTreeMap<String, serde_yaml::Value>,
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize, Default)]
65pub struct LogEntry {
66 #[serde(default)]
67 pub date: Option<String>,
68 #[serde(default)]
69 pub summary: String,
70 #[serde(default)]
71 pub commits: Vec<String>,
72 #[serde(flatten)]
73 pub extra: BTreeMap<String, serde_yaml::Value>,
74}
75
76#[derive(Debug, Clone, Serialize, Deserialize, Default)]
77pub struct HandoffState {
78 #[serde(default)]
79 pub updated: Option<String>,
80 #[serde(default)]
81 pub branch: Option<String>,
82 #[serde(default)]
83 pub build: Option<String>,
84 #[serde(default)]
85 pub tests: Option<String>,
86 #[serde(default)]
87 pub notes: Option<String>,
88 #[serde(default)]
89 pub touched_files: Vec<String>,
90 #[serde(flatten)]
91 pub extra: BTreeMap<String, serde_yaml::Value>,
92}
93
94#[derive(Debug, Clone, Serialize, Deserialize, Default, Eq, PartialEq)]
95pub struct HandupReport {
96 pub generated: String,
97 pub cwd: String,
98 #[serde(default)]
99 pub projects: Vec<HandupProject>,
100 pub recommendation: HandupRecommendation,
101}
102
103#[derive(Debug, Clone, Serialize, Deserialize, Default, Eq, PartialEq)]
104pub struct HandupProject {
105 pub name: String,
106 pub path: String,
107 pub repo_root: String,
108 pub handoff_path: Option<String>,
109 pub branch: Option<String>,
110 pub build: Option<String>,
111 pub tests: Option<String>,
112 #[serde(default)]
113 pub items: Vec<HandupItem>,
114 #[serde(default)]
115 pub todos: Vec<String>,
116}
117
118#[derive(Debug, Clone, Serialize, Deserialize, Default, Eq, PartialEq)]
119pub struct HandupItem {
120 pub id: String,
121 pub priority: String,
122 pub status: String,
123 pub title: String,
124}
125
126#[derive(Debug, Clone, Serialize, Deserialize, Default, Eq, PartialEq)]
127pub struct HandupRecommendation {
128 pub project: Option<String>,
129 pub reason: String,
130}
131
132#[derive(Debug, Clone, Copy, Eq, PartialEq)]
133pub enum ReconcileMode {
134 Sync,
135 Audit,
136}
137
138#[derive(Debug, Clone, Default, Eq, PartialEq)]
139pub struct ReconcileReport {
140 pub project: String,
141 pub captured_count: usize,
142 pub created_count: usize,
143 pub not_captured: Vec<String>,
144 pub orphaned: Vec<String>,
145 pub closed_upstream: Vec<String>,
146}
147
148#[derive(Debug, Clone, Default, Eq, PartialEq)]
149pub struct TodoSnapshot {
150 pub active_titles: Vec<String>,
151 pub closed_titles: Vec<String>,
152}
153
154#[derive(Debug, Clone, Eq, PartialEq)]
155pub struct ReconcileCreate {
156 pub title: String,
157 pub priority: Option<String>,
158}
159
160#[derive(Debug, Clone, Default, Eq, PartialEq)]
161pub struct ReconcilePlan {
162 pub creates: Vec<ReconcileCreate>,
163 pub report: ReconcileReport,
164}
165
166impl Handoff {
167 pub fn active_items(&self) -> impl Iterator<Item = &HandoffItem> {
168 self.items.iter().filter(|item| item.is_open_or_blocked())
169 }
170
171 pub fn ensure_project(&mut self, project: &str) {
172 if self.project.as_deref().unwrap_or_default().is_empty() {
173 self.project = Some(project.to_string());
174 }
175 }
176
177 pub fn ensure_id_prefix(&mut self, project: &str) {
178 if self.id.as_deref().unwrap_or_default().is_empty() {
179 self.id = Some(default_id_prefix(project));
180 }
181 }
182}
183
184impl HandoffItem {
185 pub fn is_open_or_blocked(&self) -> bool {
186 matches!(self.status.as_deref(), Some("open" | "blocked"))
187 }
188
189 pub fn todo_title(&self) -> String {
190 let base = self
191 .name
192 .as_deref()
193 .filter(|value| !value.is_empty() && *value != "null")
194 .map(titleize_slug)
195 .filter(|value| !value.is_empty())
196 .unwrap_or_else(|| self.title.clone());
197
198 if self.status.as_deref() == Some("blocked") {
199 format!("{base} [BLOCKED]")
200 } else {
201 base
202 }
203 }
204
205 pub fn doob_title(&self) -> String {
206 self.todo_title()
207 }
208
209 pub fn title_variants(&self) -> Vec<String> {
210 let mut variants = Vec::new();
211 let title = self.title.clone();
212 let blocked_title = format!("{title} [BLOCKED]");
213 let todo_title = self.todo_title();
214 let blocked_todo_title = if todo_title.ends_with(" [BLOCKED]") {
215 todo_title.clone()
216 } else {
217 format!("{todo_title} [BLOCKED]")
218 };
219
220 for value in [title, blocked_title, todo_title, blocked_todo_title] {
221 if !value.is_empty() && !variants.iter().any(|existing| existing == &value) {
222 variants.push(value);
223 }
224 }
225
226 variants
227 }
228
229 pub fn inferred_priority(&self) -> String {
230 self.priority
231 .clone()
232 .filter(|value| !value.is_empty())
233 .unwrap_or_else(|| infer_priority(self.title.as_str(), self.description.as_deref()))
234 }
235}
236
237pub fn sanitize_name(raw: &str) -> String {
238 raw.trim().to_ascii_lowercase().replace([' ', '/'], "-")
239}
240
241pub fn default_id_prefix(project: &str) -> String {
242 let cleaned = sanitize_name(project);
243 cleaned.chars().take(7).collect()
244}
245
246pub fn titleize_slug(slug: &str) -> String {
247 slug.split('-')
248 .filter(|part| !part.is_empty())
249 .map(|part| {
250 let mut chars = part.chars();
251 match chars.next() {
252 Some(first) => {
253 let mut word = first.to_uppercase().collect::<String>();
254 word.push_str(chars.as_str());
255 word
256 }
257 None => String::new(),
258 }
259 })
260 .collect::<Vec<_>>()
261 .join(" ")
262}
263
264pub fn infer_priority(title: &str, description: Option<&str>) -> String {
265 let title = title.to_ascii_lowercase();
266 let description = description.unwrap_or_default().to_ascii_lowercase();
267 let combined = format!("{title} {description}");
268
269 if [
270 "broken",
271 "fails",
272 "segfault",
273 "panic",
274 "security",
275 "blocked",
276 "urgent",
277 "can't deploy",
278 ]
279 .iter()
280 .any(|needle| combined.contains(needle))
281 {
282 return "P0".to_string();
283 }
284
285 if [
286 "fix",
287 "implement",
288 "refactor",
289 "wire",
290 "small change",
291 "known fix",
292 ]
293 .iter()
294 .any(|needle| combined.contains(needle))
295 {
296 return "P1".to_string();
297 }
298
299 "P2".to_string()
300}
301
302pub fn build_reconcile_plan(
303 project: &str,
304 handoff: &Handoff,
305 snapshot: &TodoSnapshot,
306 mode: ReconcileMode,
307) -> ReconcilePlan {
308 let mut captured_count = 0usize;
309 let mut created_count = 0usize;
310 let mut not_captured = Vec::new();
311 let mut closed_upstream = Vec::new();
312 let mut creates = Vec::new();
313 let mut handoff_titles = std::collections::BTreeSet::new();
314
315 for item in handoff.active_items() {
316 for variant in item.title_variants() {
317 handoff_titles.insert(variant);
318 }
319
320 if contains_any(&snapshot.active_titles, item) {
321 captured_count += 1;
322 continue;
323 }
324
325 if contains_any(&snapshot.closed_titles, item) {
326 closed_upstream.push(item.todo_title());
327 continue;
328 }
329
330 match mode {
331 ReconcileMode::Sync => {
332 creates.push(ReconcileCreate {
333 title: item.todo_title(),
334 priority: item.priority.clone(),
335 });
336 captured_count += 1;
337 created_count += 1;
338 }
339 ReconcileMode::Audit => not_captured.push(item.todo_title()),
340 }
341 }
342
343 let orphaned = snapshot
344 .active_titles
345 .iter()
346 .filter(|title| !handoff_titles.contains(*title))
347 .cloned()
348 .collect::<Vec<_>>();
349
350 ReconcilePlan {
351 creates,
352 report: ReconcileReport {
353 project: project.to_string(),
354 captured_count,
355 created_count,
356 not_captured,
357 orphaned,
358 closed_upstream,
359 },
360 }
361}
362
363fn contains_any(existing: &[String], item: &HandoffItem) -> bool {
364 item.title_variants()
365 .into_iter()
366 .any(|variant| existing.iter().any(|title| title == &variant))
367}
368
369#[cfg(test)]
370mod tests {
371 use super::{
372 Handoff, HandoffItem, ReconcileMode, TodoSnapshot, build_reconcile_plan, default_id_prefix,
373 infer_priority, sanitize_name, titleize_slug,
374 };
375
376 #[test]
377 fn sanitize_project_name() {
378 assert_eq!(sanitize_name("My Project/CLI"), "my-project-cli");
379 }
380
381 #[test]
382 fn default_prefix_uses_first_seven_chars() {
383 assert_eq!(default_id_prefix("atelier"), "atelier");
384 assert_eq!(default_id_prefix("sanctum"), "sanctum");
385 }
386
387 #[test]
388 fn doob_title_prefers_slug() {
389 let item = HandoffItem {
390 id: "x-1".into(),
391 name: Some("wire-render-pass".into()),
392 status: Some("blocked".into()),
393 title: "ignored".into(),
394 ..HandoffItem::default()
395 };
396
397 assert_eq!(titleize_slug("wire-render-pass"), "Wire Render Pass");
398 assert_eq!(item.doob_title(), "Wire Render Pass [BLOCKED]");
399 }
400
401 #[test]
402 fn infer_priority_uses_signal_words() {
403 assert_eq!(infer_priority("CI broken", None), "P0");
404 assert_eq!(infer_priority("Implement handup parity", None), "P1");
405 assert_eq!(infer_priority("Explore someday", None), "P2");
406 }
407
408 #[test]
409 fn reconcile_plan_is_backend_agnostic() {
410 let handoff = Handoff {
411 project: Some("hj".into()),
412 items: vec![
413 HandoffItem {
414 id: "hj-1".into(),
415 priority: Some("P1".into()),
416 status: Some("open".into()),
417 title: "Already tracked".into(),
418 ..HandoffItem::default()
419 },
420 HandoffItem {
421 id: "hj-2".into(),
422 priority: Some("P2".into()),
423 status: Some("open".into()),
424 title: "Needs create".into(),
425 ..HandoffItem::default()
426 },
427 HandoffItem {
428 id: "hj-3".into(),
429 priority: Some("P1".into()),
430 status: Some("blocked".into()),
431 title: "Closed upstream".into(),
432 ..HandoffItem::default()
433 },
434 ],
435 ..Handoff::default()
436 };
437 let snapshot = TodoSnapshot {
438 active_titles: vec!["Already tracked".into(), "Orphaned task".into()],
439 closed_titles: vec!["Closed upstream [BLOCKED]".into()],
440 };
441
442 let audit = build_reconcile_plan("hj", &handoff, &snapshot, ReconcileMode::Audit);
443 assert_eq!(audit.creates.len(), 0);
444 assert_eq!(audit.report.captured_count, 1);
445 assert_eq!(audit.report.not_captured, vec!["Needs create".to_string()]);
446 assert_eq!(
447 audit.report.closed_upstream,
448 vec!["Closed upstream [BLOCKED]".to_string()]
449 );
450 assert_eq!(audit.report.orphaned, vec!["Orphaned task".to_string()]);
451
452 let sync = build_reconcile_plan("hj", &handoff, &snapshot, ReconcileMode::Sync);
453 assert_eq!(sync.creates.len(), 1);
454 assert_eq!(sync.creates[0].title, "Needs create");
455 assert_eq!(sync.creates[0].priority.as_deref(), Some("P2"));
456 assert_eq!(sync.report.captured_count, 2);
457 assert_eq!(sync.report.created_count, 1);
458 assert!(sync.report.not_captured.is_empty());
459 }
460}