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
35const SUMMARY_ID_CHUNK_SIZE: usize = 500;
36
37#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct PrePublishSummary {
40 pub total_conversations: usize,
43 pub total_messages: usize,
45 pub total_characters: usize,
47 pub estimated_size_bytes: usize,
49
50 pub earliest_timestamp: Option<DateTime<Utc>>,
53 pub latest_timestamp: Option<DateTime<Utc>>,
55 pub date_histogram: Vec<DateHistogramEntry>,
57
58 pub workspaces: Vec<WorkspaceSummaryItem>,
61 pub agents: Vec<AgentSummaryItem>,
63
64 pub secret_scan: ScanReportSummary,
67 pub encryption_config: Option<EncryptionSummary>,
69 pub key_slots: Vec<KeySlotSummary>,
71
72 pub generated_at: DateTime<Utc>,
74}
75
76#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct DateHistogramEntry {
79 pub date: String,
81 pub message_count: usize,
83 pub conversation_count: usize,
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct WorkspaceSummaryItem {
90 pub path: String,
92 pub display_name: String,
94 pub conversation_count: usize,
96 pub message_count: usize,
98 pub date_range: DateRange,
100 pub sample_titles: Vec<String>,
102 pub included: bool,
104}
105
106#[derive(Debug, Clone, Serialize, Deserialize)]
108pub struct AgentSummaryItem {
109 pub name: String,
111 pub conversation_count: usize,
113 pub message_count: usize,
115 pub percentage: f64,
117 pub included: bool,
119}
120
121#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct DateRange {
124 pub earliest: Option<String>,
126 pub latest: Option<String>,
128}
129
130impl DateRange {
131 pub fn from_timestamps(earliest: Option<i64>, latest: Option<i64>) -> Self {
133 Self {
134 earliest: earliest
135 .and_then(DateTime::from_timestamp_millis)
136 .map(|dt| dt.to_rfc3339()),
137 latest: latest
138 .and_then(DateTime::from_timestamp_millis)
139 .map(|dt| dt.to_rfc3339()),
140 }
141 }
142
143 pub fn span_days(&self) -> Option<i64> {
145 let earliest = self.earliest.as_ref()?.parse::<DateTime<Utc>>().ok()?;
146 let latest = self.latest.as_ref()?.parse::<DateTime<Utc>>().ok()?;
147 Some((latest - earliest).num_days())
148 }
149}
150
151#[derive(Debug, Clone, Default, Serialize, Deserialize)]
153pub struct ScanReportSummary {
154 pub total_findings: usize,
156 pub by_severity: HashMap<String, usize>,
158 pub has_critical: bool,
160 pub truncated: bool,
162 pub status_message: String,
164}
165
166impl ScanReportSummary {
167 pub fn from_report(report: &SecretScanReport) -> Self {
169 let by_severity: HashMap<String, usize> = report
170 .summary
171 .by_severity
172 .iter()
173 .map(|(k, v)| (k.label().to_string(), *v))
174 .collect();
175
176 let status_message = if report.summary.total == 0 {
177 "No secrets detected".to_string()
178 } else if report.summary.has_critical {
179 format!("{} issues found (including CRITICAL)", report.summary.total)
180 } else {
181 format!("{} issues found", report.summary.total)
182 };
183
184 Self {
185 total_findings: report.summary.total,
186 by_severity,
187 has_critical: report.summary.has_critical,
188 truncated: report.summary.truncated,
189 status_message,
190 }
191 }
192
193 pub fn from_summary(summary: &SecretScanSummary) -> Self {
195 let by_severity: HashMap<String, usize> = summary
196 .by_severity
197 .iter()
198 .map(|(k, v)| (k.label().to_string(), *v))
199 .collect();
200
201 let status_message = if summary.total == 0 {
202 "No secrets detected".to_string()
203 } else if summary.has_critical {
204 format!("{} issues found (including CRITICAL)", summary.total)
205 } else {
206 format!("{} issues found", summary.total)
207 };
208
209 Self {
210 total_findings: summary.total,
211 by_severity,
212 has_critical: summary.has_critical,
213 truncated: summary.truncated,
214 status_message,
215 }
216 }
217}
218
219#[derive(Debug, Clone, Serialize, Deserialize)]
221pub struct EncryptionSummary {
222 pub algorithm: String,
224 pub key_derivation: String,
226 pub key_slot_count: usize,
228 pub estimated_decrypt_time_secs: u64,
230}
231
232impl Default for EncryptionSummary {
233 fn default() -> Self {
234 Self {
235 algorithm: "AES-256-GCM".to_string(),
236 key_derivation: "Argon2id".to_string(),
237 key_slot_count: 0,
238 estimated_decrypt_time_secs: 2,
239 }
240 }
241}
242
243#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
245#[serde(rename_all = "snake_case")]
246pub enum KeySlotType {
247 Password,
248 QrCode,
249 Recovery,
250}
251
252impl From<SlotType> for KeySlotType {
253 fn from(st: SlotType) -> Self {
254 match st {
255 SlotType::Password => KeySlotType::Password,
256 SlotType::Recovery => KeySlotType::Recovery,
257 }
258 }
259}
260
261impl KeySlotType {
262 pub fn label(self) -> &'static str {
264 match self {
265 KeySlotType::Password => "Password",
266 KeySlotType::QrCode => "QR Code",
267 KeySlotType::Recovery => "Recovery Key",
268 }
269 }
270}
271
272#[derive(Debug, Clone, Serialize, Deserialize)]
274pub struct KeySlotSummary {
275 pub slot_index: usize,
277 pub slot_type: KeySlotType,
279 pub hint: Option<String>,
281 pub created_at: Option<DateTime<Utc>>,
283}
284
285impl KeySlotSummary {
286 pub fn from_key_slot(slot: &KeySlot, index: usize) -> Self {
288 Self {
289 slot_index: index,
290 slot_type: slot.slot_type.into(),
291 hint: None, created_at: None,
293 }
294 }
295}
296
297#[derive(Debug, Clone, Default, Serialize, Deserialize)]
299pub struct ExclusionSet {
300 pub excluded_workspaces: HashSet<String>,
302 pub excluded_conversations: HashSet<i64>,
304 #[serde(skip)]
306 pub excluded_patterns: Vec<Regex>,
307 pub excluded_pattern_strings: Vec<String>,
309}
310
311impl ExclusionSet {
312 pub fn new() -> Self {
314 Self::default()
315 }
316
317 pub fn exclude_workspace(&mut self, workspace: &str) {
319 self.excluded_workspaces.insert(workspace.to_string());
320 }
321
322 pub fn include_workspace(&mut self, workspace: &str) {
324 self.excluded_workspaces.remove(workspace);
325 }
326
327 pub fn exclude_conversation(&mut self, conversation_id: i64) {
329 self.excluded_conversations.insert(conversation_id);
330 }
331
332 pub fn include_conversation(&mut self, conversation_id: i64) {
334 self.excluded_conversations.remove(&conversation_id);
335 }
336
337 pub fn is_workspace_excluded(&self, workspace: &str) -> bool {
339 self.excluded_workspaces.contains(workspace)
340 }
341
342 pub fn is_conversation_excluded(&self, conversation_id: i64) -> bool {
344 self.excluded_conversations.contains(&conversation_id)
345 }
346
347 pub fn is_excluded(&self, title: &str) -> bool {
349 self.excluded_patterns.iter().any(|re| re.is_match(title))
350 }
351
352 pub fn add_pattern(&mut self, pattern: &str) -> Result<()> {
354 let regex = Regex::new(pattern).context("Invalid exclusion pattern")?;
355 self.excluded_patterns.push(regex);
356 self.excluded_pattern_strings.push(pattern.to_string());
357 Ok(())
358 }
359
360 pub fn should_exclude(
362 &self,
363 workspace: Option<&str>,
364 conversation_id: i64,
365 title: &str,
366 ) -> bool {
367 if let Some(ws) = workspace
368 && self.is_workspace_excluded(ws)
369 {
370 return true;
371 }
372 if self.is_conversation_excluded(conversation_id) {
373 return true;
374 }
375 self.is_excluded(title)
376 }
377
378 pub fn exclusion_counts(&self) -> (usize, usize, usize) {
380 (
381 self.excluded_workspaces.len(),
382 self.excluded_conversations.len(),
383 self.excluded_patterns.len(),
384 )
385 }
386
387 pub fn has_exclusions(&self) -> bool {
389 !self.excluded_workspaces.is_empty()
390 || !self.excluded_conversations.is_empty()
391 || !self.excluded_patterns.is_empty()
392 }
393
394 pub fn compile_patterns(&mut self) -> Result<()> {
396 self.excluded_patterns.clear();
397 for pattern_str in &self.excluded_pattern_strings {
398 let regex = Regex::new(pattern_str)
399 .with_context(|| format!("Invalid exclusion pattern: {}", pattern_str))?;
400 self.excluded_patterns.push(regex);
401 }
402 Ok(())
403 }
404}
405
406#[derive(Debug, Clone, Default)]
408pub struct SummaryFilters {
409 pub agents: Option<Vec<String>>,
411 pub workspaces: Option<Vec<String>>,
413 pub since_ts: Option<i64>,
415 pub until_ts: Option<i64>,
417}
418
419#[derive(Default)]
420struct WorkspaceAggregate {
421 conversation_ids: Vec<i64>,
422 min_ts: Option<i64>,
423 max_ts: Option<i64>,
424 sample_titles: Vec<String>,
425}
426
427#[derive(Default)]
428struct ExclusionRecount {
429 conversation_ids: Vec<i64>,
430 total_messages: usize,
431 total_characters: usize,
432 earliest_ts: Option<i64>,
433 latest_ts: Option<i64>,
434}
435
436pub struct SummaryGenerator<'a> {
438 db: &'a Connection,
439}
440
441impl<'a> SummaryGenerator<'a> {
442 pub fn new(db: &'a Connection) -> Self {
444 Self { db }
445 }
446
447 pub fn generate(&self, filters: Option<&SummaryFilters>) -> Result<PrePublishSummary> {
449 let filters = filters.cloned().unwrap_or_default();
450
451 let (where_clause, params) = self.build_filter_clause(&filters);
453
454 let (total_conversations, total_messages, total_characters) =
456 self.get_counts(&where_clause, ¶ms)?;
457
458 let (earliest_ts, latest_ts) = self.get_time_range(&where_clause, ¶ms)?;
460
461 let date_histogram = self.get_date_histogram(&where_clause, ¶ms)?;
463
464 let workspaces = self.get_workspace_summary(&where_clause, ¶ms)?;
466
467 let agents = self.get_agent_summary(&where_clause, ¶ms, total_conversations)?;
469
470 let estimated_size_bytes = estimate_compressed_size(total_characters);
472
473 Ok(PrePublishSummary {
474 total_conversations,
475 total_messages,
476 total_characters,
477 estimated_size_bytes,
478 earliest_timestamp: earliest_ts.and_then(DateTime::from_timestamp_millis),
479 latest_timestamp: latest_ts.and_then(DateTime::from_timestamp_millis),
480 date_histogram,
481 workspaces,
482 agents,
483 secret_scan: ScanReportSummary::default(),
484 encryption_config: Some(EncryptionSummary::default()),
485 key_slots: Vec::new(),
486 generated_at: Utc::now(),
487 })
488 }
489
490 pub fn generate_with_exclusions(
492 &self,
493 filters: Option<&SummaryFilters>,
494 exclusions: &ExclusionSet,
495 ) -> Result<PrePublishSummary> {
496 let mut summary = self.generate(filters)?;
497
498 for ws in &mut summary.workspaces {
500 ws.included = !exclusions.is_workspace_excluded(&ws.path);
501 }
502
503 if exclusions.has_exclusions() {
504 let recount = self.recalculate_with_exclusions(filters, exclusions)?;
506
507 summary.total_conversations = recount.conversation_ids.len();
508 summary.total_messages = recount.total_messages;
509 summary.total_characters = recount.total_characters;
510 summary.estimated_size_bytes = estimate_compressed_size(recount.total_characters);
511 summary.earliest_timestamp = recount
512 .earliest_ts
513 .and_then(DateTime::from_timestamp_millis);
514 summary.latest_timestamp = recount.latest_ts.and_then(DateTime::from_timestamp_millis);
515 summary.date_histogram =
516 self.get_date_histogram_for_conversation_ids(&recount.conversation_ids)?;
517 summary.agents = self.get_agent_summary_for_conversation_ids(
518 &recount.conversation_ids,
519 summary.total_conversations,
520 )?;
521
522 let mut included_workspace_summaries = self
523 .get_workspace_summary_for_conversation_ids(&recount.conversation_ids)?
524 .into_iter()
525 .map(|workspace| (workspace.path.clone(), workspace))
526 .collect::<HashMap<_, _>>();
527 for workspace in &mut summary.workspaces {
528 if !workspace.included {
529 continue;
530 }
531 if let Some(updated) = included_workspace_summaries.remove(workspace.path.as_str())
532 {
533 *workspace = updated;
534 } else {
535 workspace.conversation_count = 0;
536 workspace.message_count = 0;
537 workspace.date_range = DateRange {
538 earliest: None,
539 latest: None,
540 };
541 workspace.sample_titles.clear();
542 }
543 workspace.included = true;
544 }
545 }
546
547 Ok(summary)
548 }
549
550 fn build_filter_clause(&self, filters: &SummaryFilters) -> (String, Vec<ParamValue>) {
552 let mut clauses = Vec::new();
553 let mut params: Vec<ParamValue> = Vec::new();
554
555 if let Some(agents) = &filters.agents {
556 if agents.is_empty() {
557 clauses.push("1=0".to_string());
558 } else {
559 let placeholders: Vec<&str> = (0..agents.len()).map(|_| "?").collect();
560 clauses.push(format!(
561 "c.agent_id IN (SELECT id FROM agents WHERE slug IN ({}))",
562 placeholders.join(", ")
563 ));
564 for agent in agents {
565 params.push(ParamValue::from(agent.as_str()));
566 }
567 }
568 }
569
570 if let Some(workspaces) = &filters.workspaces {
571 if workspaces.is_empty() {
572 clauses.push("1=0".to_string());
573 } else {
574 let placeholders: Vec<&str> = (0..workspaces.len()).map(|_| "?").collect();
575 clauses.push(format!(
576 "c.workspace_id IN (SELECT id FROM workspaces WHERE path IN ({}))",
577 placeholders.join(", ")
578 ));
579 for ws in workspaces {
580 params.push(ParamValue::from(ws.as_str()));
581 }
582 }
583 }
584
585 if let Some(since) = filters.since_ts {
586 clauses.push("c.started_at >= ?".to_string());
587 params.push(ParamValue::from(since));
588 }
589
590 if let Some(until) = filters.until_ts {
591 clauses.push("c.started_at <= ?".to_string());
592 params.push(ParamValue::from(until));
593 }
594
595 let where_clause = if clauses.is_empty() {
596 String::new()
597 } else {
598 format!(" AND {}", clauses.join(" AND "))
599 };
600
601 (where_clause, params)
602 }
603
604 fn count_messages_for_conversation_ids(&self, conversation_ids: &[i64]) -> Result<usize> {
606 let (message_count, _) =
607 self.count_messages_and_characters_for_conversation_ids(conversation_ids)?;
608 Ok(message_count)
609 }
610
611 fn count_messages_and_characters_for_conversation_ids(
613 &self,
614 conversation_ids: &[i64],
615 ) -> Result<(usize, usize)> {
616 if conversation_ids.is_empty() {
617 return Ok((0, 0));
618 }
619
620 let mut total_messages = 0usize;
621 let mut total_characters = 0usize;
622 for chunk in conversation_ids.chunks(SUMMARY_ID_CHUNK_SIZE) {
623 let params: Vec<ParamValue> = chunk.iter().copied().map(ParamValue::from).collect();
624 let placeholders = vec!["?"; params.len()].join(", ");
625 let query = format!(
626 "SELECT COUNT(*), SUM(LENGTH(content))
627 FROM messages
628 WHERE conversation_id IN ({placeholders})"
629 );
630 let (message_count, character_count): (i64, i64) = self
631 .db
632 .query_map_collect(&query, ¶ms, |row: &Row| {
633 Ok((
634 row.get_typed::<Option<i64>>(0)?.unwrap_or(0),
635 row.get_typed::<Option<i64>>(1)?.unwrap_or(0),
636 ))
637 })?
638 .into_iter()
639 .next()
640 .unwrap_or((0, 0));
641 total_messages = total_messages.saturating_add(message_count as usize);
642 total_characters = total_characters.saturating_add(character_count as usize);
643 }
644
645 Ok((total_messages, total_characters))
646 }
647
648 fn get_counts(
650 &self,
651 where_clause: &str,
652 params: &[ParamValue],
653 ) -> Result<(usize, usize, usize)> {
654 let conv_query = format!(
656 "SELECT COUNT(*) FROM conversations c WHERE 1=1{}",
657 where_clause
658 );
659 let total_conversations: i64 = self
660 .db
661 .query_row_map(&conv_query, params, |row: &Row| row.get_typed(0))
662 .context("Failed to count conversations")?;
663
664 let msg_query = format!(
667 "SELECT COUNT(*), SUM(LENGTH(content))
668 FROM messages
669 WHERE conversation_id IN (SELECT c.id FROM conversations c WHERE 1=1{})",
670 where_clause
671 );
672 let (total_messages, total_characters): (i64, i64) = self
673 .db
674 .query_map_collect(&msg_query, params, |row: &Row| {
675 Ok((
676 row.get_typed::<Option<i64>>(0)?.unwrap_or(0),
677 row.get_typed::<Option<i64>>(1)?.unwrap_or(0),
678 ))
679 })
680 .context("Failed to count messages")?
681 .into_iter()
682 .next()
683 .unwrap_or((0, 0));
684
685 Ok((
686 total_conversations as usize,
687 total_messages as usize,
688 total_characters as usize,
689 ))
690 }
691
692 fn get_time_range(
694 &self,
695 where_clause: &str,
696 params: &[ParamValue],
697 ) -> Result<(Option<i64>, Option<i64>)> {
698 let query = format!(
699 "SELECT MIN(c.started_at), MAX(c.started_at) FROM conversations c WHERE 1=1{}",
700 where_clause
701 );
702 let result: (Option<i64>, Option<i64>) = self
703 .db
704 .query_row_map(&query, params, |row: &Row| {
705 Ok((row.get_typed(0)?, row.get_typed(1)?))
706 })
707 .context("Failed to get time range")?;
708 Ok(result)
709 }
710
711 fn get_date_histogram(
713 &self,
714 where_clause: &str,
715 params: &[ParamValue],
716 ) -> Result<Vec<DateHistogramEntry>> {
717 let query = format!(
721 "SELECT created_at / 1000 / 86400,
722 COUNT(*)
723 FROM messages
724 WHERE created_at IS NOT NULL
725 AND conversation_id IN (SELECT c.id FROM conversations c WHERE 1=1{})
726 GROUP BY created_at / 1000 / 86400
727 ORDER BY created_at / 1000 / 86400",
728 where_clause
729 );
730
731 let conv_query = format!(
737 "SELECT day_epoch, COUNT(*)
738 FROM (
739 SELECT DISTINCT conversation_id, created_at / 1000 / 86400 AS day_epoch
740 FROM messages
741 WHERE created_at IS NOT NULL
742 AND conversation_id IN (SELECT c.id FROM conversations c WHERE 1=1{})
743 ) AS day_pairs
744 GROUP BY day_epoch",
745 where_clause
746 );
747
748 let day_msg_rows = self.db.query_map_collect(&query, params, |row: &Row| {
749 let day_epoch: i64 = row.get_typed::<Option<i64>>(0)?.unwrap_or(0);
750 let msg_count: i64 = row.get_typed::<Option<i64>>(1)?.unwrap_or(0);
751 Ok((day_epoch, msg_count as usize))
752 })?;
753
754 let day_conv_rows = self
755 .db
756 .query_map_collect(&conv_query, params, |row: &Row| {
757 let day_epoch: i64 = row.get_typed::<Option<i64>>(0)?.unwrap_or(0);
758 let conv_count: i64 = row.get_typed::<Option<i64>>(1)?.unwrap_or(0);
759 Ok((day_epoch, conv_count as usize))
760 })?;
761
762 let conv_map: std::collections::HashMap<i64, usize> = day_conv_rows.into_iter().collect();
763
764 use chrono::{NaiveDate, TimeDelta};
765 let epoch_base = NaiveDate::from_ymd_opt(1970, 1, 1).unwrap();
766 let entries: Vec<DateHistogramEntry> = day_msg_rows
767 .into_iter()
768 .map(|(day_epoch, message_count)| {
769 let date = epoch_base
770 .checked_add_signed(TimeDelta::days(day_epoch))
771 .map(|d| d.format("%Y-%m-%d").to_string())
772 .unwrap_or_else(|| format!("{day_epoch}"));
773 DateHistogramEntry {
774 date,
775 message_count,
776 conversation_count: conv_map.get(&day_epoch).copied().unwrap_or(0),
777 }
778 })
779 .collect();
780 Ok(entries)
781 }
782
783 fn get_workspace_summary(
785 &self,
786 where_clause: &str,
787 params: &[ParamValue],
788 ) -> Result<Vec<WorkspaceSummaryItem>> {
789 let query = format!(
790 "SELECT c.id, c.workspace_id, c.title, c.started_at
791 FROM conversations c
792 WHERE 1=1{}
793 ORDER BY c.started_at DESC",
794 where_clause
795 );
796
797 let conv_rows = self.db.query_map_collect(&query, params, |row: &Row| {
798 Ok((
799 row.get_typed::<i64>(0)?,
800 row.get_typed::<Option<i64>>(1)?,
801 row.get_typed::<Option<String>>(2)?,
802 row.get_typed::<Option<i64>>(3)?,
803 ))
804 })?;
805
806 let mut workspace_ids: Vec<i64> = conv_rows
807 .iter()
808 .filter_map(|(_, workspace_id, _, _)| *workspace_id)
809 .collect();
810 workspace_ids.sort_unstable();
811 workspace_ids.dedup();
812
813 let workspace_map = if workspace_ids.is_empty() {
814 HashMap::new()
815 } else {
816 let workspace_params: Vec<ParamValue> = workspace_ids
817 .iter()
818 .copied()
819 .map(ParamValue::from)
820 .collect();
821 let placeholders = vec!["?"; workspace_params.len()].join(", ");
822 let workspace_query =
823 format!("SELECT id, path FROM workspaces WHERE id IN ({placeholders})");
824 self.db
825 .query_map_collect(&workspace_query, &workspace_params, |row: &Row| {
826 Ok((row.get_typed::<i64>(0)?, row.get_typed::<String>(1)?))
827 })?
828 .into_iter()
829 .collect::<HashMap<_, _>>()
830 };
831
832 let mut aggregates: HashMap<String, WorkspaceAggregate> = HashMap::new();
833 for (conversation_id, workspace_id, title, started_at) in conv_rows {
834 let Some(workspace_id) = workspace_id else {
835 continue;
836 };
837 let Some(workspace) = workspace_map.get(&workspace_id) else {
838 continue;
839 };
840
841 let aggregate = aggregates.entry(workspace.clone()).or_default();
842 aggregate.conversation_ids.push(conversation_id);
843 aggregate.min_ts = match (aggregate.min_ts, started_at) {
844 (Some(existing), Some(value)) => Some(existing.min(value)),
845 (None, value) => value,
846 (existing, None) => existing,
847 };
848 aggregate.max_ts = match (aggregate.max_ts, started_at) {
849 (Some(existing), Some(value)) => Some(existing.max(value)),
850 (None, value) => value,
851 (existing, None) => existing,
852 };
853 if let Some(title) = title
854 && !title.is_empty()
855 && aggregate.sample_titles.len() < 5
856 {
857 aggregate.sample_titles.push(title);
858 }
859 }
860
861 self.workspace_items_from_aggregates(aggregates)
862 }
863
864 fn get_agent_summary(
866 &self,
867 where_clause: &str,
868 params: &[ParamValue],
869 total_conversations: usize,
870 ) -> Result<Vec<AgentSummaryItem>> {
871 let query = format!(
877 "SELECT c.id, COALESCE(a.slug, 'unknown')
878 FROM conversations c
879 LEFT JOIN agents a ON c.agent_id = a.id
880 WHERE 1=1{}",
881 where_clause
882 );
883
884 let conv_rows = self.db.query_map_collect(&query, params, |row: &Row| {
885 Ok((row.get_typed::<i64>(0)?, row.get_typed::<String>(1)?))
886 })?;
887
888 let mut aggregates: HashMap<String, Vec<i64>> = HashMap::new();
889 for (conversation_id, agent_slug) in conv_rows {
890 aggregates
891 .entry(agent_slug)
892 .or_default()
893 .push(conversation_id);
894 }
895
896 let mut agents = Vec::new();
897 for (agent, conversation_ids) in aggregates {
898 let conv_count = conversation_ids.len();
899 let msg_count = self.count_messages_for_conversation_ids(&conversation_ids)?;
900 let percentage = if total_conversations > 0 {
901 (conv_count as f64 / total_conversations as f64) * 100.0
902 } else {
903 0.0
904 };
905
906 agents.push(AgentSummaryItem {
907 name: agent,
908 conversation_count: conv_count,
909 message_count: msg_count,
910 percentage,
911 included: true,
912 });
913 }
914
915 agents.sort_by(|a, b| {
916 b.conversation_count
917 .cmp(&a.conversation_count)
918 .then_with(|| a.name.cmp(&b.name))
919 });
920
921 Ok(agents)
922 }
923
924 fn get_date_histogram_for_conversation_ids(
925 &self,
926 conversation_ids: &[i64],
927 ) -> Result<Vec<DateHistogramEntry>> {
928 if conversation_ids.is_empty() {
929 return Ok(Vec::new());
930 }
931
932 let mut message_counts: HashMap<i64, usize> = HashMap::new();
933 let mut conversation_counts: HashMap<i64, HashSet<i64>> = HashMap::new();
934 for chunk in conversation_ids.chunks(SUMMARY_ID_CHUNK_SIZE) {
935 let params: Vec<ParamValue> = chunk.iter().copied().map(ParamValue::from).collect();
936 let placeholders = vec!["?"; params.len()].join(", ");
937 let query = format!(
938 "SELECT conversation_id, created_at / 1000 / 86400, COUNT(*)
939 FROM messages
940 WHERE created_at IS NOT NULL
941 AND conversation_id IN ({placeholders})
942 GROUP BY conversation_id, created_at / 1000 / 86400"
943 );
944
945 for (conversation_id, day_epoch, message_count) in
946 self.db.query_map_collect(&query, ¶ms, |row: &Row| {
947 Ok((
948 row.get_typed::<i64>(0)?,
949 row.get_typed::<Option<i64>>(1)?.unwrap_or(0),
950 row.get_typed::<Option<i64>>(2)?.unwrap_or(0) as usize,
951 ))
952 })?
953 {
954 *message_counts.entry(day_epoch).or_insert(0) += message_count;
955 conversation_counts
956 .entry(day_epoch)
957 .or_default()
958 .insert(conversation_id);
959 }
960 }
961
962 use chrono::{NaiveDate, TimeDelta};
963 let epoch_base = NaiveDate::from_ymd_opt(1970, 1, 1).unwrap();
964 let mut days: Vec<_> = message_counts.keys().copied().collect();
965 days.sort_unstable();
966 Ok(days
967 .into_iter()
968 .map(|day_epoch| {
969 let date = epoch_base
970 .checked_add_signed(TimeDelta::days(day_epoch))
971 .map(|d| d.format("%Y-%m-%d").to_string())
972 .unwrap_or_else(|| format!("{day_epoch}"));
973 DateHistogramEntry {
974 date,
975 message_count: message_counts.get(&day_epoch).copied().unwrap_or(0),
976 conversation_count: conversation_counts
977 .get(&day_epoch)
978 .map(HashSet::len)
979 .unwrap_or(0),
980 }
981 })
982 .collect())
983 }
984
985 fn get_workspace_summary_for_conversation_ids(
986 &self,
987 conversation_ids: &[i64],
988 ) -> Result<Vec<WorkspaceSummaryItem>> {
989 if conversation_ids.is_empty() {
990 return Ok(Vec::new());
991 }
992
993 let mut aggregates: HashMap<String, WorkspaceAggregate> = HashMap::new();
994 for chunk in conversation_ids.chunks(SUMMARY_ID_CHUNK_SIZE) {
995 let params: Vec<ParamValue> = chunk.iter().copied().map(ParamValue::from).collect();
996 let placeholders = vec!["?"; params.len()].join(", ");
997 let query = format!(
998 "SELECT c.id, w.path, c.title, c.started_at
999 FROM conversations c
1000 JOIN workspaces w ON c.workspace_id = w.id
1001 WHERE c.id IN ({placeholders})
1002 ORDER BY c.started_at DESC"
1003 );
1004
1005 for (conversation_id, workspace, title, started_at) in
1006 self.db.query_map_collect(&query, ¶ms, |row: &Row| {
1007 Ok((
1008 row.get_typed::<i64>(0)?,
1009 row.get_typed::<String>(1)?,
1010 row.get_typed::<Option<String>>(2)?,
1011 row.get_typed::<Option<i64>>(3)?,
1012 ))
1013 })?
1014 {
1015 let aggregate = aggregates.entry(workspace).or_default();
1016 aggregate.conversation_ids.push(conversation_id);
1017 aggregate.min_ts = match (aggregate.min_ts, started_at) {
1018 (Some(existing), Some(value)) => Some(existing.min(value)),
1019 (None, value) => value,
1020 (existing, None) => existing,
1021 };
1022 aggregate.max_ts = match (aggregate.max_ts, started_at) {
1023 (Some(existing), Some(value)) => Some(existing.max(value)),
1024 (None, value) => value,
1025 (existing, None) => existing,
1026 };
1027 if let Some(title) = title
1028 && !title.is_empty()
1029 && aggregate.sample_titles.len() < 5
1030 {
1031 aggregate.sample_titles.push(title);
1032 }
1033 }
1034 }
1035
1036 self.workspace_items_from_aggregates(aggregates)
1037 }
1038
1039 fn workspace_items_from_aggregates(
1040 &self,
1041 aggregates: HashMap<String, WorkspaceAggregate>,
1042 ) -> Result<Vec<WorkspaceSummaryItem>> {
1043 let mut workspaces = Vec::new();
1044 for (workspace, aggregate) in aggregates {
1045 let msg_count =
1046 self.count_messages_for_conversation_ids(&aggregate.conversation_ids)?;
1047 let display_name = std::path::Path::new(&workspace)
1048 .file_name()
1049 .map(|s| s.to_string_lossy().to_string())
1050 .unwrap_or_else(|| workspace.clone());
1051
1052 workspaces.push(WorkspaceSummaryItem {
1053 path: workspace,
1054 display_name,
1055 conversation_count: aggregate.conversation_ids.len(),
1056 message_count: msg_count,
1057 date_range: DateRange::from_timestamps(aggregate.min_ts, aggregate.max_ts),
1058 sample_titles: aggregate.sample_titles,
1059 included: true,
1060 });
1061 }
1062
1063 workspaces.sort_by(|a, b| {
1064 b.conversation_count
1065 .cmp(&a.conversation_count)
1066 .then_with(|| a.path.cmp(&b.path))
1067 });
1068
1069 Ok(workspaces)
1070 }
1071
1072 fn get_agent_summary_for_conversation_ids(
1073 &self,
1074 conversation_ids: &[i64],
1075 total_conversations: usize,
1076 ) -> Result<Vec<AgentSummaryItem>> {
1077 if conversation_ids.is_empty() {
1078 return Ok(Vec::new());
1079 }
1080
1081 let mut aggregates: HashMap<String, Vec<i64>> = HashMap::new();
1082 for chunk in conversation_ids.chunks(SUMMARY_ID_CHUNK_SIZE) {
1083 let params: Vec<ParamValue> = chunk.iter().copied().map(ParamValue::from).collect();
1084 let placeholders = vec!["?"; params.len()].join(", ");
1085 let query = format!(
1086 "SELECT c.id, COALESCE(a.slug, 'unknown')
1087 FROM conversations c
1088 LEFT JOIN agents a ON c.agent_id = a.id
1089 WHERE c.id IN ({placeholders})"
1090 );
1091
1092 for (conversation_id, agent_slug) in
1093 self.db.query_map_collect(&query, ¶ms, |row: &Row| {
1094 Ok((row.get_typed::<i64>(0)?, row.get_typed::<String>(1)?))
1095 })?
1096 {
1097 aggregates
1098 .entry(agent_slug)
1099 .or_default()
1100 .push(conversation_id);
1101 }
1102 }
1103
1104 let mut agents = Vec::new();
1105 for (agent, conversation_ids) in aggregates {
1106 let conv_count = conversation_ids.len();
1107 let msg_count = self.count_messages_for_conversation_ids(&conversation_ids)?;
1108 let percentage = if total_conversations > 0 {
1109 (conv_count as f64 / total_conversations as f64) * 100.0
1110 } else {
1111 0.0
1112 };
1113
1114 agents.push(AgentSummaryItem {
1115 name: agent,
1116 conversation_count: conv_count,
1117 message_count: msg_count,
1118 percentage,
1119 included: true,
1120 });
1121 }
1122
1123 agents.sort_by(|a, b| {
1124 b.conversation_count
1125 .cmp(&a.conversation_count)
1126 .then_with(|| a.name.cmp(&b.name))
1127 });
1128
1129 Ok(agents)
1130 }
1131
1132 fn recalculate_with_exclusions(
1134 &self,
1135 filters: Option<&SummaryFilters>,
1136 exclusions: &ExclusionSet,
1137 ) -> Result<ExclusionRecount> {
1138 let (where_clause, params) = filters
1139 .map(|active_filters| self.build_filter_clause(active_filters))
1140 .unwrap_or_default();
1141
1142 let query = format!(
1143 "SELECT c.id, c.workspace_id, c.title, c.started_at
1144 FROM conversations c
1145 WHERE 1=1{}",
1146 where_clause
1147 );
1148
1149 let conv_rows = self.db.query_map_collect(&query, ¶ms, |row: &Row| {
1150 Ok((
1151 row.get_typed::<i64>(0)?,
1152 row.get_typed::<Option<i64>>(1)?,
1153 row.get_typed::<Option<String>>(2)?,
1154 row.get_typed::<Option<i64>>(3)?,
1155 ))
1156 })?;
1157 let mut workspace_ids: Vec<i64> = conv_rows
1158 .iter()
1159 .filter_map(|(_, workspace_id, _, _)| *workspace_id)
1160 .collect();
1161 workspace_ids.sort_unstable();
1162 workspace_ids.dedup();
1163 let workspace_map = if workspace_ids.is_empty() {
1164 HashMap::new()
1165 } else {
1166 let workspace_params: Vec<ParamValue> = workspace_ids
1167 .iter()
1168 .copied()
1169 .map(ParamValue::from)
1170 .collect();
1171 let placeholders = vec!["?"; workspace_params.len()].join(", ");
1172 let workspace_query =
1173 format!("SELECT id, path FROM workspaces WHERE id IN ({placeholders})");
1174 self.db
1175 .query_map_collect(&workspace_query, &workspace_params, |row: &Row| {
1176 Ok((row.get_typed::<i64>(0)?, row.get_typed::<String>(1)?))
1177 })?
1178 .into_iter()
1179 .collect()
1180 };
1181
1182 let mut included_conversation_ids = Vec::new();
1183 let mut earliest_ts: Option<i64> = None;
1184 let mut latest_ts: Option<i64> = None;
1185 for (id, workspace_id, title, started_at) in conv_rows {
1186 let workspace = workspace_id.and_then(|id| workspace_map.get(&id).cloned());
1187 let title_str = title.as_deref().unwrap_or("");
1188
1189 if exclusions.should_exclude(workspace.as_deref(), id, title_str) {
1190 continue;
1191 }
1192
1193 included_conversation_ids.push(id);
1194 earliest_ts = match (earliest_ts, started_at) {
1195 (Some(existing), Some(value)) => Some(existing.min(value)),
1196 (None, value) => value,
1197 (existing, None) => existing,
1198 };
1199 latest_ts = match (latest_ts, started_at) {
1200 (Some(existing), Some(value)) => Some(existing.max(value)),
1201 (None, value) => value,
1202 (existing, None) => existing,
1203 };
1204 }
1205
1206 if included_conversation_ids.is_empty() {
1207 return Ok(ExclusionRecount::default());
1208 }
1209
1210 let (total_messages, total_characters) =
1211 self.count_messages_and_characters_for_conversation_ids(&included_conversation_ids)?;
1212
1213 Ok(ExclusionRecount {
1214 conversation_ids: included_conversation_ids,
1215 total_messages,
1216 total_characters,
1217 earliest_ts,
1218 latest_ts,
1219 })
1220 }
1221}
1222
1223pub fn estimate_compressed_size(char_count: usize) -> usize {
1226 let base_size = (char_count as f64 * 0.4) as usize;
1227 (base_size as f64 * 1.05) as usize
1229}
1230
1231pub fn format_size(bytes: usize) -> String {
1233 const KB: usize = 1024;
1234 const MB: usize = KB * 1024;
1235 const GB: usize = MB * 1024;
1236
1237 if bytes >= GB {
1238 format!("{:.1} GB", bytes as f64 / GB as f64)
1239 } else if bytes >= MB {
1240 format!("{:.1} MB", bytes as f64 / MB as f64)
1241 } else if bytes >= KB {
1242 format!("{:.1} KB", bytes as f64 / KB as f64)
1243 } else {
1244 format!("{} bytes", bytes)
1245 }
1246}
1247
1248impl PrePublishSummary {
1249 pub fn render_overview(&self) -> String {
1251 let mut output = String::new();
1252
1253 output.push_str("CONTENT OVERVIEW\n");
1254 output.push_str("----------------\n");
1255 output.push_str(&format!("Conversations: {}\n", self.total_conversations));
1256 output.push_str(&format!("Messages: {}\n", self.total_messages));
1257 output.push_str(&format!(
1258 "Characters: {} (~{})\n",
1259 self.total_characters,
1260 format_size(self.total_characters)
1261 ));
1262 output.push_str(&format!(
1263 "Archive Size: ~{} (estimated, compressed + encrypted)\n",
1264 format_size(self.estimated_size_bytes)
1265 ));
1266 output.push('\n');
1267
1268 output.push_str("DATE RANGE\n");
1269 output.push_str("----------\n");
1270 if let (Some(earliest), Some(latest)) = (&self.earliest_timestamp, &self.latest_timestamp) {
1271 let days = (*latest - *earliest).num_days();
1272 output.push_str(&format!(
1273 "From: {} To: {} ({} days)\n",
1274 earliest.format("%Y-%m-%d"),
1275 latest.format("%Y-%m-%d"),
1276 days
1277 ));
1278 } else {
1279 output.push_str("No date information available\n");
1280 }
1281 output.push('\n');
1282
1283 output.push_str(&format!("WORKSPACES ({})\n", self.workspaces.len()));
1284 output.push_str("--------------\n");
1285 for ws in self.workspaces.iter().take(5) {
1286 let included_marker = if ws.included { " " } else { "x" };
1287 output.push_str(&format!(
1288 "[{}] {} ({} conversations)\n",
1289 included_marker, ws.display_name, ws.conversation_count
1290 ));
1291 if !ws.sample_titles.is_empty() {
1292 let titles: Vec<_> = ws.sample_titles.iter().take(3).cloned().collect();
1293 output.push_str(&format!(" \"{}\"...\n", titles.join("\", \"")));
1294 }
1295 }
1296 if self.workspaces.len() > 5 {
1297 output.push_str(&format!("... and {} more\n", self.workspaces.len() - 5));
1298 }
1299 output.push('\n');
1300
1301 output.push_str("AGENTS\n");
1302 output.push_str("------\n");
1303 for agent in &self.agents {
1304 output.push_str(&format!(
1305 " {}: {} conversations ({:.0}%)\n",
1306 agent.name, agent.conversation_count, agent.percentage
1307 ));
1308 }
1309 output.push('\n');
1310
1311 output.push_str("SECURITY\n");
1312 output.push_str("--------\n");
1313 if let Some(enc) = &self.encryption_config {
1314 output.push_str(&format!("Encryption: {}\n", enc.algorithm));
1315 output.push_str(&format!("Key Derivation: {}\n", enc.key_derivation));
1316 output.push_str(&format!("Key Slots: {}\n", enc.key_slot_count));
1317 }
1318 output.push_str(&format!(
1319 "Secret Scan: {}\n",
1320 self.secret_scan.status_message
1321 ));
1322
1323 output
1324 }
1325
1326 pub fn included_workspace_count(&self) -> usize {
1328 self.workspaces.iter().filter(|w| w.included).count()
1329 }
1330
1331 pub fn included_agent_count(&self) -> usize {
1333 self.agents.iter().filter(|a| a.included).count()
1334 }
1335
1336 pub fn set_secret_scan(&mut self, report: &SecretScanReport) {
1338 self.secret_scan = ScanReportSummary::from_report(report);
1339 }
1340
1341 pub fn set_encryption_config(&mut self, key_slots: &[KeySlot]) {
1343 let enc = EncryptionSummary {
1344 key_slot_count: key_slots.len(),
1345 ..Default::default()
1346 };
1347
1348 self.key_slots = key_slots
1349 .iter()
1350 .enumerate()
1351 .map(|(i, slot)| KeySlotSummary::from_key_slot(slot, i))
1352 .collect();
1353
1354 self.encryption_config = Some(enc);
1355 }
1356}
1357
1358#[cfg(test)]
1359mod tests {
1360 use super::*;
1361 use tempfile::TempDir;
1362
1363 fn create_test_db() -> (TempDir, Connection) {
1364 let dir = TempDir::new().unwrap();
1365 let db_path = dir.path().join("test.db");
1366 let conn = Connection::open(db_path.to_string_lossy().as_ref()).unwrap();
1367
1368 conn.execute_batch(
1369 "CREATE TABLE agents (
1370 id INTEGER PRIMARY KEY,
1371 slug TEXT NOT NULL UNIQUE
1372 );
1373 CREATE TABLE workspaces (
1374 id INTEGER PRIMARY KEY,
1375 path TEXT NOT NULL UNIQUE
1376 );
1377 CREATE TABLE conversations (
1378 id INTEGER PRIMARY KEY,
1379 agent_id INTEGER NOT NULL,
1380 workspace_id INTEGER,
1381 title TEXT,
1382 source_path TEXT NOT NULL,
1383 started_at INTEGER,
1384 ended_at INTEGER,
1385 message_count INTEGER,
1386 metadata_json TEXT,
1387 FOREIGN KEY (agent_id) REFERENCES agents(id),
1388 FOREIGN KEY (workspace_id) REFERENCES workspaces(id)
1389 );
1390 CREATE TABLE messages (
1391 id INTEGER PRIMARY KEY,
1392 conversation_id INTEGER NOT NULL,
1393 idx INTEGER NOT NULL,
1394 role TEXT NOT NULL,
1395 content TEXT NOT NULL,
1396 created_at INTEGER,
1397 FOREIGN KEY (conversation_id) REFERENCES conversations(id)
1398 );",
1399 )
1400 .unwrap();
1401
1402 (dir, conn)
1403 }
1404
1405 fn create_test_db_without_message_count() -> (TempDir, Connection) {
1406 let dir = TempDir::new().unwrap();
1407 let db_path = dir.path().join("test-no-message-count.db");
1408 let conn = Connection::open(db_path.to_string_lossy().as_ref()).unwrap();
1409
1410 conn.execute_batch(
1411 "CREATE TABLE agents (
1412 id INTEGER PRIMARY KEY,
1413 slug TEXT NOT NULL UNIQUE
1414 );
1415 CREATE TABLE workspaces (
1416 id INTEGER PRIMARY KEY,
1417 path TEXT NOT NULL UNIQUE
1418 );
1419 CREATE TABLE conversations (
1420 id INTEGER PRIMARY KEY,
1421 agent_id INTEGER NOT NULL,
1422 workspace_id INTEGER,
1423 title TEXT,
1424 source_path TEXT NOT NULL,
1425 started_at INTEGER,
1426 ended_at INTEGER,
1427 metadata_json TEXT,
1428 FOREIGN KEY (agent_id) REFERENCES agents(id),
1429 FOREIGN KEY (workspace_id) REFERENCES workspaces(id)
1430 );
1431 CREATE TABLE messages (
1432 id INTEGER PRIMARY KEY,
1433 conversation_id INTEGER NOT NULL,
1434 idx INTEGER NOT NULL,
1435 role TEXT NOT NULL,
1436 content TEXT NOT NULL,
1437 created_at INTEGER,
1438 FOREIGN KEY (conversation_id) REFERENCES conversations(id)
1439 );",
1440 )
1441 .unwrap();
1442
1443 (dir, conn)
1444 }
1445
1446 fn insert_test_data(conn: &Connection) {
1447 use frankensqlite::compat::ConnectionExt;
1448 use frankensqlite::params;
1449
1450 conn.execute("INSERT INTO agents (id, slug) VALUES (1, 'claude-code');")
1451 .unwrap();
1452 conn.execute("INSERT INTO agents (id, slug) VALUES (2, 'aider');")
1453 .unwrap();
1454 conn.execute("INSERT INTO workspaces (id, path) VALUES (1, '/home/user/project-a');")
1455 .unwrap();
1456 conn.execute("INSERT INTO workspaces (id, path) VALUES (2, '/home/user/project-b');")
1457 .unwrap();
1458
1459 conn.execute(
1461 "INSERT INTO conversations (id, agent_id, workspace_id, title, source_path, started_at)
1462 VALUES (1, 1, 1, 'Fix authentication bug', '/path/a.jsonl', 1700000000000);",
1463 )
1464 .unwrap();
1465 conn.execute(
1466 "INSERT INTO conversations (id, agent_id, workspace_id, title, source_path, started_at)
1467 VALUES (2, 1, 1, 'Add user profile', '/path/b.jsonl', 1700100000000);",
1468 )
1469 .unwrap();
1470 conn.execute(
1471 "INSERT INTO conversations (id, agent_id, workspace_id, title, source_path, started_at)
1472 VALUES (3, 2, 2, 'Setup database', '/path/c.jsonl', 1700200000000);",
1473 )
1474 .unwrap();
1475
1476 for conv_id in 1..=3i64 {
1478 let msg_count = match conv_id {
1479 1 => 5,
1480 2 => 3,
1481 3 => 4,
1482 _ => 0,
1483 };
1484 for idx in 0..msg_count {
1485 let role = if idx % 2 == 0 { "user" } else { "assistant" };
1486 let created_at = 1700000000000i64 + (conv_id * 100000000) + (idx as i64 * 1000);
1487 conn.execute_compat(
1488 "INSERT INTO messages (conversation_id, idx, role, content, created_at)
1489 VALUES (?1, ?2, ?3, ?4, ?5)",
1490 params![
1491 conv_id,
1492 idx as i64,
1493 role,
1494 format!("Test message {} for conversation {}", idx, conv_id),
1495 created_at
1496 ],
1497 )
1498 .unwrap();
1499 }
1500 }
1501 }
1502
1503 #[test]
1504 fn test_summary_generation() {
1505 let (_dir, conn) = create_test_db();
1506 insert_test_data(&conn);
1507
1508 let generator = SummaryGenerator::new(&conn);
1509 let summary = generator.generate(None).unwrap();
1510
1511 assert_eq!(summary.total_conversations, 3);
1512 assert_eq!(summary.total_messages, 12);
1513 assert!(summary.total_characters > 0);
1514 assert_eq!(summary.workspaces.len(), 2);
1515 assert_eq!(summary.agents.len(), 2);
1516 }
1517
1518 #[test]
1519 fn test_summary_generation_without_conversation_message_count_column() {
1520 let (_dir, conn) = create_test_db_without_message_count();
1521 insert_test_data(&conn);
1522
1523 let generator = SummaryGenerator::new(&conn);
1524 let summary = generator.generate(None).unwrap();
1525
1526 assert_eq!(summary.total_conversations, 3);
1527 assert_eq!(summary.total_messages, 12);
1528 assert_eq!(summary.workspaces.len(), 2);
1529 assert_eq!(summary.agents.len(), 2);
1530
1531 let project_a = summary
1532 .workspaces
1533 .iter()
1534 .find(|w| w.path == "/home/user/project-a")
1535 .unwrap();
1536 assert_eq!(project_a.message_count, 8);
1537
1538 let claude = summary
1539 .agents
1540 .iter()
1541 .find(|a| a.name.as_str().eq("claude-code"))
1542 .unwrap();
1543 assert_eq!(claude.message_count, 8);
1544 }
1545
1546 #[test]
1547 fn test_summary_with_filters() {
1548 let (_dir, conn) = create_test_db();
1549 insert_test_data(&conn);
1550
1551 let filters = SummaryFilters {
1552 agents: Some(vec!["claude-code".to_string()]),
1553 ..Default::default()
1554 };
1555
1556 let generator = SummaryGenerator::new(&conn);
1557 let summary = generator.generate(Some(&filters)).unwrap();
1558
1559 assert_eq!(summary.total_conversations, 2);
1560 assert_eq!(summary.total_messages, 8); }
1562
1563 #[test]
1564 fn test_summary_with_empty_agent_filter_matches_nothing() {
1565 let (_dir, conn) = create_test_db();
1566 insert_test_data(&conn);
1567
1568 let filters = SummaryFilters {
1569 agents: Some(vec![]),
1570 ..Default::default()
1571 };
1572
1573 let generator = SummaryGenerator::new(&conn);
1574 let summary = generator.generate(Some(&filters)).unwrap();
1575
1576 assert_eq!(summary.total_conversations, 0);
1577 assert_eq!(summary.total_messages, 0);
1578 assert!(summary.workspaces.is_empty());
1579 assert!(summary.agents.is_empty());
1580 }
1581
1582 #[test]
1583 fn test_summary_with_empty_workspace_filter_matches_nothing() {
1584 let (_dir, conn) = create_test_db();
1585 insert_test_data(&conn);
1586
1587 let filters = SummaryFilters {
1588 workspaces: Some(vec![]),
1589 ..Default::default()
1590 };
1591
1592 let generator = SummaryGenerator::new(&conn);
1593 let summary = generator.generate(Some(&filters)).unwrap();
1594
1595 assert_eq!(summary.total_conversations, 0);
1596 assert_eq!(summary.total_messages, 0);
1597 assert!(summary.workspaces.is_empty());
1598 assert!(summary.agents.is_empty());
1599 }
1600
1601 #[test]
1602 fn test_workspace_summary_message_counts_respect_time_filter() {
1603 let (_dir, conn) = create_test_db();
1604 insert_test_data(&conn);
1605
1606 let filters = SummaryFilters {
1607 since_ts: Some(1_700_050_000_000),
1608 ..Default::default()
1609 };
1610
1611 let generator = SummaryGenerator::new(&conn);
1612 let summary = generator.generate(Some(&filters)).unwrap();
1613
1614 let project_a = summary
1615 .workspaces
1616 .iter()
1617 .find(|w| w.path == "/home/user/project-a")
1618 .unwrap();
1619 assert_eq!(project_a.conversation_count, 1);
1620 assert_eq!(project_a.message_count, 3);
1621 assert!(
1622 project_a
1623 .sample_titles
1624 .iter()
1625 .all(|t| t != "Fix authentication bug")
1626 );
1627 }
1628
1629 #[test]
1630 fn test_agent_summary_message_counts_respect_time_filter() {
1631 let (_dir, conn) = create_test_db();
1632 insert_test_data(&conn);
1633
1634 let filters = SummaryFilters {
1635 since_ts: Some(1_700_050_000_000),
1636 ..Default::default()
1637 };
1638
1639 let generator = SummaryGenerator::new(&conn);
1640 let summary = generator.generate(Some(&filters)).unwrap();
1641
1642 let claude = summary
1643 .agents
1644 .iter()
1645 .find(|a| a.name.as_str().eq("claude-code"))
1646 .unwrap();
1647 assert_eq!(claude.conversation_count, 1);
1648 assert_eq!(claude.message_count, 3);
1649 }
1650
1651 #[test]
1652 fn test_workspace_summary() {
1653 let (_dir, conn) = create_test_db();
1654 insert_test_data(&conn);
1655
1656 let generator = SummaryGenerator::new(&conn);
1657 let summary = generator.generate(None).unwrap();
1658
1659 let project_a = summary
1660 .workspaces
1661 .iter()
1662 .find(|w| w.path.contains("project-a"));
1663 assert!(project_a.is_some());
1664 let project_a = project_a.unwrap();
1665 assert_eq!(project_a.conversation_count, 2);
1666 assert_eq!(project_a.display_name, "project-a");
1667 assert!(!project_a.sample_titles.is_empty());
1668 }
1669
1670 #[test]
1671 fn test_agent_summary() {
1672 let (_dir, conn) = create_test_db();
1673 insert_test_data(&conn);
1674
1675 let generator = SummaryGenerator::new(&conn);
1676 let summary = generator.generate(None).unwrap();
1677
1678 let claude = summary
1679 .agents
1680 .iter()
1681 .find(|a| a.name.as_str().eq("claude-code"));
1682 assert!(claude.is_some());
1683 let claude = claude.unwrap();
1684 assert_eq!(claude.conversation_count, 2);
1685 assert!((claude.percentage - 66.67).abs() < 1.0);
1686 }
1687
1688 #[test]
1689 fn test_date_histogram() {
1690 let (_dir, conn) = create_test_db();
1691 insert_test_data(&conn);
1692
1693 let generator = SummaryGenerator::new(&conn);
1694 let summary = generator.generate(None).unwrap();
1695
1696 assert!(!summary.date_histogram.is_empty());
1698 }
1699
1700 #[test]
1701 fn test_exclusion_set() {
1702 let mut exclusions = ExclusionSet::new();
1703
1704 exclusions.exclude_workspace("/home/user/project-a");
1705 assert!(exclusions.is_workspace_excluded("/home/user/project-a"));
1706 assert!(!exclusions.is_workspace_excluded("/home/user/project-b"));
1707
1708 exclusions.exclude_conversation(42);
1709 assert!(exclusions.is_conversation_excluded(42));
1710 assert!(!exclusions.is_conversation_excluded(43));
1711
1712 exclusions.add_pattern("(?i)secret").unwrap();
1713 assert!(exclusions.is_excluded("This is a Secret task"));
1714 assert!(!exclusions.is_excluded("This is a normal task"));
1715 }
1716
1717 #[test]
1718 fn test_exclusion_should_exclude() {
1719 let mut exclusions = ExclusionSet::new();
1720 exclusions.exclude_workspace("/excluded");
1721 exclusions.exclude_conversation(99);
1722 exclusions.add_pattern("^Private:").unwrap();
1723
1724 assert!(exclusions.should_exclude(Some("/excluded"), 1, "Normal title"));
1726 assert!(exclusions.should_exclude(Some("/normal"), 99, "Normal title"));
1728 assert!(exclusions.should_exclude(Some("/normal"), 1, "Private: Secret stuff"));
1730 assert!(!exclusions.should_exclude(Some("/normal"), 1, "Normal title"));
1732 }
1733
1734 #[test]
1735 fn test_summary_with_exclusions() {
1736 let (_dir, conn) = create_test_db();
1737 insert_test_data(&conn);
1738
1739 let mut exclusions = ExclusionSet::new();
1740 exclusions.exclude_workspace("/home/user/project-b");
1741
1742 let generator = SummaryGenerator::new(&conn);
1743 let summary = generator
1744 .generate_with_exclusions(None, &exclusions)
1745 .unwrap();
1746
1747 let project_b = summary
1749 .workspaces
1750 .iter()
1751 .find(|w| w.path.contains("project-b"));
1752 assert!(project_b.is_some());
1753 let project_b = project_b.unwrap();
1754 assert!(!project_b.included);
1755 assert_eq!(project_b.conversation_count, 1);
1756 assert_eq!(project_b.message_count, 4);
1757
1758 let project_a = summary
1759 .workspaces
1760 .iter()
1761 .find(|w| w.path.contains("project-a"))
1762 .unwrap();
1763 assert!(project_a.included);
1764 assert_eq!(project_a.conversation_count, 2);
1765 assert_eq!(project_a.message_count, 8);
1766 assert_eq!(summary.total_conversations, 2);
1767 assert_eq!(summary.total_messages, 8);
1768 assert_eq!(summary.agents.len(), 1);
1769 assert_eq!(summary.agents[0].name, "claude-code");
1770 assert_eq!(summary.agents[0].conversation_count, 2);
1771 assert!((summary.agents[0].percentage - 100.0).abs() < f64::EPSILON);
1772 assert_eq!(
1773 summary
1774 .date_histogram
1775 .iter()
1776 .map(|day| day.message_count)
1777 .sum::<usize>(),
1778 8
1779 );
1780 assert_eq!(
1781 summary.latest_timestamp,
1782 DateTime::from_timestamp_millis(1_700_100_000_000)
1783 );
1784 }
1785
1786 #[test]
1787 fn test_size_estimation() {
1788 let size = estimate_compressed_size(1_000_000);
1789 assert!(size > 400_000);
1791 assert!(size < 450_000);
1792 }
1793
1794 #[test]
1795 fn test_format_size() {
1796 assert_eq!(format_size(500), "500 bytes");
1797 assert_eq!(format_size(1500), "1.5 KB");
1798 assert_eq!(format_size(1_500_000), "1.4 MB");
1799 assert_eq!(format_size(1_500_000_000), "1.4 GB");
1800 }
1801
1802 #[test]
1803 fn test_date_range() {
1804 let range = DateRange::from_timestamps(Some(1700000000000), Some(1700100000000));
1805 assert!(range.earliest.is_some());
1806 assert!(range.latest.is_some());
1807 assert!(range.span_days().unwrap() >= 1);
1808 }
1809
1810 #[test]
1811 fn test_scan_report_summary() {
1812 let summary = ScanReportSummary::default();
1813 assert_eq!(summary.total_findings, 0);
1814 assert!(!summary.has_critical);
1815 assert!(!summary.truncated);
1816 }
1817
1818 #[test]
1819 fn test_encryption_summary() {
1820 let enc = EncryptionSummary::default();
1821 assert_eq!(enc.algorithm, "AES-256-GCM");
1822 assert_eq!(enc.key_derivation, "Argon2id");
1823 }
1824
1825 #[test]
1826 fn test_render_overview() {
1827 let (_dir, conn) = create_test_db();
1828 insert_test_data(&conn);
1829
1830 let generator = SummaryGenerator::new(&conn);
1831 let summary = generator.generate(None).unwrap();
1832 let overview = summary.render_overview();
1833
1834 assert!(overview.contains("CONTENT OVERVIEW"));
1835 assert!(overview.contains("Conversations: 3"));
1836 assert!(overview.contains("WORKSPACES"));
1837 assert!(overview.contains("AGENTS"));
1838 assert!(overview.contains("SECURITY"));
1839 }
1840
1841 #[test]
1842 fn test_empty_database() {
1843 let (_dir, conn) = create_test_db();
1844 let generator = SummaryGenerator::new(&conn);
1847 let summary = generator.generate(None).unwrap();
1848
1849 assert_eq!(summary.total_conversations, 0);
1850 assert_eq!(summary.total_messages, 0);
1851 assert_eq!(summary.total_characters, 0);
1852 assert!(summary.workspaces.is_empty());
1853 assert!(summary.agents.is_empty());
1854 }
1855
1856 #[test]
1857 fn test_key_slot_summary() {
1858 use crate::pages::encrypt::{KdfAlgorithm, KeySlot, SlotType};
1859
1860 let slot = KeySlot {
1861 id: 0,
1862 slot_type: SlotType::Password,
1863 kdf: KdfAlgorithm::Argon2id,
1864 salt: "test".to_string(),
1865 wrapped_dek: "test".to_string(),
1866 nonce: "test".to_string(),
1867 argon2_params: None,
1868 };
1869
1870 let summary = KeySlotSummary::from_key_slot(&slot, 0);
1871 assert_eq!(summary.slot_index, 0);
1872 assert_eq!(summary.slot_type, KeySlotType::Password);
1873 }
1874
1875 #[test]
1876 fn test_exclusion_compile_patterns() {
1877 let mut exclusions = ExclusionSet::new();
1878 exclusions.excluded_pattern_strings = vec!["test.*pattern".to_string()];
1879
1880 exclusions.compile_patterns().unwrap();
1881
1882 assert_eq!(exclusions.excluded_patterns.len(), 1);
1883 assert!(exclusions.is_excluded("test123pattern"));
1884 }
1885
1886 #[test]
1887 fn test_key_slot_type_label() {
1888 assert_eq!(KeySlotType::Password.label(), "Password");
1889 assert_eq!(KeySlotType::QrCode.label(), "QR Code");
1890 assert_eq!(KeySlotType::Recovery.label(), "Recovery Key");
1891 }
1892
1893 #[test]
1894 fn test_exclusion_recount_keeps_workspace_less_conversations() {
1895 let (_dir, conn) = create_test_db();
1896
1897 conn.execute("INSERT INTO agents (id, slug) VALUES (3, 'codex');")
1900 .unwrap();
1901 conn.execute(
1902 "INSERT INTO conversations (id, agent_id, workspace_id, title, source_path, started_at, message_count)
1903 VALUES (10, 3, NULL, 'General session', '/path/no-workspace.jsonl', 1700300000000, 1);",
1904 )
1905 .unwrap();
1906 conn.execute(
1907 "INSERT INTO messages (conversation_id, idx, role, content, created_at)
1908 VALUES (10, 0, 'user', 'Workspace-less message', 1700300001000);",
1909 )
1910 .unwrap();
1911
1912 let mut exclusions = ExclusionSet::new();
1913 exclusions.add_pattern("^DOES_NOT_MATCH$").unwrap();
1914
1915 let generator = SummaryGenerator::new(&conn);
1916 let summary = generator
1917 .generate_with_exclusions(None, &exclusions)
1918 .unwrap();
1919
1920 assert_eq!(summary.total_conversations, 1);
1921 assert_eq!(summary.total_messages, 1);
1922 assert!(summary.workspaces.is_empty());
1923 }
1924
1925 #[test]
1926 fn test_exclusion_recount_keeps_workspace_less_conversations_with_other_workspaces() {
1927 let (_dir, conn) = create_test_db();
1928 insert_test_data(&conn);
1929
1930 conn.execute("INSERT INTO agents (id, slug) VALUES (3, 'codex');")
1931 .unwrap();
1932 conn.execute(
1933 "INSERT INTO conversations (id, agent_id, workspace_id, title, source_path, started_at, message_count)
1934 VALUES (10, 3, NULL, 'General session', '/path/no-workspace.jsonl', 1700300000000, 1);",
1935 )
1936 .unwrap();
1937 conn.execute(
1938 "INSERT INTO messages (conversation_id, idx, role, content, created_at)
1939 VALUES (10, 0, 'user', 'Workspace-less message', 1700300001000);",
1940 )
1941 .unwrap();
1942
1943 let mut exclusions = ExclusionSet::new();
1944 exclusions.add_pattern("^DOES_NOT_MATCH$").unwrap();
1945
1946 let generator = SummaryGenerator::new(&conn);
1947 let summary = generator
1948 .generate_with_exclusions(None, &exclusions)
1949 .unwrap();
1950
1951 assert_eq!(summary.total_conversations, 4);
1952 assert_eq!(summary.total_messages, 13);
1953 assert!(
1954 summary
1955 .agents
1956 .iter()
1957 .any(|agent| agent.name.as_str().eq("codex"))
1958 );
1959 }
1960
1961 #[test]
1962 fn test_exclusion_recount_respects_active_filters() {
1963 let (_dir, conn) = create_test_db();
1964 insert_test_data(&conn);
1965
1966 let filters = SummaryFilters {
1968 agents: Some(vec!["claude-code".to_string()]),
1969 since_ts: Some(1_700_050_000_000),
1970 ..Default::default()
1971 };
1972
1973 let generator = SummaryGenerator::new(&conn);
1974 let baseline = generator.generate(Some(&filters)).unwrap();
1975 assert_eq!(baseline.total_conversations, 1);
1976 assert_eq!(baseline.total_messages, 3);
1977
1978 let mut exclusions = ExclusionSet::new();
1980 exclusions.add_pattern("^DOES_NOT_MATCH$").unwrap();
1981 let summary = generator
1982 .generate_with_exclusions(Some(&filters), &exclusions)
1983 .unwrap();
1984
1985 assert_eq!(summary.total_conversations, 1);
1986 assert_eq!(summary.total_messages, 3);
1987 }
1988
1989 #[test]
1990 fn test_pattern_exclusion_recounts_breakdowns() {
1991 let (_dir, conn) = create_test_db();
1992 insert_test_data(&conn);
1993
1994 let mut exclusions = ExclusionSet::new();
1995 exclusions.add_pattern("^Add user profile$").unwrap();
1996
1997 let generator = SummaryGenerator::new(&conn);
1998 let summary = generator
1999 .generate_with_exclusions(None, &exclusions)
2000 .unwrap();
2001
2002 assert_eq!(summary.total_conversations, 2);
2003 assert_eq!(summary.total_messages, 9);
2004 assert_eq!(
2005 summary
2006 .date_histogram
2007 .iter()
2008 .map(|day| day.message_count)
2009 .sum::<usize>(),
2010 9
2011 );
2012
2013 let project_a = summary
2014 .workspaces
2015 .iter()
2016 .find(|w| w.path == "/home/user/project-a")
2017 .unwrap();
2018 assert!(project_a.included);
2019 assert_eq!(project_a.conversation_count, 1);
2020 assert_eq!(project_a.message_count, 5);
2021 assert!(
2022 project_a
2023 .sample_titles
2024 .iter()
2025 .all(|title| title != "Add user profile")
2026 );
2027
2028 let claude = summary
2029 .agents
2030 .iter()
2031 .find(|agent| agent.name.as_str().eq("claude-code"))
2032 .unwrap();
2033 assert_eq!(claude.conversation_count, 1);
2034 assert_eq!(claude.message_count, 5);
2035 assert!((claude.percentage - 50.0).abs() < f64::EPSILON);
2036
2037 let aider = summary
2038 .agents
2039 .iter()
2040 .find(|agent| agent.name.as_str().eq("aider"))
2041 .unwrap();
2042 assert_eq!(aider.conversation_count, 1);
2043 assert_eq!(aider.message_count, 4);
2044 assert!((aider.percentage - 50.0).abs() < f64::EPSILON);
2045 }
2046}