Skip to main content

hj_core/
lib.rs

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}