Skip to main content

oxi/cli/commands/
export.rs

1//! Export / Import / Share subcommand handlers.
2
3use anyhow::Result;
4use std::path::Path;
5
6/// Handle `oxi export [SESSION_ID] [--output PATH]`
7pub fn handle_export(session_id: Option<&str>, output_path: Option<&Path>) -> Result<()> {
8    use crate::store::session::SessionManager;
9
10    let cwd = std::env::current_dir()
11        .unwrap_or_else(|_| std::path::PathBuf::from("."))
12        .to_string_lossy()
13        .to_string();
14
15    // Resolve session file
16    let session_path = if let Some(sid) = session_id {
17        // Try as a direct path first
18        let direct = std::path::Path::new(sid);
19        if direct.exists() {
20            direct.to_path_buf()
21        } else {
22            anyhow::bail!("Session not found: {}", sid);
23        }
24    } else {
25        // Find the most recent session for this CWD
26        // SessionManager::list is async but only does std::fs I/O internally.
27        let sessions = std::thread::scope(|s| {
28            s.spawn(|| {
29                let rt = tokio::runtime::Builder::new_current_thread()
30                    .enable_all()
31                    .build()?;
32                rt.block_on(SessionManager::list(&cwd, None))
33            })
34            .join()
35            .map_err(|e| anyhow::anyhow!("thread panicked: {:?}", e))?
36        })?;
37        let most_recent = sessions
38            .first()
39            .ok_or_else(|| anyhow::anyhow!("No sessions found for this project"))?;
40        // Reconstruct the path from session_dir + id
41        let session_dir: std::path::PathBuf =
42            crate::store::session::get_default_session_dir(&cwd).into();
43        session_dir.join(format!("{}.jsonl", most_recent.id))
44    };
45
46    if !session_path.exists() {
47        anyhow::bail!("Session file not found: {}", session_path.display());
48    }
49
50    // Load session entries
51    let sm = SessionManager::open(&session_path.to_string_lossy(), None, Some(&cwd));
52    let branch = sm.get_branch(None);
53
54    // Build metadata
55    let meta = crate::storage::export::ExportMeta {
56        model: None,
57        provider: None,
58        exported_at: chrono::Utc::now().timestamp_millis(),
59        total_user_tokens: None,
60        total_assistant_tokens: None,
61    };
62
63    let entries: Vec<crate::store::session::SessionEntry> = branch.into_iter().collect();
64    let html = crate::storage::export::export_to_html(
65        &entries,
66        &meta,
67        &crate::storage::export::HtmlExportOptions::default(),
68    )?;
69
70    // Determine output path
71    let out = if let Some(p) = output_path {
72        p.to_path_buf()
73    } else {
74        let sid_short = session_path
75            .file_stem()
76            .and_then(|s| s.to_str())
77            .unwrap_or("session");
78        let short = &sid_short[..8.min(sid_short.len())];
79        std::path::PathBuf::from(format!("oxi-export-{}.html", short))
80    };
81
82    std::fs::write(&out, &html)?;
83    println!(
84        "Exported {} entries to {} ({} bytes)",
85        entries.len(),
86        out.display(),
87        html.len()
88    );
89    Ok(())
90}
91
92/// Handle `oxi import <PATH>`
93pub fn handle_import(path: &Path) -> Result<()> {
94    if !path.exists() {
95        anyhow::bail!("File not found: {}", path.display());
96    }
97
98    let cwd = std::env::current_dir()
99        .unwrap_or_else(|_| std::path::PathBuf::from("."))
100        .to_string_lossy()
101        .to_string();
102
103    let resolved = crate::store::session::resolve_session_path(&path.to_string_lossy(), &cwd)
104        .map_err(|e| anyhow::anyhow!("Error resolving path: {}", e))?;
105
106    if !std::path::Path::new(&resolved).exists() {
107        anyhow::bail!("File not found: {}", resolved);
108    }
109
110    // Copy the session file into the sessions directory
111    let sessions_dir: std::path::PathBuf =
112        crate::store::session::get_default_session_dir(&cwd).into();
113    std::fs::create_dir_all(&sessions_dir)?;
114
115    let filename = path
116        .file_name()
117        .unwrap_or_else(|| std::ffi::OsStr::new("imported.jsonl"));
118    let dest = sessions_dir.join(filename);
119
120    // Avoid overwriting existing sessions
121    if dest.exists() {
122        let stem = dest
123            .file_stem()
124            .and_then(|s| s.to_str())
125            .unwrap_or("imported");
126        let ext = dest.extension().and_then(|s| s.to_str()).unwrap_or("jsonl");
127        let unique_name = format!(
128            "{}-{}.{}",
129            stem,
130            chrono::Utc::now().format("%Y%m%d%H%M%S"),
131            ext
132        );
133        let alt_dest = sessions_dir.join(&unique_name);
134        std::fs::copy(path, &alt_dest)?;
135        println!("Imported session to {}", alt_dest.display());
136    } else {
137        std::fs::copy(path, &dest)?;
138        println!("Imported session to {}", dest.display());
139    }
140    Ok(())
141}
142
143/// Handle `oxi share [SESSION_ID]`
144pub async fn handle_share(session_id: Option<&str>) -> Result<()> {
145    // Check if gh CLI is available
146    let gh_check = std::process::Command::new("gh")
147        .args(["auth", "status"])
148        .output()?;
149
150    if !gh_check.status.success() {
151        anyhow::bail!("GitHub CLI (gh) is not authenticated. Run: gh auth login");
152    }
153
154    let cwd = std::env::current_dir()
155        .unwrap_or_else(|_| std::path::PathBuf::from("."))
156        .to_string_lossy()
157        .to_string();
158
159    // Resolve session
160    let session_path = if let Some(sid) = session_id {
161        let direct = std::path::Path::new(sid);
162        if direct.exists() {
163            direct.to_path_buf()
164        } else {
165            anyhow::bail!("Session not found: {}", sid);
166        }
167    } else {
168        let sessions = crate::store::session::SessionManager::list(&cwd, None).await?;
169        let most_recent = sessions
170            .first()
171            .ok_or_else(|| anyhow::anyhow!("No sessions found for this project"))?;
172        let session_dir: std::path::PathBuf =
173            crate::store::session::get_default_session_dir(&cwd).into();
174        session_dir.join(format!("{}.jsonl", most_recent.id))
175    };
176
177    if !session_path.exists() {
178        anyhow::bail!("Session file not found: {}", session_path.display());
179    }
180
181    let sm = crate::store::session::SessionManager::open(
182        &session_path.to_string_lossy(),
183        None,
184        Some(&cwd),
185    );
186    let branch = sm.get_branch(None);
187    let entries: Vec<crate::store::session::SessionEntry> = branch.into_iter().collect();
188
189    let meta = crate::storage::export::ExportMeta {
190        model: None,
191        provider: None,
192        exported_at: chrono::Utc::now().timestamp_millis(),
193        total_user_tokens: None,
194        total_assistant_tokens: None,
195    };
196
197    let html = crate::storage::export::export_to_html(
198        &entries,
199        &meta,
200        &crate::storage::export::HtmlExportOptions::default(),
201    )?;
202
203    let temp_path = std::env::temp_dir().join("oxi-share-export.html");
204    std::fs::write(&temp_path, &html)?;
205
206    // Create gist
207    let output = tokio::process::Command::new("gh")
208        .args(["gist", "create", &temp_path.to_string_lossy()])
209        .output()
210        .await?;
211
212    let _ = std::fs::remove_file(&temp_path);
213
214    if output.status.success() {
215        let url = String::from_utf8_lossy(&output.stdout).trim().to_string();
216        println!("Gist created: {}", url);
217    } else {
218        let stderr = String::from_utf8_lossy(&output.stderr);
219        anyhow::bail!("Failed to create gist: {}", stderr.trim());
220    }
221    Ok(())
222}