interactive.rs

use std::error::Error;
use std::fmt::{self, Write as _};
use std::io::{self, Write};

use flash_runtime::background::{JobNoticeKind, LiveJob, LiveJobState};
use flash_runtime::job::JobId;

use crate::completion::CompletionCatalog;
use crate::editor::{
    EditorError, EditorEvent, EditorEventSource, EditorExternalPrint, EditorPrompt, LineEditor,
};
use crate::report::HostExit;

/// Control flow requested after evaluating one submitted edit buffer.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum EvaluationControl {
    Continue,
    Exit(u8),
}

/// A recoverable diagnostic rendered by the interactive evaluator.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct InteractiveDiagnostic {
    rendered: String,
}

impl InteractiveDiagnostic {
    #[must_use]
    pub fn new(rendered: impl Into<String>) -> Self {
        Self {
            rendered: rendered.into(),
        }
    }

    #[must_use]
    pub fn rendered(&self) -> &str {
        &self.rendered
    }
}

/// Result of one interactive evaluation attempt.
#[derive(Debug)]
pub enum InteractiveEvaluationError {
    /// A source-backed or shell-owned diagnostic after which the session may recover.
    Diagnostic(InteractiveDiagnostic),
    /// A program-output failure that makes the interactive session unusable.
    ProgramOutput(io::Error),
}

impl From<InteractiveDiagnostic> for InteractiveEvaluationError {
    fn from(diagnostic: InteractiveDiagnostic) -> Self {
        Self::Diagnostic(diagnostic)
    }
}

/// Opaque identity of one interactive notice.
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
pub struct InteractiveNoticeId(u64);

impl InteractiveNoticeId {
    /// Build a nonzero identity supplied by the evaluator.
    #[must_use]
    pub const fn new(value: u64) -> Option<Self> {
        if value == 0 { None } else { Some(Self(value)) }
    }

    /// The evaluator-local numeric identity.
    #[must_use]
    pub const fn get(self) -> u64 {
        self.0
    }
}

/// One complete editor-owned notice awaiting successful presentation.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct InteractiveNotice {
    id: InteractiveNoticeId,
    rendered: String,
}

impl InteractiveNotice {
    /// Build one notice from its opaque identity and complete rendered text.
    #[must_use]
    pub fn new(id: InteractiveNoticeId, rendered: impl Into<String>) -> Self {
        Self {
            id,
            rendered: rendered.into(),
        }
    }

    /// The identity returned to the evaluator after a successful write.
    #[must_use]
    pub const fn id(&self) -> InteractiveNoticeId {
        self.id
    }

    /// Complete text written by the editor before the next prompt.
    #[must_use]
    pub fn rendered(&self) -> &str {
        &self.rendered
    }
}

/// Failure while acknowledging a successfully written interactive notice.
#[derive(Debug)]
pub struct InteractiveNoticeError {
    message: String,
    source: Option<Box<dyn Error + Send + Sync>>,
}

impl InteractiveNoticeError {
    /// Build a notice acknowledgement failure without an underlying source.
    #[must_use]
    pub fn new(message: impl Into<String>) -> Self {
        Self {
            message: message.into(),
            source: None,
        }
    }

    /// Build a notice acknowledgement failure with an underlying source.
    pub fn with_source(
        message: impl Into<String>,
        source: impl Error + Send + Sync + 'static,
    ) -> Self {
        Self {
            message: message.into(),
            source: Some(Box::new(source)),
        }
    }
}

impl fmt::Display for InteractiveNoticeError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter.write_str(&self.message)
    }
}

impl Error for InteractiveNoticeError {
    fn source(&self) -> Option<&(dyn Error + 'static)> {
        self.source
            .as_deref()
            .map(|source| source as &(dyn Error + 'static))
    }
}

/// Format one structured runtime job notice for prompt-boundary presentation.
#[must_use]
pub fn format_job_notice(job: JobId, kind: &JobNoticeKind, command: &str) -> String {
    match kind {
        JobNoticeKind::Started { group } => format!("[{}] {group}\n", job.get()),
        JobNoticeKind::Stopped => format!("[{}] Stopped  {command}\n", job.get()),
        JobNoticeKind::Completed => format!("[{}] Done     {command}\n", job.get()),
        JobNoticeKind::ObservationFailed { message } => {
            format!("[{}] Failed   {command}: {message}\n", job.get())
        }
    }
}

/// Render one refusal listing for the live jobs a session would abandon.
#[must_use]
pub fn format_live_jobs(jobs: &[LiveJob]) -> String {
    let mut rendered = format!(
        "fsh: {} live background job{}\n",
        jobs.len(),
        if jobs.len() == 1 { "" } else { "s" }
    );
    for live in jobs {
        let state = match live.state() {
            LiveJobState::Running => "Running",
            LiveJobState::Stopped => "Stopped",
        };
        let _ = writeln!(
            rendered,
            "[{}] {state}  {}",
            live.job().get(),
            live.command()
        );
        let _ = writeln!(rendered, "    force: kill --kill %{}", live.job().get());
    }
    rendered.push_str("fsh: exit again to hang up\n");
    rendered
}

/// Whether an interactive session may end, and what to show if it may not.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum ExitDecision {
    /// The session may end now.
    Permitted,
    /// The session may not end yet; the text is one complete shell-owned notice.
    Refused {
        /// One complete refusal notice, newline-terminated.
        rendered: String,
    },
}

/// Stateful evaluation boundary owned for the lifetime of an interactive session.
pub trait InteractiveEvaluator {
    /// Snapshots live completion candidates at one deterministic prompt boundary.
    fn completion_catalog(&mut self) -> Option<CompletionCatalog> {
        None
    }

    /// Peek one notice to present before the next prompt.
    fn next_notice(&mut self) -> Option<InteractiveNotice> {
        None
    }

    /// Decide whether the session may end.
    ///
    /// Asked at every exit route. A refusal is presented through the editor, so
    /// the editor keeps sole ownership of terminal writes at this boundary.
    fn request_exit(&mut self) -> ExitDecision {
        ExitDecision::Permitted
    }

    /// Acknowledge one notice only after the editor has written it successfully.
    fn acknowledge_notice(
        &mut self,
        _notice: &InteractiveNotice,
    ) -> Result<(), InteractiveNoticeError> {
        Ok(())
    }

    /// Unconditionally clean up resources owned by a fatally ending session.
    ///
    /// The returned reports are already ordered and newline-terminated. They
    /// are presented by the driver only while the diagnostic stream remains
    /// usable.
    fn fatal_cleanup(&mut self) -> Vec<String> {
        Vec::new()
    }

    fn evaluate(
        &mut self,
        source: &str,
        output: &mut dyn Write,
    ) -> Result<EvaluationControl, InteractiveEvaluationError>;
}

/// Normal reason for leaving an interactive session.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum InteractiveExit {
    EndOfInput,
    Requested(u8),
}

/// Fatal failure that prevents an interactive session from continuing.
#[derive(Debug)]
pub enum InteractiveSessionError {
    Editor(EditorError),
    NoticeAcknowledgement(InteractiveNoticeError),
    ProgramOutput(io::Error),
    DiagnosticOutput(io::Error),
    UnsupportedEditorEvent(&'static str),
}

impl fmt::Display for InteractiveSessionError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Editor(error) => write!(formatter, "interactive editor failed: {error}"),
            Self::NoticeAcknowledgement(error) => {
                write!(
                    formatter,
                    "interactive notice acknowledgement failed: {error}"
                )
            }
            Self::ProgramOutput(error) => {
                write!(formatter, "interactive command output failed: {error}")
            }
            Self::DiagnosticOutput(error) => {
                write!(formatter, "interactive diagnostic output failed: {error}")
            }
            Self::UnsupportedEditorEvent(event) => {
                write!(
                    formatter,
                    "interactive editor event is not supported: {event}"
                )
            }
        }
    }
}

impl Error for InteractiveSessionError {
    fn source(&self) -> Option<&(dyn Error + 'static)> {
        match self {
            Self::Editor(error) => Some(error),
            Self::NoticeAcknowledgement(error) => Some(error),
            Self::ProgramOutput(error) => Some(error),
            Self::DiagnosticOutput(error) => Some(error),
            Self::UnsupportedEditorEvent(_) => None,
        }
    }
}

/// Write and acknowledge every notice the evaluator currently holds.
///
/// Run at the prompt boundary and again before a permitted exit, so a completion
/// observed while the editor owned the prompt is still shown to the user rather
/// than discarded with the session.
fn render_pending_notices(
    editor: &mut dyn LineEditor,
    evaluator: &mut dyn InteractiveEvaluator,
) -> Result<(), InteractiveSessionError> {
    while let Some(notice) = evaluator.next_notice() {
        editor
            .write_notice(notice.rendered())
            .map_err(InteractiveSessionError::Editor)?;
        evaluator
            .acknowledge_notice(&notice)
            .map_err(InteractiveSessionError::NoticeAcknowledgement)?;
    }
    Ok(())
}

struct EvaluatorEditorEvents<'evaluator> {
    evaluator: &'evaluator mut dyn InteractiveEvaluator,
    pending: Option<InteractiveNotice>,
}

impl EditorEventSource for EvaluatorEditorEvents<'_> {
    fn next_external_print(&mut self) -> Option<EditorExternalPrint> {
        let notice = self.evaluator.next_notice()?;
        let event = EditorExternalPrint::new(notice.id().get(), notice.rendered());
        self.pending = Some(notice);
        Some(event)
    }

    fn acknowledge_external_print(
        &mut self,
        event: &EditorExternalPrint,
    ) -> Result<(), EditorError> {
        let notice = self
            .pending
            .take()
            .ok_or_else(|| EditorError::new("external print is not pending"))?;
        if notice.id().get() != event.id() {
            return Err(EditorError::new(format!(
                "external print {} does not match pending notice {}",
                event.id(),
                notice.id().get()
            )));
        }
        self.evaluator
            .acknowledge_notice(&notice)
            .map_err(|error| EditorError::with_source("cannot acknowledge external print", error))
    }
}

/// Runs one synchronous interactive session with persistent editor and evaluator state.
pub fn run_interactive_session(
    editor: &mut dyn LineEditor,
    evaluator: &mut dyn InteractiveEvaluator,
    prompt: &EditorPrompt,
    program_output: &mut dyn Write,
    diagnostic_output: &mut dyn Write,
) -> Result<InteractiveExit, InteractiveSessionError> {
    loop {
        render_pending_notices(editor, evaluator)?;

        if let Some(catalog) = evaluator.completion_catalog() {
            editor.set_completion_catalog(catalog);
        }

        let event = {
            let mut events = EvaluatorEditorEvents {
                evaluator,
                pending: None,
            };
            editor
                .read_line_with_events(prompt, &mut events)
                .map_err(InteractiveSessionError::Editor)?
        };

        match event {
            EditorEvent::Submitted(source) => {
                let evaluation = evaluator.evaluate(&source, program_output);
                if let Err(InteractiveEvaluationError::ProgramOutput(error)) = evaluation {
                    return Err(InteractiveSessionError::ProgramOutput(error));
                }
                program_output
                    .flush()
                    .map_err(InteractiveSessionError::ProgramOutput)?;
                match evaluation {
                    Ok(EvaluationControl::Continue) => {}
                    Ok(EvaluationControl::Exit(status)) => match evaluator.request_exit() {
                        ExitDecision::Permitted => {
                            render_pending_notices(editor, evaluator)?;
                            return Ok(InteractiveExit::Requested(status));
                        }
                        ExitDecision::Refused { rendered } => editor
                            .write_notice(&rendered)
                            .map_err(InteractiveSessionError::Editor)?,
                    },
                    Err(InteractiveEvaluationError::Diagnostic(diagnostic)) => {
                        diagnostic_output
                            .write_all(diagnostic.rendered().as_bytes())
                            .map_err(InteractiveSessionError::DiagnosticOutput)?;
                        diagnostic_output
                            .flush()
                            .map_err(InteractiveSessionError::DiagnosticOutput)?;
                    }
                    Err(InteractiveEvaluationError::ProgramOutput(_)) => {
                        unreachable!("program-output failure is returned before the required flush")
                    }
                }
            }
            EditorEvent::Cancelled => {}
            EditorEvent::EndOfInput => match evaluator.request_exit() {
                ExitDecision::Permitted => {
                    render_pending_notices(editor, evaluator)?;
                    return Ok(InteractiveExit::EndOfInput);
                }
                ExitDecision::Refused { rendered } => editor
                    .write_notice(&rendered)
                    .map_err(InteractiveSessionError::Editor)?,
            },
            EditorEvent::HostCommand(_) => {
                return Err(InteractiveSessionError::UnsupportedEditorEvent(
                    "host command",
                ));
            }
            EditorEvent::ExternalBreak(_) => {
                return Err(InteractiveSessionError::UnsupportedEditorEvent(
                    "external break",
                ));
            }
        }
    }
}

/// Run an interactive session and map its normal or fatal result to a host exit.
///
/// Fatal cleanup always runs before status 1 is selected. If the diagnostic
/// stream itself failed, it is never reused to report that failure or cleanup
/// results.
pub fn run_interactive_driver(
    editor: &mut dyn LineEditor,
    evaluator: &mut dyn InteractiveEvaluator,
    prompt: &EditorPrompt,
    program_output: &mut dyn Write,
    diagnostic_output: &mut dyn Write,
) -> HostExit {
    match run_interactive_session(editor, evaluator, prompt, program_output, diagnostic_output) {
        Ok(InteractiveExit::EndOfInput) => HostExit::Success,
        Ok(InteractiveExit::Requested(code)) => HostExit::Code(code),
        Err(error) => {
            let cleanup_reports = evaluator.fatal_cleanup();
            if !matches!(error, InteractiveSessionError::DiagnosticOutput(_)) {
                let rendered = render_fatal_report(&cleanup_reports, &error);
                let _ = write_required(diagnostic_output, rendered.as_bytes());
            }
            HostExit::Failure
        }
    }
}

fn render_fatal_report(cleanup_reports: &[String], error: &InteractiveSessionError) -> String {
    let mut rendered = cleanup_reports.concat();
    let _ = writeln!(rendered, "fsh: {error}");
    let mut source = error.source();
    while let Some(cause) = source {
        let _ = writeln!(rendered, "fsh:   caused by: {cause}");
        source = cause.source();
    }
    rendered
}

fn write_required(output: &mut dyn Write, bytes: &[u8]) -> io::Result<()> {
    output.write_all(bytes)?;
    output.flush()
}

Return to documentation →