ccd-cli 1.0.0-alpha.2

Bootstrap and validate Continuous Context Development repositories
use anyhow::{bail, Result};
use serde::Serialize;

use crate::paths::state::StateLayout;

use super::session::{self, SessionLifecycle};

#[derive(Debug, Clone, Default)]
pub(crate) struct ExclusiveWriteOptions {
    pub(crate) actor_id: Option<String>,
    pub(crate) session_id: Option<String>,
    pub(crate) expected_revision: Option<u64>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct ExclusiveWriteGuard {
    pub(crate) expected_revision: Option<u64>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum AppendWriteOutcome {
    Applied,
    IdempotentNoop,
    OwnershipConflict,
    StaleSession,
    UnsupportedMultiwriter,
    DuplicateIdConflict,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum AppendWriteAuthority {
    OwnerOnly,
    OwnerOrSupervisor,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct AppendWriteConflict {
    pub(crate) outcome: AppendWriteOutcome,
    pub(crate) message: String,
}

impl ExclusiveWriteOptions {
    fn has_identity_fields(&self) -> bool {
        self.actor_id.is_some() || self.session_id.is_some()
    }
}

pub(crate) fn authorize_owner_surface_write(
    layout: &StateLayout,
    surface: &str,
    options: &ExclusiveWriteOptions,
) -> Result<ExclusiveWriteGuard> {
    if options.has_identity_fields() && options.expected_revision.is_none() {
        bail!(
            "`--expected-revision` is required when protected-write identity flags are provided for `{surface}`"
        );
    }

    let Some(state) = session::load_for_layout(layout)? else {
        return Ok(ExclusiveWriteGuard {
            expected_revision: options.expected_revision,
        });
    };

    if state.lifecycle() != SessionLifecycle::Autonomous {
        return Ok(ExclusiveWriteGuard {
            expected_revision: options.expected_revision,
        });
    }

    let now = session::now_epoch_s()?;
    if session::is_stale(&state, now) {
        bail!(
            "`{surface}` is protected by stale autonomous session `{}` owned by `{}`; use `ccd session-state takeover` or `ccd session-state clear` before mutating it",
            state.session_id.as_deref().unwrap_or("unknown"),
            state.owner_id.as_deref().unwrap_or("unknown"),
        );
    }

    let Some(actor_id) = options.actor_id.as_deref() else {
        bail!(
            "`{surface}` is protected by active autonomous session `{}` owned by `{}`; provide `--actor-id`, `--session-id`, and `--expected-revision` matching the active owner",
            state.session_id.as_deref().unwrap_or("unknown"),
            state.owner_id.as_deref().unwrap_or("unknown"),
        );
    };
    let Some(session_id) = options.session_id.as_deref() else {
        bail!(
            "`{surface}` is protected by active autonomous session `{}` owned by `{}`; provide `--actor-id`, `--session-id`, and `--expected-revision` matching the active owner",
            state.session_id.as_deref().unwrap_or("unknown"),
            state.owner_id.as_deref().unwrap_or("unknown"),
        );
    };
    let Some(expected_revision) = options.expected_revision else {
        bail!(
            "`{surface}` is protected by active autonomous session `{}` owned by `{}`; provide `--actor-id`, `--session-id`, and `--expected-revision` matching the active owner",
            state.session_id.as_deref().unwrap_or("unknown"),
            state.owner_id.as_deref().unwrap_or("unknown"),
        );
    };

    if state.owner_id.as_deref() != Some(actor_id) {
        bail!(
            "`{surface}` requires owner actor `{}`; got `{actor_id}`",
            state.owner_id.as_deref().unwrap_or("unknown"),
        );
    }

    if state.session_id.as_deref() != Some(session_id) {
        bail!(
            "`{surface}` requires active session id `{}`; got `{session_id}`",
            state.session_id.as_deref().unwrap_or("unknown"),
        );
    }

    Ok(ExclusiveWriteGuard {
        expected_revision: Some(expected_revision),
    })
}

pub(crate) fn authorize_append_surface_write(
    layout: &StateLayout,
    surface: &str,
    options: &ExclusiveWriteOptions,
    authority: AppendWriteAuthority,
) -> Result<Option<AppendWriteConflict>> {
    let Some(state) = session::load_for_layout(layout)? else {
        return Ok(None);
    };

    if state.lifecycle() != SessionLifecycle::Autonomous {
        return Ok(None);
    }

    let now = session::now_epoch_s()?;
    if session::is_stale(&state, now) {
        return Ok(Some(AppendWriteConflict {
            outcome: AppendWriteOutcome::StaleSession,
            message: format!(
                "`{surface}` is protected by stale autonomous session `{}` owned by `{}`; use `ccd session-state takeover` or `ccd session-state clear` before mutating it",
                state.session_id.as_deref().unwrap_or("unknown"),
                state.owner_id.as_deref().unwrap_or("unknown"),
            ),
        }));
    }

    let Some(actor_id) = options.actor_id.as_deref() else {
        return Ok(Some(AppendWriteConflict {
            outcome: AppendWriteOutcome::OwnershipConflict,
            message: missing_append_identity_message(&state, surface, authority),
        }));
    };

    let Some(session_id) = options.session_id.as_deref() else {
        return Ok(Some(AppendWriteConflict {
            outcome: AppendWriteOutcome::OwnershipConflict,
            message: missing_append_identity_message(&state, surface, authority),
        }));
    };

    if state.session_id.as_deref() != Some(session_id) {
        return Ok(Some(AppendWriteConflict {
            outcome: AppendWriteOutcome::OwnershipConflict,
            message: format!(
                "`{surface}` requires active session id `{}`; got `{session_id}`",
                state.session_id.as_deref().unwrap_or("unknown"),
            ),
        }));
    }

    let actor_allowed = match authority {
        AppendWriteAuthority::OwnerOnly => state.owner_id.as_deref() == Some(actor_id),
        AppendWriteAuthority::OwnerOrSupervisor => {
            state.owner_id.as_deref() == Some(actor_id)
                || state.supervisor_id.as_deref() == Some(actor_id)
        }
    };

    if actor_allowed {
        return Ok(None);
    }

    let expected_actor = match authority {
        AppendWriteAuthority::OwnerOnly => state.owner_id.as_deref().unwrap_or("unknown"),
        AppendWriteAuthority::OwnerOrSupervisor => state
            .owner_id
            .as_deref()
            .or(state.supervisor_id.as_deref())
            .unwrap_or("unknown"),
    };

    Ok(Some(AppendWriteConflict {
        outcome: AppendWriteOutcome::OwnershipConflict,
        message: match authority {
            AppendWriteAuthority::OwnerOnly => {
                format!("`{surface}` requires owner actor `{expected_actor}`; got `{actor_id}`")
            }
            AppendWriteAuthority::OwnerOrSupervisor => format!(
                "`{surface}` requires the active owner `{}` or supervisor `{}`; got `{actor_id}`",
                state.owner_id.as_deref().unwrap_or("unknown"),
                state.supervisor_id.as_deref().unwrap_or("unknown"),
            ),
        },
    }))
}

fn missing_append_identity_message(
    state: &session::SessionStateFile,
    surface: &str,
    authority: AppendWriteAuthority,
) -> String {
    match authority {
        AppendWriteAuthority::OwnerOnly => format!(
            "`{surface}` is protected by active autonomous session `{}` owned by `{}`; provide `--actor-id` and `--session-id` matching the active owner",
            state.session_id.as_deref().unwrap_or("unknown"),
            state.owner_id.as_deref().unwrap_or("unknown"),
        ),
        AppendWriteAuthority::OwnerOrSupervisor => format!(
            "`{surface}` is protected by active autonomous session `{}` owned by `{}`; provide `--actor-id` and `--session-id` matching the active owner or supervisor",
            state.session_id.as_deref().unwrap_or("unknown"),
            state.owner_id.as_deref().unwrap_or("unknown"),
        ),
    }
}