use std::{fs, io::Write, path::Path, process, process::Command};
use anyhow::{Context, Result, anyhow, bail};
use hjlib::doob::{DoobClient, ensure_doob_on_path, map_priority};
use hjlib::render::{render_handover_markdown, render_markdown};
use hjlib::sqlite::{HandoffDb, HandoffRow};
use hjlib::{
ExtraEntry, Handoff, HandoffItem, HandoffPaths, HandoffState, LogEntry, ReconcileMode,
ReconcileReport, RepoContext, branch_name, build_reconcile_plan, current_short_head, discover,
today,
};
use crate::cli::{CloseArgs, DbArgs, DbCommand, DetectArgs, RefreshArgs, TargetArgs};
trait TodoMemoryBackend {
fn snapshot(&self, project: &str) -> Result<hjlib::TodoSnapshot>;
fn create(&self, project: &str, item: &hjlib::ReconcileCreate) -> Result<()>;
}
impl TodoMemoryBackend for DoobClient {
fn snapshot(&self, project: &str) -> Result<hjlib::TodoSnapshot> {
DoobClient::snapshot(self, project)
}
fn create(&self, project: &str, item: &hjlib::ReconcileCreate) -> Result<()> {
let tags = vec!["handoff".to_string(), project.to_string()];
self.add(
project,
&item.title,
map_priority(item.priority.as_deref()),
&tags,
)
}
}
pub(crate) fn detect(args: DetectArgs) -> Result<()> {
let context = discover(Path::new("."))?;
if args.init {
context.refresh(false)?;
}
let paths = context.paths(None)?;
if args.root {
println!("{}", paths.repo_root.display());
return Ok(());
}
if args.project {
println!("{}", paths.project);
return Ok(());
}
if args.name {
let file_name = paths
.handoff_path
.file_name()
.and_then(|value| value.to_str())
.ok_or_else(|| anyhow!("handoff path has no filename"))?;
println!("{file_name}");
return Ok(());
}
if paths.handoff_path.exists() {
println!("{}", paths.handoff_path.display());
return Ok(());
}
if let Some(migrated) = context.migrate_root_handoff(&paths.handoff_path)? {
println!("{}", migrated.display());
return Ok(());
}
println!("{}", paths.handoff_path.display());
process::exit(2);
}
pub(crate) fn refresh(args: RefreshArgs) -> Result<()> {
let context = discover(Path::new("."))?;
let report = context.refresh(args.force)?;
println!(
"refreshed {} (packages: {})",
report.ctx_dir.display(),
report.packages.join(", ")
);
Ok(())
}
pub(crate) fn handon(args: TargetArgs) -> Result<()> {
let context = discover(Path::new("."))?;
let (paths, mut handoff) = load_target_handoff(&context, args, false)?;
auto_upsert(&paths.project, &handoff);
apply_sqlite_overrides(&paths.project, &mut handoff)?;
apply_db_log(&paths.project, &mut handoff);
let state = load_state(&paths.state_path)?;
let review = collect_review_on_wake(&handoff);
let triage = classify_items(&handoff);
let header = format!("Handoff Triage — {}", paths.repo_root.display());
println!("{header}");
println!("{}", "─".repeat(header.chars().count().min(72)));
if let Some(state) = state.as_ref() {
println!(
"Branch: {} | Build: {} | Tests: {}",
state.branch.as_deref().unwrap_or("unknown"),
state.build.as_deref().unwrap_or("unknown"),
state.tests.as_deref().unwrap_or("unknown")
);
if let Some(last_log) = state.last_log.as_deref().filter(|s| !s.is_empty()) {
println!("Last session: {last_log}");
}
if let Some(notes) = state.notes.as_deref().filter(|notes| !notes.is_empty()) {
println!("{notes}");
}
if !state.touched_files.is_empty() {
println!("Recently touched: {}", state.touched_files.join(", "));
}
}
println!();
if !review.is_empty() {
println!("Review on Wake");
println!("──────────────");
for line in review {
println!("{line}");
}
println!();
}
let all_empty = triage.p0.is_empty() && triage.p1.is_empty() && triage.p2.is_empty();
if all_empty {
println!("No open items.");
} else {
print_triage_bucket("P0", &triage.p0);
print_triage_bucket("P1", &triage.p1);
print_triage_bucket("P2", &triage.p2);
}
Ok(())
}
pub(crate) fn handover(args: TargetArgs) -> Result<()> {
let context = discover(Path::new("."))?;
let (paths, mut handoff) = load_target_handoff(&context, args, false)?;
auto_upsert(&paths.project, &handoff);
apply_sqlite_overrides(&paths.project, &mut handoff)?;
apply_db_log(&paths.project, &mut handoff);
let state = load_state(&paths.state_path)?;
let rendered = render_handover_markdown(&handoff, state.as_ref());
fs::write(&paths.handover_path, rendered)
.with_context(|| format!("failed to write {}", paths.handover_path.display()))?;
println!("{}", paths.handover_path.display());
Ok(())
}
pub(crate) fn handoff_db(args: DbArgs) -> Result<()> {
let db = HandoffDb::new()?;
match args.command {
DbCommand::Init => {
let db_path = db.init()?;
println!("db initialized: {}", db_path.display());
}
DbCommand::Upsert(args) => {
let contents = fs::read_to_string(&args.handoff)
.with_context(|| format!("failed to read {}", args.handoff.display()))?;
let handoff: Handoff = serde_yaml::from_str(&contents)
.with_context(|| format!("failed to parse {}", args.handoff.display()))?;
let today = today(Path::new("."))?;
let report = db.upsert(&args.project, &handoff, &today)?;
println!(
"synced {} item(s) for project '{}'",
report.synced, args.project
);
}
DbCommand::Query(args) => {
for row in db.query(&args.project)? {
println!(
"{:<20} {:<6} {:<10} {:<12} {:<12}",
row.id, row.priority, row.status, row.completed, row.updated
);
}
}
DbCommand::Complete(args) => {
let today = today(Path::new("."))?;
db.complete(&args.project, &args.id, &today)?;
println!("marked done: {}/{}", args.project, args.id);
}
DbCommand::Status(args) => {
let today = today(Path::new("."))?;
db.set_status(&args.project, &args.id, &args.status, &today)?;
println!(
"status updated: {}/{} -> {}",
args.project, args.id, args.status
);
}
}
Ok(())
}
pub(crate) fn reconcile(args: TargetArgs, mode: ReconcileMode) -> Result<()> {
let context = discover(Path::new("."))?;
let doob = DoobClient::new(&context.repo_root);
ensure_doob_on_path(&context.repo_root)?;
let (paths, handoff) = load_target_handoff(&context, args, false)?;
auto_upsert(&paths.project, &handoff);
let report = reconcile_handoff(&doob, &paths.project, &handoff, mode)?;
print_reconcile_report(mode, &report);
if mode == ReconcileMode::Audit
&& (!report.not_captured.is_empty() || !report.closed_upstream.is_empty())
{
process::exit(1);
}
Ok(())
}
pub(crate) fn close(args: CloseArgs) -> Result<()> {
let context = discover(Path::new("."))?;
context.refresh(args.force_refresh)?;
let (paths, mut handoff) =
load_target_handoff(&context, args.target.clone(), args.allow_create)?;
let today = today(&context.repo_root)?;
handoff.ensure_project(&paths.project);
handoff.ensure_id_prefix(&paths.project);
handoff.updated = Some(today.clone());
let log_commits = if args.log_summary.is_some() {
if args.commits.is_empty() {
current_short_head(&context.repo_root)
.map(|hash| vec![hash])
.unwrap_or_default()
} else {
args.commits.clone()
}
} else {
Vec::new()
};
if let Some(ref summary) = args.log_summary {
handoff.log.insert(
0,
LogEntry {
date: Some(today.clone()),
summary: summary.clone(),
commits: log_commits.clone(),
..LogEntry::default()
},
);
}
let mut state = build_state(&context, &paths, args.build, args.tests, args.notes)?;
let db = HandoffDb::new()?;
if let Some(ref summary) = args.log_summary {
db.log_append(&paths.project, &today, summary, &log_commits)?;
append_jsonl_log(&paths.project, &today, summary, &log_commits)?;
state.last_log = Some(summary.clone());
}
fs::create_dir_all(&paths.ctx_dir)
.with_context(|| format!("failed to create {}", paths.ctx_dir.display()))?;
fs::write(&paths.handoff_path, serde_yaml::to_string(&handoff)?)
.with_context(|| format!("failed to write {}", paths.handoff_path.display()))?;
fs::write(&paths.state_path, serde_json::to_string_pretty(&state)?)
.with_context(|| format!("failed to write {}", paths.state_path.display()))?;
let upsert = db.upsert(&paths.project, &handoff, &today)?;
let doob = DoobClient::new(&context.repo_root);
ensure_doob_on_path(&context.repo_root)?;
let reconcile_report = reconcile_handoff(&doob, &paths.project, &handoff, ReconcileMode::Sync)?;
let rendered = render_markdown(&handoff, Some(&state));
fs::write(&paths.rendered_path, rendered)
.with_context(|| format!("failed to write {}", paths.rendered_path.display()))?;
let handover = render_handover_markdown(&handoff, Some(&state));
fs::write(&paths.handover_path, handover)
.with_context(|| format!("failed to write {}", paths.handover_path.display()))?;
println!("Closed {}", paths.project);
println!("Handoff: {}", paths.handoff_path.display());
println!("State: {}", paths.state_path.display());
println!("Render: {}", paths.rendered_path.display());
println!("Handover: {}", paths.handover_path.display());
println!(
"SQLite: synced {} item(s) into {}",
upsert.synced,
upsert.db_path.display()
);
print_reconcile_report(ReconcileMode::Sync, &reconcile_report);
Ok(())
}
fn build_state(
context: &RepoContext,
paths: &HandoffPaths,
build: Option<String>,
tests: Option<String>,
notes: Option<String>,
) -> Result<HandoffState> {
let existing = if paths.state_path.exists() {
let contents = fs::read_to_string(&paths.state_path)
.with_context(|| format!("failed to read {}", paths.state_path.display()))?;
serde_json::from_str::<HandoffState>(&contents)
.with_context(|| format!("failed to parse {}", paths.state_path.display()))?
} else {
HandoffState::default()
};
Ok(HandoffState {
updated: Some(today(&context.repo_root)?),
branch: Some(branch_name(&context.repo_root).unwrap_or_else(|_| "unknown".into())),
build: build.or(existing.build).or(Some("unknown".into())),
tests: tests.or(existing.tests).or(Some("unknown".into())),
notes: notes.or(existing.notes),
touched_files: Vec::new(),
last_log: existing.last_log,
extra: existing.extra,
})
}
fn load_state(path: &Path) -> Result<Option<HandoffState>> {
if !path.exists() {
return Ok(None);
}
let contents =
fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))?;
let state = serde_json::from_str::<HandoffState>(&contents)
.with_context(|| format!("failed to parse {}", path.display()))?;
Ok(Some(state))
}
fn load_target_handoff(
context: &RepoContext,
args: TargetArgs,
allow_create: bool,
) -> Result<(HandoffPaths, Handoff)> {
let explicit_project = args.project.as_deref();
let paths = context.paths(explicit_project)?;
let handoff_path = args.handoff.unwrap_or_else(|| paths.handoff_path.clone());
if handoff_path.exists() {
let contents = fs::read_to_string(&handoff_path)
.with_context(|| format!("failed to read {}", handoff_path.display()))?;
let mut handoff: Handoff = serde_yaml::from_str(&contents)
.with_context(|| format!("failed to parse {}", handoff_path.display()))?;
validate_and_repair(&mut handoff, &handoff_path)?;
let resolved = rebind_paths_for_handoff(paths, &handoff_path, &handoff)?;
return Ok((resolved, handoff));
}
if let Some(migrated) = context.migrate_root_handoff(&handoff_path)? {
let contents = fs::read_to_string(&migrated)
.with_context(|| format!("failed to read {}", migrated.display()))?;
let mut handoff: Handoff = serde_yaml::from_str(&contents)
.with_context(|| format!("failed to parse {}", migrated.display()))?;
validate_and_repair(&mut handoff, &migrated)?;
let resolved = rebind_paths_for_handoff(paths, &migrated, &handoff)?;
return Ok((resolved, handoff));
}
if allow_create {
let handoff = Handoff {
project: Some(paths.project.clone()),
id: Some(hjlib::default_id_prefix(&paths.project)),
updated: Some(today(&context.repo_root)?),
..Handoff::default()
};
return Ok((paths, handoff));
}
bail!("handoff file not found: {}", handoff_path.display())
}
fn validate_and_repair(handoff: &mut Handoff, handoff_path: &Path) -> Result<()> {
let warnings = handoff.validate();
for w in &warnings {
eprintln!("warning: {}: {w}", handoff_path.display());
}
let repairs = handoff.repair();
if !repairs.is_empty() {
for r in &repairs {
eprintln!("repaired: {}: {r}", handoff_path.display());
}
fs::write(handoff_path, serde_yaml::to_string(handoff)?)
.with_context(|| format!("failed to write repaired {}", handoff_path.display()))?;
}
Ok(())
}
fn auto_upsert(project: &str, handoff: &Handoff) {
let Ok(db) = HandoffDb::new() else { return };
let Ok(date) = today(Path::new(".")) else {
return;
};
let old_rows = db.query(project).unwrap_or_default();
if let Err(e) = db.upsert(project, handoff, &date) {
eprintln!("warning: db upsert failed: {e}");
return;
}
sync_github_issues(&old_rows, handoff);
}
#[derive(Debug, Clone, Eq, PartialEq)]
enum IssueAction {
Close(u64),
Reopen(u64),
}
fn detect_issue_transitions(old_rows: &[HandoffRow], handoff: &Handoff) -> Vec<IssueAction> {
let mut actions = Vec::new();
for item in &handoff.items {
let Some(issue_num) = item.issue else {
continue;
};
let new_status = item.status.as_deref().unwrap_or("open");
let old_status = old_rows
.iter()
.find(|r| r.id == item.id)
.map(|r| r.status.as_str())
.unwrap_or("open");
if old_status == new_status {
continue;
}
let is_closed = matches!(new_status, "closed" | "done");
let was_closed = matches!(old_status, "closed" | "done");
if is_closed && !was_closed {
actions.push(IssueAction::Close(issue_num));
} else if !is_closed && was_closed {
actions.push(IssueAction::Reopen(issue_num));
}
}
actions
}
fn sync_github_issues(old_rows: &[HandoffRow], handoff: &Handoff) {
for action in detect_issue_transitions(old_rows, handoff) {
match action {
IssueAction::Close(n) => gh_issue_action(n, "close"),
IssueAction::Reopen(n) => gh_issue_action(n, "reopen"),
}
}
}
fn gh_issue_action(issue: u64, action: &str) {
let result = Command::new("gh")
.args(["issue", action, &issue.to_string()])
.output();
match result {
Ok(output) if output.status.success() => {
eprintln!("gh: issue #{issue} {action}d");
}
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr);
eprintln!("warning: gh issue {action} #{issue} failed: {stderr}");
}
Err(e) => {
eprintln!("warning: gh not available: {e}");
}
}
}
fn rebind_paths_for_handoff(
mut paths: HandoffPaths,
handoff_path: &Path,
handoff: &Handoff,
) -> Result<HandoffPaths> {
let ctx_dir = handoff_path
.parent()
.ok_or_else(|| anyhow!("handoff path has no parent directory"))?
.to_path_buf();
let project = handoff
.project
.as_deref()
.filter(|value| !value.is_empty())
.map(str::to_string)
.or_else(|| project_from_handoff_path(handoff_path, &paths.base_name))
.unwrap_or(paths.project.clone());
paths.ctx_dir = ctx_dir.clone();
paths.handoff_path = handoff_path.to_path_buf();
paths.state_path = state_path_for_handoff(handoff_path)?;
paths.rendered_path = ctx_dir.join("HANDOFF.md");
paths.handover_path = ctx_dir.join("HANDOVER.md");
paths.project = project;
Ok(paths)
}
fn state_path_for_handoff(handoff_path: &Path) -> Result<std::path::PathBuf> {
let file_name = handoff_path
.file_name()
.and_then(|value| value.to_str())
.ok_or_else(|| anyhow!("handoff path has no filename"))?;
let stem = file_name
.strip_suffix(".yaml")
.ok_or_else(|| anyhow!("handoff path must end with .yaml"))?;
Ok(handoff_path.with_file_name(format!("{stem}.state.json")))
}
fn project_from_handoff_path(handoff_path: &Path, base_name: &str) -> Option<String> {
let file_name = handoff_path.file_name()?.to_str()?;
let prefix = "HANDOFF.";
let suffix = format!(".{base_name}.yaml");
file_name
.strip_prefix(prefix)?
.strip_suffix(&suffix)
.filter(|value| !value.is_empty())
.map(str::to_string)
}
fn reconcile_handoff(
backend: &impl TodoMemoryBackend,
project: &str,
handoff: &Handoff,
mode: ReconcileMode,
) -> Result<ReconcileReport> {
let snapshot = backend.snapshot(project)?;
let plan = build_reconcile_plan(project, handoff, &snapshot, mode);
if mode == ReconcileMode::Sync {
for create in &plan.creates {
backend.create(project, create)?;
}
}
Ok(plan.report)
}
fn append_jsonl_log(project: &str, date: &str, summary: &str, commits: &[String]) -> Result<()> {
let home = dirs::home_dir().ok_or_else(|| anyhow!("could not determine home directory"))?;
let log_path = home.join(".ctx/handoff-log.jsonl");
if let Some(parent) = log_path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("failed to create {}", parent.display()))?;
}
let mut file = fs::OpenOptions::new()
.create(true)
.append(true)
.open(&log_path)
.with_context(|| format!("failed to open {}", log_path.display()))?;
let line = serde_json::json!({
"date": date,
"project": project,
"summary": summary,
"commits": commits,
});
writeln!(file, "{line}").with_context(|| format!("failed to write {}", log_path.display()))?;
Ok(())
}
fn apply_db_log(project: &str, handoff: &mut Handoff) {
let Ok(db) = HandoffDb::new() else { return };
let Ok(entries) = db.log_query(project) else {
return;
};
if !entries.is_empty() {
handoff.log = entries;
}
}
fn apply_sqlite_overrides(project: &str, handoff: &mut Handoff) -> Result<()> {
let db = match HandoffDb::new() {
Ok(db) => db,
Err(_) => return Ok(()),
};
let rows = match db.query(project) {
Ok(rows) => rows,
Err(_) => return Ok(()),
};
apply_handoff_rows(handoff, &rows);
Ok(())
}
fn apply_handoff_rows(handoff: &mut Handoff, rows: &[HandoffRow]) {
for row in rows {
let Some(item) = handoff.items.iter_mut().find(|item| item.id == row.id) else {
continue;
};
if !row.priority.is_empty() {
item.priority = Some(row.priority.clone());
}
if !row.status.is_empty() && item.status.as_deref() != Some(row.status.as_str()) {
item.status = Some(row.status.clone());
}
if !row.completed.is_empty() {
item.completed = Some(row.completed.clone());
}
}
}
#[derive(Debug, Default)]
struct TriageBuckets {
p0: Vec<String>,
p1: Vec<String>,
p2: Vec<String>,
}
fn collect_review_on_wake(handoff: &Handoff) -> Vec<String> {
let mut lines = Vec::new();
for item in &handoff.items {
for entry in item.extra.iter().filter(is_unreviewed_human_edit) {
let date = entry.date.as_deref().unwrap_or("unknown");
let field = entry.field.as_deref().unwrap_or("unknown");
let value = entry.value.as_deref().unwrap_or("unknown");
lines.push(format!(
"- [{}] \"{}\" — human edited `{field}` -> `{value}` on {date}",
item.id, item.title
));
if let Some(note) = entry.note.as_deref().filter(|note| !note.is_empty()) {
lines.push(format!(" {note}"));
}
}
}
lines
}
fn is_unreviewed_human_edit(entry: &&ExtraEntry) -> bool {
entry.r#type.as_deref() == Some("human-edit")
&& entry
.reviewed
.as_deref()
.is_none_or(|reviewed| reviewed.is_empty())
}
fn classify_items(handoff: &Handoff) -> TriageBuckets {
let mut buckets = TriageBuckets::default();
let mut items = handoff.active_items().collect::<Vec<_>>();
items.sort_by(|left, right| {
left.inferred_priority()
.cmp(&right.inferred_priority())
.then(left.id.cmp(&right.id))
});
for item in items {
let line = format_triage_item(item);
match item.inferred_priority().as_str() {
"P0" => buckets.p0.push(line),
"P1" => buckets.p1.push(line),
_ => buckets.p2.push(line),
}
}
buckets
}
fn format_triage_item(item: &HandoffItem) -> String {
let status = item.status.as_deref().unwrap_or("open");
format!(" · {:<20} {:<8} {}", item.id, status, item.title)
}
fn print_triage_bucket(label: &str, items: &[String]) {
if items.is_empty() {
return;
}
println!("{label} {}", "─".repeat(24));
for item in items {
println!("{item}");
}
}
fn print_reconcile_report(mode: ReconcileMode, report: &ReconcileReport) {
println!("Reconciliation — {}", report.project);
println!("{}", "─".repeat(27));
println!("{:<26} {}", "Captured in backend", report.captured_count);
if mode == ReconcileMode::Sync {
println!("{:<26} {}", "Created this run", report.created_count);
}
println!("{:<26} {}", "Not captured", report.not_captured.len());
println!("{:<26} {}", "Orphaned", report.orphaned.len());
println!("{:<26} {}", "Closed upstream", report.closed_upstream.len());
print_list("Missing items:", &report.not_captured);
print_list("Orphaned backend items:", &report.orphaned);
print_list("Closed upstream:", &report.closed_upstream);
}
fn print_list(label: &str, items: &[String]) {
if items.is_empty() {
return;
}
println!("{label}");
for item in items {
println!("- {item}");
}
}
#[cfg(test)]
mod tests {
use std::{fs, path::PathBuf};
use hjlib::sqlite::HandoffRow;
use hjlib::{ExtraEntry, Handoff, HandoffItem, HandoffPaths};
use super::{
IssueAction, apply_handoff_rows, collect_review_on_wake, detect_issue_transitions,
project_from_handoff_path, rebind_paths_for_handoff, validate_and_repair,
};
#[test]
fn validate_and_repair_fixes_log_entry_in_items_and_rewrites_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("HANDOFF.test.yaml");
let mut extra_fields = std::collections::BTreeMap::new();
extra_fields.insert(
"date".into(),
serde_yaml::Value::String("20260425:100000".into()),
);
extra_fields.insert(
"summary".into(),
serde_yaml::Value::String("did stuff".into()),
);
let mut handoff = Handoff {
items: vec![
HandoffItem {
id: "hj-1".into(),
title: "valid item".into(),
..HandoffItem::default()
},
HandoffItem {
id: String::new(),
extra_fields,
..HandoffItem::default()
},
],
..Handoff::default()
};
fs::write(&path, serde_yaml::to_string(&handoff).unwrap()).unwrap();
validate_and_repair(&mut handoff, &path).unwrap();
assert_eq!(handoff.items.len(), 1);
assert_eq!(handoff.items[0].id, "hj-1");
assert_eq!(handoff.log.len(), 1);
assert_eq!(handoff.log[0].summary, "did stuff");
let on_disk: Handoff = serde_yaml::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(on_disk.items.len(), 1);
assert_eq!(on_disk.log.len(), 1);
}
#[test]
fn validate_and_repair_noop_on_clean_handoff() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("HANDOFF.test.yaml");
let mut handoff = Handoff {
items: vec![HandoffItem {
id: "hj-1".into(),
title: "valid".into(),
..HandoffItem::default()
}],
..Handoff::default()
};
let yaml = serde_yaml::to_string(&handoff).unwrap();
fs::write(&path, &yaml).unwrap();
validate_and_repair(&mut handoff, &path).unwrap();
assert_eq!(handoff.items.len(), 1);
assert!(handoff.log.is_empty());
assert_eq!(fs::read_to_string(&path).unwrap(), yaml);
}
#[test]
fn sqlite_rows_override_handoff_status() {
let mut handoff = Handoff {
items: vec![HandoffItem {
id: "hj-1".into(),
priority: Some("P1".into()),
status: Some("open".into()),
..HandoffItem::default()
}],
..Handoff::default()
};
let rows = vec![HandoffRow {
id: "hj-1".into(),
priority: "P0".into(),
status: "blocked".into(),
completed: "2026-04-16".into(),
updated: "2026-04-16".into(),
issue: None,
}];
apply_handoff_rows(&mut handoff, &rows);
let item = &handoff.items[0];
assert_eq!(item.priority.as_deref(), Some("P0"));
assert_eq!(item.status.as_deref(), Some("blocked"));
assert_eq!(item.completed.as_deref(), Some("2026-04-16"));
}
#[test]
fn review_on_wake_finds_unreviewed_human_edits() {
let handoff = Handoff {
items: vec![HandoffItem {
id: "hj-1".into(),
title: "Ship handon".into(),
extra: vec![ExtraEntry {
date: Some("2026-04-16".into()),
r#type: Some("human-edit".into()),
field: Some("title".into()),
value: Some("Ship handon".into()),
reviewed: None,
note: Some("Needs confirmation.".into()),
..ExtraEntry::default()
}],
..HandoffItem::default()
}],
..Handoff::default()
};
let review = collect_review_on_wake(&handoff);
assert_eq!(review.len(), 2);
assert!(review[0].contains("[hj-1]"));
assert!(review[1].contains("Needs confirmation."));
}
#[test]
fn explicit_handoff_path_rebinds_state_and_project() {
let paths = HandoffPaths {
repo_root: PathBuf::from("/repo"),
ctx_dir: PathBuf::from("/repo/.ctx"),
handoff_path: PathBuf::from("/repo/.ctx/HANDOFF.hj.hj.yaml"),
state_path: PathBuf::from("/repo/.ctx/HANDOFF.hj.hj.state.json"),
rendered_path: PathBuf::from("/repo/.ctx/HANDOFF.md"),
handover_path: PathBuf::from("/repo/.ctx/HANDOVER.md"),
project: "hj".into(),
base_name: "hj".into(),
};
let explicit = PathBuf::from("/repo/.ctx/HANDOFF.hj-core.hj.yaml");
let handoff = Handoff {
project: Some("hj-core".into()),
..Handoff::default()
};
let rebound = rebind_paths_for_handoff(paths, &explicit, &handoff).unwrap();
assert_eq!(rebound.project, "hj-core");
assert_eq!(rebound.handoff_path, explicit);
assert_eq!(
rebound.state_path,
PathBuf::from("/repo/.ctx/HANDOFF.hj-core.hj.state.json")
);
}
#[test]
fn project_can_be_derived_from_handoff_filename() {
let handoff_path = PathBuf::from("/repo/.ctx/HANDOFF.hj-core.hj.yaml");
assert_eq!(
project_from_handoff_path(&handoff_path, "hj").as_deref(),
Some("hj-core")
);
}
fn row(id: &str, status: &str, issue: Option<u64>) -> HandoffRow {
HandoffRow {
id: id.into(),
priority: "P1".into(),
status: status.into(),
completed: String::new(),
updated: "2026-04-25".into(),
issue,
}
}
fn item(id: &str, status: &str, issue: Option<u64>) -> HandoffItem {
HandoffItem {
id: id.into(),
status: Some(status.into()),
issue,
..HandoffItem::default()
}
}
#[test]
fn detect_close_transition() {
let old = vec![row("hj-1", "open", Some(42))];
let handoff = Handoff {
items: vec![item("hj-1", "closed", Some(42))],
..Handoff::default()
};
assert_eq!(
detect_issue_transitions(&old, &handoff),
vec![IssueAction::Close(42)]
);
}
#[test]
fn detect_reopen_transition() {
let old = vec![row("hj-1", "closed", Some(42))];
let handoff = Handoff {
items: vec![item("hj-1", "open", Some(42))],
..Handoff::default()
};
assert_eq!(
detect_issue_transitions(&old, &handoff),
vec![IssueAction::Reopen(42)]
);
}
#[test]
fn no_action_without_issue_number() {
let old = vec![row("hj-1", "open", None)];
let handoff = Handoff {
items: vec![item("hj-1", "closed", None)],
..Handoff::default()
};
assert!(detect_issue_transitions(&old, &handoff).is_empty());
}
#[test]
fn no_action_when_status_unchanged() {
let old = vec![row("hj-1", "open", Some(42))];
let handoff = Handoff {
items: vec![item("hj-1", "open", Some(42))],
..Handoff::default()
};
assert!(detect_issue_transitions(&old, &handoff).is_empty());
}
#[test]
fn done_status_triggers_close() {
let old = vec![row("hj-1", "open", Some(7))];
let handoff = Handoff {
items: vec![item("hj-1", "done", Some(7))],
..Handoff::default()
};
assert_eq!(
detect_issue_transitions(&old, &handoff),
vec![IssueAction::Close(7)]
);
}
#[test]
fn new_item_with_closed_status_triggers_close() {
let old = vec![];
let handoff = Handoff {
items: vec![item("hj-1", "closed", Some(10))],
..Handoff::default()
};
assert_eq!(
detect_issue_transitions(&old, &handoff),
vec![IssueAction::Close(10)]
);
}
}