malvin 0.2.6

Non-interactive research and coding agent
use std::path::PathBuf;

#[derive(Clone, Debug)]
pub struct CoderSessionHeader {
    pub prompt: String,
    pub log_path: PathBuf,
    pub stdout_label: String,
    pub log_who: String,
}

#[derive(Clone, Debug)]
pub(crate) enum SessionHeaderLifecycle {
    Unbound,
    Pending(CoderSessionHeader),
    Satisfied(CoderSessionHeader),
}

impl SessionHeaderLifecycle {
    pub(crate) fn bind(&mut self, header: CoderSessionHeader) {
        *self = Self::Pending(header);
    }

    pub(crate) fn mark_satisfied_keeping_header(&mut self) {
        *self = match std::mem::replace(self, Self::Unbound) {
            Self::Pending(h) | Self::Satisfied(h) => Self::Satisfied(h),
            Self::Unbound => Self::Unbound,
        };
    }

    pub(crate) fn mark_fresh_spawn(&mut self) {
        *self = match std::mem::replace(self, Self::Unbound) {
            Self::Pending(h) | Self::Satisfied(h) => Self::Pending(h),
            Self::Unbound => Self::Unbound,
        };
    }

    pub(crate) fn require_redelivery(&mut self) {
        *self = match std::mem::replace(self, Self::Unbound) {
            Self::Pending(h) | Self::Satisfied(h) => Self::Pending(h),
            Self::Unbound => Self::Unbound,
        };
    }

    #[must_use]
    pub(crate) const fn is_satisfied(&self) -> bool {
        matches!(self, Self::Satisfied(_))
    }

    #[must_use]
    pub(crate) const fn is_unbound(&self) -> bool {
        matches!(self, Self::Unbound)
    }

    #[must_use]
    pub(crate) const fn pending_header(&self) -> Option<&CoderSessionHeader> {
        match self {
            Self::Pending(h) => Some(h),
            Self::Satisfied(_) | Self::Unbound => None,
        }
    }
}

#[cfg(test)]
mod tests {
    use super::{CoderSessionHeader, SessionHeaderLifecycle};
    use std::path::PathBuf;

    fn sample_header(label: &str) -> CoderSessionHeader {
        CoderSessionHeader {
            prompt: format!("prompt-{label}"),
            log_path: PathBuf::from(format!("/tmp/{label}.log")),
            stdout_label: label.to_string(),
            log_who: "header".into(),
        }
    }

    #[test]
    fn bind_after_satisfied_reenters_pending() {
        let mut life = SessionHeaderLifecycle::Unbound;
        life.bind(sample_header("first"));
        life.mark_satisfied_keeping_header();
        assert!(life.is_satisfied());
        life.bind(sample_header("second"));
        assert!(!life.is_satisfied());
        assert_eq!(
            life.pending_header().map(|h| h.stdout_label.as_str()),
            Some("second")
        );
    }

    #[test]
    fn require_redelivery_promotes_satisfied_header_to_pending() {
        let mut life = SessionHeaderLifecycle::Unbound;
        life.bind(sample_header("keep"));
        life.mark_satisfied_keeping_header();
        life.require_redelivery();
        assert_eq!(
            life.pending_header().map(|h| h.stdout_label.as_str()),
            Some("keep")
        );
    }

    #[test]
    fn mark_satisfied_from_unbound_stays_unbound() {
        let mut life = SessionHeaderLifecycle::Unbound;
        life.mark_satisfied_keeping_header();
        assert!(life.is_unbound());
        assert!(!life.is_satisfied());
    }

    #[test]
    fn fresh_spawn_from_satisfied_reenters_pending_with_same_header() {
        let mut life = SessionHeaderLifecycle::Unbound;
        life.bind(sample_header("spawn"));
        life.mark_satisfied_keeping_header();
        life.mark_fresh_spawn();
        assert_eq!(
            life.pending_header().map(|h| h.stdout_label.as_str()),
            Some("spawn")
        );
    }
}