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
35const SUMMARY_ID_CHUNK_SIZE: usize = 500;
36
37/// Pre-publish summary containing all information about content to be exported.
38#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct PrePublishSummary {
40    // Quantitative metrics
41    /// Total number of conversations to be exported.
42    pub total_conversations: usize,
43    /// Total number of messages across all conversations.
44    pub total_messages: usize,
45    /// Total character count of all message content.
46    pub total_characters: usize,
47    /// Estimated size in bytes after compression and encryption.
48    pub estimated_size_bytes: usize,
49
50    // Temporal scope
51    /// Earliest timestamp in the export set.
52    pub earliest_timestamp: Option<DateTime<Utc>>,
53    /// Latest timestamp in the export set.
54    pub latest_timestamp: Option<DateTime<Utc>>,
55    /// Histogram of messages per day.
56    pub date_histogram: Vec<DateHistogramEntry>,
57
58    // Content categorization
59    /// Per-workspace breakdown.
60    pub workspaces: Vec<WorkspaceSummaryItem>,
61    /// Per-agent breakdown.
62    pub agents: Vec<AgentSummaryItem>,
63
64    // Security status
65    /// Summary of secret scan results.
66    pub secret_scan: ScanReportSummary,
67    /// Encryption configuration summary.
68    pub encryption_config: Option<EncryptionSummary>,
69    /// Key slots configured for the export.
70    pub key_slots: Vec<KeySlotSummary>,
71
72    /// When this summary was generated.
73    pub generated_at: DateTime<Utc>,
74}
75
76/// Entry in the date histogram.
77#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct DateHistogramEntry {
79    /// Date in YYYY-MM-DD format.
80    pub date: String,
81    /// Number of messages on this date.
82    pub message_count: usize,
83    /// Number of unique conversations active on this date.
84    pub conversation_count: usize,
85}
86
87/// Summary of a workspace's content in the export.
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct WorkspaceSummaryItem {
90    /// Full path of the workspace.
91    pub path: String,
92    /// Display name (last path component).
93    pub display_name: String,
94    /// Number of conversations in this workspace.
95    pub conversation_count: usize,
96    /// Number of messages in this workspace.
97    pub message_count: usize,
98    /// Date range of conversations in this workspace.
99    pub date_range: DateRange,
100    /// Sample of conversation titles (first 5).
101    pub sample_titles: Vec<String>,
102    /// Whether this workspace is included in export.
103    pub included: bool,
104}
105
106/// Summary of an agent's content in the export.
107#[derive(Debug, Clone, Serialize, Deserialize)]
108pub struct AgentSummaryItem {
109    /// Agent identifier (e.g., "claude-code", "aider").
110    pub name: String,
111    /// Number of conversations from this agent.
112    pub conversation_count: usize,
113    /// Number of messages from this agent.
114    pub message_count: usize,
115    /// Percentage of total conversations.
116    pub percentage: f64,
117    /// Whether this agent is included in export.
118    pub included: bool,
119}
120
121/// Date range with optional bounds.
122#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct DateRange {
124    /// Earliest timestamp (RFC3339).
125    pub earliest: Option<String>,
126    /// Latest timestamp (RFC3339).
127    pub latest: Option<String>,
128}
129
130impl DateRange {
131    /// Create a new date range from optional timestamps.
132    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    /// Get the span in days, if both bounds are present.
144    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/// Summary of secret scan results.
152#[derive(Debug, Clone, Default, Serialize, Deserialize)]
153pub struct ScanReportSummary {
154    /// Total number of findings.
155    pub total_findings: usize,
156    /// Breakdown by severity.
157    pub by_severity: HashMap<String, usize>,
158    /// Whether any critical secrets were found.
159    pub has_critical: bool,
160    /// Whether findings were truncated due to max limit.
161    pub truncated: bool,
162    /// Status message for display.
163    pub status_message: String,
164}
165
166impl ScanReportSummary {
167    /// Create from a full secret scan report.
168    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    /// Create from a summary only.
194    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/// Summary of encryption configuration.
220#[derive(Debug, Clone, Serialize, Deserialize)]
221pub struct EncryptionSummary {
222    /// Encryption algorithm used.
223    pub algorithm: String,
224    /// Key derivation function.
225    pub key_derivation: String,
226    /// Number of key slots configured.
227    pub key_slot_count: usize,
228    /// Estimated decryption time (for display).
229    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/// Type of key slot.
244#[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    /// Display label for the slot type.
263    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/// Summary of a key slot.
273#[derive(Debug, Clone, Serialize, Deserialize)]
274pub struct KeySlotSummary {
275    /// Slot index (0-based).
276    pub slot_index: usize,
277    /// Type of the slot.
278    pub slot_type: KeySlotType,
279    /// Optional hint for the slot.
280    pub hint: Option<String>,
281    /// When the slot was created.
282    pub created_at: Option<DateTime<Utc>>,
283}
284
285impl KeySlotSummary {
286    /// Create from a KeySlot.
287    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, // Hints not stored in KeySlot currently
292            created_at: None,
293        }
294    }
295}
296
297/// Set of exclusions to apply before export.
298#[derive(Debug, Clone, Default, Serialize, Deserialize)]
299pub struct ExclusionSet {
300    /// Workspaces to exclude (full paths).
301    pub excluded_workspaces: HashSet<String>,
302    /// Conversation IDs to exclude.
303    pub excluded_conversations: HashSet<i64>,
304    /// Patterns to match against titles for exclusion.
305    #[serde(skip)]
306    pub excluded_patterns: Vec<Regex>,
307    /// Raw pattern strings (for serialization).
308    pub excluded_pattern_strings: Vec<String>,
309}
310
311impl ExclusionSet {
312    /// Create a new empty exclusion set.
313    pub fn new() -> Self {
314        Self::default()
315    }
316
317    /// Add a workspace to exclusions.
318    pub fn exclude_workspace(&mut self, workspace: &str) {
319        self.excluded_workspaces.insert(workspace.to_string());
320    }
321
322    /// Remove a workspace from exclusions.
323    pub fn include_workspace(&mut self, workspace: &str) {
324        self.excluded_workspaces.remove(workspace);
325    }
326
327    /// Add a conversation to exclusions.
328    pub fn exclude_conversation(&mut self, conversation_id: i64) {
329        self.excluded_conversations.insert(conversation_id);
330    }
331
332    /// Remove a conversation from exclusions.
333    pub fn include_conversation(&mut self, conversation_id: i64) {
334        self.excluded_conversations.remove(&conversation_id);
335    }
336
337    /// Check if a workspace is excluded.
338    pub fn is_workspace_excluded(&self, workspace: &str) -> bool {
339        self.excluded_workspaces.contains(workspace)
340    }
341
342    /// Check if a conversation is excluded.
343    pub fn is_conversation_excluded(&self, conversation_id: i64) -> bool {
344        self.excluded_conversations.contains(&conversation_id)
345    }
346
347    /// Check if a title matches any exclusion pattern.
348    pub fn is_excluded(&self, title: &str) -> bool {
349        self.excluded_patterns.iter().any(|re| re.is_match(title))
350    }
351
352    /// Add a title pattern to exclusions.
353    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    /// Check if an item should be excluded based on all criteria.
361    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    /// Get the count of excluded items.
379    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    /// Check if any exclusions are active.
388    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    /// Re-compile patterns from strings (for deserialization).
395    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/// Filters for summary generation.
407#[derive(Debug, Clone, Default)]
408pub struct SummaryFilters {
409    /// Filter to specific agents.
410    pub agents: Option<Vec<String>>,
411    /// Filter to specific workspaces.
412    pub workspaces: Option<Vec<String>>,
413    /// Filter to conversations after this timestamp (millis).
414    pub since_ts: Option<i64>,
415    /// Filter to conversations before this timestamp (millis).
416    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
436/// Generator for pre-publish summaries.
437pub struct SummaryGenerator<'a> {
438    db: &'a Connection,
439}
440
441impl<'a> SummaryGenerator<'a> {
442    /// Create a new summary generator.
443    pub fn new(db: &'a Connection) -> Self {
444        Self { db }
445    }
446
447    /// Generate a pre-publish summary with optional filters.
448    pub fn generate(&self, filters: Option<&SummaryFilters>) -> Result<PrePublishSummary> {
449        let filters = filters.cloned().unwrap_or_default();
450
451        // Build WHERE clause for filters
452        let (where_clause, params) = self.build_filter_clause(&filters);
453
454        // Get basic counts
455        let (total_conversations, total_messages, total_characters) =
456            self.get_counts(&where_clause, &params)?;
457
458        // Get time range
459        let (earliest_ts, latest_ts) = self.get_time_range(&where_clause, &params)?;
460
461        // Get date histogram
462        let date_histogram = self.get_date_histogram(&where_clause, &params)?;
463
464        // Get workspace summary
465        let workspaces = self.get_workspace_summary(&where_clause, &params)?;
466
467        // Get agent summary
468        let agents = self.get_agent_summary(&where_clause, &params, total_conversations)?;
469
470        // Estimate size (rough: ~60% of raw character count after compression)
471        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    /// Generate a summary with exclusions applied.
491    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        // Mark excluded workspaces
499        for ws in &mut summary.workspaces {
500            ws.included = !exclusions.is_workspace_excluded(&ws.path);
501        }
502
503        if exclusions.has_exclusions() {
504            // Recalculate counts excluding excluded items
505            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    /// Build filter WHERE clause.
551    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    /// Count messages across a known set of conversation IDs.
605    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    /// Count messages and characters across a known set of conversation IDs.
612    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, &params, |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    /// Get basic counts.
649    fn get_counts(
650        &self,
651        where_clause: &str,
652        params: &[ParamValue],
653    ) -> Result<(usize, usize, usize)> {
654        // Count conversations
655        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        // Count messages and characters using subquery to avoid
665        // JOIN + aggregate without GROUP BY (frankensqlite limitation).
666        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    /// Get time range.
693    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    /// Get date histogram.
712    fn get_date_histogram(
713        &self,
714        where_clause: &str,
715        params: &[ParamValue],
716    ) -> Result<Vec<DateHistogramEntry>> {
717        // Use integer day computation instead of DATE() which isn't supported
718        // by frankensqlite. The day_epoch is seconds-since-epoch / 86400.
719        // Use subquery instead of JOIN to avoid frankensqlite aggregate limitation.
720        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        // Count distinct conversations per day using a subquery approach.
732        // The subquery is explicitly aliased as `day_pairs` so frankensqlite's
733        // query planner can resolve the outer GROUP BY without falling back to
734        // an internal default-alias path (which currently surfaces as
735        // "column not found: subquery.conversation_id").
736        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    /// Get workspace summary.
784    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    /// Get agent summary.
865    fn get_agent_summary(
866        &self,
867        where_clause: &str,
868        params: &[ParamValue],
869        total_conversations: usize,
870    ) -> Result<Vec<AgentSummaryItem>> {
871        // LEFT JOIN + COALESCE on agents so the summary correctly bucketizes
872        // legacy V1 conversations with NULL agent_id under 'unknown' (and
873        // avoids a runtime row-decode crash when c.agent_id is NULL).
874        // Fetching the slug directly in the join also removes the need for
875        // the separate agent_id -> slug resolution query.
876        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, &params, |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, &params, |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, &params, |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    /// Recalculate counts with exclusions.
1133    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, &params, |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
1223/// Estimate compressed size from character count.
1224/// Uses rough heuristic: ~40% of original after compression + encryption overhead.
1225pub fn estimate_compressed_size(char_count: usize) -> usize {
1226    let base_size = (char_count as f64 * 0.4) as usize;
1227    // Add ~5% for encryption overhead (nonces, auth tags, etc.)
1228    (base_size as f64 * 1.05) as usize
1229}
1230
1231/// Format a byte size for display.
1232pub 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    /// Render a text overview of the summary.
1250    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    /// Get count of included workspaces.
1327    pub fn included_workspace_count(&self) -> usize {
1328        self.workspaces.iter().filter(|w| w.included).count()
1329    }
1330
1331    /// Get count of included agents.
1332    pub fn included_agent_count(&self) -> usize {
1333        self.agents.iter().filter(|a| a.included).count()
1334    }
1335
1336    /// Update with secret scan results.
1337    pub fn set_secret_scan(&mut self, report: &SecretScanReport) {
1338        self.secret_scan = ScanReportSummary::from_report(report);
1339    }
1340
1341    /// Update with encryption config.
1342    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        // Insert conversations
1460        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        // Insert messages
1477        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); // 5 + 3
1561    }
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        // Each conversation is on a different day
1697        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        // Excluded by workspace
1725        assert!(exclusions.should_exclude(Some("/excluded"), 1, "Normal title"));
1726        // Excluded by conversation ID
1727        assert!(exclusions.should_exclude(Some("/normal"), 99, "Normal title"));
1728        // Excluded by pattern
1729        assert!(exclusions.should_exclude(Some("/normal"), 1, "Private: Secret stuff"));
1730        // Not excluded
1731        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        // project-b should be marked as not included
1748        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        // Should be roughly 40% * 1.05 = 42% of original
1790        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        // Don't insert any data
1845
1846        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        // Conversation without workspace should still be counted when exclusions
1898        // are active but do not match this conversation.
1899        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        // Restrict to a single claude-code conversation in project-a.
1967        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        // Trigger recount path without excluding any actual rows.
1979        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}