Skip to main content

coding_agent_search/pages/
docs.rs

1//! Documentation Generation for pages export.
2//!
3//! Automatically generates comprehensive, deployment-specific documentation
4//! that is included with each published site.
5//!
6//! # Overview
7//!
8//! Generated documentation includes:
9//! - **README.md**: Main archive description for repository root
10//! - **SECURITY.md**: Detailed security model and threat analysis
11//! - **help.html**: In-app help accessible from web viewer
12//! - **recovery.html**: Password recovery instructions
13//! - **about.txt**: Simple text explanation for non-technical users
14
15use crate::pages::summary::{KeySlotType, PrePublishSummary};
16use chrono::{DateTime, Utc};
17
18const CASS_VERSION: &str = env!("CARGO_PKG_VERSION");
19const DOC_DATE_FORMAT: &str = "%Y-%m-%d";
20
21fn format_optional_doc_date(value: Option<DateTime<Utc>>, fallback: &str) -> String {
22    value
23        .map(|dt| dt.format(DOC_DATE_FORMAT).to_string())
24        .unwrap_or_else(|| fallback.to_string())
25}
26
27/// Location where a generated document should be placed.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum DocLocation {
30    /// Repository root (README.md, SECURITY.md)
31    RepoRoot,
32    /// Web root alongside index.html (help.html, about.txt)
33    WebRoot,
34}
35
36/// A generated documentation file.
37#[derive(Debug, Clone)]
38pub struct GeneratedDoc {
39    /// Filename for the document.
40    pub filename: String,
41    /// Content of the document.
42    pub content: String,
43    /// Where to place the document.
44    pub location: DocLocation,
45}
46
47/// Configuration for documentation generation.
48#[derive(Debug, Clone, Default)]
49pub struct DocConfig {
50    /// Target URL where the archive will be hosted.
51    pub target_url: Option<String>,
52    /// Repository URL for CASS source.
53    pub cass_repo_url: String,
54    /// Argon2 memory parameter in KB.
55    pub argon_memory_kb: u32,
56    /// Argon2 time iterations.
57    pub argon_iterations: u32,
58    /// Argon2 parallelism.
59    pub argon_parallelism: u32,
60}
61
62impl DocConfig {
63    /// Create a new DocConfig with default CASS repo URL.
64    pub fn new() -> Self {
65        Self {
66            target_url: None,
67            cass_repo_url: "https://github.com/Dicklesworthstone/coding_agent_session_search"
68                .to_string(),
69            argon_memory_kb: 65536,
70            argon_iterations: 3,
71            argon_parallelism: 4,
72        }
73    }
74
75    /// Set the target URL.
76    pub fn with_url(mut self, url: impl Into<String>) -> Self {
77        self.target_url = Some(url.into());
78        self
79    }
80
81    /// Set Argon2 parameters.
82    pub fn with_argon_params(mut self, memory_kb: u32, iterations: u32, parallelism: u32) -> Self {
83        self.argon_memory_kb = memory_kb;
84        self.argon_iterations = iterations;
85        self.argon_parallelism = parallelism;
86        self
87    }
88}
89
90/// Generator for export documentation.
91pub struct DocumentationGenerator {
92    config: DocConfig,
93    summary: PrePublishSummary,
94}
95
96impl DocumentationGenerator {
97    /// Create a new documentation generator.
98    pub fn new(config: DocConfig, summary: PrePublishSummary) -> Self {
99        Self { config, summary }
100    }
101
102    /// Generate all documentation files.
103    pub fn generate_all(&self) -> Vec<GeneratedDoc> {
104        vec![
105            self.generate_readme(),
106            self.generate_security_doc(),
107            self.generate_help_html(),
108            self.generate_recovery_html(),
109            self.generate_about_txt(),
110        ]
111    }
112
113    fn target_url_display(&self) -> &str {
114        self.config
115            .target_url
116            .as_deref()
117            .unwrap_or("[deployment URL]")
118    }
119
120    /// Generate README.md for repository root.
121    pub fn generate_readme(&self) -> GeneratedDoc {
122        let agent_list = self
123            .summary
124            .agents
125            .iter()
126            .map(|a| format!("- {} ({} conversations)", a.name, a.conversation_count))
127            .collect::<Vec<_>>()
128            .join("\n");
129
130        let url_display = self.target_url_display();
131
132        let start_date = format_optional_doc_date(self.summary.earliest_timestamp, "Unknown");
133        let end_date = format_optional_doc_date(self.summary.latest_timestamp, "Unknown");
134
135        let argon_params = format!(
136            "m={}KB, t={}, p={}",
137            self.config.argon_memory_kb,
138            self.config.argon_iterations,
139            self.config.argon_parallelism
140        );
141
142        let slot_count = self.summary.key_slots.len();
143        let date = Utc::now().format(DOC_DATE_FORMAT);
144
145        let content = README_TEMPLATE
146            .replace("{url}", url_display)
147            .replace(
148                "{conversation_count}",
149                &self.summary.total_conversations.to_string(),
150            )
151            .replace("{agent_list}", &agent_list)
152            .replace("{start_date}", &start_date)
153            .replace("{end_date}", &end_date)
154            .replace("{argon_params}", &argon_params)
155            .replace("{slot_count}", &slot_count.to_string())
156            .replace("{version}", CASS_VERSION)
157            .replace("{date}", &date.to_string());
158
159        GeneratedDoc {
160            filename: "README.md".to_string(),
161            content,
162            location: DocLocation::RepoRoot,
163        }
164    }
165
166    /// Generate SECURITY.md with threat model documentation.
167    pub fn generate_security_doc(&self) -> GeneratedDoc {
168        let slot_descriptions = self
169            .summary
170            .key_slots
171            .iter()
172            .map(|slot| {
173                let slot_type_label = match slot.slot_type {
174                    KeySlotType::Password => "Password-derived",
175                    KeySlotType::QrCode => "QR code (direct key)",
176                    KeySlotType::Recovery => "Recovery phrase",
177                };
178                let created_str = format_optional_doc_date(slot.created_at, "N/A");
179                format!(
180                    "- Slot {}: {} (created {})",
181                    slot.slot_index + 1,
182                    slot_type_label,
183                    created_str
184                )
185            })
186            .collect::<Vec<_>>()
187            .join("\n");
188
189        let slot_descriptions = if slot_descriptions.is_empty() {
190            "No key slots configured yet.".to_string()
191        } else {
192            slot_descriptions
193        };
194
195        let argon_memory = self.config.argon_memory_kb.to_string();
196        let argon_iterations = self.config.argon_iterations.to_string();
197        let argon_parallelism = self.config.argon_parallelism.to_string();
198        let slot_count = self.summary.key_slots.len().to_string();
199
200        let content = SECURITY_TEMPLATE
201            .replace("{memory}", &argon_memory)
202            .replace("{iterations}", &argon_iterations)
203            .replace("{parallelism}", &argon_parallelism)
204            .replace("{slot_count}", &slot_count)
205            .replace("{slot_descriptions}", &slot_descriptions)
206            .replace("{repo_url}", &self.config.cass_repo_url)
207            .replace("{version}", CASS_VERSION);
208
209        GeneratedDoc {
210            filename: "SECURITY.md".to_string(),
211            content,
212            location: DocLocation::RepoRoot,
213        }
214    }
215
216    /// Generate help.html for in-app help.
217    pub fn generate_help_html(&self) -> GeneratedDoc {
218        GeneratedDoc {
219            filename: "help.html".to_string(),
220            content: HELP_HTML_TEMPLATE.to_string(),
221            location: DocLocation::WebRoot,
222        }
223    }
224
225    /// Generate recovery.html with password recovery instructions.
226    pub fn generate_recovery_html(&self) -> GeneratedDoc {
227        let has_recovery_slot = self
228            .summary
229            .key_slots
230            .iter()
231            .any(|s| s.slot_type == KeySlotType::Recovery);
232
233        let recovery_section = if has_recovery_slot {
234            RECOVERY_WITH_KEY_SECTION
235        } else {
236            RECOVERY_NO_KEY_SECTION
237        };
238
239        let content = RECOVERY_HTML_TEMPLATE.replace("{recovery_section}", recovery_section);
240
241        GeneratedDoc {
242            filename: "recovery.html".to_string(),
243            content,
244            location: DocLocation::WebRoot,
245        }
246    }
247
248    /// Generate about.txt for non-technical users.
249    pub fn generate_about_txt(&self) -> GeneratedDoc {
250        let url_display = self.target_url_display();
251
252        let conversation_count = self.summary.total_conversations.to_string();
253        let date = Utc::now().format(DOC_DATE_FORMAT);
254
255        let content = ABOUT_TXT_TEMPLATE
256            .replace("{url}", url_display)
257            .replace("{conversation_count}", &conversation_count)
258            .replace("{date}", &date.to_string())
259            .replace("{version}", CASS_VERSION);
260
261        GeneratedDoc {
262            filename: "about.txt".to_string(),
263            content,
264            location: DocLocation::WebRoot,
265        }
266    }
267}
268
269// =============================================================================
270// Template Constants
271// =============================================================================
272
273const README_TEMPLATE: &str = r#"# Encrypted Coding Session Archive
274
275This repository contains an encrypted archive of coding session histories,
276created with [CASS](https://github.com/Dicklesworthstone/coding_agent_session_search).
277
278## Quick Access
279
280Open the web viewer: [{url}]({url})
281
282## What This Contains
283
284This archive includes {conversation_count} conversations from the following sources:
285{agent_list}
286
287Date range: {start_date} to {end_date}
288
289## Accessing the Archive
290
291### Option 1: Password
292Enter the password at the web viewer to decrypt and browse the archive.
293
294### Option 2: QR Code (if configured)
295Scan the QR code with your phone camera to auto-fill the decryption key.
296
297## Security
298
299This archive is protected with:
300- **Encryption**: AES-256-GCM (authenticated encryption)
301- **Key Derivation**: Argon2id with {argon_params}
302- **Key Slots**: {slot_count} independent decryption key(s)
303
304The encrypted archive can be safely hosted publicly. Only someone with a valid
305password or QR code can decrypt the contents.
306
307For detailed security information, see [SECURITY.md](SECURITY.md).
308
309## Recovery
310
311If you forget your password:
312- Use the recovery key (if you saved one during setup)
313- The archive owner may have additional key slots
314
315Without a valid key, the archive cannot be decrypted.
316
317---
318Generated by CASS v{version} on {date}
319"#;
320
321const SECURITY_TEMPLATE: &str = r#"# Security Model
322
323## Overview
324
325This document describes the security properties of this encrypted archive.
326
327## Threat Model
328
329### What This Protects Against
330
331- **Casual access**: Random visitors cannot read content
332- **Server compromise**: GitHub/hosting provider cannot read your data
333- **Network interception**: Content is encrypted before transmission
334- **Brute force (with strong password)**: Argon2id makes guessing expensive
335
336### What This Does NOT Protect Against
337
338- **Weak passwords**: Short or common passwords can be cracked
339- **Password sharing**: Anyone with the password can decrypt
340- **Endpoint compromise**: Malware on your device can capture passwords
341- **Targeted attacks**: Determined attackers with resources may succeed
342- **Quantum computers**: AES-256 may be weakened by future advances
343
344## Encryption Details
345
346### Envelope Encryption
347
348The archive uses envelope encryption:
3491. A random 256-bit Data Encryption Key (DEK) encrypts the data
3502. The DEK is encrypted with a Key Encryption Key (KEK) derived from your password
3513. Multiple key slots allow different passwords to decrypt the same data
352
353### Algorithms
354
355| Component | Algorithm | Parameters |
356|-----------|-----------|------------|
357| Data Encryption | AES-256-GCM | 96-bit nonce, 128-bit tag |
358| Key Derivation | Argon2id | m={memory}KB, t={iterations}, p={parallelism} |
359| DEK Encryption | AES-256-GCM | Same as data |
360| Nonce Generation | Counter-based | Prevents reuse |
361
362### Key Slots
363
364This archive has {slot_count} key slot(s):
365{slot_descriptions}
366
367Each slot contains the same DEK encrypted with a different KEK.
368
369## Verification
370
371### Checking Archive Integrity
372
373The AES-GCM authentication tag ensures:
374- Data has not been modified
375- Decryption used the correct key
376
377If decryption fails, the archive was either:
378- Corrupted in transit
379- Modified by an attacker
380- Decrypted with wrong key
381
382### Verifying Implementation
383
384This archive was created with CASS, an open-source tool. You can:
3851. Review the source code at {repo_url}
3862. Verify the implementation uses standard libraries
3873. Audit the cryptographic construction
388
389## Recommendations
390
3911. **Use a strong password**: 16+ characters, or 5+ random words
3922. **Store recovery key safely**: It is the only backup
3933. **Rotate passwords periodically**: Generate new archive with new key
3944. **Limit distribution**: Share URL only with intended recipients
395
396## Contact
397
398For security issues with CASS, see {repo_url}/security
399
400---
401Generated by CASS v{version}
402"#;
403
404const HELP_HTML_TEMPLATE: &str = r#"<!DOCTYPE html>
405<html lang="en">
406<head>
407    <meta charset="UTF-8">
408    <meta name="viewport" content="width=device-width, initial-scale=1.0">
409    <title>Help - CASS Archive</title>
410    <style>
411        :root {
412            --bg-primary: #1a1a2e;
413            --bg-secondary: #16213e;
414            --text-primary: #eee;
415            --text-secondary: #aaa;
416            --accent: #e94560;
417            --border: #333;
418        }
419        * { box-sizing: border-box; }
420        body {
421            font-family: system-ui, -apple-system, sans-serif;
422            max-width: 800px;
423            margin: 0 auto;
424            padding: 20px;
425            background: var(--bg-primary);
426            color: var(--text-primary);
427            line-height: 1.6;
428        }
429        h1, h2, h3 {
430            color: var(--text-primary);
431            border-bottom: 1px solid var(--border);
432            padding-bottom: 0.5em;
433        }
434        h1 { font-size: 1.8rem; }
435        h2 { font-size: 1.4rem; margin-top: 2em; }
436        h3 { font-size: 1.1rem; margin-top: 1.5em; border: none; }
437        code {
438            background: var(--bg-secondary);
439            padding: 2px 6px;
440            border-radius: 3px;
441            font-family: 'SF Mono', Monaco, monospace;
442            font-size: 0.9em;
443        }
444        ul { padding-left: 1.5em; }
445        li { margin: 0.5em 0; }
446        .warning {
447            background: #3d2f00;
448            padding: 15px;
449            border-left: 4px solid #ffc107;
450            border-radius: 4px;
451            margin: 1em 0;
452        }
453        .info {
454            background: #0d3a5c;
455            padding: 15px;
456            border-left: 4px solid #17a2b8;
457            border-radius: 4px;
458            margin: 1em 0;
459        }
460        a { color: var(--accent); }
461        .back-link {
462            display: inline-block;
463            margin-top: 2em;
464            padding: 10px 20px;
465            background: var(--accent);
466            color: white;
467            text-decoration: none;
468            border-radius: 4px;
469        }
470        .back-link:hover { opacity: 0.9; }
471    </style>
472</head>
473<body>
474    <h1>Help</h1>
475
476    <h2>Accessing the Archive</h2>
477    <p>Enter your password in the unlock screen. The password was set by whoever created this archive.</p>
478
479    <h3>Password Tips</h3>
480    <ul>
481        <li>Passwords are case-sensitive</li>
482        <li>Check for leading/trailing spaces</li>
483        <li>If using a passphrase, ensure correct word separators</li>
484    </ul>
485
486    <h3>QR Code Access</h3>
487    <p>If a QR code was provided, scanning it will auto-fill the decryption key.</p>
488
489    <h2>Searching</h2>
490    <p>Use the search box to find conversations:</p>
491    <ul>
492        <li><code>keyword</code> - Simple text search</li>
493        <li><code>"exact phrase"</code> - Match exact phrase</li>
494        <li><code>agent:claude_code</code> - Filter by agent</li>
495        <li><code>workspace:/projects/myapp</code> - Filter by workspace</li>
496    </ul>
497
498    <h2>Keyboard Shortcuts</h2>
499    <ul>
500        <li><code>/</code> - Focus search box</li>
501        <li><code>Esc</code> - Clear search / close dialogs</li>
502        <li><code>j</code> / <code>k</code> - Navigate conversation list</li>
503        <li><code>Enter</code> - Open selected conversation</li>
504    </ul>
505
506    <h2>Troubleshooting</h2>
507
508    <h3>Decryption Failed</h3>
509    <div class="warning">
510        <p>This usually means the password is incorrect. Double-check:</p>
511        <ul>
512            <li>Correct password (case-sensitive)</li>
513            <li>No extra spaces</li>
514            <li>Correct keyboard layout</li>
515        </ul>
516    </div>
517
518    <h3>Slow Loading</h3>
519    <p>Large archives may take time to decrypt. This happens locally in your browser - no data is sent to any server.</p>
520
521    <h3>Browser Compatibility</h3>
522    <p>Requires a modern browser with WebCrypto support:</p>
523    <ul>
524        <li>Chrome 60+</li>
525        <li>Firefox 57+</li>
526        <li>Safari 11+</li>
527        <li>Edge 79+</li>
528    </ul>
529
530    <h2>Privacy</h2>
531    <div class="info">
532        <p>All decryption happens in your browser. Your password is never sent to any server. The encrypted data is fetched and decrypted entirely client-side.</p>
533    </div>
534
535    <h2>More Information</h2>
536    <p>For technical details about the encryption, see <a href="./SECURITY.md">SECURITY.md</a>.</p>
537
538    <a href="./" class="back-link">Back to Archive</a>
539</body>
540</html>
541"#;
542
543const RECOVERY_HTML_TEMPLATE: &str = r#"<!DOCTYPE html>
544<html lang="en">
545<head>
546    <meta charset="UTF-8">
547    <meta name="viewport" content="width=device-width, initial-scale=1.0">
548    <title>Password Recovery - CASS Archive</title>
549    <style>
550        :root {
551            --bg-primary: #1a1a2e;
552            --bg-secondary: #16213e;
553            --text-primary: #eee;
554            --text-secondary: #aaa;
555            --accent: #e94560;
556            --border: #333;
557            --success: #28a745;
558            --danger: #dc3545;
559        }
560        * { box-sizing: border-box; }
561        body {
562            font-family: system-ui, -apple-system, sans-serif;
563            max-width: 800px;
564            margin: 0 auto;
565            padding: 20px;
566            background: var(--bg-primary);
567            color: var(--text-primary);
568            line-height: 1.6;
569        }
570        h1, h2 {
571            color: var(--text-primary);
572            border-bottom: 1px solid var(--border);
573            padding-bottom: 0.5em;
574        }
575        h1 { font-size: 1.8rem; }
576        h2 { font-size: 1.4rem; margin-top: 2em; }
577        .warning {
578            background: #3d2f00;
579            padding: 15px;
580            border-left: 4px solid #ffc107;
581            border-radius: 4px;
582            margin: 1em 0;
583        }
584        .danger {
585            background: #3d1f1f;
586            padding: 15px;
587            border-left: 4px solid var(--danger);
588            border-radius: 4px;
589            margin: 1em 0;
590        }
591        .success {
592            background: #1f3d2f;
593            padding: 15px;
594            border-left: 4px solid var(--success);
595            border-radius: 4px;
596            margin: 1em 0;
597        }
598        ol { padding-left: 1.5em; }
599        li { margin: 0.5em 0; }
600        a { color: var(--accent); }
601        .back-link {
602            display: inline-block;
603            margin-top: 2em;
604            padding: 10px 20px;
605            background: var(--accent);
606            color: white;
607            text-decoration: none;
608            border-radius: 4px;
609        }
610        .back-link:hover { opacity: 0.9; }
611    </style>
612</head>
613<body>
614    <h1>Password Recovery</h1>
615
616    <p>If you've forgotten your password, here are your options for recovering access to this encrypted archive.</p>
617
618{recovery_section}
619
620    <h2>Prevention for the Future</h2>
621    <ol>
622        <li>Use a password manager to store complex passwords</li>
623        <li>Write down and securely store your recovery key</li>
624        <li>Consider using a memorable passphrase (5+ random words)</li>
625        <li>Share access with a trusted person who can help recover</li>
626    </ol>
627
628    <h2>Technical Reality</h2>
629    <div class="danger">
630        <p><strong>Important:</strong> The encryption used (AES-256-GCM with Argon2id) is designed to be unbreakable without the correct password. There is no backdoor, no master key, and no way to recover data without a valid key.</p>
631        <p>This is a feature, not a bug - it ensures your data remains private even if the hosting service is compromised.</p>
632    </div>
633
634    <a href="./" class="back-link">Back to Archive</a>
635</body>
636</html>
637"#;
638
639const RECOVERY_WITH_KEY_SECTION: &str = r#"    <h2>Using Your Recovery Key</h2>
640    <div class="success">
641        <p>Good news! This archive was configured with a recovery key. If you saved your recovery key during setup, you can use it to access the archive.</p>
642    </div>
643    <ol>
644        <li>Find your saved recovery key (a series of words or characters)</li>
645        <li>Go to the main archive page</li>
646        <li>Click "Use Recovery Key" or similar option</li>
647        <li>Enter the recovery key exactly as saved</li>
648        <li>The archive will decrypt using the recovery key</li>
649    </ol>
650
651    <h2>If You Don't Have the Recovery Key</h2>
652    <div class="warning">
653        <p>Without either the password or recovery key, there is no way to decrypt this archive. The encryption is designed to be unbreakable.</p>
654    </div>
655"#;
656
657const RECOVERY_NO_KEY_SECTION: &str = r#"    <h2>Recovery Options</h2>
658    <div class="warning">
659        <p>This archive was not configured with a recovery key. Your options are limited.</p>
660    </div>
661
662    <h3>Try These Steps</h3>
663    <ol>
664        <li>Check your password manager for saved credentials</li>
665        <li>Try common password variations you might have used</li>
666        <li>Contact the person who created this archive - they may have additional key slots</li>
667        <li>Check if you have the original data to re-export with a new password</li>
668    </ol>
669"#;
670
671const ABOUT_TXT_TEMPLATE: &str = r#"ENCRYPTED CODING SESSION ARCHIVE
672================================
673
674This is an encrypted archive of coding session histories - conversations
675between a human developer and AI coding assistants like Claude, Copilot,
676or Aider.
677
678WHAT'S INSIDE
679-------------
680This archive contains {conversation_count} conversations. The contents are
681encrypted and can only be viewed with the correct password.
682
683HOW TO ACCESS
684-------------
6851. Open the web viewer at: {url}
6862. Enter the password when prompted
6873. Browse and search your conversations
688
689PRIVACY
690-------
691- All decryption happens in your web browser
692- Your password is never sent to any server
693- The encrypted data cannot be read without the password
694- Even the hosting service cannot see your conversations
695
696FORGOT YOUR PASSWORD?
697---------------------
698See the "recovery.html" file for options. Without the correct password
699or a recovery key, the archive cannot be decrypted.
700
701MORE INFORMATION
702----------------
703This archive was created with CASS (Coding Agent Session Search).
704For technical details, see SECURITY.md.
705
706---
707Created: {date}
708Version: CASS v{version}
709"#;
710
711#[cfg(test)]
712mod tests {
713    use super::*;
714    use crate::pages::summary::{
715        AgentSummaryItem, DateRange, EncryptionSummary, KeySlotSummary, PrePublishSummary,
716        ScanReportSummary, WorkspaceSummaryItem,
717    };
718
719    macro_rules! assert_doc_contains {
720        ($doc:expr, $needle:literal) => {
721            assert!($doc.content.contains($needle));
722        };
723    }
724
725    fn create_test_summary() -> PrePublishSummary {
726        PrePublishSummary {
727            total_conversations: 42,
728            total_messages: 1000,
729            total_characters: 500_000,
730            estimated_size_bytes: 200_000,
731            earliest_timestamp: Some(Utc::now() - chrono::Duration::days(30)),
732            latest_timestamp: Some(Utc::now()),
733            date_histogram: vec![],
734            workspaces: vec![WorkspaceSummaryItem {
735                path: "/home/user/project".to_string(),
736                display_name: "project".to_string(),
737                conversation_count: 20,
738                message_count: 500,
739                date_range: DateRange {
740                    earliest: None,
741                    latest: None,
742                },
743                sample_titles: vec!["Fix bug".to_string()],
744                included: true,
745            }],
746            agents: vec![
747                AgentSummaryItem {
748                    name: "claude-code".to_string(),
749                    conversation_count: 30,
750                    message_count: 700,
751                    percentage: 71.4,
752                    included: true,
753                },
754                AgentSummaryItem {
755                    name: "aider".to_string(),
756                    conversation_count: 12,
757                    message_count: 300,
758                    percentage: 28.6,
759                    included: true,
760                },
761            ],
762            secret_scan: ScanReportSummary::default(),
763            encryption_config: Some(EncryptionSummary::default()),
764            key_slots: vec![
765                KeySlotSummary {
766                    slot_index: 0,
767                    slot_type: KeySlotType::Password,
768                    hint: None,
769                    created_at: Some(Utc::now()),
770                },
771                KeySlotSummary {
772                    slot_index: 1,
773                    slot_type: KeySlotType::Recovery,
774                    hint: None,
775                    created_at: Some(Utc::now()),
776                },
777            ],
778            generated_at: Utc::now(),
779        }
780    }
781
782    #[test]
783    fn test_generate_readme() {
784        let config = DocConfig::new().with_url("https://example.github.io/archive");
785        let summary = create_test_summary();
786        let generator = DocumentationGenerator::new(config, summary);
787
788        let doc = generator.generate_readme();
789
790        assert_eq!(doc.filename, "README.md");
791        assert_eq!(doc.location, DocLocation::RepoRoot);
792        assert_doc_contains!(doc, "Encrypted Coding Session Archive");
793        assert_doc_contains!(doc, "42 conversations");
794        assert_doc_contains!(doc, "claude-code");
795        assert_doc_contains!(doc, "aider");
796        assert_doc_contains!(doc, "https://example.github.io/archive");
797        assert_doc_contains!(doc, "2 independent decryption key(s)");
798    }
799
800    #[test]
801    fn test_generate_security_doc() {
802        let config = DocConfig::new().with_argon_params(131072, 4, 8);
803        let summary = create_test_summary();
804        let generator = DocumentationGenerator::new(config, summary);
805
806        let doc = generator.generate_security_doc();
807
808        assert_eq!(doc.filename, "SECURITY.md");
809        assert_eq!(doc.location, DocLocation::RepoRoot);
810        assert_doc_contains!(doc, "Security Model");
811        assert_doc_contains!(doc, "AES-256-GCM");
812        assert_doc_contains!(doc, "Argon2id");
813        assert_doc_contains!(doc, "m=131072KB");
814        assert_doc_contains!(doc, "t=4");
815        assert_doc_contains!(doc, "p=8");
816        assert_doc_contains!(doc, "2 key slot(s)");
817        assert_doc_contains!(doc, "Password-derived");
818        assert_doc_contains!(doc, "Recovery phrase");
819    }
820
821    #[test]
822    fn test_generate_help_html() {
823        let config = DocConfig::new();
824        let summary = create_test_summary();
825        let generator = DocumentationGenerator::new(config, summary);
826
827        let doc = generator.generate_help_html();
828
829        assert_eq!(doc.filename, "help.html");
830        assert_eq!(doc.location, DocLocation::WebRoot);
831        assert_doc_contains!(doc, "<!DOCTYPE html>");
832        assert_doc_contains!(doc, "<title>Help - CASS Archive</title>");
833        assert_doc_contains!(doc, "Accessing the Archive");
834        assert_doc_contains!(doc, "Searching");
835        assert_doc_contains!(doc, "Troubleshooting");
836    }
837
838    #[test]
839    fn test_generate_recovery_html_with_key() {
840        let config = DocConfig::new();
841        let summary = create_test_summary(); // Has recovery key slot
842        let generator = DocumentationGenerator::new(config, summary);
843
844        let doc = generator.generate_recovery_html();
845
846        assert_eq!(doc.filename, "recovery.html");
847        assert_eq!(doc.location, DocLocation::WebRoot);
848        assert_doc_contains!(doc, "Password Recovery");
849        assert_doc_contains!(doc, "Using Your Recovery Key");
850        assert_doc_contains!(doc, "Good news!");
851    }
852
853    #[test]
854    fn test_generate_recovery_html_without_key() {
855        let config = DocConfig::new();
856        let mut summary = create_test_summary();
857        // Remove recovery key slot
858        summary.key_slots = vec![KeySlotSummary {
859            slot_index: 0,
860            slot_type: KeySlotType::Password,
861            hint: None,
862            created_at: Some(Utc::now()),
863        }];
864        let generator = DocumentationGenerator::new(config, summary);
865
866        let doc = generator.generate_recovery_html();
867
868        assert_doc_contains!(doc, "not configured with a recovery key");
869        assert!(!doc.content.contains("Good news!"));
870    }
871
872    #[test]
873    fn test_generate_about_txt() {
874        let config = DocConfig::new().with_url("https://example.com/archive");
875        let summary = create_test_summary();
876        let generator = DocumentationGenerator::new(config, summary);
877
878        let doc = generator.generate_about_txt();
879
880        assert_eq!(doc.filename, "about.txt");
881        assert_eq!(doc.location, DocLocation::WebRoot);
882        assert_doc_contains!(doc, "ENCRYPTED CODING SESSION ARCHIVE");
883        assert_doc_contains!(doc, "42 conversations");
884        assert_doc_contains!(doc, "https://example.com/archive");
885    }
886
887    #[test]
888    fn test_generate_all() {
889        let config = DocConfig::new();
890        let summary = create_test_summary();
891        let generator = DocumentationGenerator::new(config, summary);
892
893        let docs = generator.generate_all();
894
895        assert_eq!(docs.len(), 5);
896
897        let filenames: Vec<_> = docs.iter().map(|d| d.filename.as_str()).collect();
898        assert!(filenames.contains(&"README.md"));
899        assert!(filenames.contains(&"SECURITY.md"));
900        assert!(filenames.contains(&"help.html"));
901        assert!(filenames.contains(&"recovery.html"));
902        assert!(filenames.contains(&"about.txt"));
903
904        // Check locations
905        let repo_root_count = docs
906            .iter()
907            .filter(|d| d.location == DocLocation::RepoRoot)
908            .count();
909        let web_root_count = docs
910            .iter()
911            .filter(|d| d.location == DocLocation::WebRoot)
912            .count();
913        assert_eq!(repo_root_count, 2); // README.md, SECURITY.md
914        assert_eq!(web_root_count, 3); // help.html, recovery.html, about.txt
915    }
916
917    #[test]
918    fn test_doc_config_builder() {
919        let config = DocConfig::new()
920            .with_url("https://example.com")
921            .with_argon_params(65536, 3, 4);
922
923        assert_eq!(config.target_url, Some("https://example.com".to_string()));
924        assert_eq!(config.argon_memory_kb, 65536);
925        assert_eq!(config.argon_iterations, 3);
926        assert_eq!(config.argon_parallelism, 4);
927    }
928
929    #[test]
930    fn test_empty_key_slots() {
931        let config = DocConfig::new();
932        let mut summary = create_test_summary();
933        summary.key_slots = vec![];
934        let generator = DocumentationGenerator::new(config, summary);
935
936        let doc = generator.generate_security_doc();
937
938        assert_doc_contains!(doc, "0 key slot(s)");
939        assert_doc_contains!(doc, "No key slots configured yet");
940    }
941
942    #[test]
943    fn test_readme_without_url() {
944        let config = DocConfig::new(); // No URL set
945        let summary = create_test_summary();
946        let generator = DocumentationGenerator::new(config, summary);
947
948        let doc = generator.generate_readme();
949
950        assert_doc_contains!(doc, "[deployment URL]");
951    }
952
953    #[test]
954    fn test_about_without_url() {
955        let config = DocConfig::new();
956        let summary = create_test_summary();
957        let generator = DocumentationGenerator::new(config, summary);
958
959        let doc = generator.generate_about_txt();
960
961        assert_doc_contains!(doc, "[deployment URL]");
962    }
963
964    #[test]
965    fn test_no_placeholders_remain() {
966        let config = DocConfig::new().with_url("https://test.com");
967        let summary = create_test_summary();
968        let generator = DocumentationGenerator::new(config, summary);
969
970        let docs = generator.generate_all();
971
972        for doc in docs {
973            // Check that common placeholders are filled
974            assert!(
975                !doc.content.contains("{url}") || doc.filename == "help.html",
976                "Unfilled {{url}} in {}",
977                doc.filename
978            );
979            assert!(
980                !doc.content.contains("{conversation_count}"),
981                "Unfilled {{conversation_count}} in {}",
982                doc.filename
983            );
984            assert!(
985                !doc.content.contains("{version}"),
986                "Unfilled {{version}} in {}",
987                doc.filename
988            );
989        }
990    }
991}