Skip to main content

rab/builtin/
export.rs

1// ── Pi-compatible /export and /import commands ─────────────────────
2//
3// Full parity with pi's export functionality:
4//
5//   /export [path]      — Export session to HTML (default) or JSONL (.jsonl)
6//   /import <path>      — Import and resume a session from a JSONL file
7//
8// JSONL export writes the session header + all branch entries as JSONL,
9// re-chaining parentId to form a linear sequence (pi-compatible).
10//
11// HTML export generates a self-contained HTML file using pi's template
12// assets (template.html, template.css, template.js, marked.min.js,
13// highlight.min.js), with session data embedded as base64 JSON.
14
15use crate::agent::extension::{CommandHandler, CommandResult};
16use crate::agent::session::model::{CURRENT_SESSION_VERSION, Session, SessionHeader};
17use std::path::{Path, PathBuf};
18
19// ── Template assets ────────────────────────────────────────────────
20// Embedded from pi's export-html/ directory. Using include_bytes! to
21// avoid escaping issues with JS/CSS content.
22
23mod templates {
24    pub const TEMPLATE_HTML: &[u8] = include_bytes!("export/templates/template.html");
25    pub const TEMPLATE_CSS: &[u8] = include_bytes!("export/templates/template.css");
26    pub const TEMPLATE_JS: &[u8] = include_bytes!("export/templates/template.js");
27    pub const MARKED_JS: &[u8] = include_bytes!("export/templates/vendor/marked.min.js");
28    pub const HIGHLIGHT_JS: &[u8] = include_bytes!("export/templates/vendor/highlight.min.js");
29}
30
31// ── Path argument parsing (pi-compatible) ────────────────────────
32//
33// Matches pi's `getPathCommandArgument()` exactly:
34//   /export            → None
35//   /export path.html  → Some("path.html")
36//   /export "path with spaces.html" → Some("path with spaces.html")
37//   /export 'path with spaces.html' → Some("path with spaces.html")
38
39/// Parse the path argument from a command text like `/export path` or `/import path`.
40/// Returns `None` if no argument is given (command used bare).
41pub fn get_path_command_argument(text: &str, command: &str) -> Option<String> {
42    if text == command {
43        return None;
44    }
45    let prefix = format!("{} ", command);
46    if !text.starts_with(&prefix) {
47        return None;
48    }
49
50    let args_string = text[prefix.len()..].trim_start();
51    if args_string.is_empty() {
52        return None;
53    }
54
55    let first_char = args_string.chars().next().unwrap();
56    if first_char == '"' || first_char == '\'' {
57        let closing = args_string[1..].find(first_char)?;
58        return Some(args_string[1..=closing].to_string());
59    }
60
61    let first_whitespace = args_string.find(char::is_whitespace);
62    match first_whitespace {
63        Some(idx) => Some(args_string[..idx].to_string()),
64        None => Some(args_string.to_string()),
65    }
66}
67
68// ── Export error type ──────────────────────────────────────────────
69
70#[derive(Debug)]
71pub enum ExportError {
72    NoSession,
73    InMemorySession,
74    IoError(std::io::Error),
75    JsonError(serde_json::Error),
76    TemplateError(String),
77}
78
79impl std::fmt::Display for ExportError {
80    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
81        match self {
82            ExportError::NoSession => write!(f, "No active session"),
83            ExportError::InMemorySession => {
84                write!(
85                    f,
86                    "Cannot export an in-memory session without a session file"
87                )
88            }
89            ExportError::IoError(e) => write!(f, "IO error: {}", e),
90            ExportError::JsonError(e) => write!(f, "JSON error: {}", e),
91            ExportError::TemplateError(e) => write!(f, "Template error: {}", e),
92        }
93    }
94}
95
96impl std::error::Error for ExportError {}
97
98impl From<std::io::Error> for ExportError {
99    fn from(e: std::io::Error) -> Self {
100        ExportError::IoError(e)
101    }
102}
103
104impl From<serde_json::Error> for ExportError {
105    fn from(e: serde_json::Error) -> Self {
106        ExportError::JsonError(e)
107    }
108}
109
110// ── JSONL export ───────────────────────────────────────────────────
111//
112// Pi-compatible: writes session header + branch entries as JSONL,
113// re-chaining parentId to form a linear sequence.
114
115/// Export the current session branch to a JSONL file.
116///
117/// * `session` — The session to export
118/// * `cwd` — Current working directory (for resolving relative paths)
119/// * `output_path` — Target file path. If omitted, generates a timestamped name in cwd
120///
121/// Returns the resolved output file path.
122pub fn export_to_jsonl(
123    session: &Session,
124    cwd: &Path,
125    output_path: Option<&str>,
126) -> Result<PathBuf, ExportError> {
127    let file_path = match output_path {
128        Some(p) => crate::builtin::resolve_path(p, cwd),
129        None => {
130            let ts = chrono::Utc::now()
131                .format("session-%Y-%m-%dT%H-%M-%S")
132                .to_string();
133            cwd.join(format!("{}.jsonl", ts))
134        }
135    };
136
137    // Create parent directory
138    if let Some(parent) = file_path.parent() {
139        std::fs::create_dir_all(parent)?;
140    }
141
142    // Build header (pi-compatible: uses fresh timestamp, not original createdAt)
143    let meta = session.metadata();
144    let header = SessionHeader {
145        type_: "session".to_string(),
146        version: Some(CURRENT_SESSION_VERSION),
147        id: meta.id.clone(),
148        timestamp: chrono::Utc::now().to_rfc3339(),
149        cwd: meta.cwd.clone(),
150        parent_session: meta.parent_session_path.clone(),
151    };
152
153    // Get branch entries (pi-compatible: linearized path from root to leaf)
154    let branch_entries = session
155        .get_branch(None)
156        .map_err(|e| ExportError::TemplateError(format!("Failed to get branch: {}", e)))?;
157
158    // Build JSONL content
159    let mut lines = Vec::with_capacity(branch_entries.len() + 1);
160    lines.push(serde_json::to_string(&header)?);
161
162    // Re-chain parentIds to form a linear sequence (pi-compatible)
163    let mut prev_id: Option<String> = None;
164    for entry in &branch_entries {
165        let mut value = serde_json::to_value(entry)?;
166        if let Some(obj) = value.as_object_mut() {
167            match prev_id {
168                Some(ref pid) => {
169                    obj.insert(
170                        "parentId".to_string(),
171                        serde_json::Value::String(pid.clone()),
172                    );
173                }
174                None => {
175                    obj.insert("parentId".to_string(), serde_json::Value::Null);
176                }
177            }
178        }
179        prev_id = Some(entry.id().to_string());
180        lines.push(serde_json::to_string(&value)?);
181    }
182
183    let content = lines.join("\n") + "\n";
184    std::fs::write(&file_path, content)?;
185
186    Ok(file_path)
187}
188
189// ── HTML export ────────────────────────────────────────────────────
190//
191// Pi-compatible: generates a self-contained HTML file using pi's
192// template assets with session data embedded as base64 JSON.
193
194/// Theme color mapping for export CSS variables.
195struct ExportThemeColors {
196    /// CSS custom property declarations
197    theme_vars: String,
198    /// Body background color
199    body_bg: String,
200    /// Card/container background color
201    container_bg: String,
202    /// Info block background color
203    info_bg: String,
204}
205
206/// Load theme colors for export from the current theme.
207/// Mirrors pi's `generateThemeVars()` and `deriveExportColors()`.
208fn load_export_theme_colors(theme_name: Option<&str>) -> ExportThemeColors {
209    // Try to load the theme config and resolve hex colors
210    let colors = resolve_theme_hex_colors(theme_name.unwrap_or("dark"));
211
212    // Build CSS custom property declarations (pi-compatible)
213    let mut lines: Vec<String> = Vec::new();
214    // Explicitly list the keys we want in the export (matching pi's approach)
215    let export_keys = [
216        "text",
217        "dim",
218        "muted",
219        "accent",
220        "success",
221        "error",
222        "warning",
223        "border",
224        "borderAccent",
225        "selectedBg",
226        "hover",
227        "userMessageBg",
228        "userMessageText",
229        "thinkingText",
230        "customMessageBg",
231        "customMessageLabel",
232        "customMessageText",
233        "toolPendingBg",
234        "toolSuccessBg",
235        "toolErrorBg",
236        "toolOutput",
237        "toolTitle",
238        "toolDiffAdded",
239        "toolDiffRemoved",
240        "toolDiffContext",
241        "mdHeading",
242        "mdLink",
243        "mdLinkUrl",
244        "mdCode",
245        "mdCodeBlock",
246        "mdCodeBlockBorder",
247        "mdQuote",
248        "mdQuoteBorder",
249        "mdHr",
250        "mdListBullet",
251        "syntaxComment",
252        "syntaxKeyword",
253        "syntaxNumber",
254        "syntaxString",
255        "syntaxFunction",
256        "syntaxType",
257        "syntaxVariable",
258        "syntaxOperator",
259        "syntaxPunctuation",
260    ];
261
262    for key in &export_keys {
263        if let Some(value) = colors.get(*key) {
264            lines.push(format!("--{}: {};", key, value));
265        }
266    }
267
268    // For any remaining colors from the config that aren't in export_keys, include them too
269    for (key, value) in &colors {
270        if !export_keys.contains(&key.as_str()) {
271            lines.push(format!("--{}: {};", key, value));
272        }
273    }
274
275    let theme_vars = lines.join("\n      ");
276
277    // Derive export background colors from userMessageBg (pi-compatible)
278    let user_message_bg = colors
279        .get("userMessageBg")
280        .map(|s| s.as_str())
281        .unwrap_or("#343541");
282
283    let derived = derive_export_colors(user_message_bg);
284
285    ExportThemeColors {
286        theme_vars,
287        body_bg: derived.page_bg,
288        container_bg: derived.card_bg,
289        info_bg: derived.info_bg,
290    }
291}
292
293/// Parse a hex color string to RGB components.
294fn parse_hex_color(hex: &str) -> Option<(u8, u8, u8)> {
295    let hex = hex.trim_start_matches('#');
296    if hex.len() != 6 {
297        return None;
298    }
299    let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
300    let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
301    let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
302    Some((r, g, b))
303}
304
305/// Calculate relative luminance of an sRGB color.
306fn relative_luminance(r: u8, g: u8, b: u8) -> f64 {
307    let to_linear = |c: u8| {
308        let s = c as f64 / 255.0;
309        if s <= 0.03928 {
310            s / 12.92
311        } else {
312            ((s + 0.055) / 1.055).powf(2.4)
313        }
314    };
315    0.2126 * to_linear(r) + 0.7152 * to_linear(g) + 0.0722 * to_linear(b)
316}
317
318/// Adjust color brightness — factor > 1 lightens, < 1 darkens.
319fn adjust_brightness(hex: &str, factor: f64) -> String {
320    let (r, g, b) = match parse_hex_color(hex) {
321        Some(c) => c,
322        None => return hex.to_string(),
323    };
324    let adj = |c: u8| (c as f64 * factor).clamp(0.0, 255.0).round() as u8;
325    format!("#{:02x}{:02x}{:02x}", adj(r), adj(g), adj(b))
326}
327
328/// Derive export background colors from a base color (pi-compatible).
329struct DerivedExportColors {
330    page_bg: String,
331    card_bg: String,
332    info_bg: String,
333}
334
335fn derive_export_colors(base_color: &str) -> DerivedExportColors {
336    let rgb = match parse_hex_color(base_color) {
337        Some(c) => c,
338        None => {
339            return DerivedExportColors {
340                page_bg: "#18181e".to_string(),
341                card_bg: "#1e1e24".to_string(),
342                info_bg: "#3c3728".to_string(),
343            };
344        }
345    };
346
347    let luminance = relative_luminance(rgb.0, rgb.1, rgb.2);
348    let is_light = luminance > 0.5;
349
350    if is_light {
351        DerivedExportColors {
352            page_bg: adjust_brightness(base_color, 0.96),
353            card_bg: base_color.to_string(),
354            info_bg: format!(
355                "#{:02x}{:02x}{:02x}",
356                (rgb.0 as u16 + 10).min(255) as u8,
357                (rgb.1 as u16 + 5).min(255) as u8,
358                (rgb.2 as u16).max(20).saturating_sub(20) as u8,
359            ),
360        }
361    } else {
362        DerivedExportColors {
363            page_bg: adjust_brightness(base_color, 0.7),
364            card_bg: adjust_brightness(base_color, 0.85),
365            info_bg: format!(
366                "#{:02x}{:02x}{:02x}",
367                (rgb.0 as u16 + 20).min(255) as u8,
368                (rgb.1 as u16 + 15).min(255) as u8,
369                rgb.2,
370            ),
371        }
372    }
373}
374
375/// Resolve theme hex colors by name.
376/// Falls back to dark theme if the requested theme can't be loaded.
377fn resolve_theme_hex_colors(theme_name: &str) -> std::collections::HashMap<String, String> {
378    // Use the theme system's own resolution
379    let config = crate::agent::ui::theme::load_theme_config(theme_name)
380        .or_else(|_| crate::agent::ui::theme::load_theme_config("dark"))
381        .unwrap_or_else(|_| {
382            // Ultimate fallback: construct minimal dark theme config
383            use crate::agent::ui::theme::{ColorValue, ThemeConfig};
384            let mut colors = std::collections::HashMap::new();
385            let entries: Vec<(&str, &str)> = vec![
386                ("text", "#d4d4d4"),
387                ("dim", "#666666"),
388                ("muted", "#808080"),
389                ("accent", "#8abeb7"),
390                ("success", "#b5bd68"),
391                ("error", "#cc6666"),
392                ("warning", "#e8a838"),
393                ("border", "#333"),
394                ("borderAccent", "#8abeb7"),
395                ("selectedBg", "#2a2a2a"),
396                ("hover", "#333"),
397                ("userMessageBg", "#343541"),
398                ("userMessageText", "#d4d4d4"),
399                ("thinkingText", "#808080"),
400                ("customMessageBg", "#1e1e24"),
401                ("customMessageLabel", "#8abeb7"),
402                ("customMessageText", "#d4d4d4"),
403                ("toolPendingBg", "#282832"),
404                ("toolSuccessBg", "#283228"),
405                ("toolErrorBg", "#3c2828"),
406                ("toolOutput", "#808080"),
407                ("toolTitle", "#d4d4d4"),
408                ("toolDiffAdded", "#22c55e"),
409                ("toolDiffRemoved", "#ef4444"),
410                ("toolDiffContext", "#808080"),
411                ("mdHeading", "#e8a838"),
412                ("mdLink", "#8abeb7"),
413                ("mdLinkUrl", "#5f87af"),
414                ("mdCode", "#e8a838"),
415                ("mdCodeBlock", "#808080"),
416                ("mdCodeBlockBorder", "#444"),
417                ("mdQuote", "#808080"),
418                ("mdQuoteBorder", "#555"),
419                ("mdHr", "#555"),
420                ("mdListBullet", "#8abeb7"),
421                ("syntaxComment", "#6a9955"),
422                ("syntaxKeyword", "#569cd6"),
423                ("syntaxNumber", "#b5cea8"),
424                ("syntaxString", "#ce9178"),
425                ("syntaxFunction", "#dcdcaa"),
426                ("syntaxType", "#4ec9b0"),
427                ("syntaxVariable", "#9cdcfe"),
428                ("syntaxOperator", "#d4d4d4"),
429                ("syntaxPunctuation", "#d4d4d4"),
430            ];
431            for (k, v) in entries {
432                colors.insert(k.to_string(), ColorValue::HexOrVar(v.to_string()));
433            }
434            ThemeConfig {
435                name: "dark".to_string(),
436                vars: std::collections::HashMap::new(),
437                colors,
438            }
439        });
440
441    crate::agent::ui::theme::RabTheme::resolve_colors(&config)
442}
443
444/// Serialize session data to the JSON format expected by pi's template.
445///
446/// Returns a serde_json::Value matching pi's SessionData interface:
447/// ```typescript
448/// { header, entries, leafId, systemPrompt?, tools?, renderedTools? }
449/// ```
450fn build_session_data_json(
451    session: &Session,
452    system_prompt: Option<&str>,
453) -> Result<serde_json::Value, ExportError> {
454    let meta = session.metadata();
455
456    let mut header = serde_json::json!({
457        "type": "session",
458        "version": CURRENT_SESSION_VERSION,
459        "id": meta.id,
460        "timestamp": meta.created_at,
461        "cwd": meta.cwd,
462    });
463
464    // Pi-compatible: only include parentSession if it exists (omit when None)
465    if let Some(ref ps) = meta.parent_session_path
466        && let Some(obj) = header.as_object_mut()
467    {
468        obj.insert(
469            "parentSession".to_string(),
470            serde_json::Value::String(ps.clone()),
471        );
472    }
473
474    let leaf_id = session.get_leaf_id();
475
476    let mut data = serde_json::json!({
477        "header": header,
478        "entries": session.get_entries(),
479        "leafId": leaf_id,
480    });
481
482    // Add optional systemPrompt
483    if let Some(sp) = system_prompt
484        && let Some(obj) = data.as_object_mut()
485    {
486        obj.insert(
487            "systemPrompt".to_string(),
488            serde_json::Value::String(sp.to_string()),
489        );
490    }
491
492    // Note: `tools` and `renderedTools` are omitted for now.
493    // The template JS handles their absence gracefully.
494    // Tools can be added later when we have a tool renderer system.
495
496    Ok(data)
497}
498
499/// Export the session to a self-contained HTML file.
500///
501/// * `session` — The session to export
502/// * `system_prompt` — Optional system prompt text
503/// * `cwd` — Current working directory (for resolving relative paths)
504/// * `output_path` — Target file path. If omitted, generates a name in cwd
505/// * `theme_name` — Theme name for colors (defaults to current theme)
506///
507/// Returns the resolved output file path.
508pub fn export_to_html(
509    session: &Session,
510    system_prompt: Option<&str>,
511    cwd: &Path,
512    output_path: Option<&str>,
513    theme_name: Option<&str>,
514) -> Result<PathBuf, ExportError> {
515    // Determine output path (pi-compatible default naming)
516    let file_path = match output_path {
517        Some(p) => crate::builtin::resolve_path(p, cwd),
518        None => {
519            let session_id = &session.metadata().id;
520            let short_id = if session_id.len() > 8 {
521                &session_id[..8]
522            } else {
523                session_id.as_str()
524            };
525            cwd.join(format!("rab-session-{}.html", short_id))
526        }
527    };
528
529    // Create parent directory
530    if let Some(parent) = file_path.parent() {
531        std::fs::create_dir_all(parent)?;
532    }
533
534    // Resolve theme name (default to current theme)
535    let theme_name = theme_name
536        .map(|s| s.to_string())
537        .unwrap_or_else(|| crate::agent::ui::theme::current_theme().name.clone());
538
539    // Build session data JSON
540    let session_data = build_session_data_json(session, system_prompt)?;
541    let session_data_json = serde_json::to_string(&session_data)?;
542
543    // Base64 encode session data (pi-compatible)
544    use base64::Engine as _;
545    let session_data_base64 =
546        base64::engine::general_purpose::STANDARD.encode(session_data_json.as_bytes());
547
548    // Load theme colors
549    let export_colors = load_export_theme_colors(Some(&theme_name));
550
551    // Read template files
552    let template_html = String::from_utf8(templates::TEMPLATE_HTML.to_vec()).map_err(|e| {
553        ExportError::TemplateError(format!("Invalid UTF-8 in template.html: {}", e))
554    })?;
555    let template_css = String::from_utf8(templates::TEMPLATE_CSS.to_vec())
556        .map_err(|e| ExportError::TemplateError(format!("Invalid UTF-8 in template.css: {}", e)))?;
557    let template_js = String::from_utf8(templates::TEMPLATE_JS.to_vec())
558        .map_err(|e| ExportError::TemplateError(format!("Invalid UTF-8 in template.js: {}", e)))?;
559    let marked_js = String::from_utf8(templates::MARKED_JS.to_vec()).map_err(|e| {
560        ExportError::TemplateError(format!("Invalid UTF-8 in marked.min.js: {}", e))
561    })?;
562    let highlight_js = String::from_utf8(templates::HIGHLIGHT_JS.to_vec()).map_err(|e| {
563        ExportError::TemplateError(format!("Invalid UTF-8 in highlight.min.js: {}", e))
564    })?;
565
566    // Build CSS with theme variables injected (pi-compatible)
567    let css = template_css
568        .replace("{{THEME_VARS}}", &export_colors.theme_vars)
569        .replace("{{BODY_BG}}", &export_colors.body_bg)
570        .replace("{{CONTAINER_BG}}", &export_colors.container_bg)
571        .replace("{{INFO_BG}}", &export_colors.info_bg);
572
573    // Build final HTML (pi-compatible template injection)
574    let html = template_html
575        .replace("{{CSS}}", &css)
576        .replace("{{JS}}", &template_js)
577        .replace("{{SESSION_DATA}}", &session_data_base64)
578        .replace("{{MARKED_JS}}", &marked_js)
579        .replace("{{HIGHLIGHT_JS}}", &highlight_js);
580
581    std::fs::write(&file_path, html)?;
582
583    Ok(file_path)
584}
585
586// ── /export command handler ────────────────────────────────────────
587
588/// Handler for `/export` command.
589/// Parses the path argument (pi-compatible) and returns ExportSession result.
590pub struct ExportCommand;
591
592impl CommandHandler for ExportCommand {
593    fn execute(&self, args: &str) -> anyhow::Result<CommandResult> {
594        let text = if args.is_empty() {
595            "/export".to_string()
596        } else {
597            format!("/export {}", args)
598        };
599
600        let path = get_path_command_argument(&text, "/export");
601        Ok(CommandResult::ExportSession { path })
602    }
603}
604
605// ── /import command handler ────────────────────────────────────────
606
607/// Handler for `/import` command.
608/// Parses the path argument (pi-compatible) and returns ImportSession result.
609pub struct ImportCommand;
610
611impl CommandHandler for ImportCommand {
612    fn execute(&self, args: &str) -> anyhow::Result<CommandResult> {
613        let text = if args.is_empty() {
614            "/import".to_string()
615        } else {
616            format!("/import {}", args)
617        };
618
619        let path = get_path_command_argument(&text, "/import");
620        match path {
621            Some(p) => Ok(CommandResult::ImportSession { path: p }),
622            None => Ok(CommandResult::Info(
623                "Usage: /import <path.jsonl>".to_string(),
624            )),
625        }
626    }
627}
628
629// ── Tests ──────────────────────────────────────────────────────────
630
631#[cfg(test)]
632mod tests {
633    use super::*;
634
635    #[test]
636    fn test_get_path_no_arg() {
637        assert_eq!(get_path_command_argument("/export", "/export"), None);
638        assert_eq!(get_path_command_argument("/import", "/import"), None);
639    }
640
641    #[test]
642    fn test_get_path_simple() {
643        assert_eq!(
644            get_path_command_argument("/export output.html", "/export"),
645            Some("output.html".to_string())
646        );
647    }
648
649    #[test]
650    fn test_get_path_quoted_double() {
651        assert_eq!(
652            get_path_command_argument("/export \"my session.html\"", "/export"),
653            Some("my session.html".to_string())
654        );
655    }
656
657    #[test]
658    fn test_get_path_quoted_single() {
659        assert_eq!(
660            get_path_command_argument("/export 'my session.html'", "/export"),
661            Some("my session.html".to_string())
662        );
663    }
664
665    #[test]
666    fn test_get_path_no_close_quote() {
667        assert_eq!(
668            get_path_command_argument("/export \"no close", "/export"),
669            None
670        );
671    }
672
673    #[test]
674    fn test_get_path_command_prefix_mismatch() {
675        assert_eq!(
676            get_path_command_argument("/exporter out.html", "/export"),
677            None
678        );
679    }
680
681    #[test]
682    fn test_get_path_only_whitespace() {
683        assert_eq!(get_path_command_argument("/export  ", "/export"), None);
684        assert_eq!(get_path_command_argument("/import  ", "/import"), None);
685    }
686
687    #[test]
688    fn test_export_command_no_args() {
689        let cmd = ExportCommand;
690        let result = cmd.execute("").unwrap();
691        match result {
692            CommandResult::ExportSession { path } => assert_eq!(path, None),
693            _ => panic!("Expected ExportSession with None"),
694        }
695    }
696
697    #[test]
698    fn test_export_command_with_path() {
699        let cmd = ExportCommand;
700        let result = cmd.execute("test.html").unwrap();
701        match result {
702            CommandResult::ExportSession { path } => {
703                assert_eq!(path, Some("test.html".to_string()));
704            }
705            _ => panic!("Expected ExportSession with path"),
706        }
707    }
708
709    #[test]
710    fn test_import_command_no_args() {
711        let cmd = ImportCommand;
712        let result = cmd.execute("").unwrap();
713        match result {
714            CommandResult::Info(msg) => assert!(msg.contains("Usage:")),
715            _ => panic!("Expected Info message"),
716        }
717    }
718
719    #[test]
720    fn test_import_command_with_path() {
721        let cmd = ImportCommand;
722        let result = cmd.execute("session.jsonl").unwrap();
723        match result {
724            CommandResult::ImportSession { path } => {
725                assert_eq!(path, "session.jsonl");
726            }
727            _ => panic!("Expected ImportSession"),
728        }
729    }
730
731    #[test]
732    fn test_parse_hex_color() {
733        assert_eq!(parse_hex_color("#ff0000"), Some((255, 0, 0)));
734        assert_eq!(parse_hex_color("00ff00"), Some((0, 255, 0)));
735        assert_eq!(parse_hex_color("#fff"), None);
736        assert_eq!(parse_hex_color("invalid"), None);
737    }
738
739    #[test]
740    fn test_relative_luminance() {
741        let black = relative_luminance(0, 0, 0);
742        let white = relative_luminance(255, 255, 255);
743        assert!(black < 0.1);
744        assert!(white > 0.9);
745    }
746
747    #[test]
748    fn test_derive_export_colors_dark() {
749        let derived = derive_export_colors("#343541");
750        // Dark theme: pageBg should be darker than cardBg
751        let page_rgb = parse_hex_color(&derived.page_bg).unwrap();
752        let card_rgb = parse_hex_color(&derived.card_bg).unwrap();
753        assert!(page_rgb.0 < card_rgb.0); // Darker page background
754    }
755
756    #[test]
757    fn test_derive_export_colors_light() {
758        let derived = derive_export_colors("#ffffff");
759        // Light theme: pageBg should be slightly darker than white
760        let page_rgb = parse_hex_color(&derived.page_bg).unwrap();
761        assert!(page_rgb.0 < 255);
762    }
763
764    #[test]
765    fn test_adjust_brightness() {
766        let result = adjust_brightness("#808080", 0.5);
767        let rgb = parse_hex_color(&result).unwrap();
768        // 128 * 0.5 = 64
769        assert!(rgb.0 <= 70 && rgb.0 >= 60);
770    }
771}