use serde::{Deserialize, Serialize};
pub const MAX_HANDOFF_LINES: usize = 150;
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct Distilled {
pub source_tool: String, pub session_id: String,
pub timestamp_iso: String, pub task: String,
pub open_questions: Vec<String>,
pub next_action: String,
pub recent_files: Vec<String>, pub failed_approaches: Vec<String>, pub git_context: String, pub progress_log: String, }
pub fn render_handoff(d: &Distilled, resume_mode: &str) -> String {
let mode = if resume_mode.is_empty() {
"ask"
} else {
resume_mode
};
let mut lines: Vec<String> = Vec::new();
lines.push(format!(
"# [CARRYOVER] Last updated {} from {}",
d.timestamp_iso, d.source_tool
));
lines.push(String::new());
lines.push(format!("# CARRYOVER RESUME (mode: {mode})"));
lines.push(String::new());
lines.push(format!(
"This file contains a 50-line summary of your prior session in {}. Before acting:",
d.source_tool
));
lines.push("1. Summarize the carryover content back to the user in 1-2 sentences.".to_string());
lines.push("2. Ask the user what they want to do next.".to_string());
lines.push("3. Do not assume continuation — wait for confirmation.".to_string());
lines.push(String::new());
lines.push("---".to_string());
lines.push(String::new());
lines.push("## Task".to_string());
lines.push(d.task.clone());
lines.push(String::new());
if !d.recent_files.is_empty() {
lines.push("## Recent files".to_string());
for f in &d.recent_files {
lines.push(format!("- {f}"));
}
lines.push(String::new());
}
if !d.failed_approaches.is_empty() {
lines.push("## Failed approaches".to_string());
for fa in &d.failed_approaches {
lines.push(format!("- {fa}"));
}
lines.push(String::new());
}
if !d.open_questions.is_empty() {
lines.push("## Open questions".to_string());
for q in &d.open_questions {
lines.push(format!("- {q}"));
}
lines.push(String::new());
}
lines.push("## Next action".to_string());
lines.push(d.next_action.clone());
lines.push(String::new());
if !d.git_context.is_empty() && d.git_context != "<no git context>" {
lines.push("## Git context".to_string());
lines.push(d.git_context.clone());
lines.push(String::new());
}
if !d.progress_log.is_empty() {
lines.push("## Progress log".to_string());
for line in d.progress_log.lines() {
lines.push(line.to_string());
}
lines.push(String::new());
}
if lines.len() > MAX_HANDOFF_LINES {
lines.truncate(MAX_HANDOFF_LINES - 1);
lines.push("…(truncated)".to_string());
}
let mut out = lines.join("\n");
if !out.ends_with('\n') {
out.push('\n');
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn base() -> Distilled {
Distilled {
source_tool: "claude".to_string(),
session_id: "sess-1".to_string(),
timestamp_iso: "2026-04-28".to_string(),
task: "do the thing".to_string(),
open_questions: vec![],
next_action: "next step".to_string(),
recent_files: vec![],
failed_approaches: vec![],
git_context: "<no git context>".to_string(),
progress_log: String::new(),
}
}
#[test]
fn renders_title_line_with_tool_and_timestamp() {
let out = render_handoff(&base(), "ask");
assert!(
out.starts_with("# [CARRYOVER] Last updated 2026-04-28 from claude"),
"title mismatch: {out}"
);
}
#[test]
fn renders_resume_protocol_header_with_mode() {
let out = render_handoff(&base(), "ask");
assert!(out.contains("CARRYOVER RESUME (mode: ask)"));
}
#[test]
fn truncates_to_max_lines() {
let mut d = base();
d.open_questions = (0..500).map(|i| format!("question {i}")).collect();
let out = render_handoff(&d, "ask");
let line_count = out.lines().count();
assert!(
line_count <= MAX_HANDOFF_LINES,
"expected ≤{MAX_HANDOFF_LINES} lines, got {line_count}"
);
}
#[test]
fn non_coding_session_omits_recent_files() {
let out = render_handoff(&base(), "ask");
assert!(!out.contains("## Recent files"));
}
#[test]
fn git_context_sentinel_is_omitted() {
let out = render_handoff(&base(), "ask");
assert!(!out.contains("## Git context"));
}
#[test]
fn output_ends_with_newline() {
let out = render_handoff(&base(), "ask");
assert!(out.ends_with('\n'));
}
#[test]
fn empty_resume_mode_defaults_to_ask() {
let out = render_handoff(&base(), "");
assert!(out.contains("CARRYOVER RESUME (mode: ask)"));
}
}