use std::io::Read as _;
use std::path::Path;
use std::process::ExitCode;
use anyhow::{bail, Context, Result};
use serde::{Deserialize, Serialize};
use crate::db;
use crate::output::CommandReport;
use crate::paths::state::StateLayout;
use crate::profile;
use crate::repo::marker as repo_marker;
use crate::state::protected_write::{self, ExclusiveWriteOptions};
use crate::state::runtime::{
self as runtime_state, RuntimeHandoffItem, RuntimeHandoffState, RuntimeLifecycle,
};
use crate::state::{compiled as compiled_state, projection_metadata};
#[derive(Debug, Default, Deserialize)]
pub(crate) struct HandoffWriteInput {
pub(crate) title: String,
#[serde(default)]
pub(crate) immediate_actions: Vec<String>,
#[serde(default)]
pub(crate) completed_state: Vec<String>,
#[serde(default)]
pub(crate) operational_guardrails: Vec<String>,
#[serde(default)]
pub(crate) key_files: Vec<String>,
#[serde(default)]
pub(crate) definition_of_done: Vec<String>,
}
#[derive(Serialize)]
pub struct HandoffWriteReport {
command: &'static str,
ok: bool,
path: String,
profile: String,
native_state_path: String,
title: String,
revision: u64,
}
impl CommandReport for HandoffWriteReport {
fn exit_code(&self) -> ExitCode {
ExitCode::SUCCESS
}
fn render_text(&self) {
println!("Wrote handoff to {}", self.native_state_path);
println!("Title: {}", self.title);
println!("Revision: {}", self.revision);
}
}
fn ensure_profile_exists(layout: &StateLayout, repo_root: &Path) -> Result<()> {
let profile_root = layout.profile_root();
if profile_root.is_dir() {
return Ok(());
}
bail!(
"profile `{}` does not exist at {}; bootstrap it with `ccd attach --path {}` before writing the handoff",
layout.profile(),
profile_root.display(),
repo_root.display()
)
}
fn ensure_repo_linked(repo_root: &Path) -> Result<String> {
let Some(marker) = repo_marker::load(repo_root)? else {
bail!(
"repo is not linked: {} is missing; run `ccd attach --path {}` or `ccd link --path {}` first",
repo_root.join(repo_marker::MARKER_FILE).display(),
repo_root.display(),
repo_root.display()
)
};
Ok(marker.locality_id)
}
fn items_from_strings(strings: Vec<String>) -> Vec<RuntimeHandoffItem> {
strings
.into_iter()
.map(|text| RuntimeHandoffItem {
text,
lifecycle: RuntimeLifecycle::Active,
})
.collect()
}
fn validate_input(input: &HandoffWriteInput) -> Result<()> {
if input.title == "No active session" {
bail!("handoff title `No active session` is a neutral placeholder and cannot be persisted as a real handoff; provide a meaningful next-session title");
}
if input.immediate_actions.is_empty() {
bail!(
"immediate_actions must not be empty; a handoff without next steps is not actionable"
);
}
if input.definition_of_done.is_empty() {
bail!("definition_of_done must not be empty; a handoff without exit criteria is not verifiable");
}
Ok(())
}
pub(crate) fn run_with_input(
repo_root: &Path,
explicit_profile: Option<&str>,
write_options: ExclusiveWriteOptions,
input: HandoffWriteInput,
) -> Result<HandoffWriteReport> {
let profile = profile::resolve(explicit_profile)?;
let layout = StateLayout::resolve(repo_root, profile.clone())?;
ensure_profile_exists(&layout, repo_root)?;
let locality_id = ensure_repo_linked(repo_root)?;
validate_input(&input)?;
let title = input.title;
let handoff = RuntimeHandoffState {
title: title.clone(),
immediate_actions: items_from_strings(input.immediate_actions),
completed_state: items_from_strings(input.completed_state),
operational_guardrails: items_from_strings(input.operational_guardrails),
key_files: items_from_strings(input.key_files),
definition_of_done: items_from_strings(input.definition_of_done),
};
let guard = protected_write::authorize_owner_surface_write(&layout, "handoff", &write_options)?;
let db = db::StateDb::open_for_layout(&layout)?;
let revision = match guard.expected_revision {
Some(expected_revision) => {
match db::handoff::write_if_revision_matches(db.conn(), &handoff, expected_revision)? {
db::handoff::ExclusiveWriteResult::Applied { revision } => revision,
db::handoff::ExclusiveWriteResult::RevisionConflict { current_revision } => bail!(
"`handoff` revision conflict: expected {expected_revision}, current is {current_revision}"
),
}
}
None => runtime_state::persist_canonical_handoff_state(&layout, &handoff)?,
};
let updated_runtime = runtime_state::load_runtime_state(repo_root, &layout, &locality_id)?;
let compiled = compiled_state::refresh_after_write_with_loaded(&layout, &updated_runtime)?;
if let Err(error) = projection_metadata::record_for_compiled_store(&layout, &compiled) {
projection_metadata::warn_record_error(&layout, &error);
}
Ok(HandoffWriteReport {
command: "handoff-write",
ok: true,
path: repo_root.display().to_string(),
profile: profile.to_string(),
native_state_path: layout.state_db_path().display().to_string(),
title,
revision,
})
}
pub fn run(
repo_root: &Path,
explicit_profile: Option<&str>,
write_options: ExclusiveWriteOptions,
) -> Result<HandoffWriteReport> {
let mut buf = String::new();
std::io::stdin()
.read_to_string(&mut buf)
.context("failed to read handoff JSON from stdin")?;
let input: HandoffWriteInput =
serde_json::from_str(&buf).context("failed to parse handoff JSON from stdin")?;
run_with_input(repo_root, explicit_profile, write_options, input)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_minimal_input() {
let json = r#"{"title": "Next: something"}"#;
let input: HandoffWriteInput = serde_json::from_str(json).unwrap();
assert_eq!(input.title, "Next: something");
assert!(input.immediate_actions.is_empty());
}
#[test]
fn parse_full_input() {
let json = r#"{
"title": "Next: implement feature X",
"immediate_actions": ["Read the spec.", "Write tests."],
"completed_state": ["Scaffolded module."],
"operational_guardrails": ["Do not touch auth."],
"key_files": ["`src/foo.rs`"],
"definition_of_done": ["Tests pass.", "Docs updated."]
}"#;
let input: HandoffWriteInput = serde_json::from_str(json).unwrap();
assert_eq!(input.title, "Next: implement feature X");
assert_eq!(input.immediate_actions.len(), 2);
assert_eq!(input.completed_state.len(), 1);
assert_eq!(input.operational_guardrails.len(), 1);
assert_eq!(input.key_files.len(), 1);
assert_eq!(input.definition_of_done.len(), 2);
}
#[test]
fn items_from_strings_sets_active_lifecycle() {
let items = items_from_strings(vec!["a".to_owned(), "b".to_owned()]);
assert_eq!(items.len(), 2);
assert!(items.iter().all(|item| item.lifecycle.is_active()));
}
#[test]
fn validate_rejects_neutral_title() {
let input = HandoffWriteInput {
title: "No active session".to_owned(),
immediate_actions: vec!["Do something.".to_owned()],
definition_of_done: vec!["Done.".to_owned()],
..Default::default()
};
let err = validate_input(&input).unwrap_err();
assert!(err.to_string().contains("neutral placeholder"));
}
#[test]
fn validate_rejects_empty_immediate_actions() {
let input = HandoffWriteInput {
title: "Next: something".to_owned(),
immediate_actions: vec![],
definition_of_done: vec!["Done.".to_owned()],
..Default::default()
};
let err = validate_input(&input).unwrap_err();
assert!(err.to_string().contains("immediate_actions"));
}
#[test]
fn validate_rejects_empty_definition_of_done() {
let input = HandoffWriteInput {
title: "Next: something".to_owned(),
immediate_actions: vec!["Do it.".to_owned()],
definition_of_done: vec![],
..Default::default()
};
let err = validate_input(&input).unwrap_err();
assert!(err.to_string().contains("definition_of_done"));
}
#[test]
fn validate_accepts_valid_input() {
let input = HandoffWriteInput {
title: "Next: implement X".to_owned(),
immediate_actions: vec!["Read spec.".to_owned()],
definition_of_done: vec!["Tests pass.".to_owned()],
..Default::default()
};
assert!(validate_input(&input).is_ok());
}
}