1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum DocLocation {
30 RepoRoot,
32 WebRoot,
34}
35
36#[derive(Debug, Clone)]
38pub struct GeneratedDoc {
39 pub filename: String,
41 pub content: String,
43 pub location: DocLocation,
45}
46
47#[derive(Debug, Clone, Default)]
49pub struct DocConfig {
50 pub target_url: Option<String>,
52 pub cass_repo_url: String,
54 pub argon_memory_kb: u32,
56 pub argon_iterations: u32,
58 pub argon_parallelism: u32,
60}
61
62impl DocConfig {
63 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 pub fn with_url(mut self, url: impl Into<String>) -> Self {
77 self.target_url = Some(url.into());
78 self
79 }
80
81 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
90pub struct DocumentationGenerator {
92 config: DocConfig,
93 summary: PrePublishSummary,
94}
95
96impl DocumentationGenerator {
97 pub fn new(config: DocConfig, summary: PrePublishSummary) -> Self {
99 Self { config, summary }
100 }
101
102 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 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 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 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 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 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
269const 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(); 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 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 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); assert_eq!(web_root_count, 3); }
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(); 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 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}