thirdshift 0.10.1

Turn a GitHub issue into a ready-for-review pull request, unattended
//! Interpret a Harness session once, including optional retained records.

use std::path::PathBuf;
use std::process::{ExitStatus, Output};

use anyhow::{Result, anyhow};

use super::{muse, opencode, said};
use crate::interrupt;

mod stream;
pub(super) use stream::Stream;

/// Live interpretation has only line condensation. Consume it after the
/// process owner has stopped or waited for the child and joined its workers.
pub struct Interpretation {
    cli: &'static str,
    decoder: Box<dyn Decoder>,
    retained: Retained,
}

/// Reporting survives ordinary failures; only success supplies Resume facts.
pub struct Completion {
    pub report: Option<Report>,
    pub outcome: Result<Ended>,
}

pub struct Report {
    pub warnings: Vec<String>,
    pub summary: Option<String>,
}

#[derive(Debug)]
pub struct Ended {
    pub session_id: Option<String>,
    pub killed: Vec<String>,
    pub final_message: Option<String>,
}

impl Ended {
    pub fn killed_work(&self) -> Vec<&str> {
        self.killed.iter().map(String::as_str).collect()
    }
}

/// Each real protocol must supply its outcome explicitly. Streams with no
/// terminal outcome remain accepted, as before, unless execution failed.
pub(super) enum TurnOutcome {
    NotFailed,
    Failed,
}

impl TurnOutcome {
    pub(super) fn from_failed(failed: bool) -> Self {
        if failed {
            Self::Failed
        } else {
            Self::NotFailed
        }
    }
}

pub(super) struct Facts {
    pub report: Report,
    pub ended: Ended,
    pub outcome: TurnOutcome,
    /// May be a retried diagnostic even when the turn did not fail.
    pub diagnostic: Option<String>,
}

pub(super) trait Decoder: Send {
    fn condense(&mut self, raw: &str) -> Vec<String>;
    fn complete(self: Box<Self>) -> Facts;
}

pub(super) enum Retained {
    None,
    Muse(Option<PathBuf>),
    OpenCode(PathBuf),
}

impl Retained {
    fn reconcile(self, facts: &mut Facts) -> Result<()> {
        match self {
            Self::None => {}
            Self::Muse(root) => {
                if let Some(log) = root
                    .as_deref()
                    .zip(facts.ended.session_id.as_deref())
                    .and_then(|(root, id)| muse::log::read(root, id))
                {
                    facts.report.summary = log.summary();
                    if log.message.is_some() {
                        facts.ended.final_message = log.message;
                    }
                }
            }
            Self::OpenCode(worktree) => {
                if let Some(id) = facts.ended.session_id.as_deref()
                    && let Some(export) = opencode::export::read(&worktree, id)?
                {
                    // A readable export is authoritative, even without text.
                    facts.ended.final_message = export.message;
                    facts.report.summary = export.summary;
                    if export.failure.is_some() {
                        facts.outcome = TurnOutcome::Failed;
                        facts.diagnostic = facts.diagnostic.take().or(export.failure);
                    }
                }
            }
        }
        Ok(())
    }
}

enum Failure {
    Execution(anyhow::Error),
    Rejected {
        status: ExitStatus,
        diagnostic: Option<String>,
    },
}

impl Failure {
    fn session_error(self, cli: &str) -> anyhow::Error {
        match self {
            Self::Execution(error) => error,
            Self::Rejected { status, diagnostic } => {
                let ended = if status.success() {
                    "'s turn failed".to_string()
                } else {
                    format!(
                        " exited {}",
                        status
                            .code()
                            .map_or("by signal".to_string(), |code| code.to_string())
                    )
                };
                match diagnostic {
                    Some(error) => anyhow!("{cli}{ended}: {error}"),
                    None => anyhow!("{cli}{ended}"),
                }
            }
        }
    }
}

impl Interpretation {
    pub(super) fn new(cli: &'static str, decoder: Box<dyn Decoder>, retained: Retained) -> Self {
        Self {
            cli,
            decoder,
            retained,
        }
    }

    /// Unknown or malformed lines produce no progress, never an error.
    pub fn condense(&mut self, raw: &str) -> Vec<String> {
        self.decoder.condense(raw)
    }

    pub fn finish(self, execution: Result<ExitStatus>) -> Completion {
        let cli = self.cli;
        let (report, outcome) = self.complete(execution);
        Completion {
            report,
            outcome: outcome.map_err(|failure| failure.session_error(cli)),
        }
    }

    /// Captured checks use exactly the same validation and retained policy,
    /// but keep their own refusal context and raw-output fallback.
    pub(super) fn check_output(mut self, output: &Output, refusal: &str) -> Result<()> {
        for line in String::from_utf8_lossy(&output.stdout).lines() {
            self.condense(line);
        }
        let (_, outcome) = self.complete(Ok(output.status));
        outcome.map(drop).map_err(|failure| match failure {
            Failure::Execution(error) => error,
            Failure::Rejected { diagnostic, .. } => {
                anyhow!("{refusal}: {}", diagnostic.unwrap_or_else(|| said(output)))
            }
        })
    }

    fn complete(self, execution: Result<ExitStatus>) -> (Option<Report>, Result<Ended, Failure>) {
        if let Err(error) = interrupt::check() {
            return (None, Err(Failure::Execution(error)));
        }
        let mut facts = self.decoder.complete();
        let recovered = self.retained.reconcile(&mut facts);
        if let Err(error) = interrupt::check() {
            return (None, Err(Failure::Execution(error)));
        }
        let outcome = execution
            .and_then(|status| recovered.map(|()| status))
            .map_err(Failure::Execution)
            .and_then(|status| {
                if !status.success() || matches!(facts.outcome, TurnOutcome::Failed) {
                    Err(Failure::Rejected {
                        status,
                        diagnostic: facts.diagnostic,
                    })
                } else {
                    Ok(facts.ended)
                }
            });
        (Some(facts.report), outcome)
    }
}