1use 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
15pub fn new_managed_session_id() -> String {
17 Uuid::new_v4().to_string()
18}
19
20pub fn detected_session_id(pid: u32, started_at: OffsetDateTime) -> String {
22 format!("detected-{pid}-{}", started_at.unix_timestamp())
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
27pub struct SessionHistoryEntry {
28 pub info: SessionInfo,
30 pub finished_at: Option<OffsetDateTime>,
32 pub exit_code: Option<i32>,
34 pub duration_ms: Option<i64>,
36 pub summary: SummaryCounts,
38}
39
40impl SessionHistoryEntry {
41 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 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
64pub struct SessionState {
65 pub info: SessionInfo,
67 pub finished_at: Option<OffsetDateTime>,
69 pub exit_code: Option<i32>,
71 pub duration_ms: Option<i64>,
73 pub summary: SummaryCounts,
75 pub logs: VecDeque<LogEntry>,
77 pub diagnostics: Vec<DiagnosticRecord>,
79 pub artifacts: Vec<ArtifactRecord>,
81 pub last_updated_at: OffsetDateTime,
83 max_logs: usize,
84}
85
86impl SessionState {
87 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 pub fn is_running(&self) -> bool {
106 self.info.status == SessionStatus::Running
107 }
108
109 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 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 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 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
220pub struct LogFilter {
221 pub errors: bool,
223 pub warnings: bool,
225 pub notes: bool,
227 pub help: bool,
229 pub info: bool,
231 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 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 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 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#[derive(Debug, Clone, PartialEq, Eq)]
309pub enum SessionSelection {
310 Active(String),
312 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}