1use crate::model::types::{Conversation, Message, MessageRole, Workspace};
2use crate::search::query::SearchHit;
3use crate::storage::sqlite::FrankenStorage;
4use crate::ui::components::theme::ThemePalette;
5use anyhow::Result;
6use frankensqlite::compat::{ConnectionExt, RowExt};
7use frankensqlite::{FrankenError, Row};
8use lru::LruCache;
9use once_cell::sync::Lazy;
10use parking_lot::RwLock;
11use std::num::NonZeroUsize;
12use std::sync::Arc;
13use std::sync::atomic::{AtomicU64, Ordering};
14
15#[derive(Clone, Copy, Debug, PartialEq, Eq)]
20pub enum InputMode {
21 Query,
22 Agent,
23 Workspace,
24 CreatedFrom,
25 CreatedTo,
26 PaneFilter,
27 DetailFind,
29}
30
31#[derive(Clone, Debug)]
36pub struct ConversationView {
37 pub convo: Conversation,
38 pub messages: Vec<Message>,
39 pub workspace: Option<Workspace>,
40}
41
42fn normalized_ui_source_identity_sql_expr(
43 source_id_column: &str,
44 origin_host_column: &str,
45) -> String {
46 format!(
47 "CASE WHEN TRIM(COALESCE({source_id_column}, '')) = '' THEN CASE WHEN TRIM(COALESCE({origin_host_column}, '')) = '' THEN '{local}' ELSE TRIM(COALESCE({origin_host_column}, '')) END \
48 WHEN LOWER(TRIM(COALESCE({source_id_column}, ''))) = '{local}' THEN '{local}' \
49 ELSE TRIM(COALESCE({source_id_column}, '')) END",
50 local = crate::sources::provenance::LOCAL_SOURCE_ID,
51 )
52}
53
54fn normalize_ui_source_id_value(source_id: Option<&str>) -> String {
55 let trimmed = source_id.unwrap_or_default().trim();
56 if trimmed.is_empty()
57 || trimmed.eq_ignore_ascii_case(crate::sources::provenance::LOCAL_SOURCE_ID)
58 {
59 crate::sources::provenance::LOCAL_SOURCE_ID.to_string()
60 } else {
61 trimmed.to_string()
62 }
63}
64
65fn normalize_ui_source_id_parts(source_id: Option<&str>, origin_host: Option<&str>) -> String {
66 let trimmed_source_id = source_id.unwrap_or_default().trim();
67 if !trimmed_source_id.is_empty() {
68 return normalize_ui_source_id_value(Some(trimmed_source_id));
69 }
70
71 origin_host
72 .map(str::trim)
73 .filter(|value| !value.is_empty())
74 .map(str::to_string)
75 .unwrap_or_else(|| crate::sources::provenance::LOCAL_SOURCE_ID.to_string())
76}
77
78fn normalize_ui_hit_source_id(hit: &SearchHit) -> String {
79 let trimmed_source_id = hit.source_id.trim();
80 if !trimmed_source_id.is_empty() {
81 return normalize_ui_source_id_value(Some(trimmed_source_id));
82 }
83
84 if let Some(host) = hit
85 .origin_host
86 .as_deref()
87 .map(str::trim)
88 .filter(|value| !value.is_empty())
89 {
90 return host.to_string();
91 }
92
93 if hit.origin_kind.trim().eq_ignore_ascii_case("ssh")
94 || hit.origin_kind.trim().eq_ignore_ascii_case("remote")
95 {
96 return "remote".to_string();
97 }
98
99 crate::sources::provenance::LOCAL_SOURCE_ID.to_string()
100}
101
102#[derive(Debug, Default)]
108pub struct CacheStats {
109 pub hits: AtomicU64,
110 pub misses: AtomicU64,
111 pub evictions: AtomicU64,
112}
113
114impl CacheStats {
115 pub fn get(&self) -> (u64, u64, u64) {
117 (
118 self.hits.load(Ordering::Relaxed),
119 self.misses.load(Ordering::Relaxed),
120 self.evictions.load(Ordering::Relaxed),
121 )
122 }
123
124 pub fn hit_rate(&self) -> f64 {
126 let hits = self.hits.load(Ordering::Relaxed);
127 let misses = self.misses.load(Ordering::Relaxed);
128 let total = hits + misses;
129 if total == 0 {
130 0.0
131 } else {
132 hits as f64 / total as f64
133 }
134 }
135}
136
137const NUM_SHARDS: usize = 16;
139
140const DEFAULT_CAPACITY_PER_SHARD: usize = 256;
142
143pub struct ConversationCache {
153 shards: [RwLock<LruCache<u64, Arc<ConversationView>>>; NUM_SHARDS],
154 stats: CacheStats,
155}
156
157impl ConversationCache {
158 pub fn new(capacity_per_shard: usize) -> Self {
160 Self {
161 shards: std::array::from_fn(|_| {
162 RwLock::new(LruCache::new(
163 NonZeroUsize::new(capacity_per_shard).unwrap_or(NonZeroUsize::MIN),
164 ))
165 }),
166 stats: CacheStats::default(),
167 }
168 }
169
170 #[inline]
172 fn hash_key(cache_scope: Option<&str>, source_id: Option<&str>, source_path: &str) -> u64 {
173 use std::hash::{Hash, Hasher};
174 let mut hasher = rustc_hash::FxHasher::default();
175 cache_scope.unwrap_or("").hash(&mut hasher);
176 if let Some(source_id) = source_id {
177 normalize_ui_source_id_value(Some(source_id)).hash(&mut hasher);
178 } else {
179 "".hash(&mut hasher);
180 }
181 source_path.hash(&mut hasher);
182 hasher.finish()
183 }
184
185 #[inline]
187 fn shard_index(hash: u64) -> usize {
188 (hash as usize) % NUM_SHARDS
189 }
190
191 pub fn get(&self, source_id: Option<&str>, source_path: &str) -> Option<Arc<ConversationView>> {
193 self.get_scoped("", source_id, source_path)
194 }
195
196 pub fn get_scoped(
198 &self,
199 cache_scope: &str,
200 source_id: Option<&str>,
201 source_path: &str,
202 ) -> Option<Arc<ConversationView>> {
203 let hash = Self::hash_key(Some(cache_scope), source_id, source_path);
204 let shard_idx = Self::shard_index(hash);
205 let mut shard = self.shards[shard_idx].write();
206
207 if let Some(cached) = shard.get(&hash) {
208 self.stats.hits.fetch_add(1, Ordering::Relaxed);
209 Some(Arc::clone(cached))
210 } else {
211 self.stats.misses.fetch_add(1, Ordering::Relaxed);
212 None
213 }
214 }
215
216 pub fn insert(
218 &self,
219 source_id: Option<&str>,
220 source_path: &str,
221 view: ConversationView,
222 ) -> Arc<ConversationView> {
223 self.insert_scoped("", source_id, source_path, view)
224 }
225
226 pub fn insert_scoped(
228 &self,
229 cache_scope: &str,
230 source_id: Option<&str>,
231 source_path: &str,
232 view: ConversationView,
233 ) -> Arc<ConversationView> {
234 let hash = Self::hash_key(Some(cache_scope), source_id, source_path);
235 let shard_idx = Self::shard_index(hash);
236 let arc = Arc::new(view);
237
238 let mut shard = self.shards[shard_idx].write();
239 if shard.len() == shard.cap().get() && !shard.contains(&hash) {
241 self.stats.evictions.fetch_add(1, Ordering::Relaxed);
242 }
243 shard.put(hash, Arc::clone(&arc));
244
245 arc
246 }
247
248 pub fn invalidate(&self, source_id: Option<&str>, source_path: &str) {
250 self.invalidate_scoped("", source_id, source_path)
251 }
252
253 pub fn invalidate_scoped(&self, cache_scope: &str, source_id: Option<&str>, source_path: &str) {
255 let hash = Self::hash_key(Some(cache_scope), source_id, source_path);
256 let shard_idx = Self::shard_index(hash);
257 let mut shard = self.shards[shard_idx].write();
258 shard.pop(&hash);
259 }
260
261 pub fn invalidate_all(&self) {
263 for shard in &self.shards {
264 shard.write().clear();
265 }
266 }
267
268 pub fn stats(&self) -> &CacheStats {
270 &self.stats
271 }
272
273 pub fn len(&self) -> usize {
275 self.shards.iter().map(|s| s.read().len()).sum()
276 }
277
278 pub fn is_empty(&self) -> bool {
280 self.len() == 0
281 }
282}
283
284pub static CONVERSATION_CACHE: Lazy<ConversationCache> = Lazy::new(|| {
286 let capacity = dotenvy::var("CASS_CONV_CACHE_SIZE")
287 .ok()
288 .and_then(|s| s.parse().ok())
289 .unwrap_or(DEFAULT_CAPACITY_PER_SHARD);
290 ConversationCache::new(capacity)
291});
292
293fn storage_cache_scope(storage: &FrankenStorage) -> Option<String> {
294 storage
295 .database_path()
296 .ok()
297 .map(|path| path.to_string_lossy().into_owned())
298}
299
300fn ui_conversation_row_parts(
301 row: &Row,
302) -> std::result::Result<(i64, Conversation, Option<Workspace>), FrankenError> {
303 let convo_id: i64 = row.get_typed(0)?;
304 let workspace_path = row
305 .get_typed::<Option<String>>(3)?
306 .map(std::path::PathBuf::from);
307 let metadata_json = row
308 .get_typed::<Option<String>>(11)?
309 .and_then(|s| serde_json::from_str(&s).ok())
310 .or_else(|| {
311 row.get_typed::<Option<Vec<u8>>>(14)
312 .ok()
313 .flatten()
314 .and_then(|b| rmp_serde::from_slice(&b).ok())
315 })
316 .unwrap_or_default();
317 let convo = Conversation {
318 id: Some(convo_id),
319 agent_slug: row.get_typed(1)?,
320 workspace: workspace_path.clone(),
321 external_id: row.get_typed(5)?,
322 title: row.get_typed(6)?,
323 source_path: std::path::PathBuf::from(row.get_typed::<String>(7)?),
324 started_at: row.get_typed(8)?,
325 ended_at: row.get_typed(9)?,
326 approx_tokens: row.get_typed(10)?,
327 metadata_json,
328 messages: Vec::new(),
329 source_id: normalize_ui_source_id_parts(
330 row.get_typed::<Option<String>>(12)?.as_deref(),
331 row.get_typed::<Option<String>>(13)?.as_deref(),
332 ),
333 origin_host: row.get_typed(13)?,
334 };
335 let workspace = row.get_typed::<Option<i64>>(2)?.map(|id| Workspace {
336 id: Some(id),
337 path: workspace_path.unwrap_or_default(),
338 display_name: row.get_typed(4).ok().flatten(),
339 });
340 Ok((convo_id, convo, workspace))
341}
342
343fn load_conversation_by_id_uncached(
344 storage: &FrankenStorage,
345 conversation_id: i64,
346) -> Result<Option<ConversationView>> {
347 let rows = storage.raw().query_map_collect(
351 "SELECT c.id, COALESCE(a.slug, 'unknown'), w.id, w.path, w.display_name, c.external_id, c.title, c.source_path,
352 c.started_at, c.ended_at, c.approx_tokens, c.metadata_json, c.source_id, c.origin_host, c.metadata_bin
353 FROM conversations c
354 LEFT JOIN agents a ON c.agent_id = a.id
355 LEFT JOIN workspaces w ON c.workspace_id = w.id
356 WHERE c.id = ?1
357 LIMIT 1",
358 frankensqlite::params![conversation_id],
359 ui_conversation_row_parts,
360 )?;
361 if let Some((convo_id, convo, workspace)) = rows.into_iter().next() {
362 let messages = storage.fetch_messages(convo_id)?;
363 return Ok(Some(ConversationView {
364 convo,
365 messages,
366 workspace,
367 }));
368 }
369 Ok(None)
370}
371
372pub(crate) fn load_conversation_uncached(
379 storage: &FrankenStorage,
380 source_id: Option<&str>,
381 source_path: &str,
382) -> Result<Option<ConversationView>> {
383 let normalized_source_sql =
384 normalized_ui_source_identity_sql_expr("c.source_id", "c.origin_host");
385 let (sql, params) = if let Some(source_id) = source_id {
388 (
389 format!(
390 "SELECT c.id, COALESCE(a.slug, 'unknown'), w.id, w.path, w.display_name, c.external_id, c.title, c.source_path,
391 c.started_at, c.ended_at, c.approx_tokens, c.metadata_json, c.source_id, c.origin_host, c.metadata_bin
392 FROM conversations c
393 LEFT JOIN agents a ON c.agent_id = a.id
394 LEFT JOIN workspaces w ON c.workspace_id = w.id
395 WHERE c.source_path = ?1 AND {normalized_source_sql} = ?2
396 ORDER BY c.started_at DESC LIMIT 1"
397 ),
398 frankensqlite::params![source_path, normalize_ui_source_id_value(Some(source_id))],
399 )
400 } else {
401 (
402 format!(
403 "SELECT c.id, COALESCE(a.slug, 'unknown'), w.id, w.path, w.display_name, c.external_id, c.title, c.source_path,
404 c.started_at, c.ended_at, c.approx_tokens, c.metadata_json, c.source_id, c.origin_host, c.metadata_bin
405 FROM conversations c
406 LEFT JOIN agents a ON c.agent_id = a.id
407 LEFT JOIN workspaces w ON c.workspace_id = w.id
408 WHERE c.source_path = ?1
409 ORDER BY CASE WHEN {normalized_source_sql} = '{local}' THEN 0 ELSE 1 END,
410 c.started_at DESC
411 LIMIT 1",
412 local = crate::sources::provenance::LOCAL_SOURCE_ID,
413 ),
414 frankensqlite::params![source_path],
415 )
416 };
417 let rows = storage
418 .raw()
419 .query_map_collect(&sql, params, ui_conversation_row_parts)?;
420 if let Some((convo_id, convo, workspace)) = rows.into_iter().next() {
421 let messages = storage.fetch_messages(convo_id)?;
422 return Ok(Some(ConversationView {
423 convo,
424 messages,
425 workspace,
426 }));
427 }
428 Ok(None)
429}
430
431fn cached_conversation_matches_lookup_head(
445 storage: &FrankenStorage,
446 source_id: Option<&str>,
447 source_path: &str,
448 cached: &ConversationView,
449) -> Result<bool> {
450 let Some(cached_id) = cached.convo.id else {
451 return Ok(false);
452 };
453
454 let normalized_source_sql = normalized_ui_source_identity_sql_expr("source_id", "origin_host");
455 let (sql, params) = if let Some(source_id) = source_id {
456 (
457 format!(
458 "SELECT id, {normalized_source_sql} FROM conversations WHERE source_path = ?1 AND {normalized_source_sql} = ?2 ORDER BY started_at DESC LIMIT 1"
459 ),
460 frankensqlite::params![source_path, normalize_ui_source_id_value(Some(source_id))],
461 )
462 } else {
463 (
464 format!(
465 "SELECT id, {normalized_source_sql} FROM conversations WHERE source_path = ?1 ORDER BY CASE WHEN {normalized_source_sql} = '{local}' THEN 0 ELSE 1 END, started_at DESC LIMIT 1",
466 local = crate::sources::provenance::LOCAL_SOURCE_ID,
467 ),
468 frankensqlite::params![source_path],
469 )
470 };
471
472 let rows = storage.raw().query_map_collect(&sql, params, |row: &Row| {
473 Ok((row.get_typed::<i64>(0)?, row.get_typed::<String>(1)?))
474 })?;
475
476 Ok(
477 matches!(rows.into_iter().next(), Some((latest_id, latest_source_id)) if latest_id == cached_id && latest_source_id == cached.convo.source_id),
478 )
479}
480
481pub fn load_conversation(
482 storage: &FrankenStorage,
483 source_path: &str,
484) -> Result<Option<ConversationView>> {
485 let cache_scope = storage_cache_scope(storage);
486
487 if let Some(scope) = cache_scope.as_deref()
489 && let Some(cached) = CONVERSATION_CACHE.get_scoped(scope, None, source_path)
490 {
491 match cached_conversation_matches_lookup_head(storage, None, source_path, &cached) {
492 Ok(true) => {
493 return Ok(Some((*cached).clone()));
495 }
496 Ok(false) => {
497 CONVERSATION_CACHE.invalidate_scoped(scope, None, source_path);
498 }
499 Err(_) => {
500 return Ok(Some((*cached).clone()));
501 }
502 }
503 }
504
505 let view = load_conversation_uncached(storage, None, source_path)?;
507
508 if let Some(v) = view {
510 if let Some(scope) = cache_scope.as_deref() {
511 CONVERSATION_CACHE.insert_scoped(scope, None, source_path, v.clone());
512 }
513 return Ok(Some(v));
514 }
515
516 Ok(None)
517}
518
519pub fn load_conversation_for_source(
521 storage: &FrankenStorage,
522 source_id: &str,
523 source_path: &str,
524) -> Result<Option<ConversationView>> {
525 let cache_scope = storage_cache_scope(storage);
526
527 if let Some(scope) = cache_scope.as_deref()
528 && let Some(cached) = CONVERSATION_CACHE.get_scoped(scope, Some(source_id), source_path)
529 {
530 match cached_conversation_matches_lookup_head(
531 storage,
532 Some(source_id),
533 source_path,
534 &cached,
535 ) {
536 Ok(true) => {
537 return Ok(Some((*cached).clone()));
538 }
539 Ok(false) => {
540 CONVERSATION_CACHE.invalidate_scoped(scope, Some(source_id), source_path);
541 }
542 Err(_) => {
543 return Ok(Some((*cached).clone()));
544 }
545 }
546 }
547
548 let view = load_conversation_uncached(storage, Some(source_id), source_path)?;
549
550 if let Some(v) = view {
551 if let Some(scope) = cache_scope.as_deref() {
552 CONVERSATION_CACHE.insert_scoped(scope, Some(source_id), source_path, v.clone());
553 }
554 return Ok(Some(v));
555 }
556
557 Ok(None)
558}
559
560pub(crate) fn search_hit_has_identity_hint(hit: &SearchHit) -> bool {
561 let snippet = hit.snippet.trim();
562 let snippet_prefix = snippet.strip_suffix("...").unwrap_or(snippet).trim();
563 let title = hit.title.trim();
564 hit.conversation_id.is_some()
565 || hit.line_number.is_some()
566 || hit.created_at.is_some()
567 || !hit.content.is_empty()
568 || !snippet_prefix.is_empty()
569 || !title.is_empty()
570}
571
572pub(crate) fn search_hit_has_secondary_identity_hint(hit: &SearchHit) -> bool {
573 let snippet = hit.snippet.trim();
574 let snippet_prefix = snippet.strip_suffix("...").unwrap_or(snippet).trim();
575 let title = hit.title.trim();
576 hit.line_number.is_some_and(|line| line > 0)
577 || hit.created_at.is_some()
578 || !hit.content.is_empty()
579 || !snippet_prefix.is_empty()
580 || !title.is_empty()
581}
582
583pub(crate) fn conversation_view_matches_hit(view: &ConversationView, hit: &SearchHit) -> bool {
584 let conversation_id_mismatch = match hit.conversation_id {
585 Some(expected_conversation_id) if view.convo.id == Some(expected_conversation_id) => {
586 return true;
587 }
588 Some(_) => true,
589 None => false,
590 };
591 let normalized_hit_source_id = normalize_ui_hit_source_id(hit);
592 if view.convo.source_id != normalized_hit_source_id
593 || view.convo.source_path != std::path::Path::new(&hit.source_path)
594 {
595 return false;
596 }
597
598 let snippet = hit.snippet.trim();
599 let snippet_prefix = snippet.strip_suffix("...").unwrap_or(snippet).trim();
600 let hit_title = hit.title.trim();
601 let convo_title = view
602 .convo
603 .title
604 .as_deref()
605 .map(str::trim)
606 .filter(|title| !title.is_empty());
607 let has_identity_hint = search_hit_has_identity_hint(hit);
608 let has_strong_message_identity_hint = hit.created_at.is_some() || !hit.content.is_empty();
609 if conversation_id_mismatch && !search_hit_has_secondary_identity_hint(hit) {
610 return false;
611 }
612 if !has_identity_hint {
613 return true;
614 }
615
616 if !hit_title.is_empty() {
617 match convo_title {
618 Some(title) if title != hit_title && !has_strong_message_identity_hint => return false,
619 None if hit.line_number.is_none()
620 && hit.created_at.is_none()
621 && hit.content.is_empty()
622 && snippet_prefix.is_empty() =>
623 {
624 return false;
625 }
626 _ => {}
627 }
628 }
629
630 view.messages.iter().enumerate().any(|(pos, msg)| {
631 let line_from_idx = (msg.idx >= 0).then_some((msg.idx as usize) + 1);
632 let line_from_pos = pos + 1;
633
634 if let Some(expected_line) = hit.line_number
635 && line_from_idx != Some(expected_line)
636 && line_from_pos != expected_line
637 {
638 return false;
639 }
640
641 if let Some(expected_created_at) = hit.created_at {
642 let created_matches = msg.created_at == Some(expected_created_at)
643 || (msg.created_at.is_none()
644 && view.convo.started_at == Some(expected_created_at)
645 && hit
646 .line_number
647 .is_some_and(|line| line == line_from_idx.unwrap_or(line_from_pos)));
648 if !created_matches {
649 return false;
650 }
651
652 return true;
655 }
656
657 if !hit.content.is_empty() {
658 return msg.content == hit.content;
659 }
660
661 if !snippet_prefix.is_empty() {
662 return msg.content.contains(snippet_prefix);
663 }
664
665 true
666 })
667}
668
669pub fn load_conversation_for_hit(
670 storage: &FrankenStorage,
671 hit: &SearchHit,
672) -> Result<Option<ConversationView>> {
673 let cache_scope = storage_cache_scope(storage);
674 if let Some(scope) = cache_scope.as_deref()
675 && let Some(cached) = CONVERSATION_CACHE.get_scoped(
676 scope,
677 Some(normalize_ui_hit_source_id(hit).as_str()),
678 &hit.source_path,
679 )
680 {
681 if conversation_view_matches_hit(&cached, hit) {
682 return Ok(Some((*cached).clone()));
683 }
684 let normalized_hit_source_id = normalize_ui_hit_source_id(hit);
685 CONVERSATION_CACHE.invalidate_scoped(
686 scope,
687 Some(normalized_hit_source_id.as_str()),
688 &hit.source_path,
689 );
690 }
691
692 let fallback_hit = if let Some(conversation_id) = hit.conversation_id {
693 if let Some(view) = load_conversation_by_id_uncached(storage, conversation_id)?
694 && conversation_view_matches_hit(&view, hit)
695 {
696 return Ok(Some(view));
697 }
698 let mut fallback_hit = hit.clone();
699 fallback_hit.conversation_id = None;
700 fallback_hit
701 } else {
702 hit.clone()
703 };
704
705 let normalized_source_sql =
706 normalized_ui_source_identity_sql_expr("c.source_id", "c.origin_host");
707 let sql = format!(
710 "SELECT c.id, COALESCE(a.slug, 'unknown'), w.id, w.path, w.display_name, c.external_id, c.title, c.source_path,
711 c.started_at, c.ended_at, c.approx_tokens, c.metadata_json, c.source_id, c.origin_host, c.metadata_bin
712 FROM conversations c
713 LEFT JOIN agents a ON c.agent_id = a.id
714 LEFT JOIN workspaces w ON c.workspace_id = w.id
715 WHERE c.source_path = ?1 AND {normalized_source_sql} = ?2
716 ORDER BY c.started_at DESC"
717 );
718 let rows = storage.raw().query_map_collect(
719 &sql,
720 frankensqlite::params![
721 fallback_hit.source_path.as_str(),
722 normalize_ui_hit_source_id(&fallback_hit)
723 ],
724 ui_conversation_row_parts,
725 )?;
726
727 for (convo_id, convo, workspace) in rows {
728 let messages = storage.fetch_messages(convo_id)?;
729 let view = ConversationView {
730 convo,
731 messages,
732 workspace,
733 };
734 if conversation_view_matches_hit(&view, &fallback_hit) {
735 return Ok(Some(view));
736 }
737 }
738
739 if search_hit_has_identity_hint(&fallback_hit) {
740 Ok(None)
741 } else {
742 load_conversation_uncached(
743 storage,
744 Some(normalize_ui_hit_source_id(&fallback_hit).as_str()),
745 &fallback_hit.source_path,
746 )
747 }
748}
749
750pub fn load_conversation_arc(
755 storage: &FrankenStorage,
756 source_path: &str,
757) -> Result<Option<Arc<ConversationView>>> {
758 let cache_scope = storage_cache_scope(storage);
759
760 if let Some(scope) = cache_scope.as_deref()
762 && let Some(cached) = CONVERSATION_CACHE.get_scoped(scope, None, source_path)
763 {
764 match cached_conversation_matches_lookup_head(storage, None, source_path, &cached) {
765 Ok(true) => {
766 return Ok(Some(cached));
767 }
768 Ok(false) => {
769 CONVERSATION_CACHE.invalidate_scoped(scope, None, source_path);
770 }
771 Err(_) => {
772 return Ok(Some(cached));
773 }
774 }
775 }
776
777 let view = load_conversation_uncached(storage, None, source_path)?;
779
780 if let Some(v) = view {
782 if let Some(scope) = cache_scope.as_deref() {
783 let arc = CONVERSATION_CACHE.insert_scoped(scope, None, source_path, v);
784 return Ok(Some(arc));
785 }
786 return Ok(Some(Arc::new(v)));
787 }
788
789 Ok(None)
790}
791
792pub fn log_conversation_cache_stats() {
796 let (hits, misses, evictions) = CONVERSATION_CACHE.stats().get();
797 let hit_rate = CONVERSATION_CACHE.stats().hit_rate();
798 let count = CONVERSATION_CACHE.len();
799
800 tracing::debug!(
801 target: "cass::perf::conversation_cache",
802 hits = hits,
803 misses = misses,
804 evictions = evictions,
805 hit_rate = format!("{:.1}%", hit_rate * 100.0),
806 cached_count = count,
807 "Conversation cache statistics"
808 );
809}
810
811pub fn role_style(role: &MessageRole, palette: ThemePalette) -> ftui::Style {
812 match role {
813 MessageRole::User => ftui::Style::new().fg(palette.user),
814 MessageRole::Agent => ftui::Style::new().fg(palette.agent),
815 MessageRole::Tool => ftui::Style::new().fg(palette.tool),
816 MessageRole::System => ftui::Style::new().fg(palette.system),
817 MessageRole::Other(_) => ftui::Style::new().fg(palette.hint),
818 }
819}
820
821#[derive(Clone, Copy, Debug, PartialEq, Eq)]
827pub enum RankingMode {
828 RecentHeavy,
829 Balanced,
830 RelevanceHeavy,
831 MatchQualityHeavy,
832 DateNewest,
833 DateOldest,
834}
835
836pub fn format_time_short(ms: i64) -> String {
839 use chrono::{DateTime, Datelike, Utc};
840 let now = Utc::now();
841 DateTime::<Utc>::from_timestamp_millis(ms)
842 .map(|dt| {
843 if dt.year() == now.year() {
844 dt.format("%b %d").to_string()
845 } else {
846 dt.format("%b %d, %Y").to_string()
847 }
848 })
849 .unwrap_or_else(|| "?".to_string())
850}
851
852#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
876pub enum CockpitPanel {
877 #[default]
878 DiffStrategy,
880 ResizeRegime,
882 BudgetHealth,
884 Timeline,
886}
887
888impl CockpitPanel {
889 pub fn label(self) -> &'static str {
890 match self {
891 Self::DiffStrategy => "Diff",
892 Self::ResizeRegime => "Resize",
893 Self::BudgetHealth => "Budget",
894 Self::Timeline => "Timeline",
895 }
896 }
897
898 pub fn next(self) -> Self {
899 match self {
900 Self::DiffStrategy => Self::ResizeRegime,
901 Self::ResizeRegime => Self::BudgetHealth,
902 Self::BudgetHealth => Self::Timeline,
903 Self::Timeline => Self::DiffStrategy,
904 }
905 }
906
907 pub fn prev(self) -> Self {
908 match self {
909 Self::DiffStrategy => Self::Timeline,
910 Self::ResizeRegime => Self::DiffStrategy,
911 Self::BudgetHealth => Self::ResizeRegime,
912 Self::Timeline => Self::BudgetHealth,
913 }
914 }
915
916 pub const ALL: [CockpitPanel; 4] = [
918 Self::DiffStrategy,
919 Self::ResizeRegime,
920 Self::BudgetHealth,
921 Self::Timeline,
922 ];
923}
924
925#[derive(Clone, Debug)]
931pub struct CockpitEmptyPolicy {
932 pub no_data: &'static str,
934 pub awaiting: &'static str,
936 pub disabled: &'static str,
938}
939
940impl Default for CockpitEmptyPolicy {
941 fn default() -> Self {
942 Self {
943 no_data: "\u{2014}", awaiting: "awaiting first event\u{2026}",
945 disabled: "(disabled)",
946 }
947 }
948}
949
950#[derive(Clone, Debug, Default)]
956pub struct DiffStrategyContract {
957 pub last_was_full_redraw: bool,
959 pub dirty_row_count: u32,
961 pub total_row_count: u32,
963 pub reason: &'static str,
965 pub consecutive_full_redraws: u32,
967 pub full_redraw_ratio: f64,
969}
970
971impl DiffStrategyContract {
972 pub fn dirty_ratio(&self) -> f64 {
974 if self.total_row_count == 0 {
975 0.0
976 } else {
977 self.dirty_row_count as f64 / self.total_row_count as f64
978 }
979 }
980
981 pub fn has_data(&self) -> bool {
983 self.total_row_count > 0
984 }
985}
986
987#[derive(Clone, Debug)]
994pub struct ResizeRegimeContract {
995 pub regime: &'static str,
997 pub terminal_size: Option<(u16, u16)>,
999 pub bocpd_p_burst: Option<f64>,
1001 pub bocpd_delay_ms: Option<u32>,
1003 pub history_len: usize,
1005 pub last_action: &'static str,
1007 pub last_dt_ms: f64,
1009 pub last_event_rate: f64,
1011}
1012
1013impl Default for ResizeRegimeContract {
1014 fn default() -> Self {
1015 Self {
1016 regime: "\u{2014}",
1017 terminal_size: None,
1018 bocpd_p_burst: None,
1019 bocpd_delay_ms: None,
1020 history_len: 0,
1021 last_action: "\u{2014}",
1022 last_dt_ms: 0.0,
1023 last_event_rate: 0.0,
1024 }
1025 }
1026}
1027
1028impl ResizeRegimeContract {
1029 pub fn has_data(&self) -> bool {
1031 self.regime != "\u{2014}"
1032 }
1033}
1034
1035#[derive(Clone, Debug)]
1042pub struct BudgetHealthContract {
1043 pub degradation: &'static str,
1045 pub budget_us: f64,
1047 pub frame_time_us: f64,
1049 pub pid_output: f64,
1051 pub in_warmup: bool,
1053 pub frames_observed: u32,
1055 pub pressure: f64,
1057}
1058
1059impl Default for BudgetHealthContract {
1060 fn default() -> Self {
1061 Self {
1062 degradation: "\u{2014}",
1063 budget_us: 0.0,
1064 frame_time_us: 0.0,
1065 pid_output: 0.0,
1066 in_warmup: true,
1067 frames_observed: 0,
1068 pressure: 0.0,
1069 }
1070 }
1071}
1072
1073impl BudgetHealthContract {
1074 pub fn has_data(&self) -> bool {
1076 self.frames_observed > 0
1077 }
1078
1079 pub fn is_over_budget(&self) -> bool {
1081 self.pressure > 1.0
1082 }
1083}
1084
1085#[derive(Clone, Debug)]
1091pub struct CockpitTimelineEvent {
1092 pub source: CockpitPanel,
1094 pub summary: String,
1096 pub event_idx: u64,
1098 pub elapsed_secs: f64,
1100 pub severity: TimelineEventSeverity,
1102}
1103
1104#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
1106pub enum TimelineEventSeverity {
1107 #[default]
1109 Info,
1110 StateChange,
1112 Warning,
1114}
1115
1116#[derive(Clone, Debug)]
1124pub struct TimelineContract {
1125 pub events: std::collections::VecDeque<CockpitTimelineEvent>,
1127 pub capacity: usize,
1129}
1130
1131const TIMELINE_CAPACITY: usize = 64;
1133
1134impl Default for TimelineContract {
1135 fn default() -> Self {
1136 Self::new()
1137 }
1138}
1139
1140impl TimelineContract {
1141 pub fn new() -> Self {
1142 Self {
1143 events: std::collections::VecDeque::with_capacity(TIMELINE_CAPACITY),
1144 capacity: TIMELINE_CAPACITY,
1145 }
1146 }
1147
1148 pub fn push(&mut self, event: CockpitTimelineEvent) {
1150 if self.events.len() >= self.capacity {
1151 self.events.pop_front();
1152 }
1153 self.events.push_back(event);
1154 }
1155
1156 pub fn has_data(&self) -> bool {
1158 !self.events.is_empty()
1159 }
1160
1161 pub fn len(&self) -> usize {
1163 self.events.len()
1164 }
1165
1166 pub fn is_empty(&self) -> bool {
1168 self.events.is_empty()
1169 }
1170}
1171
1172#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
1178pub enum CockpitMode {
1179 #[default]
1181 Overlay,
1182 Expanded,
1184}
1185
1186impl CockpitMode {
1187 pub fn cycle(self) -> Self {
1189 match self {
1190 Self::Overlay => Self::Expanded,
1191 Self::Expanded => Self::Overlay,
1192 }
1193 }
1194
1195 pub fn label(self) -> &'static str {
1197 match self {
1198 Self::Overlay => "overlay",
1199 Self::Expanded => "expanded",
1200 }
1201 }
1202}
1203
1204#[derive(Clone, Debug, Default)]
1210pub struct CockpitState {
1211 pub active_panel: CockpitPanel,
1213 pub enabled: bool,
1215 pub mode: CockpitMode,
1217 pub diff: DiffStrategyContract,
1219 pub resize: ResizeRegimeContract,
1221 pub budget: BudgetHealthContract,
1223 pub timeline: TimelineContract,
1225 pub empty_policy: CockpitEmptyPolicy,
1227}
1228
1229impl CockpitState {
1230 pub fn new() -> Self {
1231 Self {
1232 timeline: TimelineContract::new(),
1233 ..Default::default()
1234 }
1235 }
1236
1237 pub fn has_any_data(&self) -> bool {
1239 self.diff.has_data()
1240 || self.resize.has_data()
1241 || self.budget.has_data()
1242 || self.timeline.has_data()
1243 }
1244
1245 pub fn empty_message(&self, panel: CockpitPanel) -> &'static str {
1247 match panel {
1248 CockpitPanel::DiffStrategy => {
1249 if self.diff.has_data() {
1250 ""
1251 } else {
1252 self.empty_policy.awaiting
1253 }
1254 }
1255 CockpitPanel::ResizeRegime => {
1256 if self.resize.has_data() {
1257 ""
1258 } else {
1259 self.empty_policy.awaiting
1260 }
1261 }
1262 CockpitPanel::BudgetHealth => {
1263 if self.budget.has_data() {
1264 ""
1265 } else {
1266 self.empty_policy.awaiting
1267 }
1268 }
1269 CockpitPanel::Timeline => {
1270 if self.timeline.has_data() {
1271 ""
1272 } else {
1273 self.empty_policy.no_data
1274 }
1275 }
1276 }
1277 }
1278}
1279
1280#[cfg(test)]
1285mod tests {
1286 use super::*;
1287 use crate::search::query::MatchType;
1288 use std::path::PathBuf;
1289 use tempfile::tempdir;
1290
1291 fn make_test_view(id: i64) -> ConversationView {
1292 ConversationView {
1293 convo: Conversation {
1294 id: Some(id),
1295 agent_slug: "claude".to_string(),
1296 workspace: Some(PathBuf::from("/test/workspace")),
1297 external_id: Some(format!("ext-{}", id)),
1298 title: Some(format!("Test Conversation {}", id)),
1299 source_path: PathBuf::from(format!("/test/path/{}.jsonl", id)),
1300 started_at: Some(1704067200 + id),
1301 ended_at: None,
1302 approx_tokens: Some(1000),
1303 metadata_json: serde_json::json!({"test": true}),
1304 messages: Vec::new(),
1305 source_id: "local".to_string(),
1306 origin_host: None,
1307 },
1308 messages: vec![Message {
1309 id: Some(1),
1310 idx: 0,
1311 role: MessageRole::User,
1312 author: None,
1313 created_at: Some(1704067200),
1314 content: "Test message".to_string(),
1315 extra_json: serde_json::json!({}),
1316 snippets: Vec::new(),
1317 }],
1318 workspace: Some(Workspace {
1319 id: Some(1),
1320 path: PathBuf::from("/test/workspace"),
1321 display_name: None,
1322 }),
1323 }
1324 }
1325
1326 #[test]
1327 fn test_cache_insert_and_get() {
1328 let cache = ConversationCache::new(10);
1329 let view = make_test_view(1);
1330 let source_path = "/test/path/1.jsonl";
1331
1332 let arc = cache.insert(None, source_path, view.clone());
1334 assert_eq!(arc.convo.id, Some(1));
1335
1336 let cached = cache.get(None, source_path);
1338 assert!(cached.is_some());
1339 assert_eq!(cached.unwrap().convo.id, Some(1));
1340
1341 let (hits, misses, _) = cache.stats().get();
1343 assert_eq!(hits, 1);
1344 assert_eq!(misses, 0);
1345 }
1346
1347 #[test]
1348 fn test_cache_distinguishes_same_path_across_sources() {
1349 let cache = ConversationCache::new(10);
1350 let source_path = "/test/shared/session.jsonl";
1351
1352 let mut local = make_test_view(1);
1353 local.convo.source_path = PathBuf::from(source_path);
1354 local.convo.source_id = "local".to_string();
1355 let mut remote = make_test_view(2);
1356 remote.convo.source_path = PathBuf::from(source_path);
1357 remote.convo.source_id = "work-laptop".to_string();
1358
1359 cache.insert(Some("local"), source_path, local);
1360 cache.insert(Some("work-laptop"), source_path, remote);
1361
1362 let local_cached = cache.get(Some("local"), source_path).expect("local cached");
1363 let remote_cached = cache
1364 .get(Some("work-laptop"), source_path)
1365 .expect("remote cached");
1366
1367 assert_eq!(local_cached.convo.source_id, "local");
1368 assert_eq!(remote_cached.convo.source_id, "work-laptop");
1369 assert_ne!(local_cached.convo.id, remote_cached.convo.id);
1370 }
1371
1372 #[test]
1373 fn load_conversation_cache_is_scoped_by_database_path() {
1374 use crate::storage::sqlite::FrankenStorage;
1375
1376 let shared_path = "/shared/cross-db-session.jsonl";
1377 let tmp_a = tempfile::TempDir::new().expect("tempdir a");
1378 let db_path_a = tmp_a.path().join("cass-a.db");
1379 let storage_a = FrankenStorage::open(&db_path_a).expect("open storage a");
1380 let conn_a = storage_a.raw();
1381 let scope_a =
1382 storage_cache_scope(&storage_a).unwrap_or_else(|| db_path_a.display().to_string());
1383
1384 let tmp_b = tempfile::TempDir::new().expect("tempdir b");
1385 let db_path_b = tmp_b.path().join("cass-b.db");
1386 let storage_b = FrankenStorage::open(&db_path_b).expect("open storage b");
1387 let conn_b = storage_b.raw();
1388 let scope_b =
1389 storage_cache_scope(&storage_b).unwrap_or_else(|| db_path_b.display().to_string());
1390
1391 CONVERSATION_CACHE.invalidate_scoped(&scope_a, None, shared_path);
1392 CONVERSATION_CACHE.invalidate_scoped(&scope_b, None, shared_path);
1393
1394 for conn in [&conn_a, &conn_b] {
1395 conn.execute("INSERT INTO agents (id, slug, name, kind, created_at, updated_at) VALUES (1, 'claude_code', 'Claude Code', 'local', 0, 0)")
1396 .expect("insert agent");
1397 }
1398
1399 {
1400 use frankensqlite::compat::{ParamValue, param_slice_to_values};
1401 let p = [ParamValue::from(shared_path.to_string())];
1402 conn_a.execute_with_params(
1403 "INSERT INTO conversations (id, agent_id, external_id, title, source_path, source_id, started_at) VALUES (1, 1, 'db-a', 'DB A Session', ?1, 'local', 100)",
1404 ¶m_slice_to_values(&p),
1405 )
1406 .expect("insert db a conversation");
1407 conn_b.execute_with_params(
1408 "INSERT INTO conversations (id, agent_id, external_id, title, source_path, source_id, started_at) VALUES (1, 1, 'db-b', 'DB B Session', ?1, 'local', 100)",
1409 ¶m_slice_to_values(&p),
1410 )
1411 .expect("insert db b conversation");
1412 }
1413 conn_a.execute(
1414 "INSERT INTO messages (id, conversation_id, idx, role, content) VALUES (1, 1, 0, 'user', 'db a body')",
1415 )
1416 .expect("insert db a message");
1417 conn_b.execute(
1418 "INSERT INTO messages (id, conversation_id, idx, role, content) VALUES (1, 1, 0, 'user', 'db b body')",
1419 )
1420 .expect("insert db b message");
1421
1422 let from_a = load_conversation(&storage_a, shared_path)
1423 .expect("load from db a")
1424 .expect("db a conversation present");
1425 assert_eq!(from_a.convo.title.as_deref(), Some("DB A Session"));
1426 assert_eq!(from_a.messages[0].content, "db a body");
1427
1428 let from_b = load_conversation(&storage_b, shared_path)
1429 .expect("load from db b")
1430 .expect("db b conversation present");
1431 assert_eq!(from_b.convo.title.as_deref(), Some("DB B Session"));
1432 assert_eq!(from_b.messages[0].content, "db b body");
1433
1434 CONVERSATION_CACHE.invalidate_scoped(&scope_a, None, shared_path);
1435 CONVERSATION_CACHE.invalidate_scoped(&scope_b, None, shared_path);
1436 }
1437
1438 #[test]
1439 fn load_conversation_for_source_selects_blank_remote_source_id_via_origin_host() {
1440 use crate::storage::sqlite::FrankenStorage;
1441
1442 let tmp = tempfile::TempDir::new().expect("tempdir");
1443 let db_path = tmp.path().join("cass.db");
1444 let storage = FrankenStorage::open(&db_path).expect("open storage");
1445 let conn = storage.raw();
1446 let shared_path = "/shared/session.jsonl";
1447
1448 conn.execute("INSERT INTO agents (id, slug, name, kind, created_at, updated_at) VALUES (1, 'claude_code', 'Claude Code', 'local', 0, 0)")
1449 .expect("insert agent");
1450 conn.execute(
1451 "INSERT INTO sources (id, kind, host_label, created_at, updated_at) VALUES (' ', 'ssh', 'user@laptop', 0, 0)",
1452 )
1453 .expect("insert blank-id remote source");
1454 {
1455 use frankensqlite::compat::{ParamValue, param_slice_to_values};
1456 let p = [ParamValue::from(shared_path.to_string())];
1457 conn.execute_with_params(
1458 "INSERT INTO conversations (id, agent_id, external_id, title, source_path, source_id, origin_host, started_at) VALUES (1, 1, 'remote-ext', 'Remote Session', ?1, ' ', 'user@laptop', 200)",
1459 ¶m_slice_to_values(&p),
1460 )
1461 .expect("insert remote conversation");
1462 }
1463 conn.execute(
1464 "INSERT INTO messages (id, conversation_id, idx, role, content) VALUES (1, 1, 0, 'user', 'remote body')",
1465 )
1466 .expect("insert remote message");
1467
1468 let loaded = load_conversation_for_source(&storage, "user@laptop", shared_path)
1469 .expect("load conversation")
1470 .expect("conversation present");
1471
1472 assert_eq!(loaded.convo.source_id, "user@laptop");
1473 assert_eq!(loaded.convo.origin_host.as_deref(), Some("user@laptop"));
1474 assert_eq!(loaded.convo.title.as_deref(), Some("Remote Session"));
1475 assert_eq!(loaded.messages[0].content, "remote body");
1476 }
1477
1478 #[test]
1479 fn load_conversation_for_source_selects_exact_source_id() {
1480 use crate::storage::sqlite::FrankenStorage;
1481
1482 let tmp = tempfile::TempDir::new().expect("tempdir");
1483 let db_path = tmp.path().join("cass.db");
1484 let storage = FrankenStorage::open(&db_path).expect("open storage");
1485 let conn = storage.raw();
1486 let shared_path = "/shared/session.jsonl";
1487
1488 conn.execute("INSERT INTO agents (id, slug, name, kind, created_at, updated_at) VALUES (1, 'claude_code', 'Claude Code', 'local', 0, 0)")
1489 .expect("insert agent");
1490 conn.execute(
1491 "INSERT INTO sources (id, kind, host_label, created_at, updated_at) VALUES (' local ', 'local', 'local', 0, 0)",
1492 )
1493 .expect("insert local source");
1494 conn.execute(
1495 "INSERT INTO sources (id, kind, host_label, created_at, updated_at) VALUES ('work-laptop', 'ssh', 'work-laptop', 0, 0)",
1496 )
1497 .expect("insert source");
1498 {
1499 use frankensqlite::compat::{ParamValue, param_slice_to_values};
1500 let p = [ParamValue::from(shared_path.to_string())];
1501 conn.execute_with_params(
1502 "INSERT INTO conversations (id, agent_id, external_id, title, source_path, source_id, started_at) VALUES (1, 1, 'local-ext', 'Local Session', ?1, ' local ', 200)",
1503 ¶m_slice_to_values(&p),
1504 )
1505 .expect("insert local conversation");
1506 conn.execute_with_params(
1507 "INSERT INTO conversations (id, agent_id, external_id, title, source_path, source_id, started_at) VALUES (2, 1, 'remote-ext', 'Remote Session', ?1, 'work-laptop', 100)",
1508 ¶m_slice_to_values(&p),
1509 )
1510 .expect("insert remote conversation");
1511 }
1512 conn.execute(
1513 "INSERT INTO messages (id, conversation_id, idx, role, content) VALUES (1, 1, 0, 'user', 'local body')",
1514 )
1515 .expect("insert local message");
1516 conn.execute(
1517 "INSERT INTO messages (id, conversation_id, idx, role, content) VALUES (2, 2, 0, 'user', 'remote body')",
1518 )
1519 .expect("insert remote message");
1520
1521 let local = load_conversation_for_source(&storage, "local", shared_path)
1522 .expect("load local")
1523 .expect("local conversation");
1524 let remote = load_conversation_for_source(&storage, "work-laptop", shared_path)
1525 .expect("load remote")
1526 .expect("remote conversation");
1527
1528 assert_eq!(local.convo.source_id, "local");
1529 assert_eq!(local.convo.title.as_deref(), Some("Local Session"));
1530 assert_eq!(local.messages[0].content, "local body");
1531
1532 assert_eq!(remote.convo.source_id, "work-laptop");
1533 assert_eq!(remote.convo.title.as_deref(), Some("Remote Session"));
1534 assert_eq!(remote.messages[0].content, "remote body");
1535 }
1536
1537 #[test]
1538 fn load_conversation_for_source_invalidates_cache_when_newer_conversation_arrives() {
1539 use crate::storage::sqlite::FrankenStorage;
1540
1541 let tmp = tempfile::TempDir::new().expect("tempdir");
1542 let db_path = tmp.path().join("cass.db");
1543 let storage = FrankenStorage::open(&db_path).expect("open storage");
1544 let conn = storage.raw();
1545 let shared_path = "/shared/source-specific-session.jsonl";
1546
1547 CONVERSATION_CACHE.invalidate(Some("local"), shared_path);
1548
1549 conn.execute("INSERT INTO agents (id, slug, name, kind, created_at, updated_at) VALUES (1, 'claude_code', 'Claude Code', 'local', 0, 0)")
1550 .expect("insert agent");
1551 {
1552 use frankensqlite::compat::{ParamValue, param_slice_to_values};
1553 let p = [ParamValue::from(shared_path.to_string())];
1554 conn.execute_with_params(
1555 "INSERT INTO conversations (id, agent_id, external_id, title, source_path, source_id, started_at) VALUES (1, 1, 'old-ext', 'Old Session', ?1, 'local', 100)",
1556 ¶m_slice_to_values(&p),
1557 )
1558 .expect("insert old conversation");
1559 }
1560 conn.execute(
1561 "INSERT INTO messages (id, conversation_id, idx, role, content) VALUES (1, 1, 0, 'user', 'old body')",
1562 )
1563 .expect("insert old message");
1564
1565 let first = load_conversation_for_source(&storage, "local", shared_path)
1566 .expect("load old conversation")
1567 .expect("old conversation present");
1568 assert_eq!(first.convo.title.as_deref(), Some("Old Session"));
1569 assert_eq!(first.messages[0].content, "old body");
1570
1571 {
1572 use frankensqlite::compat::{ParamValue, param_slice_to_values};
1573 let p = [ParamValue::from(shared_path.to_string())];
1574 conn.execute_with_params(
1575 "INSERT INTO conversations (id, agent_id, external_id, title, source_path, source_id, started_at) VALUES (2, 1, 'new-ext', 'New Session', ?1, 'local', 200)",
1576 ¶m_slice_to_values(&p),
1577 )
1578 .expect("insert new conversation");
1579 }
1580 conn.execute(
1581 "INSERT INTO messages (id, conversation_id, idx, role, content) VALUES (2, 2, 0, 'user', 'new body')",
1582 )
1583 .expect("insert new message");
1584
1585 let second = load_conversation_for_source(&storage, "local", shared_path)
1586 .expect("load new conversation")
1587 .expect("new conversation present");
1588
1589 assert_eq!(second.convo.title.as_deref(), Some("New Session"));
1590 assert_eq!(second.messages[0].content, "new body");
1591
1592 CONVERSATION_CACHE.invalidate(Some("local"), shared_path);
1593 }
1594
1595 #[test]
1596 fn load_conversation_prefers_local_source_for_shared_path() {
1597 use crate::storage::sqlite::FrankenStorage;
1598
1599 let tmp = tempfile::TempDir::new().expect("tempdir");
1600 let db_path = tmp.path().join("cass.db");
1601 let storage = FrankenStorage::open(&db_path).expect("open storage");
1602 let conn = storage.raw();
1603 let shared_path = "/shared/session.jsonl";
1604
1605 conn.execute("INSERT INTO agents (id, slug, name, kind, created_at, updated_at) VALUES (1, 'claude_code', 'Claude Code', 'local', 0, 0)")
1606 .expect("insert agent");
1607 conn.execute(
1608 "INSERT INTO sources (id, kind, host_label, created_at, updated_at) VALUES (' local ', 'local', 'local', 0, 0)",
1609 )
1610 .expect("insert local source");
1611 conn.execute(
1612 "INSERT INTO sources (id, kind, host_label, created_at, updated_at) VALUES ('work-laptop', 'ssh', 'work-laptop', 0, 0)",
1613 )
1614 .expect("insert source");
1615 {
1616 use frankensqlite::compat::{ParamValue, param_slice_to_values};
1617 let p = [ParamValue::from(shared_path.to_string())];
1618 conn.execute_with_params(
1619 "INSERT INTO conversations (id, agent_id, external_id, title, source_path, source_id, started_at) VALUES (1, 1, 'local-ext', 'Local Session', ?1, ' local ', 100)",
1620 ¶m_slice_to_values(&p),
1621 )
1622 .expect("insert local conversation");
1623 conn.execute_with_params(
1624 "INSERT INTO conversations (id, agent_id, external_id, title, source_path, source_id, started_at) VALUES (2, 1, 'remote-ext', 'Remote Session', ?1, 'work-laptop', 200)",
1625 ¶m_slice_to_values(&p),
1626 )
1627 .expect("insert remote conversation");
1628 }
1629 conn.execute(
1630 "INSERT INTO messages (id, conversation_id, idx, role, content) VALUES (1, 1, 0, 'user', 'local body')",
1631 )
1632 .expect("insert local message");
1633 conn.execute(
1634 "INSERT INTO messages (id, conversation_id, idx, role, content) VALUES (2, 2, 0, 'user', 'remote body')",
1635 )
1636 .expect("insert remote message");
1637
1638 let loaded = load_conversation(&storage, shared_path)
1639 .expect("load conversation")
1640 .expect("conversation present");
1641
1642 assert_eq!(loaded.convo.source_id, "local");
1643 assert_eq!(loaded.convo.title.as_deref(), Some("Local Session"));
1644 assert_eq!(loaded.messages[0].content, "local body");
1645 }
1646
1647 #[test]
1648 fn load_conversation_uses_cached_value_when_validation_query_fails() {
1649 use crate::storage::sqlite::FrankenStorage;
1650
1651 let tmp = tempfile::TempDir::new().expect("tempdir");
1652 let db_path = tmp.path().join("cass.db");
1653 let storage = FrankenStorage::open(&db_path).expect("open storage");
1654 let conn = storage.raw();
1655 let shared_path = "/shared/cached-when-db-breaks.jsonl";
1656
1657 CONVERSATION_CACHE.invalidate(None, shared_path);
1658 CONVERSATION_CACHE.invalidate(Some("local"), shared_path);
1659
1660 conn.execute("INSERT INTO agents (id, slug, name, kind, created_at, updated_at) VALUES (1, 'claude_code', 'Claude Code', 'local', 0, 0)")
1661 .expect("insert agent");
1662 {
1663 use frankensqlite::compat::{ParamValue, param_slice_to_values};
1664 let p = [ParamValue::from(shared_path.to_string())];
1665 conn.execute_with_params(
1666 "INSERT INTO conversations (id, agent_id, external_id, title, source_path, source_id, started_at) VALUES (1, 1, 'local-ext', 'Cached Session', ?1, 'local', 100)",
1667 ¶m_slice_to_values(&p),
1668 )
1669 .expect("insert local conversation");
1670 }
1671 conn.execute(
1672 "INSERT INTO messages (id, conversation_id, idx, role, content) VALUES (1, 1, 0, 'user', 'cached body')",
1673 )
1674 .expect("insert local message");
1675
1676 let cached = load_conversation(&storage, shared_path)
1677 .expect("load initial conversation")
1678 .expect("conversation present");
1679 assert_eq!(cached.convo.title.as_deref(), Some("Cached Session"));
1680 assert_eq!(cached.messages[0].content, "cached body");
1681
1682 conn.execute("DROP TABLE conversations")
1683 .expect("drop conversations to force validation failure");
1684
1685 let still_cached = load_conversation(&storage, shared_path)
1686 .expect("use cached conversation after validation failure")
1687 .expect("cached conversation still present");
1688
1689 assert_eq!(still_cached.convo.title.as_deref(), Some("Cached Session"));
1690 assert_eq!(still_cached.messages[0].content, "cached body");
1691
1692 CONVERSATION_CACHE.invalidate(None, shared_path);
1693 CONVERSATION_CACHE.invalidate(Some("local"), shared_path);
1694 }
1695
1696 #[test]
1697 fn conversation_view_matches_hit_normalizes_blank_remote_source_id_via_origin_host() {
1698 let view = ConversationView {
1699 convo: Conversation {
1700 id: Some(1),
1701 agent_slug: "claude_code".to_string(),
1702 workspace: None,
1703 external_id: Some("ext-1".to_string()),
1704 title: Some("Session".to_string()),
1705 source_path: std::path::PathBuf::from("/shared/session.jsonl"),
1706 started_at: Some(100),
1707 ended_at: None,
1708 approx_tokens: None,
1709 metadata_json: serde_json::Value::Null,
1710 messages: Vec::new(),
1711 source_id: "user@laptop".to_string(),
1712 origin_host: Some("user@laptop".to_string()),
1713 },
1714 messages: vec![Message {
1715 id: Some(1),
1716 idx: 0,
1717 role: MessageRole::User,
1718 author: None,
1719 created_at: Some(101),
1720 content: "body".to_string(),
1721 extra_json: serde_json::Value::Null,
1722 snippets: Vec::new(),
1723 }],
1724 workspace: None,
1725 };
1726
1727 let hit = SearchHit {
1728 title: "Session".to_string(),
1729 snippet: String::new(),
1730 content: String::new(),
1731 content_hash: 0,
1732 score: 0.0,
1733 conversation_id: None,
1734 source_path: "/shared/session.jsonl".to_string(),
1735 agent: "claude_code".to_string(),
1736 workspace: String::new(),
1737 workspace_original: None,
1738 created_at: None,
1739 line_number: None,
1740 match_type: Default::default(),
1741 source_id: " ".to_string(),
1742 origin_kind: "remote".to_string(),
1743 origin_host: Some("user@laptop".to_string()),
1744 };
1745
1746 assert!(conversation_view_matches_hit(&view, &hit));
1747 }
1748
1749 #[test]
1750 fn conversation_view_matches_hit_normalizes_local_source_id_variants() {
1751 let view = ConversationView {
1752 convo: Conversation {
1753 id: Some(1),
1754 agent_slug: "claude_code".to_string(),
1755 workspace: None,
1756 external_id: Some("ext-1".to_string()),
1757 title: Some("Session".to_string()),
1758 source_path: std::path::PathBuf::from("/shared/session.jsonl"),
1759 started_at: Some(100),
1760 ended_at: None,
1761 approx_tokens: None,
1762 metadata_json: serde_json::Value::Null,
1763 messages: Vec::new(),
1764 source_id: "local".to_string(),
1765 origin_host: None,
1766 },
1767 messages: vec![Message {
1768 id: Some(1),
1769 idx: 0,
1770 role: MessageRole::User,
1771 author: None,
1772 created_at: Some(101),
1773 content: "body".to_string(),
1774 extra_json: serde_json::Value::Null,
1775 snippets: Vec::new(),
1776 }],
1777 workspace: None,
1778 };
1779
1780 let hit = SearchHit {
1781 title: "Session".to_string(),
1782 snippet: String::new(),
1783 content: String::new(),
1784 content_hash: 0,
1785 score: 0.0,
1786 conversation_id: None,
1787 source_path: "/shared/session.jsonl".to_string(),
1788 agent: "claude_code".to_string(),
1789 workspace: String::new(),
1790 workspace_original: None,
1791 created_at: None,
1792 line_number: None,
1793 match_type: Default::default(),
1794 source_id: " LOCAL ".to_string(),
1795 origin_kind: "local".to_string(),
1796 origin_host: None,
1797 };
1798
1799 assert!(conversation_view_matches_hit(&view, &hit));
1800 }
1801
1802 #[test]
1803 fn conversation_view_matches_hit_falls_back_when_stale_conversation_id_has_other_hints() {
1804 let view = ConversationView {
1805 convo: Conversation {
1806 id: Some(1),
1807 agent_slug: "claude_code".to_string(),
1808 workspace: None,
1809 external_id: Some("ext-1".to_string()),
1810 title: Some("Session".to_string()),
1811 source_path: std::path::PathBuf::from("/shared/session.jsonl"),
1812 started_at: Some(100),
1813 ended_at: None,
1814 approx_tokens: None,
1815 metadata_json: serde_json::Value::Null,
1816 messages: Vec::new(),
1817 source_id: "local".to_string(),
1818 origin_host: None,
1819 },
1820 messages: vec![Message {
1821 id: Some(1),
1822 idx: 0,
1823 role: MessageRole::User,
1824 author: None,
1825 created_at: Some(101),
1826 content: "body".to_string(),
1827 extra_json: serde_json::Value::Null,
1828 snippets: Vec::new(),
1829 }],
1830 workspace: None,
1831 };
1832
1833 let hit = SearchHit {
1834 title: "Session".to_string(),
1835 snippet: String::new(),
1836 content: "body".to_string(),
1837 content_hash: 0,
1838 score: 0.0,
1839 conversation_id: Some(999),
1840 source_path: "/shared/session.jsonl".to_string(),
1841 agent: "claude_code".to_string(),
1842 workspace: String::new(),
1843 workspace_original: None,
1844 created_at: Some(101),
1845 line_number: Some(1),
1846 match_type: Default::default(),
1847 source_id: "local".to_string(),
1848 origin_kind: "local".to_string(),
1849 origin_host: None,
1850 };
1851
1852 assert!(conversation_view_matches_hit(&view, &hit));
1853 }
1854
1855 #[test]
1856 fn conversation_view_matches_hit_rejects_stale_conversation_id_without_other_hints() {
1857 let view = ConversationView {
1858 convo: Conversation {
1859 id: Some(1),
1860 agent_slug: "claude_code".to_string(),
1861 workspace: None,
1862 external_id: Some("ext-1".to_string()),
1863 title: Some("Session".to_string()),
1864 source_path: std::path::PathBuf::from("/shared/session.jsonl"),
1865 started_at: Some(100),
1866 ended_at: None,
1867 approx_tokens: None,
1868 metadata_json: serde_json::Value::Null,
1869 messages: vec![],
1870 source_id: "local".to_string(),
1871 origin_host: None,
1872 },
1873 messages: vec![Message {
1874 id: Some(1),
1875 idx: 0,
1876 role: MessageRole::User,
1877 author: None,
1878 created_at: Some(101),
1879 content: "body".to_string(),
1880 extra_json: serde_json::Value::Null,
1881 snippets: Vec::new(),
1882 }],
1883 workspace: None,
1884 };
1885
1886 let hit = SearchHit {
1887 title: String::new(),
1888 snippet: String::new(),
1889 content: String::new(),
1890 content_hash: 0,
1891 score: 0.0,
1892 conversation_id: Some(999),
1893 source_path: "/shared/session.jsonl".to_string(),
1894 agent: "claude_code".to_string(),
1895 workspace: String::new(),
1896 workspace_original: None,
1897 created_at: None,
1898 line_number: None,
1899 match_type: Default::default(),
1900 source_id: "local".to_string(),
1901 origin_kind: "local".to_string(),
1902 origin_host: None,
1903 };
1904
1905 assert!(!conversation_view_matches_hit(&view, &hit));
1906 }
1907
1908 #[test]
1909 fn load_conversation_for_source_uses_cached_value_when_validation_query_fails() {
1910 use crate::storage::sqlite::FrankenStorage;
1911
1912 let tmp = tempfile::TempDir::new().expect("tempdir");
1913 let db_path = tmp.path().join("cass.db");
1914 let storage = FrankenStorage::open(&db_path).expect("open storage");
1915 let conn = storage.raw();
1916 let shared_path = "/shared/source-cache-when-db-breaks.jsonl";
1917
1918 CONVERSATION_CACHE.invalidate(Some("local"), shared_path);
1919
1920 conn.execute("INSERT INTO agents (id, slug, name, kind, created_at, updated_at) VALUES (1, 'claude_code', 'Claude Code', 'local', 0, 0)")
1921 .expect("insert agent");
1922 {
1923 use frankensqlite::compat::{ParamValue, param_slice_to_values};
1924 let p = [ParamValue::from(shared_path.to_string())];
1925 conn.execute_with_params(
1926 "INSERT INTO conversations (id, agent_id, external_id, title, source_path, source_id, started_at) VALUES (1, 1, 'local-ext', 'Cached Session', ?1, 'local', 100)",
1927 ¶m_slice_to_values(&p),
1928 )
1929 .expect("insert local conversation");
1930 }
1931 conn.execute(
1932 "INSERT INTO messages (id, conversation_id, idx, role, content) VALUES (1, 1, 0, 'user', 'cached body')",
1933 )
1934 .expect("insert local message");
1935
1936 let cached = load_conversation_for_source(&storage, "local", shared_path)
1937 .expect("load initial conversation")
1938 .expect("conversation present");
1939 assert_eq!(cached.convo.title.as_deref(), Some("Cached Session"));
1940 assert_eq!(cached.messages[0].content, "cached body");
1941
1942 conn.execute("DROP TABLE conversations")
1943 .expect("drop conversations to force validation failure");
1944
1945 let still_cached = load_conversation_for_source(&storage, " LOCAL ", shared_path)
1946 .expect("use cached conversation after validation failure")
1947 .expect("cached conversation still present");
1948
1949 assert_eq!(still_cached.convo.title.as_deref(), Some("Cached Session"));
1950 assert_eq!(still_cached.messages[0].content, "cached body");
1951
1952 CONVERSATION_CACHE.invalidate(Some("local"), shared_path);
1953 }
1954
1955 #[test]
1956 fn load_conversation_invalidates_path_only_cache_when_local_source_appears() {
1957 use crate::storage::sqlite::FrankenStorage;
1958
1959 let tmp = tempfile::TempDir::new().expect("tempdir");
1960 let db_path = tmp.path().join("cass.db");
1961 let storage = FrankenStorage::open(&db_path).expect("open storage");
1962 let conn = storage.raw();
1963 let shared_path = "/shared/late-local-session.jsonl";
1964
1965 CONVERSATION_CACHE.invalidate(None, shared_path);
1966 CONVERSATION_CACHE.invalidate(Some("local"), shared_path);
1967 CONVERSATION_CACHE.invalidate(Some("work-laptop"), shared_path);
1968
1969 conn.execute("INSERT INTO agents (id, slug, name, kind, created_at, updated_at) VALUES (1, 'claude_code', 'Claude Code', 'local', 0, 0)")
1970 .expect("insert agent");
1971 conn.execute(
1972 "INSERT INTO sources (id, kind, host_label, created_at, updated_at) VALUES ('work-laptop', 'ssh', 'work-laptop', 0, 0)",
1973 )
1974 .expect("insert source");
1975 {
1976 use frankensqlite::compat::{ParamValue, param_slice_to_values};
1977 let p = [ParamValue::from(shared_path.to_string())];
1978 conn.execute_with_params(
1979 "INSERT INTO conversations (id, agent_id, external_id, title, source_path, source_id, started_at) VALUES (1, 1, 'remote-ext', 'Remote Session', ?1, 'work-laptop', 200)",
1980 ¶m_slice_to_values(&p),
1981 )
1982 .expect("insert remote conversation");
1983 }
1984 conn.execute(
1985 "INSERT INTO messages (id, conversation_id, idx, role, content) VALUES (1, 1, 0, 'user', 'remote body')",
1986 )
1987 .expect("insert remote message");
1988
1989 let first = load_conversation(&storage, shared_path)
1990 .expect("load remote conversation")
1991 .expect("remote conversation present");
1992 assert_eq!(first.convo.source_id, "work-laptop");
1993 assert_eq!(first.messages[0].content, "remote body");
1994
1995 {
1996 use frankensqlite::compat::{ParamValue, param_slice_to_values};
1997 let p = [ParamValue::from(shared_path.to_string())];
1998 conn.execute_with_params(
1999 "INSERT INTO conversations (id, agent_id, external_id, title, source_path, source_id, started_at) VALUES (2, 1, 'local-ext', 'Local Session', ?1, 'local', 100)",
2000 ¶m_slice_to_values(&p),
2001 )
2002 .expect("insert local conversation");
2003 }
2004 conn.execute(
2005 "INSERT INTO messages (id, conversation_id, idx, role, content) VALUES (2, 2, 0, 'user', 'local body')",
2006 )
2007 .expect("insert local message");
2008
2009 let second = load_conversation(&storage, shared_path)
2010 .expect("load local conversation")
2011 .expect("local conversation present");
2012
2013 assert_eq!(second.convo.source_id, "local");
2014 assert_eq!(second.convo.title.as_deref(), Some("Local Session"));
2015 assert_eq!(second.messages[0].content, "local body");
2016
2017 CONVERSATION_CACHE.invalidate(None, shared_path);
2018 CONVERSATION_CACHE.invalidate(Some("local"), shared_path);
2019 CONVERSATION_CACHE.invalidate(Some("work-laptop"), shared_path);
2020 }
2021
2022 #[test]
2023 fn load_conversation_for_hit_selects_exact_conversation_within_same_source_and_path() {
2024 use crate::storage::sqlite::FrankenStorage;
2025
2026 let tmp = tempfile::TempDir::new().expect("tempdir");
2027 let db_path = tmp.path().join("cass.db");
2028 let storage = FrankenStorage::open(&db_path).expect("open storage");
2029 let conn = storage.raw();
2030 let shared_path = "/shared/cursor.sqlite";
2031
2032 conn.execute("INSERT INTO agents (id, slug, name, kind, created_at, updated_at) VALUES (1, 'cursor', 'Cursor', 'local', 0, 0)")
2033 .expect("insert agent");
2034 conn.execute(
2035 "INSERT INTO conversations (id, agent_id, external_id, title, source_path, source_id, started_at) VALUES (1, 1, 'old-ext', 'Old Session', '/shared/cursor.sqlite', 'local', 100)",
2036 )
2037 .expect("insert old conversation");
2038 conn.execute(
2039 "INSERT INTO conversations (id, agent_id, external_id, title, source_path, source_id, started_at) VALUES (2, 1, 'new-ext', 'New Session', '/shared/cursor.sqlite', 'local', 200)",
2040 )
2041 .expect("insert new conversation");
2042 conn.execute(
2043 "INSERT INTO messages (id, conversation_id, idx, role, created_at, content) VALUES (1, 1, 0, 'user', 101, 'old conversation body')",
2044 )
2045 .expect("insert old message");
2046 conn.execute(
2047 "INSERT INTO messages (id, conversation_id, idx, role, created_at, content) VALUES (2, 2, 0, 'user', 201, 'new conversation body')",
2048 )
2049 .expect("insert new message");
2050
2051 let hit = SearchHit {
2052 title: "New Session".to_string(),
2053 snippet: "new conversation body".to_string(),
2054 content: "new conversation body".to_string(),
2055 content_hash: 0,
2056 conversation_id: None,
2057 score: 0.0,
2058 source_path: shared_path.to_string(),
2059 agent: "cursor".to_string(),
2060 workspace: String::new(),
2061 workspace_original: None,
2062 created_at: Some(201),
2063 line_number: Some(1),
2064 match_type: Default::default(),
2065 source_id: "local".to_string(),
2066 origin_kind: "local".to_string(),
2067 origin_host: None,
2068 };
2069
2070 let loaded = load_conversation_for_hit(&storage, &hit)
2071 .expect("load exact conversation")
2072 .expect("matching conversation");
2073
2074 assert_eq!(loaded.convo.external_id.as_deref(), Some("new-ext"));
2075 assert_eq!(loaded.convo.title.as_deref(), Some("New Session"));
2076 assert_eq!(loaded.messages[0].content, "new conversation body");
2077 }
2078
2079 #[test]
2080 fn load_conversation_for_hit_accepts_matching_timestamp_even_when_hit_content_is_stale() {
2081 use crate::storage::sqlite::FrankenStorage;
2082
2083 let tmp = tempfile::TempDir::new().expect("tempdir");
2084 let db_path = tmp.path().join("cass.db");
2085 let storage = FrankenStorage::open(&db_path).expect("open storage");
2086 let conn = storage.raw();
2087 let shared_path = "/shared/cursor.sqlite";
2088
2089 conn.execute("INSERT INTO agents (id, slug, name, kind, created_at, updated_at) VALUES (1, 'cursor', 'Cursor', 'local', 0, 0)")
2090 .expect("insert agent");
2091 conn.execute(
2092 "INSERT INTO conversations (id, agent_id, external_id, title, source_path, source_id, started_at) VALUES (1, 1, 'new-ext', 'New Session', '/shared/cursor.sqlite', 'local', 200)",
2093 )
2094 .expect("insert conversation");
2095 conn.execute(
2096 "INSERT INTO messages (id, conversation_id, idx, role, created_at, content) VALUES (1, 1, 0, 'user', 201, 'new conversation body')",
2097 )
2098 .expect("insert message");
2099
2100 let hit = SearchHit {
2101 title: "New Session".to_string(),
2102 snippet: "rendered fragment".to_string(),
2103 content: "stale search fragment".to_string(),
2104 content_hash: 0,
2105 conversation_id: None,
2106 score: 0.0,
2107 source_path: shared_path.to_string(),
2108 agent: "cursor".to_string(),
2109 workspace: String::new(),
2110 workspace_original: None,
2111 created_at: Some(201),
2112 line_number: None,
2113 match_type: Default::default(),
2114 source_id: "local".to_string(),
2115 origin_kind: "local".to_string(),
2116 origin_host: None,
2117 };
2118
2119 let loaded = load_conversation_for_hit(&storage, &hit)
2120 .expect("load exact conversation")
2121 .expect("matching conversation");
2122
2123 assert_eq!(loaded.convo.external_id.as_deref(), Some("new-ext"));
2124 assert_eq!(loaded.messages[0].content, "new conversation body");
2125 }
2126
2127 #[test]
2128 fn load_conversation_for_hit_falls_back_when_conversation_id_is_stale() {
2129 let tmp = tempdir().expect("tempdir");
2130 let db_path = tmp.path().join("cass.db");
2131 let storage = FrankenStorage::open(&db_path).expect("open db");
2132 let conn = storage.raw();
2133 conn.execute("INSERT INTO agents (id, slug, name, kind, created_at, updated_at) VALUES (1, 'claude_code', 'Claude Code', 'local', 0, 0)")
2134 .expect("insert agent");
2135 conn.execute(
2136 "INSERT INTO conversations (id, agent_id, external_id, title, source_path, source_id, started_at) VALUES (1, 1, 'exact-ext', 'Database Title', '/shared/cursor.sqlite', 'local', 200)",
2137 )
2138 .expect("insert conversation");
2139 conn.execute(
2140 "INSERT INTO messages (id, conversation_id, idx, role, created_at, content) VALUES (1, 1, 0, 'user', 201, 'db body')",
2141 )
2142 .expect("insert message");
2143
2144 let hit = SearchHit {
2145 title: "Database Title".to_string(),
2146 snippet: "db body".to_string(),
2147 content: "db body".to_string(),
2148 content_hash: 0,
2149 conversation_id: Some(999),
2150 score: 1.0,
2151 source_path: "/shared/cursor.sqlite".to_string(),
2152 agent: "claude_code".to_string(),
2153 workspace: String::new(),
2154 workspace_original: None,
2155 created_at: Some(201),
2156 line_number: Some(1),
2157 match_type: MatchType::Exact,
2158 source_id: "local".to_string(),
2159 origin_kind: "local".to_string(),
2160 origin_host: None,
2161 };
2162 let loaded = load_conversation_for_hit(&storage, &hit)
2163 .expect("load attempt succeeds")
2164 .expect("should fall back to provenance match after stale conversation id misses");
2165
2166 assert_eq!(loaded.convo.id, Some(1));
2167 assert_eq!(
2168 loaded.convo.source_path,
2169 std::path::Path::new("/shared/cursor.sqlite")
2170 );
2171 }
2172
2173 #[test]
2174 fn load_conversation_for_hit_uses_origin_host_when_db_source_id_is_blank_remote() {
2175 use crate::storage::sqlite::FrankenStorage;
2176
2177 let tmp = tempfile::TempDir::new().expect("tempdir");
2178 let db_path = tmp.path().join("cass.db");
2179 let storage = FrankenStorage::open(&db_path).expect("open storage");
2180 let conn = storage.raw();
2181 let shared_path = "/shared/remote.sqlite";
2182
2183 conn.execute("INSERT INTO agents (id, slug, name, kind, created_at, updated_at) VALUES (1, 'cursor', 'Cursor', 'local', 0, 0)")
2184 .expect("insert agent");
2185 conn.execute(
2186 "INSERT INTO sources (id, kind, host_label, created_at, updated_at) VALUES (' ', 'ssh', 'user@laptop', 0, 0)",
2187 )
2188 .expect("insert blank-id remote source");
2189 conn.execute(
2190 "INSERT INTO conversations (id, agent_id, external_id, title, source_path, source_id, origin_host, started_at) VALUES (1, 1, 'remote-ext', 'Remote Session', '/shared/remote.sqlite', ' ', 'user@laptop', 200)",
2191 )
2192 .expect("insert conversation");
2193 conn.execute(
2194 "INSERT INTO messages (id, conversation_id, idx, role, created_at, content) VALUES (1, 1, 0, 'user', 201, 'db body')",
2195 )
2196 .expect("insert message");
2197
2198 let hit = SearchHit {
2199 title: "Remote Session".to_string(),
2200 snippet: String::new(),
2201 content: String::new(),
2202 content_hash: 0,
2203 conversation_id: Some(1),
2204 score: 0.0,
2205 source_path: shared_path.to_string(),
2206 agent: "cursor".to_string(),
2207 workspace: String::new(),
2208 workspace_original: None,
2209 created_at: None,
2210 line_number: None,
2211 match_type: Default::default(),
2212 source_id: " ".to_string(),
2213 origin_kind: "remote".to_string(),
2214 origin_host: Some("user@laptop".to_string()),
2215 };
2216
2217 let loaded = load_conversation_for_hit(&storage, &hit)
2218 .expect("load exact conversation")
2219 .expect("matching conversation");
2220
2221 assert_eq!(loaded.convo.id, Some(1));
2222 assert_eq!(loaded.convo.source_id, "user@laptop");
2223 assert_eq!(loaded.convo.origin_host.as_deref(), Some("user@laptop"));
2224 }
2225
2226 #[test]
2227 fn load_conversation_for_hit_prefers_exact_conversation_id_over_stale_path() {
2228 use crate::storage::sqlite::FrankenStorage;
2229
2230 let tmp = tempfile::TempDir::new().expect("tempdir");
2231 let db_path = tmp.path().join("cass.db");
2232 let storage = FrankenStorage::open(&db_path).expect("open storage");
2233 let conn = storage.raw();
2234
2235 conn.execute("INSERT INTO agents (id, slug, name, kind, created_at, updated_at) VALUES (1, 'cursor', 'Cursor', 'local', 0, 0)")
2236 .expect("insert agent");
2237 conn.execute(
2238 "INSERT INTO sources (id, kind, host_label, created_at, updated_at) VALUES (' local ', 'local', 'local', 0, 0)",
2239 )
2240 .expect("insert local source");
2241 conn.execute(
2242 "INSERT INTO conversations (id, agent_id, external_id, title, source_path, source_id, started_at) VALUES (1, 1, 'exact-ext', 'Database Title', '/db/real/path.sqlite', ' local ', 200)",
2243 )
2244 .expect("insert conversation");
2245 conn.execute(
2246 "INSERT INTO messages (id, conversation_id, idx, role, created_at, content) VALUES (1, 1, 0, 'user', 201, 'db body')",
2247 )
2248 .expect("insert message");
2249
2250 let hit = SearchHit {
2251 title: "Stale Indexed Title".to_string(),
2252 snippet: String::new(),
2253 content: String::new(),
2254 content_hash: 0,
2255 conversation_id: Some(1),
2256 score: 0.0,
2257 source_path: "/stale/index/path.sqlite".to_string(),
2258 agent: "cursor".to_string(),
2259 workspace: String::new(),
2260 workspace_original: None,
2261 created_at: None,
2262 line_number: None,
2263 match_type: Default::default(),
2264 source_id: "remote-laptop".to_string(),
2265 origin_kind: "remote".to_string(),
2266 origin_host: Some("dev@laptop".to_string()),
2267 };
2268
2269 let loaded = load_conversation_for_hit(&storage, &hit)
2270 .expect("load exact conversation")
2271 .expect("matching conversation");
2272
2273 assert_eq!(loaded.convo.id, Some(1));
2274 assert_eq!(
2275 loaded.convo.source_path.to_string_lossy(),
2276 "/db/real/path.sqlite"
2277 );
2278 assert_eq!(loaded.convo.source_id, "local");
2279 }
2280
2281 #[test]
2282 fn load_conversation_for_hit_prefers_exact_conversation_id_over_stale_title() {
2283 use crate::storage::sqlite::FrankenStorage;
2284
2285 let tmp = tempfile::TempDir::new().expect("tempdir");
2286 let db_path = tmp.path().join("cass.db");
2287 let storage = FrankenStorage::open(&db_path).expect("open storage");
2288 let conn = storage.raw();
2289 let shared_path = "/shared/cursor.sqlite";
2290
2291 conn.execute("INSERT INTO agents (id, slug, name, kind, created_at, updated_at) VALUES (1, 'cursor', 'Cursor', 'local', 0, 0)")
2292 .expect("insert agent");
2293 conn.execute(
2294 "INSERT INTO conversations (id, agent_id, external_id, title, source_path, source_id, started_at) VALUES (1, 1, 'exact-ext', 'Database Title', '/shared/cursor.sqlite', 'local', 200)",
2295 )
2296 .expect("insert conversation");
2297 conn.execute(
2298 "INSERT INTO messages (id, conversation_id, idx, role, created_at, content) VALUES (1, 1, 0, 'user', 201, 'db body')",
2299 )
2300 .expect("insert message");
2301
2302 let hit = SearchHit {
2303 title: "Stale Indexed Title".to_string(),
2304 snippet: String::new(),
2305 content: String::new(),
2306 content_hash: 0,
2307 conversation_id: Some(1),
2308 score: 0.0,
2309 source_path: shared_path.to_string(),
2310 agent: "cursor".to_string(),
2311 workspace: String::new(),
2312 workspace_original: None,
2313 created_at: None,
2314 line_number: None,
2315 match_type: Default::default(),
2316 source_id: "local".to_string(),
2317 origin_kind: "local".to_string(),
2318 origin_host: None,
2319 };
2320
2321 let loaded = load_conversation_for_hit(&storage, &hit)
2322 .expect("load exact conversation")
2323 .expect("matching conversation");
2324
2325 assert_eq!(loaded.convo.id, Some(1));
2326 assert_eq!(loaded.convo.title.as_deref(), Some("Database Title"));
2327 }
2328
2329 #[test]
2330 fn load_conversation_for_hit_ignores_stale_title_when_exact_content_identifies_match() {
2331 use crate::storage::sqlite::FrankenStorage;
2332
2333 let tmp = tempfile::TempDir::new().expect("tempdir");
2334 let db_path = tmp.path().join("cass.db");
2335 let storage = FrankenStorage::open(&db_path).expect("open storage");
2336 let conn = storage.raw();
2337 let shared_path = "/shared/cursor.sqlite";
2338
2339 conn.execute("INSERT INTO agents (id, slug, name, kind, created_at, updated_at) VALUES (1, 'cursor', 'Cursor', 'local', 0, 0)")
2340 .expect("insert agent");
2341 conn.execute(
2342 "INSERT INTO conversations (id, agent_id, external_id, title, source_path, source_id, started_at) VALUES (1, 1, 'old-ext', 'Old Session', '/shared/cursor.sqlite', 'local', 100)",
2343 )
2344 .expect("insert old conversation");
2345 conn.execute(
2346 "INSERT INTO conversations (id, agent_id, external_id, title, source_path, source_id, started_at) VALUES (2, 1, 'new-ext', 'New Session', '/shared/cursor.sqlite', 'local', 200)",
2347 )
2348 .expect("insert new conversation");
2349 conn.execute(
2350 "INSERT INTO messages (id, conversation_id, idx, role, content) VALUES (1, 1, 0, 'user', 'old conversation body')",
2351 )
2352 .expect("insert old message");
2353 conn.execute(
2354 "INSERT INTO messages (id, conversation_id, idx, role, content) VALUES (2, 2, 0, 'user', 'new conversation body')",
2355 )
2356 .expect("insert new message");
2357
2358 let hit = SearchHit {
2359 title: "Stale Indexed Title".to_string(),
2360 snippet: "new conversation body".to_string(),
2361 content: "new conversation body".to_string(),
2362 content_hash: 0,
2363 conversation_id: None,
2364 score: 0.0,
2365 source_path: shared_path.to_string(),
2366 agent: "cursor".to_string(),
2367 workspace: String::new(),
2368 workspace_original: None,
2369 created_at: None,
2370 line_number: Some(1),
2371 match_type: Default::default(),
2372 source_id: "local".to_string(),
2373 origin_kind: "local".to_string(),
2374 origin_host: None,
2375 };
2376
2377 let loaded = load_conversation_for_hit(&storage, &hit)
2378 .expect("load exact conversation")
2379 .expect("matching conversation");
2380
2381 assert_eq!(loaded.convo.external_id.as_deref(), Some("new-ext"));
2382 assert_eq!(loaded.convo.title.as_deref(), Some("New Session"));
2383 assert_eq!(loaded.messages[0].content, "new conversation body");
2384 }
2385
2386 #[test]
2387 fn load_conversation_for_hit_uses_title_only_identity_hint() {
2388 use crate::storage::sqlite::FrankenStorage;
2389
2390 let tmp = tempfile::TempDir::new().expect("tempdir");
2391 let db_path = tmp.path().join("cass.db");
2392 let storage = FrankenStorage::open(&db_path).expect("open storage");
2393 let conn = storage.raw();
2394 let shared_path = "/shared/cursor.sqlite";
2395
2396 conn.execute("INSERT INTO agents (id, slug, name, kind, created_at, updated_at) VALUES (1, 'cursor', 'Cursor', 'local', 0, 0)")
2397 .expect("insert agent");
2398 conn.execute(
2399 "INSERT INTO conversations (id, agent_id, external_id, title, source_path, source_id, started_at) VALUES (1, 1, 'old-ext', 'Old Session', '/shared/cursor.sqlite', 'local', 100)",
2400 )
2401 .expect("insert old conversation");
2402 conn.execute(
2403 "INSERT INTO conversations (id, agent_id, external_id, title, source_path, source_id, started_at) VALUES (2, 1, 'new-ext', 'New Session', '/shared/cursor.sqlite', 'local', 200)",
2404 )
2405 .expect("insert new conversation");
2406 conn.execute(
2407 "INSERT INTO messages (id, conversation_id, idx, role, content) VALUES (1, 1, 0, 'user', 'old conversation body')",
2408 )
2409 .expect("insert old message");
2410 conn.execute(
2411 "INSERT INTO messages (id, conversation_id, idx, role, content) VALUES (2, 2, 0, 'user', 'new conversation body')",
2412 )
2413 .expect("insert new message");
2414
2415 let hit = SearchHit {
2416 title: "Old Session".to_string(),
2417 snippet: String::new(),
2418 content: String::new(),
2419 content_hash: 0,
2420 conversation_id: None,
2421 score: 0.0,
2422 source_path: shared_path.to_string(),
2423 agent: "cursor".to_string(),
2424 workspace: String::new(),
2425 workspace_original: None,
2426 created_at: None,
2427 line_number: None,
2428 match_type: Default::default(),
2429 source_id: "local".to_string(),
2430 origin_kind: "local".to_string(),
2431 origin_host: None,
2432 };
2433
2434 let loaded = load_conversation_for_hit(&storage, &hit)
2435 .expect("load attempt succeeds")
2436 .expect("matching conversation");
2437
2438 assert_eq!(loaded.convo.external_id.as_deref(), Some("old-ext"));
2439 assert_eq!(loaded.convo.title.as_deref(), Some("Old Session"));
2440 }
2441
2442 #[test]
2443 fn load_conversation_for_hit_does_not_fall_back_to_wrong_conversation_when_identity_misses() {
2444 use crate::storage::sqlite::FrankenStorage;
2445
2446 let tmp = tempfile::TempDir::new().expect("tempdir");
2447 let db_path = tmp.path().join("cass.db");
2448 let storage = FrankenStorage::open(&db_path).expect("open storage");
2449 let conn = storage.raw();
2450 let shared_path = "/shared/cursor.sqlite";
2451
2452 conn.execute("INSERT INTO agents (id, slug, name, kind, created_at, updated_at) VALUES (1, 'cursor', 'Cursor', 'local', 0, 0)")
2453 .expect("insert agent");
2454 conn.execute(
2455 "INSERT INTO conversations (id, agent_id, external_id, title, source_path, source_id, started_at) VALUES (1, 1, 'old-ext', 'Old Session', '/shared/cursor.sqlite', 'local', 100)",
2456 )
2457 .expect("insert old conversation");
2458 conn.execute(
2459 "INSERT INTO conversations (id, agent_id, external_id, title, source_path, source_id, started_at) VALUES (2, 1, 'new-ext', 'New Session', '/shared/cursor.sqlite', 'local', 200)",
2460 )
2461 .expect("insert new conversation");
2462 conn.execute(
2463 "INSERT INTO messages (id, conversation_id, idx, role, created_at, content) VALUES (1, 1, 0, 'user', 101, 'old conversation body')",
2464 )
2465 .expect("insert old message");
2466 conn.execute(
2467 "INSERT INTO messages (id, conversation_id, idx, role, created_at, content) VALUES (2, 2, 0, 'user', 201, 'new conversation body')",
2468 )
2469 .expect("insert new message");
2470
2471 let hit = SearchHit {
2472 title: "Missing Session".to_string(),
2473 snippet: "missing conversation body".to_string(),
2474 content: "missing conversation body".to_string(),
2475 content_hash: 0,
2476 conversation_id: None,
2477 score: 0.0,
2478 source_path: shared_path.to_string(),
2479 agent: "cursor".to_string(),
2480 workspace: String::new(),
2481 workspace_original: None,
2482 created_at: Some(999),
2483 line_number: Some(1),
2484 match_type: Default::default(),
2485 source_id: "local".to_string(),
2486 origin_kind: "local".to_string(),
2487 origin_host: None,
2488 };
2489
2490 let loaded = load_conversation_for_hit(&storage, &hit).expect("load attempt succeeds");
2491 assert!(
2492 loaded.is_none(),
2493 "identity-mismatched hits must not fall back to an arbitrary conversation"
2494 );
2495 }
2496
2497 #[test]
2498 fn test_cache_miss() {
2499 let cache = ConversationCache::new(10);
2500
2501 let cached = cache.get(None, "/nonexistent/path.jsonl");
2503 assert!(cached.is_none());
2504
2505 let (hits, misses, _) = cache.stats().get();
2507 assert_eq!(hits, 0);
2508 assert_eq!(misses, 1);
2509 }
2510
2511 #[test]
2512 fn test_cache_invalidation() {
2513 let cache = ConversationCache::new(10);
2514 let view = make_test_view(1);
2515 let source_path = "/test/path/1.jsonl";
2516
2517 cache.insert(None, source_path, view);
2519 assert!(cache.get(None, source_path).is_some());
2520
2521 cache.invalidate(None, source_path);
2523 assert!(cache.get(None, source_path).is_none());
2524 }
2525
2526 #[test]
2527 fn test_cache_invalidate_all() {
2528 let cache = ConversationCache::new(10);
2529
2530 for i in 0..5 {
2532 let view = make_test_view(i);
2533 let source_path = format!("/test/path/{}.jsonl", i);
2534 cache.insert(None, &source_path, view);
2535 }
2536
2537 assert_eq!(cache.len(), 5);
2538
2539 cache.invalidate_all();
2541 assert_eq!(cache.len(), 0);
2542 assert!(cache.is_empty());
2543 }
2544
2545 #[test]
2546 fn test_cache_lru_eviction() {
2547 let cache = ConversationCache::new(2); for i in 0..100 {
2553 let view = make_test_view(i);
2554 let source_path = format!("/test/path/{}.jsonl", i);
2555 cache.insert(None, &source_path, view);
2556 }
2557
2558 let (_, _, evictions) = cache.stats().get();
2560 assert!(evictions > 0, "Expected some evictions with small capacity");
2561 }
2562
2563 #[test]
2564 fn test_cache_hit_rate() {
2565 let cache = ConversationCache::new(10);
2566 let view = make_test_view(1);
2567 let source_path = "/test/path/1.jsonl";
2568
2569 assert_eq!(cache.stats().hit_rate(), 0.0);
2571
2572 cache.insert(None, source_path, view);
2574 let _ = cache.get(None, source_path);
2575 let _ = cache.get(None, source_path);
2576
2577 let hit_rate = cache.stats().hit_rate();
2579 assert!(
2580 hit_rate > 0.5,
2581 "Expected >50% hit rate, got {:.1}%",
2582 hit_rate * 100.0
2583 );
2584 }
2585
2586 #[test]
2587 fn test_cache_shard_distribution() {
2588 let cache = ConversationCache::new(100);
2589
2590 for i in 0..1000 {
2592 let view = make_test_view(i);
2593 let source_path = format!("/various/paths/{}/session.jsonl", i);
2594 cache.insert(None, &source_path, view);
2595 }
2596
2597 assert_eq!(cache.len(), 1000);
2599 }
2600
2601 #[test]
2602 fn test_cache_concurrent_access() {
2603 use std::thread;
2604
2605 let cache = Arc::new(ConversationCache::new(100));
2606 let mut handles = vec![];
2607
2608 for t in 0..4 {
2610 let cache = Arc::clone(&cache);
2611 handles.push(thread::spawn(move || {
2612 for i in 0..250 {
2613 let id = t * 250 + i;
2614 let view = make_test_view(id);
2615 let source_path = format!("/test/path/{}.jsonl", id);
2616 cache.insert(None, &source_path, view);
2617 }
2618 }));
2619 }
2620
2621 for _ in 0..4 {
2623 let cache = Arc::clone(&cache);
2624 handles.push(thread::spawn(move || {
2625 for i in 0..1000 {
2626 let source_path = format!("/test/path/{}.jsonl", i);
2627 let _ = cache.get(None, &source_path);
2628 }
2629 }));
2630 }
2631
2632 for handle in handles {
2633 handle.join().unwrap();
2634 }
2635
2636 let (hits, misses, _) = cache.stats().get();
2638 assert!(hits + misses > 0, "Expected some cache operations");
2639 }
2640
2641 #[test]
2646 fn cockpit_panel_label_and_navigation() {
2647 assert_eq!(CockpitPanel::DiffStrategy.label(), "Diff");
2648 assert_eq!(CockpitPanel::ResizeRegime.label(), "Resize");
2649 assert_eq!(CockpitPanel::BudgetHealth.label(), "Budget");
2650 assert_eq!(CockpitPanel::Timeline.label(), "Timeline");
2651
2652 let mut p = CockpitPanel::DiffStrategy;
2654 p = p.next();
2655 assert_eq!(p, CockpitPanel::ResizeRegime);
2656 p = p.next();
2657 assert_eq!(p, CockpitPanel::BudgetHealth);
2658 p = p.next();
2659 assert_eq!(p, CockpitPanel::Timeline);
2660 p = p.next();
2661 assert_eq!(p, CockpitPanel::DiffStrategy);
2662
2663 p = CockpitPanel::DiffStrategy;
2665 p = p.prev();
2666 assert_eq!(p, CockpitPanel::Timeline);
2667 p = p.prev();
2668 assert_eq!(p, CockpitPanel::BudgetHealth);
2669 p = p.prev();
2670 assert_eq!(p, CockpitPanel::ResizeRegime);
2671 p = p.prev();
2672 assert_eq!(p, CockpitPanel::DiffStrategy);
2673 }
2674
2675 #[test]
2676 fn cockpit_panel_all_constant() {
2677 assert_eq!(CockpitPanel::ALL.len(), 4);
2678 assert_eq!(CockpitPanel::ALL[0], CockpitPanel::DiffStrategy);
2679 assert_eq!(CockpitPanel::ALL[3], CockpitPanel::Timeline);
2680 }
2681
2682 #[test]
2683 fn diff_strategy_contract_defaults_no_data() {
2684 let diff = DiffStrategyContract::default();
2685 assert!(!diff.has_data());
2686 assert_eq!(diff.dirty_ratio(), 0.0);
2687 assert!(!diff.last_was_full_redraw);
2688 }
2689
2690 #[test]
2691 fn diff_strategy_contract_dirty_ratio() {
2692 let diff = DiffStrategyContract {
2693 dirty_row_count: 10,
2694 total_row_count: 40,
2695 ..Default::default()
2696 };
2697 assert!(diff.has_data());
2698 assert!((diff.dirty_ratio() - 0.25).abs() < f64::EPSILON);
2699 }
2700
2701 #[test]
2702 fn resize_regime_contract_defaults_no_data() {
2703 let resize = ResizeRegimeContract::default();
2704 assert!(!resize.has_data());
2705 assert_eq!(resize.regime, "\u{2014}");
2706 }
2707
2708 #[test]
2709 fn resize_regime_contract_with_data() {
2710 let resize = ResizeRegimeContract {
2711 regime: "Burst",
2712 terminal_size: Some((120, 40)),
2713 bocpd_p_burst: Some(0.87),
2714 history_len: 5,
2715 last_action: "defer",
2716 ..Default::default()
2717 };
2718 assert!(resize.has_data());
2719 assert_eq!(resize.terminal_size, Some((120, 40)));
2720 }
2721
2722 #[test]
2723 fn budget_health_contract_defaults_no_data() {
2724 let budget = BudgetHealthContract::default();
2725 assert!(!budget.has_data());
2726 assert!(!budget.is_over_budget());
2727 }
2728
2729 #[test]
2730 fn budget_health_contract_over_budget() {
2731 let budget = BudgetHealthContract {
2732 budget_us: 16_666.0,
2733 frame_time_us: 25_000.0,
2734 pressure: 1.5,
2735 frames_observed: 100,
2736 ..Default::default()
2737 };
2738 assert!(budget.has_data());
2739 assert!(budget.is_over_budget());
2740 }
2741
2742 #[test]
2743 fn timeline_contract_push_and_eviction() {
2744 let mut timeline = TimelineContract {
2745 events: std::collections::VecDeque::new(),
2746 capacity: 3,
2747 };
2748 assert!(timeline.is_empty());
2749 assert!(!timeline.has_data());
2750
2751 for i in 0..5 {
2752 timeline.push(CockpitTimelineEvent {
2753 source: CockpitPanel::BudgetHealth,
2754 summary: format!("event {i}"),
2755 event_idx: i,
2756 elapsed_secs: i as f64,
2757 severity: TimelineEventSeverity::Info,
2758 });
2759 }
2760
2761 assert_eq!(timeline.len(), 3);
2762 assert!(timeline.has_data());
2763 assert_eq!(timeline.events[0].event_idx, 2);
2765 assert_eq!(timeline.events[2].event_idx, 4);
2766 }
2767
2768 #[test]
2769 fn cockpit_state_empty_messages() {
2770 let state = CockpitState::new();
2771 assert!(!state.has_any_data());
2772
2773 assert!(!state.empty_message(CockpitPanel::DiffStrategy).is_empty());
2775 assert!(!state.empty_message(CockpitPanel::ResizeRegime).is_empty());
2776 assert!(!state.empty_message(CockpitPanel::BudgetHealth).is_empty());
2777 assert!(!state.empty_message(CockpitPanel::Timeline).is_empty());
2778 }
2779
2780 #[test]
2781 fn cockpit_state_partial_data() {
2782 let mut state = CockpitState::new();
2783 state.resize = ResizeRegimeContract {
2784 regime: "Steady",
2785 ..Default::default()
2786 };
2787 assert!(state.has_any_data());
2788 assert_eq!(state.empty_message(CockpitPanel::ResizeRegime), "");
2790 assert!(!state.empty_message(CockpitPanel::DiffStrategy).is_empty());
2792 }
2793
2794 #[test]
2795 fn timeline_event_severity_default_is_info() {
2796 assert_eq!(
2797 TimelineEventSeverity::default(),
2798 TimelineEventSeverity::Info
2799 );
2800 }
2801
2802 #[test]
2803 fn cockpit_empty_policy_defaults() {
2804 let policy = CockpitEmptyPolicy::default();
2805 assert_eq!(policy.no_data, "\u{2014}");
2806 assert!(policy.awaiting.contains("awaiting"));
2807 assert!(policy.disabled.contains("disabled"));
2808 }
2809
2810 #[test]
2813 fn cockpit_mode_default_is_overlay() {
2814 assert_eq!(CockpitMode::default(), CockpitMode::Overlay);
2815 }
2816
2817 #[test]
2818 fn cockpit_mode_cycle() {
2819 assert_eq!(CockpitMode::Overlay.cycle(), CockpitMode::Expanded);
2820 assert_eq!(CockpitMode::Expanded.cycle(), CockpitMode::Overlay);
2821 }
2822
2823 #[test]
2824 fn cockpit_mode_labels() {
2825 assert_eq!(CockpitMode::Overlay.label(), "overlay");
2826 assert_eq!(CockpitMode::Expanded.label(), "expanded");
2827 }
2828
2829 #[test]
2830 fn cockpit_state_includes_mode() {
2831 let state = CockpitState::new();
2832 assert_eq!(state.mode, CockpitMode::Overlay);
2833 assert!(!state.enabled);
2834 }
2835}