use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Handoff {
#[serde(default)]
pub project: Option<String>,
#[serde(default)]
pub id: Option<String>,
#[serde(default)]
pub updated: Option<String>,
#[serde(default)]
pub items: Vec<HandoffItem>,
#[serde(default)]
pub log: Vec<LogEntry>,
#[serde(flatten)]
pub extra: BTreeMap<String, serde_yaml::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct HandoffItem {
pub id: String,
#[serde(default)]
pub doob_uuid: Option<String>,
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub priority: Option<String>,
#[serde(default)]
pub status: Option<String>,
#[serde(default)]
pub title: String,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub files: Vec<String>,
#[serde(default)]
pub completed: Option<String>,
#[serde(default)]
pub extra: Vec<ExtraEntry>,
#[serde(flatten)]
pub extra_fields: BTreeMap<String, serde_yaml::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ExtraEntry {
#[serde(default)]
pub date: Option<String>,
#[serde(default)]
pub r#type: Option<String>,
#[serde(default)]
pub field: Option<String>,
#[serde(default)]
pub value: Option<String>,
#[serde(default)]
pub reviewed: Option<String>,
#[serde(default)]
pub note: Option<String>,
#[serde(flatten)]
pub extra_fields: BTreeMap<String, serde_yaml::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct LogEntry {
#[serde(default)]
pub date: Option<String>,
#[serde(default)]
pub summary: String,
#[serde(default)]
pub commits: Vec<String>,
#[serde(flatten)]
pub extra: BTreeMap<String, serde_yaml::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct HandoffState {
#[serde(default)]
pub updated: Option<String>,
#[serde(default)]
pub branch: Option<String>,
#[serde(default)]
pub build: Option<String>,
#[serde(default)]
pub tests: Option<String>,
#[serde(default)]
pub notes: Option<String>,
#[serde(default)]
pub touched_files: Vec<String>,
#[serde(flatten)]
pub extra: BTreeMap<String, serde_yaml::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, Eq, PartialEq)]
pub struct HandupReport {
pub generated: String,
pub cwd: String,
#[serde(default)]
pub projects: Vec<HandupProject>,
pub recommendation: HandupRecommendation,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, Eq, PartialEq)]
pub struct HandupProject {
pub name: String,
pub path: String,
pub repo_root: String,
pub handoff_path: Option<String>,
pub branch: Option<String>,
pub build: Option<String>,
pub tests: Option<String>,
#[serde(default)]
pub items: Vec<HandupItem>,
#[serde(default)]
pub todos: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, Eq, PartialEq)]
pub struct HandupItem {
pub id: String,
pub priority: String,
pub status: String,
pub title: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, Eq, PartialEq)]
pub struct HandupRecommendation {
pub project: Option<String>,
pub reason: String,
}
impl Handoff {
pub fn active_items(&self) -> impl Iterator<Item = &HandoffItem> {
self.items.iter().filter(|item| item.is_open_or_blocked())
}
pub fn ensure_project(&mut self, project: &str) {
if self.project.as_deref().unwrap_or_default().is_empty() {
self.project = Some(project.to_string());
}
}
pub fn ensure_id_prefix(&mut self, project: &str) {
if self.id.as_deref().unwrap_or_default().is_empty() {
self.id = Some(default_id_prefix(project));
}
}
}
impl HandoffItem {
pub fn is_open_or_blocked(&self) -> bool {
matches!(self.status.as_deref(), Some("open" | "blocked"))
}
pub fn doob_title(&self) -> String {
let base = self
.name
.as_deref()
.filter(|value| !value.is_empty() && *value != "null")
.map(titleize_slug)
.filter(|value| !value.is_empty())
.unwrap_or_else(|| self.title.clone());
if self.status.as_deref() == Some("blocked") {
format!("{base} [BLOCKED]")
} else {
base
}
}
pub fn title_variants(&self) -> Vec<String> {
let mut variants = Vec::new();
let title = self.title.clone();
let blocked_title = format!("{title} [BLOCKED]");
let doob_title = self.doob_title();
let blocked_doob_title = if doob_title.ends_with(" [BLOCKED]") {
doob_title.clone()
} else {
format!("{doob_title} [BLOCKED]")
};
for value in [title, blocked_title, doob_title, blocked_doob_title] {
if !value.is_empty() && !variants.iter().any(|existing| existing == &value) {
variants.push(value);
}
}
variants
}
pub fn inferred_priority(&self) -> String {
self.priority
.clone()
.filter(|value| !value.is_empty())
.unwrap_or_else(|| infer_priority(self.title.as_str(), self.description.as_deref()))
}
}
pub fn sanitize_name(raw: &str) -> String {
raw.trim().to_ascii_lowercase().replace([' ', '/'], "-")
}
pub fn default_id_prefix(project: &str) -> String {
let cleaned = sanitize_name(project);
cleaned.chars().take(7).collect()
}
pub fn titleize_slug(slug: &str) -> String {
slug.split('-')
.filter(|part| !part.is_empty())
.map(|part| {
let mut chars = part.chars();
match chars.next() {
Some(first) => {
let mut word = first.to_uppercase().collect::<String>();
word.push_str(chars.as_str());
word
}
None => String::new(),
}
})
.collect::<Vec<_>>()
.join(" ")
}
pub fn infer_priority(title: &str, description: Option<&str>) -> String {
let title = title.to_ascii_lowercase();
let description = description.unwrap_or_default().to_ascii_lowercase();
let combined = format!("{title} {description}");
if [
"broken",
"fails",
"segfault",
"panic",
"security",
"blocked",
"urgent",
"can't deploy",
]
.iter()
.any(|needle| combined.contains(needle))
{
return "P0".to_string();
}
if [
"fix",
"implement",
"refactor",
"wire",
"small change",
"known fix",
]
.iter()
.any(|needle| combined.contains(needle))
{
return "P1".to_string();
}
"P2".to_string()
}
#[cfg(test)]
mod tests {
use super::{HandoffItem, default_id_prefix, infer_priority, sanitize_name, titleize_slug};
#[test]
fn sanitize_project_name() {
assert_eq!(sanitize_name("My Project/CLI"), "my-project-cli");
}
#[test]
fn default_prefix_uses_first_seven_chars() {
assert_eq!(default_id_prefix("atelier"), "atelier");
assert_eq!(default_id_prefix("sanctum"), "sanctum");
}
#[test]
fn doob_title_prefers_slug() {
let item = HandoffItem {
id: "x-1".into(),
name: Some("wire-render-pass".into()),
status: Some("blocked".into()),
title: "ignored".into(),
..HandoffItem::default()
};
assert_eq!(titleize_slug("wire-render-pass"), "Wire Render Pass");
assert_eq!(item.doob_title(), "Wire Render Pass [BLOCKED]");
}
#[test]
fn infer_priority_uses_signal_words() {
assert_eq!(infer_priority("CI broken", None), "P0");
assert_eq!(infer_priority("Implement handup parity", None), "P1");
assert_eq!(infer_priority("Explore someday", None), "P2");
}
}