lean_ctx/core/import/
codex.rs1use std::collections::HashSet;
4use std::path::{Path, PathBuf};
5
6use rusqlite::{Connection, types::ValueRef};
7use serde_json::Value;
8
9use super::database::{DatabaseSnapshot, snapshot_history_database};
10use super::{
11 ImportResult, ImportSource, SourceRefusal, finish_result, home_dir, push_error, read_json_file,
12 read_jsonl_files, session_id, staging,
13};
14
15const MAX_SQLITE_ROWS_PER_TABLE: usize = 10_000;
16const MAX_SCAN_DEPTH: usize = 8;
17
18#[must_use]
20pub fn import() -> ImportResult {
21 let Some(home) = home_dir() else {
22 return ImportResult::default();
23 };
24 import_from_root(&home.join(".codex"))
25}
26
27#[must_use]
29pub fn import_from_root(root: &Path) -> ImportResult {
30 if !root.exists() {
31 return ImportResult::default();
32 }
33
34 let mut jsonl = Vec::new();
35 let mut json = Vec::new();
36 let mut sqlite = Vec::new();
37 let mut errors = Vec::new();
38 collect_files(root, 0, &mut jsonl, &mut json, &mut sqlite, &mut errors);
39 jsonl.sort();
40 json.sort();
41 sqlite.sort();
42
43 let mut result = read_jsonl_files(ImportSource::Codex, root, jsonl);
44 for path in json {
45 result.merge(read_json_file(ImportSource::Codex, root, &path));
46 }
47 for path in sqlite {
48 match snapshot_history_database(root, &path) {
58 Ok(snapshot) => result.merge(read_sqlite_file(root, &snapshot)),
59 Err(refusal) => result.refuse(refusal),
60 }
61 }
62 for error in errors {
63 push_error(&mut result, error);
64 }
65 result
66}
67
68fn collect_files(
69 dir: &Path,
70 depth: usize,
71 jsonl: &mut Vec<PathBuf>,
72 json: &mut Vec<PathBuf>,
73 sqlite: &mut Vec<PathBuf>,
74 errors: &mut Vec<String>,
75) {
76 if depth > MAX_SCAN_DEPTH {
77 return;
78 }
79 let entries = match std::fs::read_dir(dir) {
80 Ok(entries) => entries,
81 Err(error) => {
82 errors.push(format!("{}: {error}", dir.display()));
83 return;
84 }
85 };
86 for entry in entries {
87 let entry = match entry {
88 Ok(entry) => entry,
89 Err(error) => {
90 errors.push(format!("{}: {error}", dir.display()));
91 continue;
92 }
93 };
94 let path = entry.path();
95 if path.is_dir() {
96 collect_files(&path, depth + 1, jsonl, json, sqlite, errors);
97 } else if path.is_file() {
98 match path.extension().and_then(|extension| extension.to_str()) {
99 Some("jsonl") => jsonl.push(path),
100 Some("json") if is_named_session_file(&path) => json.push(path),
101 Some("sqlite" | "sqlite3" | "db") => sqlite.push(path),
102 _ => {}
103 }
104 }
105 }
106}
107
108fn is_named_session_file(path: &Path) -> bool {
109 let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
110 return false;
111 };
112 let name = name.to_ascii_lowercase();
113 ["history", "session", "rollout", "transcript"]
114 .iter()
115 .any(|marker| name.contains(marker))
116}
117
118fn read_sqlite_file(root: &Path, snapshot: &DatabaseSnapshot) -> ImportResult {
127 let path = snapshot.source_path();
130 let (pending, read) =
131 staging::read_database(root, path, |pending| read_rows(snapshot, pending));
132 let session = session_id(ImportSource::Codex, path, pending.rules());
133 let mut result = ImportResult::default();
134 let mut touched = HashSet::new();
135 let mut seen = HashSet::new();
136 match pending.settle(
137 read,
138 ImportSource::Codex,
139 &session,
140 &mut result,
141 &mut touched,
142 &mut seen,
143 ) {
144 Ok(()) => result.sessions_found = 1,
145 Err(refusal) => {
148 let label = path.display().to_string();
149 push_error(&mut result, staging::not_imported(&label, refusal));
150 }
151 }
152 finish_result(&mut result, &touched);
153 result
154}
155
156fn read_rows(
159 snapshot: &DatabaseSnapshot,
160 pending: &mut staging::Pending,
161) -> Result<(), SourceRefusal> {
162 let unreadable = |_: rusqlite::Error| SourceRefusal::DatabaseUnreadable;
163 let connection = snapshot.open().map_err(unreadable)?;
164 for table in tables(&connection).map_err(unreadable)? {
165 let query = format!(
166 "SELECT * FROM {} LIMIT {MAX_SQLITE_ROWS_PER_TABLE}",
167 quote_identifier(&table)
168 );
169 let mut statement = connection.prepare(&query).map_err(unreadable)?;
170 let columns = statement.column_count();
171 let mut rows = statement.query([]).map_err(unreadable)?;
172 while let Some(row) = rows.next().map_err(unreadable)? {
173 for column in 0..columns {
174 let Ok(ValueRef::Text(bytes)) = row.get_ref(column) else {
175 continue;
176 };
177 let text = String::from_utf8_lossy(bytes);
178 let original = serde_json::from_str::<Value>(&text)
179 .unwrap_or_else(|_| Value::String(text.into_owned()));
180 let Some(value) = pending.admit(original) else {
184 continue;
185 };
186 staging::select(&value, |found| pending.stage(found));
187 }
188 }
189 }
190 Ok(())
191}
192
193fn tables(connection: &Connection) -> rusqlite::Result<Vec<String>> {
194 let mut statement = connection.prepare(
195 "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'",
196 )?;
197 statement
198 .query_map([], |row| row.get(0))?
199 .collect::<rusqlite::Result<Vec<String>>>()
200}
201
202fn quote_identifier(identifier: &str) -> String {
203 format!("\"{}\"", identifier.replace('\"', "\"\""))
204}