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}