ccd-cli 1.0.0-beta.1

Bootstrap and validate Continuous Context Development repositories
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};

/// Input format accepted on stdin.
///
/// Each section is a list of strings. All items are created with `active`
/// lifecycle.
#[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());
    }
}