1use crate::pages::encrypt::{KeySlot, SlotType};
25use crate::pages::secret_scan::{SecretScanReport, SecretScanSummary};
26use anyhow::{Context, Result};
27use chrono::{DateTime, Utc};
28use frankensqlite::Connection;
29use frankensqlite::Row;
30use frankensqlite::compat::{ConnectionExt, ParamValue, RowExt};
31use regex::Regex;
32use serde::{Deserialize, Serialize};
33use std::collections::{HashMap, HashSet};
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct PrePublishSummary {
38 pub total_conversations: usize,
41 pub total_messages: usize,
43 pub total_characters: usize,
45 pub estimated_size_bytes: usize,
47
48 pub earliest_timestamp: Option<DateTime<Utc>>,
51 pub latest_timestamp: Option<DateTime<Utc>>,
53 pub date_histogram: Vec<DateHistogramEntry>,
55
56 pub workspaces: Vec<WorkspaceSummaryItem>,
59 pub agents: Vec<AgentSummaryItem>,
61
62 pub secret_scan: ScanReportSummary,
65 pub encryption_config: Option<EncryptionSummary>,
67 pub key_slots: Vec<KeySlotSummary>,
69
70 pub generated_at: DateTime<Utc>,
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct DateHistogramEntry {
77 pub date: String,
79 pub message_count: usize,
81 pub conversation_count: usize,
83}
84
85#[derive(Debug, Clone, Serialize, Deserialize)]
87pub struct WorkspaceSummaryItem {
88 pub path: String,
90 pub display_name: String,
92 pub conversation_count: usize,
94 pub message_count: usize,
96 pub date_range: DateRange,
98 pub sample_titles: Vec<String>,
100 pub included: bool,
102}
103
104#[derive(Debug, Clone, Serialize, Deserialize)]
106pub struct AgentSummaryItem {
107 pub name: String,
109 pub conversation_count: usize,
111 pub message_count: usize,
113 pub percentage: f64,
115 pub included: bool,
117}
118
119#[derive(Debug, Clone, Serialize, Deserialize)]
121pub struct DateRange {
122 pub earliest: Option<String>,
124 pub latest: Option<String>,
126}
127
128impl DateRange {
129 pub fn from_timestamps(earliest: Option<i64>, latest: Option<i64>) -> Self {
131 Self {
132 earliest: earliest
133 .and_then(DateTime::from_timestamp_millis)
134 .map(|dt| dt.to_rfc3339()),
135 latest: latest
136 .and_then(DateTime::from_timestamp_millis)
137 .map(|dt| dt.to_rfc3339()),
138 }
139 }
140
141 pub fn span_days(&self) -> Option<i64> {
143 let earliest = self.earliest.as_ref()?.parse::<DateTime<Utc>>().ok()?;
144 let latest = self.latest.as_ref()?.parse::<DateTime<Utc>>().ok()?;
145 Some((latest - earliest).num_days())
146 }
147}
148
149#[derive(Debug, Clone, Default, Serialize, Deserialize)]
151pub struct ScanReportSummary {
152 pub total_findings: usize,
154 pub by_severity: HashMap<String, usize>,
156 pub has_critical: bool,
158 pub truncated: bool,
160 pub status_message: String,
162}
163
164impl ScanReportSummary {
165 pub fn from_report(report: &SecretScanReport) -> Self {
167 let by_severity: HashMap<String, usize> = report
168 .summary
169 .by_severity
170 .iter()
171 .map(|(k, v)| (k.label().to_string(), *v))
172 .collect();
173
174 let status_message = if report.summary.total == 0 {
175 "No secrets detected".to_string()
176 } else if report.summary.has_critical {
177 format!("{} issues found (including CRITICAL)", report.summary.total)
178 } else {
179 format!("{} issues found", report.summary.total)
180 };
181
182 Self {
183 total_findings: report.summary.total,
184 by_severity,
185 has_critical: report.summary.has_critical,
186 truncated: report.summary.truncated,
187 status_message,
188 }
189 }
190
191 pub fn from_summary(summary: &SecretScanSummary) -> Self {
193 let by_severity: HashMap<String, usize> = summary
194 .by_severity
195 .iter()
196 .map(|(k, v)| (k.label().to_string(), *v))
197 .collect();
198
199 let status_message = if summary.total == 0 {
200 "No secrets detected".to_string()
201 } else if summary.has_critical {
202 format!("{} issues found (including CRITICAL)", summary.total)
203 } else {
204 format!("{} issues found", summary.total)
205 };
206
207 Self {
208 total_findings: summary.total,
209 by_severity,
210 has_critical: summary.has_critical,
211 truncated: summary.truncated,
212 status_message,
213 }
214 }
215}
216
217#[derive(Debug, Clone, Serialize, Deserialize)]
219pub struct EncryptionSummary {
220 pub algorithm: String,
222 pub key_derivation: String,
224 pub key_slot_count: usize,
226 pub estimated_decrypt_time_secs: u64,
228}
229
230impl Default for EncryptionSummary {
231 fn default() -> Self {
232 Self {
233 algorithm: "AES-256-GCM".to_string(),
234 key_derivation: "Argon2id".to_string(),
235 key_slot_count: 0,
236 estimated_decrypt_time_secs: 2,
237 }
238 }
239}
240
241#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
243#[serde(rename_all = "snake_case")]
244pub enum KeySlotType {
245 Password,
246 QrCode,
247 Recovery,
248}
249
250impl From<SlotType> for KeySlotType {
251 fn from(st: SlotType) -> Self {
252 match st {
253 SlotType::Password => KeySlotType::Password,
254 SlotType::Recovery => KeySlotType::Recovery,
255 }
256 }
257}
258
259impl KeySlotType {
260 pub fn label(self) -> &'static str {
262 match self {
263 KeySlotType::Password => "Password",
264 KeySlotType::QrCode => "QR Code",
265 KeySlotType::Recovery => "Recovery Key",
266 }
267 }
268}
269
270#[derive(Debug, Clone, Serialize, Deserialize)]
272pub struct KeySlotSummary {
273 pub slot_index: usize,
275 pub slot_type: KeySlotType,
277 pub hint: Option<String>,
279 pub created_at: Option<DateTime<Utc>>,
281}
282
283impl KeySlotSummary {
284 pub fn from_key_slot(slot: &KeySlot, index: usize) -> Self {
286 Self {
287 slot_index: index,
288 slot_type: slot.slot_type.into(),
289 hint: None, created_at: None,
291 }
292 }
293}
294
295#[derive(Debug, Clone, Default, Serialize, Deserialize)]
297pub struct ExclusionSet {
298 pub excluded_workspaces: HashSet<String>,
300 pub excluded_conversations: HashSet<i64>,
302 #[serde(skip)]
304 pub excluded_patterns: Vec<Regex>,
305 pub excluded_pattern_strings: Vec<String>,
307}
308
309impl ExclusionSet {
310 pub fn new() -> Self {
312 Self::default()
313 }
314
315 pub fn exclude_workspace(&mut self, workspace: &str) {
317 self.excluded_workspaces.insert(workspace.to_string());
318 }
319
320 pub fn include_workspace(&mut self, workspace: &str) {
322 self.excluded_workspaces.remove(workspace);
323 }
324
325 pub fn exclude_conversation(&mut self, conversation_id: i64) {
327 self.excluded_conversations.insert(conversation_id);
328 }
329
330 pub fn include_conversation(&mut self, conversation_id: i64) {
332 self.excluded_conversations.remove(&conversation_id);
333 }
334
335 pub fn is_workspace_excluded(&self, workspace: &str) -> bool {
337 self.excluded_workspaces.contains(workspace)
338 }
339
340 pub fn is_conversation_excluded(&self, conversation_id: i64) -> bool {
342 self.excluded_conversations.contains(&conversation_id)
343 }
344
345 pub fn is_excluded(&self, title: &str) -> bool {
347 self.excluded_patterns.iter().any(|re| re.is_match(title))
348 }
349
350 pub fn add_pattern(&mut self, pattern: &str) -> Result<()> {
352 let regex = Regex::new(pattern).context("Invalid exclusion pattern")?;
353 self.excluded_patterns.push(regex);
354 self.excluded_pattern_strings.push(pattern.to_string());
355 Ok(())
356 }
357
358 pub fn should_exclude(
360 &self,
361 workspace: Option<&str>,
362 conversation_id: i64,
363 title: &str,
364 ) -> bool {
365 if let Some(ws) = workspace
366 && self.is_workspace_excluded(ws)
367 {
368 return true;
369 }
370 if self.is_conversation_excluded(conversation_id) {
371 return true;
372 }
373 self.is_excluded(title)
374 }
375
376 pub fn exclusion_counts(&self) -> (usize, usize, usize) {
378 (
379 self.excluded_workspaces.len(),
380 self.excluded_conversations.len(),
381 self.excluded_patterns.len(),
382 )
383 }
384
385 pub fn has_exclusions(&self) -> bool {
387 !self.excluded_workspaces.is_empty()
388 || !self.excluded_conversations.is_empty()
389 || !self.excluded_patterns.is_empty()
390 }
391
392 pub fn compile_patterns(&mut self) -> Result<()> {
394 self.excluded_patterns.clear();
395 for pattern_str in &self.excluded_pattern_strings {
396 let regex = Regex::new(pattern_str)
397 .with_context(|| format!("Invalid exclusion pattern: {}", pattern_str))?;
398 self.excluded_patterns.push(regex);
399 }
400 Ok(())
401 }
402}
403
404#[derive(Debug, Clone, Default)]
406pub struct SummaryFilters {
407 pub agents: Option<Vec<String>>,
409 pub workspaces: Option<Vec<String>>,
411 pub since_ts: Option<i64>,
413 pub until_ts: Option<i64>,
415}
416
417pub struct SummaryGenerator<'a> {
419 db: &'a Connection,
420}
421
422impl<'a> SummaryGenerator<'a> {
423 pub fn new(db: &'a Connection) -> Self {
425 Self { db }
426 }
427
428 pub fn generate(&self, filters: Option<&SummaryFilters>) -> Result<PrePublishSummary> {
430 let filters = filters.cloned().unwrap_or_default();
431
432 let (where_clause, params) = self.build_filter_clause(&filters);
434
435 let (total_conversations, total_messages, total_characters) =
437 self.get_counts(&where_clause, ¶ms)?;
438
439 let (earliest_ts, latest_ts) = self.get_time_range(&where_clause, ¶ms)?;
441
442 let date_histogram = self.get_date_histogram(&where_clause, ¶ms)?;
444
445 let workspaces = self.get_workspace_summary(&where_clause, ¶ms)?;
447
448 let agents = self.get_agent_summary(&where_clause, ¶ms, total_conversations)?;
450
451 let estimated_size_bytes = estimate_compressed_size(total_characters);
453
454 Ok(PrePublishSummary {
455 total_conversations,
456 total_messages,
457 total_characters,
458 estimated_size_bytes,
459 earliest_timestamp: earliest_ts.and_then(DateTime::from_timestamp_millis),
460 latest_timestamp: latest_ts.and_then(DateTime::from_timestamp_millis),
461 date_histogram,
462 workspaces,
463 agents,
464 secret_scan: ScanReportSummary::default(),
465 encryption_config: Some(EncryptionSummary::default()),
466 key_slots: Vec::new(),
467 generated_at: Utc::now(),
468 })
469 }
470
471 pub fn generate_with_exclusions(
473 &self,
474 filters: Option<&SummaryFilters>,
475 exclusions: &ExclusionSet,
476 ) -> Result<PrePublishSummary> {
477 let mut summary = self.generate(filters)?;
478
479 for ws in &mut summary.workspaces {
481 ws.included = !exclusions.is_workspace_excluded(&ws.path);
482 }
483
484 let included_workspaces: HashSet<_> = summary
486 .workspaces
487 .iter()
488 .filter(|w| w.included)
489 .map(|w| w.path.clone())
490 .collect();
491
492 if exclusions.has_exclusions() {
493 let (conv_count, msg_count, char_count) =
495 self.recalculate_with_exclusions(filters, &included_workspaces, exclusions)?;
496
497 summary.total_conversations = conv_count;
498 summary.total_messages = msg_count;
499 summary.total_characters = char_count;
500 summary.estimated_size_bytes = estimate_compressed_size(char_count);
501 }
502
503 Ok(summary)
504 }
505
506 fn build_filter_clause(&self, filters: &SummaryFilters) -> (String, Vec<ParamValue>) {
508 let mut clauses = Vec::new();
509 let mut params: Vec<ParamValue> = Vec::new();
510
511 if let Some(agents) = &filters.agents {
512 if agents.is_empty() {
513 clauses.push("1=0".to_string());
514 } else {
515 let placeholders: Vec<&str> = (0..agents.len()).map(|_| "?").collect();
516 clauses.push(format!(
517 "c.agent_id IN (SELECT id FROM agents WHERE slug IN ({}))",
518 placeholders.join(", ")
519 ));
520 for agent in agents {
521 params.push(ParamValue::from(agent.as_str()));
522 }
523 }
524 }
525
526 if let Some(workspaces) = &filters.workspaces {
527 if workspaces.is_empty() {
528 clauses.push("1=0".to_string());
529 } else {
530 let placeholders: Vec<&str> = (0..workspaces.len()).map(|_| "?").collect();
531 clauses.push(format!(
532 "c.workspace_id IN (SELECT id FROM workspaces WHERE path IN ({}))",
533 placeholders.join(", ")
534 ));
535 for ws in workspaces {
536 params.push(ParamValue::from(ws.as_str()));
537 }
538 }
539 }
540
541 if let Some(since) = filters.since_ts {
542 clauses.push("c.started_at >= ?".to_string());
543 params.push(ParamValue::from(since));
544 }
545
546 if let Some(until) = filters.until_ts {
547 clauses.push("c.started_at <= ?".to_string());
548 params.push(ParamValue::from(until));
549 }
550
551 let where_clause = if clauses.is_empty() {
552 String::new()
553 } else {
554 format!(" AND {}", clauses.join(" AND "))
555 };
556
557 (where_clause, params)
558 }
559
560 fn count_messages_for_conversation_ids(&self, conversation_ids: &[i64]) -> Result<usize> {
562 if conversation_ids.is_empty() {
563 return Ok(0);
564 }
565
566 let params: Vec<ParamValue> = conversation_ids
567 .iter()
568 .copied()
569 .map(ParamValue::from)
570 .collect();
571 let placeholders = vec!["?"; params.len()].join(", ");
572 let query =
573 format!("SELECT COUNT(*) FROM messages WHERE conversation_id IN ({placeholders})");
574 let count: i64 = self
575 .db
576 .query_row_map(&query, ¶ms, |row: &Row| row.get_typed(0))?;
577 Ok(count as usize)
578 }
579
580 fn get_counts(
582 &self,
583 where_clause: &str,
584 params: &[ParamValue],
585 ) -> Result<(usize, usize, usize)> {
586 let conv_query = format!(
588 "SELECT COUNT(*) FROM conversations c WHERE 1=1{}",
589 where_clause
590 );
591 let total_conversations: i64 = self
592 .db
593 .query_row_map(&conv_query, params, |row: &Row| row.get_typed(0))
594 .context("Failed to count conversations")?;
595
596 let msg_query = format!(
599 "SELECT COUNT(*), SUM(LENGTH(content))
600 FROM messages
601 WHERE conversation_id IN (SELECT c.id FROM conversations c WHERE 1=1{})",
602 where_clause
603 );
604 let (total_messages, total_characters): (i64, i64) = self
605 .db
606 .query_map_collect(&msg_query, params, |row: &Row| {
607 Ok((
608 row.get_typed::<Option<i64>>(0)?.unwrap_or(0),
609 row.get_typed::<Option<i64>>(1)?.unwrap_or(0),
610 ))
611 })
612 .context("Failed to count messages")?
613 .into_iter()
614 .next()
615 .unwrap_or((0, 0));
616
617 Ok((
618 total_conversations as usize,
619 total_messages as usize,
620 total_characters as usize,
621 ))
622 }
623
624 fn get_time_range(
626 &self,
627 where_clause: &str,
628 params: &[ParamValue],
629 ) -> Result<(Option<i64>, Option<i64>)> {
630 let query = format!(
631 "SELECT MIN(c.started_at), MAX(c.started_at) FROM conversations c WHERE 1=1{}",
632 where_clause
633 );
634 let result: (Option<i64>, Option<i64>) = self
635 .db
636 .query_row_map(&query, params, |row: &Row| {
637 Ok((row.get_typed(0)?, row.get_typed(1)?))
638 })
639 .context("Failed to get time range")?;
640 Ok(result)
641 }
642
643 fn get_date_histogram(
645 &self,
646 where_clause: &str,
647 params: &[ParamValue],
648 ) -> Result<Vec<DateHistogramEntry>> {
649 let query = format!(
653 "SELECT created_at / 1000 / 86400,
654 COUNT(*)
655 FROM messages
656 WHERE created_at IS NOT NULL
657 AND conversation_id IN (SELECT c.id FROM conversations c WHERE 1=1{})
658 GROUP BY created_at / 1000 / 86400
659 ORDER BY created_at / 1000 / 86400",
660 where_clause
661 );
662
663 let conv_query = format!(
665 "SELECT day_epoch, COUNT(*)
666 FROM (
667 SELECT DISTINCT conversation_id, created_at / 1000 / 86400 AS day_epoch
668 FROM messages
669 WHERE created_at IS NOT NULL
670 AND conversation_id IN (SELECT c.id FROM conversations c WHERE 1=1{})
671 )
672 GROUP BY day_epoch",
673 where_clause
674 );
675
676 let day_msg_rows = self.db.query_map_collect(&query, params, |row: &Row| {
677 let day_epoch: i64 = row.get_typed::<Option<i64>>(0)?.unwrap_or(0);
678 let msg_count: i64 = row.get_typed::<Option<i64>>(1)?.unwrap_or(0);
679 Ok((day_epoch, msg_count as usize))
680 })?;
681
682 let day_conv_rows = self
683 .db
684 .query_map_collect(&conv_query, params, |row: &Row| {
685 let day_epoch: i64 = row.get_typed::<Option<i64>>(0)?.unwrap_or(0);
686 let conv_count: i64 = row.get_typed::<Option<i64>>(1)?.unwrap_or(0);
687 Ok((day_epoch, conv_count as usize))
688 })?;
689
690 let conv_map: std::collections::HashMap<i64, usize> = day_conv_rows.into_iter().collect();
691
692 use chrono::{NaiveDate, TimeDelta};
693 let epoch_base = NaiveDate::from_ymd_opt(1970, 1, 1).unwrap();
694 let entries: Vec<DateHistogramEntry> = day_msg_rows
695 .into_iter()
696 .map(|(day_epoch, message_count)| {
697 let date = epoch_base
698 .checked_add_signed(TimeDelta::days(day_epoch))
699 .map(|d| d.format("%Y-%m-%d").to_string())
700 .unwrap_or_else(|| format!("{day_epoch}"));
701 DateHistogramEntry {
702 date,
703 message_count,
704 conversation_count: conv_map.get(&day_epoch).copied().unwrap_or(0),
705 }
706 })
707 .collect();
708 Ok(entries)
709 }
710
711 fn get_workspace_summary(
713 &self,
714 where_clause: &str,
715 params: &[ParamValue],
716 ) -> Result<Vec<WorkspaceSummaryItem>> {
717 #[derive(Default)]
718 struct WorkspaceAggregate {
719 conversation_ids: Vec<i64>,
720 min_ts: Option<i64>,
721 max_ts: Option<i64>,
722 sample_titles: Vec<String>,
723 }
724
725 let query = format!(
726 "SELECT c.id, c.workspace_id, c.title, c.started_at
727 FROM conversations c
728 WHERE 1=1{}
729 ORDER BY c.started_at DESC",
730 where_clause
731 );
732
733 let conv_rows = self.db.query_map_collect(&query, params, |row: &Row| {
734 Ok((
735 row.get_typed::<i64>(0)?,
736 row.get_typed::<Option<i64>>(1)?,
737 row.get_typed::<Option<String>>(2)?,
738 row.get_typed::<Option<i64>>(3)?,
739 ))
740 })?;
741
742 let mut workspace_ids: Vec<i64> = conv_rows
743 .iter()
744 .filter_map(|(_, workspace_id, _, _)| *workspace_id)
745 .collect();
746 workspace_ids.sort_unstable();
747 workspace_ids.dedup();
748
749 let workspace_map = if workspace_ids.is_empty() {
750 HashMap::new()
751 } else {
752 let workspace_params: Vec<ParamValue> = workspace_ids
753 .iter()
754 .copied()
755 .map(ParamValue::from)
756 .collect();
757 let placeholders = vec!["?"; workspace_params.len()].join(", ");
758 let workspace_query =
759 format!("SELECT id, path FROM workspaces WHERE id IN ({placeholders})");
760 self.db
761 .query_map_collect(&workspace_query, &workspace_params, |row: &Row| {
762 Ok((row.get_typed::<i64>(0)?, row.get_typed::<String>(1)?))
763 })?
764 .into_iter()
765 .collect::<HashMap<_, _>>()
766 };
767
768 let mut aggregates: HashMap<String, WorkspaceAggregate> = HashMap::new();
769 for (conversation_id, workspace_id, title, started_at) in conv_rows {
770 let Some(workspace_id) = workspace_id else {
771 continue;
772 };
773 let Some(workspace) = workspace_map.get(&workspace_id) else {
774 continue;
775 };
776
777 let aggregate = aggregates.entry(workspace.clone()).or_default();
778 aggregate.conversation_ids.push(conversation_id);
779 aggregate.min_ts = match (aggregate.min_ts, started_at) {
780 (Some(existing), Some(value)) => Some(existing.min(value)),
781 (None, value) => value,
782 (existing, None) => existing,
783 };
784 aggregate.max_ts = match (aggregate.max_ts, started_at) {
785 (Some(existing), Some(value)) => Some(existing.max(value)),
786 (None, value) => value,
787 (existing, None) => existing,
788 };
789 if let Some(title) = title
790 && !title.is_empty()
791 && aggregate.sample_titles.len() < 5
792 {
793 aggregate.sample_titles.push(title);
794 }
795 }
796
797 let mut workspaces = Vec::new();
798 for (workspace, aggregate) in aggregates {
799 let msg_count =
800 self.count_messages_for_conversation_ids(&aggregate.conversation_ids)?;
801 let display_name = std::path::Path::new(&workspace)
803 .file_name()
804 .map(|s| s.to_string_lossy().to_string())
805 .unwrap_or_else(|| workspace.clone());
806
807 workspaces.push(WorkspaceSummaryItem {
808 path: workspace,
809 display_name,
810 conversation_count: aggregate.conversation_ids.len(),
811 message_count: msg_count,
812 date_range: DateRange::from_timestamps(aggregate.min_ts, aggregate.max_ts),
813 sample_titles: aggregate.sample_titles,
814 included: true,
815 });
816 }
817
818 workspaces.sort_by(|a, b| {
819 b.conversation_count
820 .cmp(&a.conversation_count)
821 .then_with(|| a.path.cmp(&b.path))
822 });
823
824 Ok(workspaces)
825 }
826
827 fn get_agent_summary(
829 &self,
830 where_clause: &str,
831 params: &[ParamValue],
832 total_conversations: usize,
833 ) -> Result<Vec<AgentSummaryItem>> {
834 let query = format!(
840 "SELECT c.id, COALESCE(a.slug, 'unknown')
841 FROM conversations c
842 LEFT JOIN agents a ON c.agent_id = a.id
843 WHERE 1=1{}",
844 where_clause
845 );
846
847 let conv_rows = self.db.query_map_collect(&query, params, |row: &Row| {
848 Ok((row.get_typed::<i64>(0)?, row.get_typed::<String>(1)?))
849 })?;
850
851 let mut aggregates: HashMap<String, Vec<i64>> = HashMap::new();
852 for (conversation_id, agent_slug) in conv_rows {
853 aggregates
854 .entry(agent_slug)
855 .or_default()
856 .push(conversation_id);
857 }
858
859 let mut agents = Vec::new();
860 for (agent, conversation_ids) in aggregates {
861 let conv_count = conversation_ids.len();
862 let msg_count = self.count_messages_for_conversation_ids(&conversation_ids)?;
863 let percentage = if total_conversations > 0 {
864 (conv_count as f64 / total_conversations as f64) * 100.0
865 } else {
866 0.0
867 };
868
869 agents.push(AgentSummaryItem {
870 name: agent,
871 conversation_count: conv_count,
872 message_count: msg_count,
873 percentage,
874 included: true,
875 });
876 }
877
878 agents.sort_by(|a, b| {
879 b.conversation_count
880 .cmp(&a.conversation_count)
881 .then_with(|| a.name.cmp(&b.name))
882 });
883
884 Ok(agents)
885 }
886
887 fn recalculate_with_exclusions(
889 &self,
890 filters: Option<&SummaryFilters>,
891 included_workspaces: &HashSet<String>,
892 exclusions: &ExclusionSet,
893 ) -> Result<(usize, usize, usize)> {
894 let enforce_workspace_inclusion = !included_workspaces.is_empty();
895
896 let (where_clause, params) = filters
897 .map(|active_filters| self.build_filter_clause(active_filters))
898 .unwrap_or_default();
899
900 let query = format!(
901 "SELECT c.id, c.workspace_id, c.title
902 FROM conversations c
903 WHERE 1=1{}",
904 where_clause
905 );
906
907 let conv_rows = self.db.query_map_collect(&query, ¶ms, |row: &Row| {
908 Ok((
909 row.get_typed::<i64>(0)?,
910 row.get_typed::<Option<i64>>(1)?,
911 row.get_typed::<Option<String>>(2)?,
912 ))
913 })?;
914 let workspace_ids: Vec<i64> = conv_rows
915 .iter()
916 .filter_map(|(_, workspace_id, _)| *workspace_id)
917 .collect();
918 let workspace_map = if workspace_ids.is_empty() {
919 HashMap::new()
920 } else {
921 let workspace_params: Vec<ParamValue> = workspace_ids
922 .iter()
923 .copied()
924 .map(ParamValue::from)
925 .collect();
926 let placeholders = vec!["?"; workspace_params.len()].join(", ");
927 let workspace_query =
928 format!("SELECT id, path FROM workspaces WHERE id IN ({placeholders})");
929 self.db
930 .query_map_collect(&workspace_query, &workspace_params, |row: &Row| {
931 Ok((row.get_typed::<i64>(0)?, row.get_typed::<String>(1)?))
932 })?
933 .into_iter()
934 .collect()
935 };
936
937 let mut included_conversation_ids = Vec::new();
938 for (id, workspace_id, title) in conv_rows {
939 let workspace = workspace_id.and_then(|id| workspace_map.get(&id).cloned());
940 let title_str = title.as_deref().unwrap_or("");
941
942 if exclusions.should_exclude(workspace.as_deref(), id, title_str) {
943 continue;
944 }
945
946 if enforce_workspace_inclusion
947 && let Some(ws) = &workspace
948 && !included_workspaces.contains(ws)
949 {
950 continue;
951 }
952
953 included_conversation_ids.push(id);
954 }
955
956 if included_conversation_ids.is_empty() {
957 return Ok((0, 0, 0));
958 }
959
960 let msg_params: Vec<ParamValue> = included_conversation_ids
961 .iter()
962 .copied()
963 .map(ParamValue::from)
964 .collect();
965 let placeholders = vec!["?"; msg_params.len()].join(", ");
966 let msg_query = format!(
967 "SELECT COUNT(*), SUM(LENGTH(content))
968 FROM messages
969 WHERE conversation_id IN ({placeholders})"
970 );
971 let (msg_count, char_count): (i64, i64) = self
972 .db
973 .query_map_collect(&msg_query, &msg_params, |row: &Row| {
974 Ok((
975 row.get_typed::<Option<i64>>(0)?.unwrap_or(0),
976 row.get_typed::<Option<i64>>(1)?.unwrap_or(0),
977 ))
978 })?
979 .into_iter()
980 .next()
981 .unwrap_or((0, 0));
982
983 Ok((
984 included_conversation_ids.len(),
985 msg_count as usize,
986 char_count as usize,
987 ))
988 }
989}
990
991pub fn estimate_compressed_size(char_count: usize) -> usize {
994 let base_size = (char_count as f64 * 0.4) as usize;
995 (base_size as f64 * 1.05) as usize
997}
998
999pub fn format_size(bytes: usize) -> String {
1001 const KB: usize = 1024;
1002 const MB: usize = KB * 1024;
1003 const GB: usize = MB * 1024;
1004
1005 if bytes >= GB {
1006 format!("{:.1} GB", bytes as f64 / GB as f64)
1007 } else if bytes >= MB {
1008 format!("{:.1} MB", bytes as f64 / MB as f64)
1009 } else if bytes >= KB {
1010 format!("{:.1} KB", bytes as f64 / KB as f64)
1011 } else {
1012 format!("{} bytes", bytes)
1013 }
1014}
1015
1016impl PrePublishSummary {
1017 pub fn render_overview(&self) -> String {
1019 let mut output = String::new();
1020
1021 output.push_str("CONTENT OVERVIEW\n");
1022 output.push_str("----------------\n");
1023 output.push_str(&format!("Conversations: {}\n", self.total_conversations));
1024 output.push_str(&format!("Messages: {}\n", self.total_messages));
1025 output.push_str(&format!(
1026 "Characters: {} (~{})\n",
1027 self.total_characters,
1028 format_size(self.total_characters)
1029 ));
1030 output.push_str(&format!(
1031 "Archive Size: ~{} (estimated, compressed + encrypted)\n",
1032 format_size(self.estimated_size_bytes)
1033 ));
1034 output.push('\n');
1035
1036 output.push_str("DATE RANGE\n");
1037 output.push_str("----------\n");
1038 if let (Some(earliest), Some(latest)) = (&self.earliest_timestamp, &self.latest_timestamp) {
1039 let days = (*latest - *earliest).num_days();
1040 output.push_str(&format!(
1041 "From: {} To: {} ({} days)\n",
1042 earliest.format("%Y-%m-%d"),
1043 latest.format("%Y-%m-%d"),
1044 days
1045 ));
1046 } else {
1047 output.push_str("No date information available\n");
1048 }
1049 output.push('\n');
1050
1051 output.push_str(&format!("WORKSPACES ({})\n", self.workspaces.len()));
1052 output.push_str("--------------\n");
1053 for ws in self.workspaces.iter().take(5) {
1054 let included_marker = if ws.included { " " } else { "x" };
1055 output.push_str(&format!(
1056 "[{}] {} ({} conversations)\n",
1057 included_marker, ws.display_name, ws.conversation_count
1058 ));
1059 if !ws.sample_titles.is_empty() {
1060 let titles: Vec<_> = ws.sample_titles.iter().take(3).cloned().collect();
1061 output.push_str(&format!(" \"{}\"...\n", titles.join("\", \"")));
1062 }
1063 }
1064 if self.workspaces.len() > 5 {
1065 output.push_str(&format!("... and {} more\n", self.workspaces.len() - 5));
1066 }
1067 output.push('\n');
1068
1069 output.push_str("AGENTS\n");
1070 output.push_str("------\n");
1071 for agent in &self.agents {
1072 output.push_str(&format!(
1073 " {}: {} conversations ({:.0}%)\n",
1074 agent.name, agent.conversation_count, agent.percentage
1075 ));
1076 }
1077 output.push('\n');
1078
1079 output.push_str("SECURITY\n");
1080 output.push_str("--------\n");
1081 if let Some(enc) = &self.encryption_config {
1082 output.push_str(&format!("Encryption: {}\n", enc.algorithm));
1083 output.push_str(&format!("Key Derivation: {}\n", enc.key_derivation));
1084 output.push_str(&format!("Key Slots: {}\n", enc.key_slot_count));
1085 }
1086 output.push_str(&format!(
1087 "Secret Scan: {}\n",
1088 self.secret_scan.status_message
1089 ));
1090
1091 output
1092 }
1093
1094 pub fn included_workspace_count(&self) -> usize {
1096 self.workspaces.iter().filter(|w| w.included).count()
1097 }
1098
1099 pub fn included_agent_count(&self) -> usize {
1101 self.agents.iter().filter(|a| a.included).count()
1102 }
1103
1104 pub fn set_secret_scan(&mut self, report: &SecretScanReport) {
1106 self.secret_scan = ScanReportSummary::from_report(report);
1107 }
1108
1109 pub fn set_encryption_config(&mut self, key_slots: &[KeySlot]) {
1111 let enc = EncryptionSummary {
1112 key_slot_count: key_slots.len(),
1113 ..Default::default()
1114 };
1115
1116 self.key_slots = key_slots
1117 .iter()
1118 .enumerate()
1119 .map(|(i, slot)| KeySlotSummary::from_key_slot(slot, i))
1120 .collect();
1121
1122 self.encryption_config = Some(enc);
1123 }
1124}
1125
1126#[cfg(test)]
1127mod tests {
1128 use super::*;
1129 use tempfile::TempDir;
1130
1131 fn create_test_db() -> (TempDir, Connection) {
1132 let dir = TempDir::new().unwrap();
1133 let db_path = dir.path().join("test.db");
1134 let conn = Connection::open(db_path.to_string_lossy().as_ref()).unwrap();
1135
1136 conn.execute_batch(
1137 "CREATE TABLE agents (
1138 id INTEGER PRIMARY KEY,
1139 slug TEXT NOT NULL UNIQUE
1140 );
1141 CREATE TABLE workspaces (
1142 id INTEGER PRIMARY KEY,
1143 path TEXT NOT NULL UNIQUE
1144 );
1145 CREATE TABLE conversations (
1146 id INTEGER PRIMARY KEY,
1147 agent_id INTEGER NOT NULL,
1148 workspace_id INTEGER,
1149 title TEXT,
1150 source_path TEXT NOT NULL,
1151 started_at INTEGER,
1152 ended_at INTEGER,
1153 message_count INTEGER,
1154 metadata_json TEXT,
1155 FOREIGN KEY (agent_id) REFERENCES agents(id),
1156 FOREIGN KEY (workspace_id) REFERENCES workspaces(id)
1157 );
1158 CREATE TABLE messages (
1159 id INTEGER PRIMARY KEY,
1160 conversation_id INTEGER NOT NULL,
1161 idx INTEGER NOT NULL,
1162 role TEXT NOT NULL,
1163 content TEXT NOT NULL,
1164 created_at INTEGER,
1165 FOREIGN KEY (conversation_id) REFERENCES conversations(id)
1166 );",
1167 )
1168 .unwrap();
1169
1170 (dir, conn)
1171 }
1172
1173 fn create_test_db_without_message_count() -> (TempDir, Connection) {
1174 let dir = TempDir::new().unwrap();
1175 let db_path = dir.path().join("test-no-message-count.db");
1176 let conn = Connection::open(db_path.to_string_lossy().as_ref()).unwrap();
1177
1178 conn.execute_batch(
1179 "CREATE TABLE agents (
1180 id INTEGER PRIMARY KEY,
1181 slug TEXT NOT NULL UNIQUE
1182 );
1183 CREATE TABLE workspaces (
1184 id INTEGER PRIMARY KEY,
1185 path TEXT NOT NULL UNIQUE
1186 );
1187 CREATE TABLE conversations (
1188 id INTEGER PRIMARY KEY,
1189 agent_id INTEGER NOT NULL,
1190 workspace_id INTEGER,
1191 title TEXT,
1192 source_path TEXT NOT NULL,
1193 started_at INTEGER,
1194 ended_at INTEGER,
1195 metadata_json TEXT,
1196 FOREIGN KEY (agent_id) REFERENCES agents(id),
1197 FOREIGN KEY (workspace_id) REFERENCES workspaces(id)
1198 );
1199 CREATE TABLE messages (
1200 id INTEGER PRIMARY KEY,
1201 conversation_id INTEGER NOT NULL,
1202 idx INTEGER NOT NULL,
1203 role TEXT NOT NULL,
1204 content TEXT NOT NULL,
1205 created_at INTEGER,
1206 FOREIGN KEY (conversation_id) REFERENCES conversations(id)
1207 );",
1208 )
1209 .unwrap();
1210
1211 (dir, conn)
1212 }
1213
1214 fn insert_test_data(conn: &Connection) {
1215 use frankensqlite::compat::ConnectionExt;
1216 use frankensqlite::params;
1217
1218 conn.execute("INSERT INTO agents (id, slug) VALUES (1, 'claude-code');")
1219 .unwrap();
1220 conn.execute("INSERT INTO agents (id, slug) VALUES (2, 'aider');")
1221 .unwrap();
1222 conn.execute("INSERT INTO workspaces (id, path) VALUES (1, '/home/user/project-a');")
1223 .unwrap();
1224 conn.execute("INSERT INTO workspaces (id, path) VALUES (2, '/home/user/project-b');")
1225 .unwrap();
1226
1227 conn.execute(
1229 "INSERT INTO conversations (id, agent_id, workspace_id, title, source_path, started_at)
1230 VALUES (1, 1, 1, 'Fix authentication bug', '/path/a.jsonl', 1700000000000);",
1231 )
1232 .unwrap();
1233 conn.execute(
1234 "INSERT INTO conversations (id, agent_id, workspace_id, title, source_path, started_at)
1235 VALUES (2, 1, 1, 'Add user profile', '/path/b.jsonl', 1700100000000);",
1236 )
1237 .unwrap();
1238 conn.execute(
1239 "INSERT INTO conversations (id, agent_id, workspace_id, title, source_path, started_at)
1240 VALUES (3, 2, 2, 'Setup database', '/path/c.jsonl', 1700200000000);",
1241 )
1242 .unwrap();
1243
1244 for conv_id in 1..=3i64 {
1246 let msg_count = match conv_id {
1247 1 => 5,
1248 2 => 3,
1249 3 => 4,
1250 _ => 0,
1251 };
1252 for idx in 0..msg_count {
1253 let role = if idx % 2 == 0 { "user" } else { "assistant" };
1254 let created_at = 1700000000000i64 + (conv_id * 100000000) + (idx as i64 * 1000);
1255 conn.execute_compat(
1256 "INSERT INTO messages (conversation_id, idx, role, content, created_at)
1257 VALUES (?1, ?2, ?3, ?4, ?5)",
1258 params![
1259 conv_id,
1260 idx as i64,
1261 role,
1262 format!("Test message {} for conversation {}", idx, conv_id),
1263 created_at
1264 ],
1265 )
1266 .unwrap();
1267 }
1268 }
1269 }
1270
1271 #[test]
1272 fn test_summary_generation() {
1273 let (_dir, conn) = create_test_db();
1274 insert_test_data(&conn);
1275
1276 let generator = SummaryGenerator::new(&conn);
1277 let summary = generator.generate(None).unwrap();
1278
1279 assert_eq!(summary.total_conversations, 3);
1280 assert_eq!(summary.total_messages, 12);
1281 assert!(summary.total_characters > 0);
1282 assert_eq!(summary.workspaces.len(), 2);
1283 assert_eq!(summary.agents.len(), 2);
1284 }
1285
1286 #[test]
1287 fn test_summary_generation_without_conversation_message_count_column() {
1288 let (_dir, conn) = create_test_db_without_message_count();
1289 insert_test_data(&conn);
1290
1291 let generator = SummaryGenerator::new(&conn);
1292 let summary = generator.generate(None).unwrap();
1293
1294 assert_eq!(summary.total_conversations, 3);
1295 assert_eq!(summary.total_messages, 12);
1296 assert_eq!(summary.workspaces.len(), 2);
1297 assert_eq!(summary.agents.len(), 2);
1298
1299 let project_a = summary
1300 .workspaces
1301 .iter()
1302 .find(|w| w.path == "/home/user/project-a")
1303 .unwrap();
1304 assert_eq!(project_a.message_count, 8);
1305
1306 let claude = summary
1307 .agents
1308 .iter()
1309 .find(|a| a.name == "claude-code")
1310 .unwrap();
1311 assert_eq!(claude.message_count, 8);
1312 }
1313
1314 #[test]
1315 fn test_summary_with_filters() {
1316 let (_dir, conn) = create_test_db();
1317 insert_test_data(&conn);
1318
1319 let filters = SummaryFilters {
1320 agents: Some(vec!["claude-code".to_string()]),
1321 ..Default::default()
1322 };
1323
1324 let generator = SummaryGenerator::new(&conn);
1325 let summary = generator.generate(Some(&filters)).unwrap();
1326
1327 assert_eq!(summary.total_conversations, 2);
1328 assert_eq!(summary.total_messages, 8); }
1330
1331 #[test]
1332 fn test_summary_with_empty_agent_filter_matches_nothing() {
1333 let (_dir, conn) = create_test_db();
1334 insert_test_data(&conn);
1335
1336 let filters = SummaryFilters {
1337 agents: Some(vec![]),
1338 ..Default::default()
1339 };
1340
1341 let generator = SummaryGenerator::new(&conn);
1342 let summary = generator.generate(Some(&filters)).unwrap();
1343
1344 assert_eq!(summary.total_conversations, 0);
1345 assert_eq!(summary.total_messages, 0);
1346 assert!(summary.workspaces.is_empty());
1347 assert!(summary.agents.is_empty());
1348 }
1349
1350 #[test]
1351 fn test_summary_with_empty_workspace_filter_matches_nothing() {
1352 let (_dir, conn) = create_test_db();
1353 insert_test_data(&conn);
1354
1355 let filters = SummaryFilters {
1356 workspaces: Some(vec![]),
1357 ..Default::default()
1358 };
1359
1360 let generator = SummaryGenerator::new(&conn);
1361 let summary = generator.generate(Some(&filters)).unwrap();
1362
1363 assert_eq!(summary.total_conversations, 0);
1364 assert_eq!(summary.total_messages, 0);
1365 assert!(summary.workspaces.is_empty());
1366 assert!(summary.agents.is_empty());
1367 }
1368
1369 #[test]
1370 fn test_workspace_summary_message_counts_respect_time_filter() {
1371 let (_dir, conn) = create_test_db();
1372 insert_test_data(&conn);
1373
1374 let filters = SummaryFilters {
1375 since_ts: Some(1_700_050_000_000),
1376 ..Default::default()
1377 };
1378
1379 let generator = SummaryGenerator::new(&conn);
1380 let summary = generator.generate(Some(&filters)).unwrap();
1381
1382 let project_a = summary
1383 .workspaces
1384 .iter()
1385 .find(|w| w.path == "/home/user/project-a")
1386 .unwrap();
1387 assert_eq!(project_a.conversation_count, 1);
1388 assert_eq!(project_a.message_count, 3);
1389 assert!(
1390 project_a
1391 .sample_titles
1392 .iter()
1393 .all(|t| t != "Fix authentication bug")
1394 );
1395 }
1396
1397 #[test]
1398 fn test_agent_summary_message_counts_respect_time_filter() {
1399 let (_dir, conn) = create_test_db();
1400 insert_test_data(&conn);
1401
1402 let filters = SummaryFilters {
1403 since_ts: Some(1_700_050_000_000),
1404 ..Default::default()
1405 };
1406
1407 let generator = SummaryGenerator::new(&conn);
1408 let summary = generator.generate(Some(&filters)).unwrap();
1409
1410 let claude = summary
1411 .agents
1412 .iter()
1413 .find(|a| a.name == "claude-code")
1414 .unwrap();
1415 assert_eq!(claude.conversation_count, 1);
1416 assert_eq!(claude.message_count, 3);
1417 }
1418
1419 #[test]
1420 fn test_workspace_summary() {
1421 let (_dir, conn) = create_test_db();
1422 insert_test_data(&conn);
1423
1424 let generator = SummaryGenerator::new(&conn);
1425 let summary = generator.generate(None).unwrap();
1426
1427 let project_a = summary
1428 .workspaces
1429 .iter()
1430 .find(|w| w.path.contains("project-a"));
1431 assert!(project_a.is_some());
1432 let project_a = project_a.unwrap();
1433 assert_eq!(project_a.conversation_count, 2);
1434 assert_eq!(project_a.display_name, "project-a");
1435 assert!(!project_a.sample_titles.is_empty());
1436 }
1437
1438 #[test]
1439 fn test_agent_summary() {
1440 let (_dir, conn) = create_test_db();
1441 insert_test_data(&conn);
1442
1443 let generator = SummaryGenerator::new(&conn);
1444 let summary = generator.generate(None).unwrap();
1445
1446 let claude = summary.agents.iter().find(|a| a.name == "claude-code");
1447 assert!(claude.is_some());
1448 let claude = claude.unwrap();
1449 assert_eq!(claude.conversation_count, 2);
1450 assert!((claude.percentage - 66.67).abs() < 1.0);
1451 }
1452
1453 #[test]
1454 fn test_date_histogram() {
1455 let (_dir, conn) = create_test_db();
1456 insert_test_data(&conn);
1457
1458 let generator = SummaryGenerator::new(&conn);
1459 let summary = generator.generate(None).unwrap();
1460
1461 assert!(!summary.date_histogram.is_empty());
1463 }
1464
1465 #[test]
1466 fn test_exclusion_set() {
1467 let mut exclusions = ExclusionSet::new();
1468
1469 exclusions.exclude_workspace("/home/user/project-a");
1470 assert!(exclusions.is_workspace_excluded("/home/user/project-a"));
1471 assert!(!exclusions.is_workspace_excluded("/home/user/project-b"));
1472
1473 exclusions.exclude_conversation(42);
1474 assert!(exclusions.is_conversation_excluded(42));
1475 assert!(!exclusions.is_conversation_excluded(43));
1476
1477 exclusions.add_pattern("(?i)secret").unwrap();
1478 assert!(exclusions.is_excluded("This is a Secret task"));
1479 assert!(!exclusions.is_excluded("This is a normal task"));
1480 }
1481
1482 #[test]
1483 fn test_exclusion_should_exclude() {
1484 let mut exclusions = ExclusionSet::new();
1485 exclusions.exclude_workspace("/excluded");
1486 exclusions.exclude_conversation(99);
1487 exclusions.add_pattern("^Private:").unwrap();
1488
1489 assert!(exclusions.should_exclude(Some("/excluded"), 1, "Normal title"));
1491 assert!(exclusions.should_exclude(Some("/normal"), 99, "Normal title"));
1493 assert!(exclusions.should_exclude(Some("/normal"), 1, "Private: Secret stuff"));
1495 assert!(!exclusions.should_exclude(Some("/normal"), 1, "Normal title"));
1497 }
1498
1499 #[test]
1500 fn test_summary_with_exclusions() {
1501 let (_dir, conn) = create_test_db();
1502 insert_test_data(&conn);
1503
1504 let mut exclusions = ExclusionSet::new();
1505 exclusions.exclude_workspace("/home/user/project-b");
1506
1507 let generator = SummaryGenerator::new(&conn);
1508 let summary = generator
1509 .generate_with_exclusions(None, &exclusions)
1510 .unwrap();
1511
1512 let project_b = summary
1514 .workspaces
1515 .iter()
1516 .find(|w| w.path.contains("project-b"));
1517 assert!(project_b.is_some());
1518 assert!(!project_b.unwrap().included);
1519 }
1520
1521 #[test]
1522 fn test_size_estimation() {
1523 let size = estimate_compressed_size(1_000_000);
1524 assert!(size > 400_000);
1526 assert!(size < 450_000);
1527 }
1528
1529 #[test]
1530 fn test_format_size() {
1531 assert_eq!(format_size(500), "500 bytes");
1532 assert_eq!(format_size(1500), "1.5 KB");
1533 assert_eq!(format_size(1_500_000), "1.4 MB");
1534 assert_eq!(format_size(1_500_000_000), "1.4 GB");
1535 }
1536
1537 #[test]
1538 fn test_date_range() {
1539 let range = DateRange::from_timestamps(Some(1700000000000), Some(1700100000000));
1540 assert!(range.earliest.is_some());
1541 assert!(range.latest.is_some());
1542 assert!(range.span_days().unwrap() >= 1);
1543 }
1544
1545 #[test]
1546 fn test_scan_report_summary() {
1547 let summary = ScanReportSummary::default();
1548 assert_eq!(summary.total_findings, 0);
1549 assert!(!summary.has_critical);
1550 assert!(!summary.truncated);
1551 }
1552
1553 #[test]
1554 fn test_encryption_summary() {
1555 let enc = EncryptionSummary::default();
1556 assert_eq!(enc.algorithm, "AES-256-GCM");
1557 assert_eq!(enc.key_derivation, "Argon2id");
1558 }
1559
1560 #[test]
1561 fn test_render_overview() {
1562 let (_dir, conn) = create_test_db();
1563 insert_test_data(&conn);
1564
1565 let generator = SummaryGenerator::new(&conn);
1566 let summary = generator.generate(None).unwrap();
1567 let overview = summary.render_overview();
1568
1569 assert!(overview.contains("CONTENT OVERVIEW"));
1570 assert!(overview.contains("Conversations: 3"));
1571 assert!(overview.contains("WORKSPACES"));
1572 assert!(overview.contains("AGENTS"));
1573 assert!(overview.contains("SECURITY"));
1574 }
1575
1576 #[test]
1577 fn test_empty_database() {
1578 let (_dir, conn) = create_test_db();
1579 let generator = SummaryGenerator::new(&conn);
1582 let summary = generator.generate(None).unwrap();
1583
1584 assert_eq!(summary.total_conversations, 0);
1585 assert_eq!(summary.total_messages, 0);
1586 assert_eq!(summary.total_characters, 0);
1587 assert!(summary.workspaces.is_empty());
1588 assert!(summary.agents.is_empty());
1589 }
1590
1591 #[test]
1592 fn test_key_slot_summary() {
1593 use crate::pages::encrypt::{KdfAlgorithm, KeySlot, SlotType};
1594
1595 let slot = KeySlot {
1596 id: 0,
1597 slot_type: SlotType::Password,
1598 kdf: KdfAlgorithm::Argon2id,
1599 salt: "test".to_string(),
1600 wrapped_dek: "test".to_string(),
1601 nonce: "test".to_string(),
1602 argon2_params: None,
1603 };
1604
1605 let summary = KeySlotSummary::from_key_slot(&slot, 0);
1606 assert_eq!(summary.slot_index, 0);
1607 assert_eq!(summary.slot_type, KeySlotType::Password);
1608 }
1609
1610 #[test]
1611 fn test_exclusion_compile_patterns() {
1612 let mut exclusions = ExclusionSet::new();
1613 exclusions.excluded_pattern_strings = vec!["test.*pattern".to_string()];
1614
1615 exclusions.compile_patterns().unwrap();
1616
1617 assert_eq!(exclusions.excluded_patterns.len(), 1);
1618 assert!(exclusions.is_excluded("test123pattern"));
1619 }
1620
1621 #[test]
1622 fn test_key_slot_type_label() {
1623 assert_eq!(KeySlotType::Password.label(), "Password");
1624 assert_eq!(KeySlotType::QrCode.label(), "QR Code");
1625 assert_eq!(KeySlotType::Recovery.label(), "Recovery Key");
1626 }
1627
1628 #[test]
1629 fn test_exclusion_recount_keeps_workspace_less_conversations() {
1630 let (_dir, conn) = create_test_db();
1631
1632 conn.execute("INSERT INTO agents (id, slug) VALUES (3, 'codex');")
1635 .unwrap();
1636 conn.execute(
1637 "INSERT INTO conversations (id, agent_id, workspace_id, title, source_path, started_at, message_count)
1638 VALUES (10, 3, NULL, 'General session', '/path/no-workspace.jsonl', 1700300000000, 1);",
1639 )
1640 .unwrap();
1641 conn.execute(
1642 "INSERT INTO messages (conversation_id, idx, role, content, created_at)
1643 VALUES (10, 0, 'user', 'Workspace-less message', 1700300001000);",
1644 )
1645 .unwrap();
1646
1647 let mut exclusions = ExclusionSet::new();
1648 exclusions.add_pattern("^DOES_NOT_MATCH$").unwrap();
1649
1650 let generator = SummaryGenerator::new(&conn);
1651 let summary = generator
1652 .generate_with_exclusions(None, &exclusions)
1653 .unwrap();
1654
1655 assert_eq!(summary.total_conversations, 1);
1656 assert_eq!(summary.total_messages, 1);
1657 assert!(summary.workspaces.is_empty());
1658 }
1659
1660 #[test]
1661 fn test_exclusion_recount_respects_active_filters() {
1662 let (_dir, conn) = create_test_db();
1663 insert_test_data(&conn);
1664
1665 let filters = SummaryFilters {
1667 agents: Some(vec!["claude-code".to_string()]),
1668 since_ts: Some(1_700_050_000_000),
1669 ..Default::default()
1670 };
1671
1672 let generator = SummaryGenerator::new(&conn);
1673 let baseline = generator.generate(Some(&filters)).unwrap();
1674 assert_eq!(baseline.total_conversations, 1);
1675 assert_eq!(baseline.total_messages, 3);
1676
1677 let mut exclusions = ExclusionSet::new();
1679 exclusions.add_pattern("^DOES_NOT_MATCH$").unwrap();
1680 let summary = generator
1681 .generate_with_exclusions(Some(&filters), &exclusions)
1682 .unwrap();
1683
1684 assert_eq!(summary.total_conversations, 1);
1685 assert_eq!(summary.total_messages, 3);
1686 }
1687}