Skip to main content

cargowatch_core/
session.rs

1//! Session state aggregation and filtering helpers.
2
3use std::collections::VecDeque;
4use std::path::PathBuf;
5
6use serde::{Deserialize, Serialize};
7use time::OffsetDateTime;
8use uuid::Uuid;
9
10use crate::event::{
11    ArtifactRecord, DetectedProcess, DiagnosticRecord, LogEntry, SessionEvent, SessionFinished,
12    SessionInfo, SessionMode, SessionStatus, Severity, SummaryCounts,
13};
14
15/// Create a new managed-session identifier.
16pub fn new_managed_session_id() -> String {
17    Uuid::new_v4().to_string()
18}
19
20/// Create a deterministic detected-session identifier from pid and start time.
21pub fn detected_session_id(pid: u32, started_at: OffsetDateTime) -> String {
22    format!("detected-{pid}-{}", started_at.unix_timestamp())
23}
24
25/// History row displayed in the UI.
26#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
27pub struct SessionHistoryEntry {
28    /// Session metadata.
29    pub info: SessionInfo,
30    /// End timestamp if known.
31    pub finished_at: Option<OffsetDateTime>,
32    /// Final exit code.
33    pub exit_code: Option<i32>,
34    /// Duration in milliseconds.
35    pub duration_ms: Option<i64>,
36    /// Summary counts.
37    pub summary: SummaryCounts,
38}
39
40impl SessionHistoryEntry {
41    /// Build a short status line for lists and tables.
42    pub fn status_label(&self) -> &'static str {
43        match self.info.status {
44            SessionStatus::Running => "running",
45            SessionStatus::Succeeded => "succeeded",
46            SessionStatus::Failed => "failed",
47            SessionStatus::Cancelled => "cancelled",
48            SessionStatus::Lost => "lost",
49        }
50    }
51
52    /// Compose the command line for display.
53    pub fn command_line(&self) -> String {
54        if self.info.command.is_empty() {
55            self.info.title.clone()
56        } else {
57            self.info.command.join(" ")
58        }
59    }
60}
61
62/// Session payload used by the TUI and persistence.
63#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
64pub struct SessionState {
65    /// Stable metadata.
66    pub info: SessionInfo,
67    /// Latest completion time.
68    pub finished_at: Option<OffsetDateTime>,
69    /// Exit code if known.
70    pub exit_code: Option<i32>,
71    /// Duration in milliseconds if known.
72    pub duration_ms: Option<i64>,
73    /// Aggregated summary counts.
74    pub summary: SummaryCounts,
75    /// Bounded raw/rendered logs.
76    pub logs: VecDeque<LogEntry>,
77    /// Structured diagnostics.
78    pub diagnostics: Vec<DiagnosticRecord>,
79    /// Recorded artifacts.
80    pub artifacts: Vec<ArtifactRecord>,
81    /// Last update observed.
82    pub last_updated_at: OffsetDateTime,
83    max_logs: usize,
84}
85
86impl SessionState {
87    /// Create a new state container from session metadata.
88    pub fn new(info: SessionInfo, max_logs: usize) -> Self {
89        let started_at = info.started_at;
90        Self {
91            info,
92            finished_at: None,
93            exit_code: None,
94            duration_ms: None,
95            summary: SummaryCounts::default(),
96            logs: VecDeque::with_capacity(max_logs.min(1_024)),
97            diagnostics: Vec::new(),
98            artifacts: Vec::new(),
99            last_updated_at: started_at,
100            max_logs,
101        }
102    }
103
104    /// Return whether the session is currently active.
105    pub fn is_running(&self) -> bool {
106        self.info.status == SessionStatus::Running
107    }
108
109    /// Return the best display command.
110    pub fn command_line(&self) -> String {
111        if self.info.command.is_empty() {
112            self.info.title.clone()
113        } else {
114            self.info.command.join(" ")
115        }
116    }
117
118    /// Return a short workspace label.
119    pub fn workspace_label(&self) -> String {
120        self.info
121            .workspace_root
122            .as_ref()
123            .or(Some(&self.info.cwd))
124            .map(|path| path.display().to_string())
125            .unwrap_or_else(|| "<unknown>".to_string())
126    }
127
128    /// Build a history entry from the current state.
129    pub fn history_entry(&self) -> SessionHistoryEntry {
130        SessionHistoryEntry {
131            info: self.info.clone(),
132            finished_at: self.finished_at,
133            exit_code: self.exit_code,
134            duration_ms: self.duration_ms,
135            summary: self.summary,
136        }
137    }
138
139    /// Apply an incoming event to the state.
140    pub fn apply(&mut self, event: &SessionEvent) {
141        match event {
142            SessionEvent::OutputLine { session_id, entry }
143                if *session_id == self.info.session_id =>
144            {
145                self.last_updated_at = entry.timestamp;
146                if let Some(severity) = entry.severity {
147                    self.summary.observe(severity);
148                }
149                self.push_log(entry.clone());
150            }
151            SessionEvent::Diagnostic {
152                session_id,
153                diagnostic,
154            } if *session_id == self.info.session_id => {
155                self.last_updated_at = diagnostic.timestamp;
156                self.summary.observe(diagnostic.severity);
157                self.diagnostics.push(diagnostic.clone());
158            }
159            SessionEvent::ArtifactBuilt {
160                session_id,
161                artifact,
162            } if *session_id == self.info.session_id => {
163                self.last_updated_at = artifact.timestamp;
164                self.artifacts.push(artifact.clone());
165            }
166            SessionEvent::SessionFinished(finished)
167                if finished.session_id == self.info.session_id =>
168            {
169                self.apply_finished(finished.clone());
170            }
171            SessionEvent::ProcessUpdated(process)
172                if process.session_id == self.info.session_id
173                    && self.info.mode == SessionMode::Detected =>
174            {
175                self.apply_detected_update(process.clone());
176            }
177            SessionEvent::ProcessGone {
178                session_id,
179                observed_at,
180                ..
181            } if *session_id == self.info.session_id => {
182                self.info.status = SessionStatus::Lost;
183                self.finished_at = Some(*observed_at);
184                self.last_updated_at = *observed_at;
185            }
186            _ => {}
187        }
188    }
189
190    fn push_log(&mut self, entry: LogEntry) {
191        if self.logs.len() == self.max_logs {
192            self.logs.pop_front();
193        }
194        self.logs.push_back(entry);
195    }
196
197    fn apply_finished(&mut self, finished: SessionFinished) {
198        self.info.status = finished.status;
199        self.finished_at = Some(finished.finished_at);
200        self.exit_code = finished.exit_code;
201        self.duration_ms = Some(finished.duration_ms);
202        self.summary = finished.summary;
203        self.last_updated_at = finished.finished_at;
204    }
205
206    fn apply_detected_update(&mut self, process: DetectedProcess) {
207        self.info.command = process.command;
208        self.info.cwd = process.cwd.unwrap_or_else(|| PathBuf::from("."));
209        self.info.workspace_root = process.workspace_root;
210        self.info.classification = Some(process.classification);
211        self.info.external_pid = Some(process.pid);
212        self.last_updated_at = process.last_seen_at;
213        self.duration_ms = Some(process.elapsed_ms);
214        self.info.status = SessionStatus::Running;
215    }
216}
217
218/// A severity-aware filter used by the log and diagnostic panes.
219#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
220pub struct LogFilter {
221    /// Show error entries.
222    pub errors: bool,
223    /// Show warning entries.
224    pub warnings: bool,
225    /// Show note entries.
226    pub notes: bool,
227    /// Show help entries.
228    pub help: bool,
229    /// Show info entries.
230    pub info: bool,
231    /// Text search query.
232    pub search: Option<String>,
233}
234
235impl Default for LogFilter {
236    fn default() -> Self {
237        Self {
238            errors: true,
239            warnings: true,
240            notes: true,
241            help: true,
242            info: true,
243            search: None,
244        }
245    }
246}
247
248impl LogFilter {
249    /// Enable only a single severity band.
250    pub fn only(severity: Severity) -> Self {
251        let mut filter = Self {
252            errors: false,
253            warnings: false,
254            notes: false,
255            help: false,
256            info: false,
257            search: None,
258        };
259        match severity {
260            Severity::Error => filter.errors = true,
261            Severity::Warning => filter.warnings = true,
262            Severity::Note => filter.notes = true,
263            Severity::Help => filter.help = true,
264            Severity::Info | Severity::Success => filter.info = true,
265        }
266        filter
267    }
268
269    /// Return whether a log entry passes this filter.
270    pub fn matches_log(&self, entry: &LogEntry) -> bool {
271        let severity_match = match entry.severity.unwrap_or(Severity::Info) {
272            Severity::Error => self.errors,
273            Severity::Warning => self.warnings,
274            Severity::Note => self.notes,
275            Severity::Help => self.help,
276            Severity::Info | Severity::Success => self.info,
277        };
278        severity_match && self.matches_text(&entry.text)
279    }
280
281    /// Return whether a diagnostic passes this filter.
282    pub fn matches_diagnostic(&self, diagnostic: &DiagnosticRecord) -> bool {
283        let severity_match = match diagnostic.severity {
284            Severity::Error => self.errors,
285            Severity::Warning => self.warnings,
286            Severity::Note => self.notes,
287            Severity::Help => self.help,
288            Severity::Info | Severity::Success => self.info,
289        };
290        severity_match
291            && self.matches_text(
292                diagnostic
293                    .rendered
294                    .as_deref()
295                    .unwrap_or(&diagnostic.message),
296            )
297    }
298
299    fn matches_text(&self, text: &str) -> bool {
300        match self.search.as_deref() {
301            Some(query) if !query.is_empty() => text.to_lowercase().contains(&query.to_lowercase()),
302            _ => true,
303        }
304    }
305}
306
307/// Selection source for the left pane.
308#[derive(Debug, Clone, PartialEq, Eq)]
309pub enum SessionSelection {
310    /// An in-memory active session.
311    Active(String),
312    /// A history entry loaded from SQLite.
313    History(String),
314}
315
316#[cfg(test)]
317mod tests {
318    use time::OffsetDateTime;
319
320    use super::*;
321    use crate::event::{OutputStream, SessionMode, SessionStatus};
322
323    #[test]
324    fn filter_matches_severity_and_search() {
325        let filter = LogFilter {
326            errors: true,
327            warnings: false,
328            notes: false,
329            help: false,
330            info: false,
331            search: Some("borrow".to_string()),
332        };
333        let entry = LogEntry {
334            sequence: 1,
335            timestamp: OffsetDateTime::now_utc(),
336            stream: OutputStream::Stderr,
337            text: "error[E0502]: cannot borrow `x` as mutable".to_string(),
338            raw: None,
339            severity: Some(Severity::Error),
340        };
341
342        assert!(filter.matches_log(&entry));
343    }
344
345    #[test]
346    fn state_applies_finished_event() {
347        let started_at = OffsetDateTime::now_utc();
348        let info = SessionInfo {
349            session_id: "session-1".to_string(),
350            mode: SessionMode::Managed,
351            title: "cargo check".to_string(),
352            command: vec!["cargo".to_string(), "check".to_string()],
353            cwd: PathBuf::from("/tmp/demo"),
354            workspace_root: Some(PathBuf::from("/tmp/demo")),
355            started_at,
356            status: SessionStatus::Running,
357            external_pid: None,
358            classification: None,
359        };
360        let mut state = SessionState::new(info, 32);
361        let finished = SessionFinished {
362            session_id: "session-1".to_string(),
363            finished_at: started_at + time::Duration::seconds(3),
364            status: SessionStatus::Failed,
365            exit_code: Some(101),
366            duration_ms: 3_000,
367            summary: SummaryCounts {
368                errors: 2,
369                warnings: 1,
370                notes: 0,
371                help: 0,
372                info: 3,
373            },
374        };
375
376        state.apply(&SessionEvent::SessionFinished(finished));
377
378        assert_eq!(state.info.status, SessionStatus::Failed);
379        assert_eq!(state.exit_code, Some(101));
380        assert_eq!(state.summary.errors, 2);
381    }
382}