cargowatch-core 0.1.0

Shared domain types, configuration, and session state for CargoWatch.
Documentation
//! Session state aggregation and filtering helpers.

use std::collections::VecDeque;
use std::path::PathBuf;

use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
use uuid::Uuid;

use crate::event::{
    ArtifactRecord, DetectedProcess, DiagnosticRecord, LogEntry, SessionEvent, SessionFinished,
    SessionInfo, SessionMode, SessionStatus, Severity, SummaryCounts,
};

/// Create a new managed-session identifier.
pub fn new_managed_session_id() -> String {
    Uuid::new_v4().to_string()
}

/// Create a deterministic detected-session identifier from pid and start time.
pub fn detected_session_id(pid: u32, started_at: OffsetDateTime) -> String {
    format!("detected-{pid}-{}", started_at.unix_timestamp())
}

/// History row displayed in the UI.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SessionHistoryEntry {
    /// Session metadata.
    pub info: SessionInfo,
    /// End timestamp if known.
    pub finished_at: Option<OffsetDateTime>,
    /// Final exit code.
    pub exit_code: Option<i32>,
    /// Duration in milliseconds.
    pub duration_ms: Option<i64>,
    /// Summary counts.
    pub summary: SummaryCounts,
}

impl SessionHistoryEntry {
    /// Build a short status line for lists and tables.
    pub fn status_label(&self) -> &'static str {
        match self.info.status {
            SessionStatus::Running => "running",
            SessionStatus::Succeeded => "succeeded",
            SessionStatus::Failed => "failed",
            SessionStatus::Cancelled => "cancelled",
            SessionStatus::Lost => "lost",
        }
    }

    /// Compose the command line for display.
    pub fn command_line(&self) -> String {
        if self.info.command.is_empty() {
            self.info.title.clone()
        } else {
            self.info.command.join(" ")
        }
    }
}

/// Session payload used by the TUI and persistence.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SessionState {
    /// Stable metadata.
    pub info: SessionInfo,
    /// Latest completion time.
    pub finished_at: Option<OffsetDateTime>,
    /// Exit code if known.
    pub exit_code: Option<i32>,
    /// Duration in milliseconds if known.
    pub duration_ms: Option<i64>,
    /// Aggregated summary counts.
    pub summary: SummaryCounts,
    /// Bounded raw/rendered logs.
    pub logs: VecDeque<LogEntry>,
    /// Structured diagnostics.
    pub diagnostics: Vec<DiagnosticRecord>,
    /// Recorded artifacts.
    pub artifacts: Vec<ArtifactRecord>,
    /// Last update observed.
    pub last_updated_at: OffsetDateTime,
    max_logs: usize,
}

impl SessionState {
    /// Create a new state container from session metadata.
    pub fn new(info: SessionInfo, max_logs: usize) -> Self {
        let started_at = info.started_at;
        Self {
            info,
            finished_at: None,
            exit_code: None,
            duration_ms: None,
            summary: SummaryCounts::default(),
            logs: VecDeque::with_capacity(max_logs.min(1_024)),
            diagnostics: Vec::new(),
            artifacts: Vec::new(),
            last_updated_at: started_at,
            max_logs,
        }
    }

    /// Return whether the session is currently active.
    pub fn is_running(&self) -> bool {
        self.info.status == SessionStatus::Running
    }

    /// Return the best display command.
    pub fn command_line(&self) -> String {
        if self.info.command.is_empty() {
            self.info.title.clone()
        } else {
            self.info.command.join(" ")
        }
    }

    /// Return a short workspace label.
    pub fn workspace_label(&self) -> String {
        self.info
            .workspace_root
            .as_ref()
            .or(Some(&self.info.cwd))
            .map(|path| path.display().to_string())
            .unwrap_or_else(|| "<unknown>".to_string())
    }

    /// Build a history entry from the current state.
    pub fn history_entry(&self) -> SessionHistoryEntry {
        SessionHistoryEntry {
            info: self.info.clone(),
            finished_at: self.finished_at,
            exit_code: self.exit_code,
            duration_ms: self.duration_ms,
            summary: self.summary,
        }
    }

    /// Apply an incoming event to the state.
    pub fn apply(&mut self, event: &SessionEvent) {
        match event {
            SessionEvent::OutputLine { session_id, entry }
                if *session_id == self.info.session_id =>
            {
                self.last_updated_at = entry.timestamp;
                if let Some(severity) = entry.severity {
                    self.summary.observe(severity);
                }
                self.push_log(entry.clone());
            }
            SessionEvent::Diagnostic {
                session_id,
                diagnostic,
            } if *session_id == self.info.session_id => {
                self.last_updated_at = diagnostic.timestamp;
                self.summary.observe(diagnostic.severity);
                self.diagnostics.push(diagnostic.clone());
            }
            SessionEvent::ArtifactBuilt {
                session_id,
                artifact,
            } if *session_id == self.info.session_id => {
                self.last_updated_at = artifact.timestamp;
                self.artifacts.push(artifact.clone());
            }
            SessionEvent::SessionFinished(finished)
                if finished.session_id == self.info.session_id =>
            {
                self.apply_finished(finished.clone());
            }
            SessionEvent::ProcessUpdated(process)
                if process.session_id == self.info.session_id
                    && self.info.mode == SessionMode::Detected =>
            {
                self.apply_detected_update(process.clone());
            }
            SessionEvent::ProcessGone {
                session_id,
                observed_at,
                ..
            } if *session_id == self.info.session_id => {
                self.info.status = SessionStatus::Lost;
                self.finished_at = Some(*observed_at);
                self.last_updated_at = *observed_at;
            }
            _ => {}
        }
    }

    fn push_log(&mut self, entry: LogEntry) {
        if self.logs.len() == self.max_logs {
            self.logs.pop_front();
        }
        self.logs.push_back(entry);
    }

    fn apply_finished(&mut self, finished: SessionFinished) {
        self.info.status = finished.status;
        self.finished_at = Some(finished.finished_at);
        self.exit_code = finished.exit_code;
        self.duration_ms = Some(finished.duration_ms);
        self.summary = finished.summary;
        self.last_updated_at = finished.finished_at;
    }

    fn apply_detected_update(&mut self, process: DetectedProcess) {
        self.info.command = process.command;
        self.info.cwd = process.cwd.unwrap_or_else(|| PathBuf::from("."));
        self.info.workspace_root = process.workspace_root;
        self.info.classification = Some(process.classification);
        self.info.external_pid = Some(process.pid);
        self.last_updated_at = process.last_seen_at;
        self.duration_ms = Some(process.elapsed_ms);
        self.info.status = SessionStatus::Running;
    }
}

/// A severity-aware filter used by the log and diagnostic panes.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct LogFilter {
    /// Show error entries.
    pub errors: bool,
    /// Show warning entries.
    pub warnings: bool,
    /// Show note entries.
    pub notes: bool,
    /// Show help entries.
    pub help: bool,
    /// Show info entries.
    pub info: bool,
    /// Text search query.
    pub search: Option<String>,
}

impl Default for LogFilter {
    fn default() -> Self {
        Self {
            errors: true,
            warnings: true,
            notes: true,
            help: true,
            info: true,
            search: None,
        }
    }
}

impl LogFilter {
    /// Enable only a single severity band.
    pub fn only(severity: Severity) -> Self {
        let mut filter = Self {
            errors: false,
            warnings: false,
            notes: false,
            help: false,
            info: false,
            search: None,
        };
        match severity {
            Severity::Error => filter.errors = true,
            Severity::Warning => filter.warnings = true,
            Severity::Note => filter.notes = true,
            Severity::Help => filter.help = true,
            Severity::Info | Severity::Success => filter.info = true,
        }
        filter
    }

    /// Return whether a log entry passes this filter.
    pub fn matches_log(&self, entry: &LogEntry) -> bool {
        let severity_match = match entry.severity.unwrap_or(Severity::Info) {
            Severity::Error => self.errors,
            Severity::Warning => self.warnings,
            Severity::Note => self.notes,
            Severity::Help => self.help,
            Severity::Info | Severity::Success => self.info,
        };
        severity_match && self.matches_text(&entry.text)
    }

    /// Return whether a diagnostic passes this filter.
    pub fn matches_diagnostic(&self, diagnostic: &DiagnosticRecord) -> bool {
        let severity_match = match diagnostic.severity {
            Severity::Error => self.errors,
            Severity::Warning => self.warnings,
            Severity::Note => self.notes,
            Severity::Help => self.help,
            Severity::Info | Severity::Success => self.info,
        };
        severity_match
            && self.matches_text(
                diagnostic
                    .rendered
                    .as_deref()
                    .unwrap_or(&diagnostic.message),
            )
    }

    fn matches_text(&self, text: &str) -> bool {
        match self.search.as_deref() {
            Some(query) if !query.is_empty() => text.to_lowercase().contains(&query.to_lowercase()),
            _ => true,
        }
    }
}

/// Selection source for the left pane.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SessionSelection {
    /// An in-memory active session.
    Active(String),
    /// A history entry loaded from SQLite.
    History(String),
}

#[cfg(test)]
mod tests {
    use time::OffsetDateTime;

    use super::*;
    use crate::event::{OutputStream, SessionMode, SessionStatus};

    #[test]
    fn filter_matches_severity_and_search() {
        let filter = LogFilter {
            errors: true,
            warnings: false,
            notes: false,
            help: false,
            info: false,
            search: Some("borrow".to_string()),
        };
        let entry = LogEntry {
            sequence: 1,
            timestamp: OffsetDateTime::now_utc(),
            stream: OutputStream::Stderr,
            text: "error[E0502]: cannot borrow `x` as mutable".to_string(),
            raw: None,
            severity: Some(Severity::Error),
        };

        assert!(filter.matches_log(&entry));
    }

    #[test]
    fn state_applies_finished_event() {
        let started_at = OffsetDateTime::now_utc();
        let info = SessionInfo {
            session_id: "session-1".to_string(),
            mode: SessionMode::Managed,
            title: "cargo check".to_string(),
            command: vec!["cargo".to_string(), "check".to_string()],
            cwd: PathBuf::from("/tmp/demo"),
            workspace_root: Some(PathBuf::from("/tmp/demo")),
            started_at,
            status: SessionStatus::Running,
            external_pid: None,
            classification: None,
        };
        let mut state = SessionState::new(info, 32);
        let finished = SessionFinished {
            session_id: "session-1".to_string(),
            finished_at: started_at + time::Duration::seconds(3),
            status: SessionStatus::Failed,
            exit_code: Some(101),
            duration_ms: 3_000,
            summary: SummaryCounts {
                errors: 2,
                warnings: 1,
                notes: 0,
                help: 0,
                info: 3,
            },
        };

        state.apply(&SessionEvent::SessionFinished(finished));

        assert_eq!(state.info.status, SessionStatus::Failed);
        assert_eq!(state.exit_code, Some(101));
        assert_eq!(state.summary.errors, 2);
    }
}