Skip to main content

coding_agent_search/pages/
summary.rs

1//! Pre-Publish Summary generation for pages export.
2//!
3//! Generates a comprehensive summary of all content that will be published,
4//! enabling users to review and modify their selection before proceeding.
5//!
6//! # Overview
7//!
8//! The summary provides:
9//! - **Quantitative metrics**: Total conversations, messages, and estimated size
10//! - **Temporal scope**: Date range and activity histogram
11//! - **Content categorization**: Breakdown by workspace and agent
12//! - **Security status**: Encryption configuration and secret scan results
13//!
14//! # Example
15//!
16//! ```ignore
17//! use crate::pages::summary::{PrePublishSummary, SummaryGenerator};
18//!
19//! let generator = SummaryGenerator::new(&db_conn);
20//! let summary = generator.generate(None)?;
21//! println!("{}", summary.render_overview());
22//! ```
23
24use 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/// Pre-publish summary containing all information about content to be exported.
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct PrePublishSummary {
38    // Quantitative metrics
39    /// Total number of conversations to be exported.
40    pub total_conversations: usize,
41    /// Total number of messages across all conversations.
42    pub total_messages: usize,
43    /// Total character count of all message content.
44    pub total_characters: usize,
45    /// Estimated size in bytes after compression and encryption.
46    pub estimated_size_bytes: usize,
47
48    // Temporal scope
49    /// Earliest timestamp in the export set.
50    pub earliest_timestamp: Option<DateTime<Utc>>,
51    /// Latest timestamp in the export set.
52    pub latest_timestamp: Option<DateTime<Utc>>,
53    /// Histogram of messages per day.
54    pub date_histogram: Vec<DateHistogramEntry>,
55
56    // Content categorization
57    /// Per-workspace breakdown.
58    pub workspaces: Vec<WorkspaceSummaryItem>,
59    /// Per-agent breakdown.
60    pub agents: Vec<AgentSummaryItem>,
61
62    // Security status
63    /// Summary of secret scan results.
64    pub secret_scan: ScanReportSummary,
65    /// Encryption configuration summary.
66    pub encryption_config: Option<EncryptionSummary>,
67    /// Key slots configured for the export.
68    pub key_slots: Vec<KeySlotSummary>,
69
70    /// When this summary was generated.
71    pub generated_at: DateTime<Utc>,
72}
73
74/// Entry in the date histogram.
75#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct DateHistogramEntry {
77    /// Date in YYYY-MM-DD format.
78    pub date: String,
79    /// Number of messages on this date.
80    pub message_count: usize,
81    /// Number of unique conversations active on this date.
82    pub conversation_count: usize,
83}
84
85/// Summary of a workspace's content in the export.
86#[derive(Debug, Clone, Serialize, Deserialize)]
87pub struct WorkspaceSummaryItem {
88    /// Full path of the workspace.
89    pub path: String,
90    /// Display name (last path component).
91    pub display_name: String,
92    /// Number of conversations in this workspace.
93    pub conversation_count: usize,
94    /// Number of messages in this workspace.
95    pub message_count: usize,
96    /// Date range of conversations in this workspace.
97    pub date_range: DateRange,
98    /// Sample of conversation titles (first 5).
99    pub sample_titles: Vec<String>,
100    /// Whether this workspace is included in export.
101    pub included: bool,
102}
103
104/// Summary of an agent's content in the export.
105#[derive(Debug, Clone, Serialize, Deserialize)]
106pub struct AgentSummaryItem {
107    /// Agent identifier (e.g., "claude-code", "aider").
108    pub name: String,
109    /// Number of conversations from this agent.
110    pub conversation_count: usize,
111    /// Number of messages from this agent.
112    pub message_count: usize,
113    /// Percentage of total conversations.
114    pub percentage: f64,
115    /// Whether this agent is included in export.
116    pub included: bool,
117}
118
119/// Date range with optional bounds.
120#[derive(Debug, Clone, Serialize, Deserialize)]
121pub struct DateRange {
122    /// Earliest timestamp (RFC3339).
123    pub earliest: Option<String>,
124    /// Latest timestamp (RFC3339).
125    pub latest: Option<String>,
126}
127
128impl DateRange {
129    /// Create a new date range from optional timestamps.
130    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    /// Get the span in days, if both bounds are present.
142    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/// Summary of secret scan results.
150#[derive(Debug, Clone, Default, Serialize, Deserialize)]
151pub struct ScanReportSummary {
152    /// Total number of findings.
153    pub total_findings: usize,
154    /// Breakdown by severity.
155    pub by_severity: HashMap<String, usize>,
156    /// Whether any critical secrets were found.
157    pub has_critical: bool,
158    /// Whether findings were truncated due to max limit.
159    pub truncated: bool,
160    /// Status message for display.
161    pub status_message: String,
162}
163
164impl ScanReportSummary {
165    /// Create from a full secret scan report.
166    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    /// Create from a summary only.
192    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/// Summary of encryption configuration.
218#[derive(Debug, Clone, Serialize, Deserialize)]
219pub struct EncryptionSummary {
220    /// Encryption algorithm used.
221    pub algorithm: String,
222    /// Key derivation function.
223    pub key_derivation: String,
224    /// Number of key slots configured.
225    pub key_slot_count: usize,
226    /// Estimated decryption time (for display).
227    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/// Type of key slot.
242#[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    /// Display label for the slot type.
261    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/// Summary of a key slot.
271#[derive(Debug, Clone, Serialize, Deserialize)]
272pub struct KeySlotSummary {
273    /// Slot index (0-based).
274    pub slot_index: usize,
275    /// Type of the slot.
276    pub slot_type: KeySlotType,
277    /// Optional hint for the slot.
278    pub hint: Option<String>,
279    /// When the slot was created.
280    pub created_at: Option<DateTime<Utc>>,
281}
282
283impl KeySlotSummary {
284    /// Create from a KeySlot.
285    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, // Hints not stored in KeySlot currently
290            created_at: None,
291        }
292    }
293}
294
295/// Set of exclusions to apply before export.
296#[derive(Debug, Clone, Default, Serialize, Deserialize)]
297pub struct ExclusionSet {
298    /// Workspaces to exclude (full paths).
299    pub excluded_workspaces: HashSet<String>,
300    /// Conversation IDs to exclude.
301    pub excluded_conversations: HashSet<i64>,
302    /// Patterns to match against titles for exclusion.
303    #[serde(skip)]
304    pub excluded_patterns: Vec<Regex>,
305    /// Raw pattern strings (for serialization).
306    pub excluded_pattern_strings: Vec<String>,
307}
308
309impl ExclusionSet {
310    /// Create a new empty exclusion set.
311    pub fn new() -> Self {
312        Self::default()
313    }
314
315    /// Add a workspace to exclusions.
316    pub fn exclude_workspace(&mut self, workspace: &str) {
317        self.excluded_workspaces.insert(workspace.to_string());
318    }
319
320    /// Remove a workspace from exclusions.
321    pub fn include_workspace(&mut self, workspace: &str) {
322        self.excluded_workspaces.remove(workspace);
323    }
324
325    /// Add a conversation to exclusions.
326    pub fn exclude_conversation(&mut self, conversation_id: i64) {
327        self.excluded_conversations.insert(conversation_id);
328    }
329
330    /// Remove a conversation from exclusions.
331    pub fn include_conversation(&mut self, conversation_id: i64) {
332        self.excluded_conversations.remove(&conversation_id);
333    }
334
335    /// Check if a workspace is excluded.
336    pub fn is_workspace_excluded(&self, workspace: &str) -> bool {
337        self.excluded_workspaces.contains(workspace)
338    }
339
340    /// Check if a conversation is excluded.
341    pub fn is_conversation_excluded(&self, conversation_id: i64) -> bool {
342        self.excluded_conversations.contains(&conversation_id)
343    }
344
345    /// Check if a title matches any exclusion pattern.
346    pub fn is_excluded(&self, title: &str) -> bool {
347        self.excluded_patterns.iter().any(|re| re.is_match(title))
348    }
349
350    /// Add a title pattern to exclusions.
351    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    /// Check if an item should be excluded based on all criteria.
359    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    /// Get the count of excluded items.
377    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    /// Check if any exclusions are active.
386    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    /// Re-compile patterns from strings (for deserialization).
393    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/// Filters for summary generation.
405#[derive(Debug, Clone, Default)]
406pub struct SummaryFilters {
407    /// Filter to specific agents.
408    pub agents: Option<Vec<String>>,
409    /// Filter to specific workspaces.
410    pub workspaces: Option<Vec<String>>,
411    /// Filter to conversations after this timestamp (millis).
412    pub since_ts: Option<i64>,
413    /// Filter to conversations before this timestamp (millis).
414    pub until_ts: Option<i64>,
415}
416
417/// Generator for pre-publish summaries.
418pub struct SummaryGenerator<'a> {
419    db: &'a Connection,
420}
421
422impl<'a> SummaryGenerator<'a> {
423    /// Create a new summary generator.
424    pub fn new(db: &'a Connection) -> Self {
425        Self { db }
426    }
427
428    /// Generate a pre-publish summary with optional filters.
429    pub fn generate(&self, filters: Option<&SummaryFilters>) -> Result<PrePublishSummary> {
430        let filters = filters.cloned().unwrap_or_default();
431
432        // Build WHERE clause for filters
433        let (where_clause, params) = self.build_filter_clause(&filters);
434
435        // Get basic counts
436        let (total_conversations, total_messages, total_characters) =
437            self.get_counts(&where_clause, &params)?;
438
439        // Get time range
440        let (earliest_ts, latest_ts) = self.get_time_range(&where_clause, &params)?;
441
442        // Get date histogram
443        let date_histogram = self.get_date_histogram(&where_clause, &params)?;
444
445        // Get workspace summary
446        let workspaces = self.get_workspace_summary(&where_clause, &params)?;
447
448        // Get agent summary
449        let agents = self.get_agent_summary(&where_clause, &params, total_conversations)?;
450
451        // Estimate size (rough: ~60% of raw character count after compression)
452        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    /// Generate a summary with exclusions applied.
472    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        // Mark excluded workspaces
480        for ws in &mut summary.workspaces {
481            ws.included = !exclusions.is_workspace_excluded(&ws.path);
482        }
483
484        // Recalculate totals based on included workspaces
485        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            // Recalculate counts excluding excluded items
494            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    /// Build filter WHERE clause.
507    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    /// Count messages across a known set of conversation IDs.
561    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, &params, |row: &Row| row.get_typed(0))?;
577        Ok(count as usize)
578    }
579
580    /// Get basic counts.
581    fn get_counts(
582        &self,
583        where_clause: &str,
584        params: &[ParamValue],
585    ) -> Result<(usize, usize, usize)> {
586        // Count conversations
587        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        // Count messages and characters using subquery to avoid
597        // JOIN + aggregate without GROUP BY (frankensqlite limitation).
598        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    /// Get time range.
625    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    /// Get date histogram.
644    fn get_date_histogram(
645        &self,
646        where_clause: &str,
647        params: &[ParamValue],
648    ) -> Result<Vec<DateHistogramEntry>> {
649        // Use integer day computation instead of DATE() which isn't supported
650        // by frankensqlite. The day_epoch is seconds-since-epoch / 86400.
651        // Use subquery instead of JOIN to avoid frankensqlite aggregate limitation.
652        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        // Count distinct conversations per day using a subquery approach.
664        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    /// Get workspace summary.
712    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            // Extract display name
802            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    /// Get agent summary.
828    fn get_agent_summary(
829        &self,
830        where_clause: &str,
831        params: &[ParamValue],
832        total_conversations: usize,
833    ) -> Result<Vec<AgentSummaryItem>> {
834        // LEFT JOIN + COALESCE on agents so the summary correctly bucketizes
835        // legacy V1 conversations with NULL agent_id under 'unknown' (and
836        // avoids a runtime row-decode crash when c.agent_id is NULL).
837        // Fetching the slug directly in the join also removes the need for
838        // the separate agent_id -> slug resolution query.
839        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    /// Recalculate counts with exclusions.
888    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, &params, |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
991/// Estimate compressed size from character count.
992/// Uses rough heuristic: ~40% of original after compression + encryption overhead.
993pub fn estimate_compressed_size(char_count: usize) -> usize {
994    let base_size = (char_count as f64 * 0.4) as usize;
995    // Add ~5% for encryption overhead (nonces, auth tags, etc.)
996    (base_size as f64 * 1.05) as usize
997}
998
999/// Format a byte size for display.
1000pub 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    /// Render a text overview of the summary.
1018    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    /// Get count of included workspaces.
1095    pub fn included_workspace_count(&self) -> usize {
1096        self.workspaces.iter().filter(|w| w.included).count()
1097    }
1098
1099    /// Get count of included agents.
1100    pub fn included_agent_count(&self) -> usize {
1101        self.agents.iter().filter(|a| a.included).count()
1102    }
1103
1104    /// Update with secret scan results.
1105    pub fn set_secret_scan(&mut self, report: &SecretScanReport) {
1106        self.secret_scan = ScanReportSummary::from_report(report);
1107    }
1108
1109    /// Update with encryption config.
1110    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        // Insert conversations
1228        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        // Insert messages
1245        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); // 5 + 3
1329    }
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        // Each conversation is on a different day
1462        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        // Excluded by workspace
1490        assert!(exclusions.should_exclude(Some("/excluded"), 1, "Normal title"));
1491        // Excluded by conversation ID
1492        assert!(exclusions.should_exclude(Some("/normal"), 99, "Normal title"));
1493        // Excluded by pattern
1494        assert!(exclusions.should_exclude(Some("/normal"), 1, "Private: Secret stuff"));
1495        // Not excluded
1496        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        // project-b should be marked as not included
1513        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        // Should be roughly 40% * 1.05 = 42% of original
1525        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        // Don't insert any data
1580
1581        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        // Conversation without workspace should still be counted when exclusions
1633        // are active but do not match this conversation.
1634        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        // Restrict to a single claude-code conversation in project-a.
1666        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        // Trigger recount path without excluding any actual rows.
1678        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}