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
132impl Handoff {
133    pub fn active_items(&self) -> impl Iterator<Item = &HandoffItem> {
134        self.items.iter().filter(|item| item.is_open_or_blocked())
135    }
136
137    pub fn ensure_project(&mut self, project: &str) {
138        if self.project.as_deref().unwrap_or_default().is_empty() {
139            self.project = Some(project.to_string());
140        }
141    }
142
143    pub fn ensure_id_prefix(&mut self, project: &str) {
144        if self.id.as_deref().unwrap_or_default().is_empty() {
145            self.id = Some(default_id_prefix(project));
146        }
147    }
148}
149
150impl HandoffItem {
151    pub fn is_open_or_blocked(&self) -> bool {
152        matches!(self.status.as_deref(), Some("open" | "blocked"))
153    }
154
155    pub fn doob_title(&self) -> String {
156        let base = self
157            .name
158            .as_deref()
159            .filter(|value| !value.is_empty() && *value != "null")
160            .map(titleize_slug)
161            .filter(|value| !value.is_empty())
162            .unwrap_or_else(|| self.title.clone());
163
164        if self.status.as_deref() == Some("blocked") {
165            format!("{base} [BLOCKED]")
166        } else {
167            base
168        }
169    }
170
171    pub fn title_variants(&self) -> Vec<String> {
172        let mut variants = Vec::new();
173        let title = self.title.clone();
174        let blocked_title = format!("{title} [BLOCKED]");
175        let doob_title = self.doob_title();
176        let blocked_doob_title = if doob_title.ends_with(" [BLOCKED]") {
177            doob_title.clone()
178        } else {
179            format!("{doob_title} [BLOCKED]")
180        };
181
182        for value in [title, blocked_title, doob_title, blocked_doob_title] {
183            if !value.is_empty() && !variants.iter().any(|existing| existing == &value) {
184                variants.push(value);
185            }
186        }
187
188        variants
189    }
190
191    pub fn inferred_priority(&self) -> String {
192        self.priority
193            .clone()
194            .filter(|value| !value.is_empty())
195            .unwrap_or_else(|| infer_priority(self.title.as_str(), self.description.as_deref()))
196    }
197}
198
199pub fn sanitize_name(raw: &str) -> String {
200    raw.trim().to_ascii_lowercase().replace([' ', '/'], "-")
201}
202
203pub fn default_id_prefix(project: &str) -> String {
204    let cleaned = sanitize_name(project);
205    cleaned.chars().take(7).collect()
206}
207
208pub fn titleize_slug(slug: &str) -> String {
209    slug.split('-')
210        .filter(|part| !part.is_empty())
211        .map(|part| {
212            let mut chars = part.chars();
213            match chars.next() {
214                Some(first) => {
215                    let mut word = first.to_uppercase().collect::<String>();
216                    word.push_str(chars.as_str());
217                    word
218                }
219                None => String::new(),
220            }
221        })
222        .collect::<Vec<_>>()
223        .join(" ")
224}
225
226pub fn infer_priority(title: &str, description: Option<&str>) -> String {
227    let title = title.to_ascii_lowercase();
228    let description = description.unwrap_or_default().to_ascii_lowercase();
229    let combined = format!("{title} {description}");
230
231    if [
232        "broken",
233        "fails",
234        "segfault",
235        "panic",
236        "security",
237        "blocked",
238        "urgent",
239        "can't deploy",
240    ]
241    .iter()
242    .any(|needle| combined.contains(needle))
243    {
244        return "P0".to_string();
245    }
246
247    if [
248        "fix",
249        "implement",
250        "refactor",
251        "wire",
252        "small change",
253        "known fix",
254    ]
255    .iter()
256    .any(|needle| combined.contains(needle))
257    {
258        return "P1".to_string();
259    }
260
261    "P2".to_string()
262}
263
264#[cfg(test)]
265mod tests {
266    use super::{HandoffItem, default_id_prefix, infer_priority, sanitize_name, titleize_slug};
267
268    #[test]
269    fn sanitize_project_name() {
270        assert_eq!(sanitize_name("My Project/CLI"), "my-project-cli");
271    }
272
273    #[test]
274    fn default_prefix_uses_first_seven_chars() {
275        assert_eq!(default_id_prefix("atelier"), "atelier");
276        assert_eq!(default_id_prefix("sanctum"), "sanctum");
277    }
278
279    #[test]
280    fn doob_title_prefers_slug() {
281        let item = HandoffItem {
282            id: "x-1".into(),
283            name: Some("wire-render-pass".into()),
284            status: Some("blocked".into()),
285            title: "ignored".into(),
286            ..HandoffItem::default()
287        };
288
289        assert_eq!(titleize_slug("wire-render-pass"), "Wire Render Pass");
290        assert_eq!(item.doob_title(), "Wire Render Pass [BLOCKED]");
291    }
292
293    #[test]
294    fn infer_priority_uses_signal_words() {
295        assert_eq!(infer_priority("CI broken", None), "P0");
296        assert_eq!(infer_priority("Implement handup parity", None), "P1");
297        assert_eq!(infer_priority("Explore someday", None), "P2");
298    }
299}