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,
};
pub fn new_managed_session_id() -> String {
Uuid::new_v4().to_string()
}
pub fn detected_session_id(pid: u32, started_at: OffsetDateTime) -> String {
format!("detected-{pid}-{}", started_at.unix_timestamp())
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SessionHistoryEntry {
pub info: SessionInfo,
pub finished_at: Option<OffsetDateTime>,
pub exit_code: Option<i32>,
pub duration_ms: Option<i64>,
pub summary: SummaryCounts,
}
impl SessionHistoryEntry {
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",
}
}
pub fn command_line(&self) -> String {
if self.info.command.is_empty() {
self.info.title.clone()
} else {
self.info.command.join(" ")
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SessionState {
pub info: SessionInfo,
pub finished_at: Option<OffsetDateTime>,
pub exit_code: Option<i32>,
pub duration_ms: Option<i64>,
pub summary: SummaryCounts,
pub logs: VecDeque<LogEntry>,
pub diagnostics: Vec<DiagnosticRecord>,
pub artifacts: Vec<ArtifactRecord>,
pub last_updated_at: OffsetDateTime,
max_logs: usize,
}
impl SessionState {
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,
}
}
pub fn is_running(&self) -> bool {
self.info.status == SessionStatus::Running
}
pub fn command_line(&self) -> String {
if self.info.command.is_empty() {
self.info.title.clone()
} else {
self.info.command.join(" ")
}
}
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())
}
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,
}
}
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;
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct LogFilter {
pub errors: bool,
pub warnings: bool,
pub notes: bool,
pub help: bool,
pub info: bool,
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 {
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
}
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)
}
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,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SessionSelection {
Active(String),
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);
}
}