use anyhow::Result;
use colored::Colorize;
use crate::capture::watchers::default_registry;
use crate::config::Config;
use crate::storage::Database;
#[derive(clap::Args)]
#[command(
about = "Import sessions from AI coding tools",
long_about = "Import sessions from AI coding tools.\n\n\
Supported tools:\n \
- Aider (markdown chat history files)\n \
- Amp (JSON files in ~/.local/share/amp/threads/)\n \
- Claude Code (JSONL files in ~/.claude/projects/)\n \
- Cline (VS Code extension storage)\n \
- Codex CLI (JSONL files in ~/.codex/sessions/)\n \
- Continue.dev (JSON files in ~/.continue/sessions/)\n \
- Gemini CLI (JSON files in ~/.gemini/tmp/)\n \
- OpenCode (JSON files in ~/.local/share/opencode/storage/)\n \
- Roo Code (JSON in VS Code extension storage)",
after_help = "EXAMPLES:\n \
lore import Import new sessions from all tools\n \
lore import --dry-run Preview what would be imported\n \
lore import --force Re-import all sessions"
)]
pub struct Args {
#[arg(long)]
#[arg(
long_help = "By default, Lore tracks which session files have been imported\n\
and skips them on subsequent runs. Use this flag to re-import\n\
all sessions, which may update existing records."
)]
pub force: bool,
#[arg(long)]
#[arg(long_help = "Shows what sessions would be imported without actually\n\
modifying the database. Useful for verifying before import.")]
pub dry_run: bool,
}
pub fn run(args: Args) -> Result<()> {
let db = Database::open_default()?;
let config = Config::load()?;
let registry = default_registry();
let watchers = registry.enabled_watchers(&config.watchers);
let mut total_imported = 0;
let mut total_skipped = 0;
let mut total_errors = 0;
let mut tools_imported_from = 0;
if watchers.is_empty() {
println!("{}", "No enabled watchers found.".yellow());
println!();
println!("Check your configuration with: {}", "lore config".cyan());
println!("Run {} to detect and enable watchers.", "lore init".cyan());
return Ok(());
}
for watcher in &watchers {
let info = watcher.info();
println!("{}", format!("Importing from {}...", info.name).dimmed());
let sources = match watcher.find_sources() {
Ok(sources) => sources,
Err(e) => {
tracing::warn!("Failed to find sources for {}: {}", info.name, e);
println!(" {}", format!("Error finding sources: {e}").red());
total_errors += 1;
continue;
}
};
if sources.is_empty() {
println!(" {}", "No sessions found".dimmed());
continue;
}
println!(" Found {} source files", sources.len().to_string().green());
let mut watcher_imported = 0;
let mut watcher_skipped = 0;
let mut watcher_errors = 0;
for path in sources {
let path_str = path.to_string_lossy();
if !args.force && db.session_exists_by_source(&path_str)? {
watcher_skipped += 1;
tracing::debug!("Skipping already imported: {}", path_str);
continue;
}
match watcher.parse_source(&path) {
Ok(sessions) => {
if sessions.is_empty() {
tracing::debug!("No sessions in source: {}", path_str);
watcher_skipped += 1;
continue;
}
for (session, messages) in sessions {
if messages.is_empty() {
tracing::debug!("Skipping empty session: {}", session.id);
watcher_skipped += 1;
continue;
}
if args.dry_run {
let dir = session
.working_directory
.split('/')
.next_back()
.unwrap_or(&session.working_directory);
println!(
" {} {} ({} messages, {})",
"Would import:".dimmed(),
&session.id.to_string()[..8].cyan(),
messages.len(),
dir
);
watcher_imported += 1;
} else {
db.insert_session(&session)?;
for msg in &messages {
db.insert_message(msg)?;
}
let dir = session
.working_directory
.split('/')
.next_back()
.unwrap_or(&session.working_directory);
println!(
" {} {} ({} messages, {})",
"Imported:".green(),
&session.id.to_string()[..8].cyan(),
messages.len(),
dir
);
watcher_imported += 1;
}
}
}
Err(e) => {
tracing::warn!("Failed to parse {}: {}", path_str, e);
watcher_errors += 1;
}
}
}
if watcher_imported > 0 {
tools_imported_from += 1;
}
total_imported += watcher_imported;
total_skipped += watcher_skipped;
total_errors += watcher_errors;
}
println!();
if args.dry_run {
println!(
"{}",
format!(
"Dry run: would import {total_imported} sessions from {tools_imported_from} tools"
)
.bold()
);
if total_skipped > 0 || total_errors > 0 {
println!(" ({total_skipped} skipped, {total_errors} errors)");
}
} else {
println!(
"{}",
format!("Imported {total_imported} sessions from {tools_imported_from} tools").bold()
);
if total_skipped > 0 || total_errors > 0 {
println!(" ({total_skipped} skipped, {total_errors} errors)");
}
if total_imported > 0 {
println!();
println!(
"{}",
"Run 'lore sessions' to see imported sessions".dimmed()
);
}
}
Ok(())
}
#[derive(Debug, Default)]
pub struct ImportStats {
pub imported: usize,
pub skipped: usize,
pub errors: usize,
pub tools_count: usize,
}
pub fn run_import(force: bool, dry_run: bool) -> Result<ImportStats> {
let db = Database::open_default()?;
let config = Config::load()?;
let registry = default_registry();
let watchers = registry.enabled_watchers(&config.watchers);
let mut stats = ImportStats::default();
for watcher in &watchers {
let info = watcher.info();
println!("{}", format!("Importing from {}...", info.name).dimmed());
let sources = match watcher.find_sources() {
Ok(sources) => sources,
Err(e) => {
tracing::warn!("Failed to find sources for {}: {}", info.name, e);
println!(" {}", format!("Error finding sources: {e}").red());
stats.errors += 1;
continue;
}
};
if sources.is_empty() {
println!(" {}", "No sessions found".dimmed());
continue;
}
println!(" Found {} source files", sources.len().to_string().green());
let mut watcher_imported = 0;
for path in sources {
let path_str = path.to_string_lossy();
if !force && db.session_exists_by_source(&path_str)? {
stats.skipped += 1;
tracing::debug!("Skipping already imported: {}", path_str);
continue;
}
match watcher.parse_source(&path) {
Ok(sessions) => {
if sessions.is_empty() {
tracing::debug!("No sessions in source: {}", path_str);
stats.skipped += 1;
continue;
}
for (session, messages) in sessions {
if messages.is_empty() {
tracing::debug!("Skipping empty session: {}", session.id);
stats.skipped += 1;
continue;
}
if dry_run {
let dir = session
.working_directory
.split('/')
.next_back()
.unwrap_or(&session.working_directory);
println!(
" {} {} ({} messages, {})",
"Would import:".dimmed(),
&session.id.to_string()[..8].cyan(),
messages.len(),
dir
);
watcher_imported += 1;
} else {
db.insert_session(&session)?;
for msg in &messages {
db.insert_message(msg)?;
}
let dir = session
.working_directory
.split('/')
.next_back()
.unwrap_or(&session.working_directory);
println!(
" {} {} ({} messages, {})",
"Imported:".green(),
&session.id.to_string()[..8].cyan(),
messages.len(),
dir
);
watcher_imported += 1;
}
}
}
Err(e) => {
tracing::warn!("Failed to parse {}: {}", path_str, e);
stats.errors += 1;
}
}
}
if watcher_imported > 0 {
stats.tools_count += 1;
}
stats.imported += watcher_imported;
}
Ok(stats)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_import_stats_default() {
let stats = ImportStats::default();
assert_eq!(stats.imported, 0);
assert_eq!(stats.skipped, 0);
assert_eq!(stats.errors, 0);
assert_eq!(stats.tools_count, 0);
}
#[test]
fn test_import_stats_fields_are_accessible() {
let stats = ImportStats {
imported: 10,
skipped: 5,
errors: 2,
tools_count: 3,
};
assert_eq!(stats.imported, 10);
assert_eq!(stats.skipped, 5);
assert_eq!(stats.errors, 2);
assert_eq!(stats.tools_count, 3);
}
}