Skip to content

Commit 1837fd5

Browse files
zampierilucasclaude
andcommitted
feat: add mcfly shell history importer
Adds direct SQLite database import support for mcfly shell history. Reads from mcfly's history.db without requiring mcfly binary in PATH. Implementation: - Direct SQLite database access via sqlx - Cross-platform database path detection - Imports complete command history from commands table - Session ID mapping with UUID generation - Hostname support with username information - Custom database file path support via --file option - Full test coverage with real database validation Database locations: - ~/.mcfly/history.db (legacy) - ~/.local/share/mcfly/history.db (XDG default) - $XDG_DATA_HOME/mcfly/history.db - ~/Library/Application Support/McFly/history.db (macOS) - %LOCALAPPDATA%\McFly\data\history.db (Windows) Usage: atuin import mcfly [--file <path>] 🤖 Generated with Claude Code Co-Authored-By: Claude <[email protected]> Signed-off-by: Lucas Zampieri <[email protected]>
1 parent cb157f7 commit 1837fd5

File tree

3 files changed

+349
-2
lines changed

3 files changed

+349
-2
lines changed
Lines changed: 318 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,318 @@
1+
use std::collections::HashMap;
2+
use std::path::PathBuf;
3+
4+
use async_trait::async_trait;
5+
use atuin_common::utils::uuid_v7;
6+
use directories::UserDirs;
7+
use eyre::{Result, eyre};
8+
use sqlx::sqlite::SqlitePool;
9+
use time::OffsetDateTime;
10+
11+
use super::{Importer, Loader};
12+
use crate::history::History;
13+
use crate::utils::{get_hostname, get_username};
14+
15+
#[derive(sqlx::FromRow, Debug)]
16+
struct McflyCommand {
17+
cmd: String,
18+
session_id: String,
19+
when_run: i64,
20+
exit_code: i64,
21+
dir: String,
22+
}
23+
24+
#[derive(Debug)]
25+
pub struct Mcfly {
26+
entries: Vec<History>,
27+
}
28+
29+
impl Mcfly {
30+
/// Find mcfly database path following mcfly's logic
31+
pub fn histpath() -> Result<PathBuf> {
32+
// Check for legacy path first (~/.mcfly/history.db)
33+
if let Some(user_dirs) = UserDirs::new() {
34+
let legacy_path = user_dirs.home_dir().join(".mcfly").join("history.db");
35+
if legacy_path.exists() {
36+
return Ok(legacy_path);
37+
}
38+
}
39+
40+
// Use XDG data directory on Linux/Unix
41+
if let Ok(xdg_data_home) = std::env::var("XDG_DATA_HOME") {
42+
let path = PathBuf::from(xdg_data_home).join("mcfly").join("history.db");
43+
if path.exists() {
44+
return Ok(path);
45+
}
46+
}
47+
48+
// Default XDG location (~/.local/share/mcfly/history.db)
49+
if let Some(user_dirs) = UserDirs::new() {
50+
let default_path = user_dirs.home_dir()
51+
.join(".local")
52+
.join("share")
53+
.join("mcfly")
54+
.join("history.db");
55+
if default_path.exists() {
56+
return Ok(default_path);
57+
}
58+
}
59+
60+
// macOS
61+
if cfg!(target_os = "macos") {
62+
if let Some(user_dirs) = UserDirs::new() {
63+
let macos_path = user_dirs.home_dir()
64+
.join("Library")
65+
.join("Application Support")
66+
.join("McFly")
67+
.join("history.db");
68+
if macos_path.exists() {
69+
return Ok(macos_path);
70+
}
71+
}
72+
}
73+
74+
// Windows
75+
if cfg!(target_os = "windows") {
76+
if let Ok(local_data) = std::env::var("LOCALAPPDATA") {
77+
let windows_path = PathBuf::from(local_data)
78+
.join("McFly")
79+
.join("data")
80+
.join("history.db");
81+
if windows_path.exists() {
82+
return Ok(windows_path);
83+
}
84+
}
85+
}
86+
87+
Err(eyre!("Could not find mcfly database. Searched common locations but no history.db found."))
88+
}
89+
90+
/// Import from mcfly database directly
91+
async fn from_db(db_path: PathBuf) -> Result<Self> {
92+
let db_url = format!("sqlite://{}", db_path.to_string_lossy());
93+
let pool = SqlitePool::connect(&db_url).await
94+
.map_err(|e| eyre!("Failed to connect to mcfly database at {}: {}", db_path.display(), e))?;
95+
96+
Self::from_pool(pool).await
97+
}
98+
99+
/// Import from mcfly database at specific path
100+
pub async fn from_file<P: AsRef<std::path::Path>>(db_path: P) -> Result<Self> {
101+
let path = db_path.as_ref().to_path_buf();
102+
if !path.exists() {
103+
return Err(eyre!("mcfly database not found at: {}", path.display()));
104+
}
105+
Self::from_db(path).await
106+
}
107+
108+
async fn from_pool(pool: SqlitePool) -> Result<Self> {
109+
let commands: Vec<McflyCommand> = sqlx::query_as(
110+
"SELECT cmd, session_id, when_run, exit_code, dir FROM commands
111+
WHERE cmd IS NOT NULL AND cmd != ''
112+
ORDER BY when_run"
113+
)
114+
.fetch_all(&pool)
115+
.await
116+
.map_err(|e| eyre!("Failed to query mcfly commands: {}", e))?;
117+
118+
let mut session_map = HashMap::new();
119+
let hostname = format!("{}:{}", get_hostname(), get_username());
120+
121+
let entries = commands
122+
.into_iter()
123+
.map(|cmd| {
124+
let timestamp = OffsetDateTime::from_unix_timestamp(cmd.when_run)
125+
.unwrap_or_else(|_| OffsetDateTime::now_utc());
126+
127+
// Map session_id to UUID, creating new ones as needed
128+
let session = session_map.entry(cmd.session_id.clone()).or_insert_with(uuid_v7);
129+
130+
History::import()
131+
.timestamp(timestamp)
132+
.command(cmd.cmd)
133+
.cwd(cmd.dir)
134+
.exit(cmd.exit_code)
135+
.session(session.as_simple().to_string())
136+
.hostname(hostname.clone())
137+
.build()
138+
.into()
139+
})
140+
.collect();
141+
142+
Ok(Self { entries })
143+
}
144+
}
145+
146+
#[async_trait]
147+
impl Importer for Mcfly {
148+
const NAME: &'static str = "mcfly";
149+
150+
async fn new() -> Result<Self> {
151+
let db_path = Self::histpath()?;
152+
Self::from_db(db_path).await
153+
}
154+
155+
async fn entries(&mut self) -> Result<usize> {
156+
Ok(self.entries.len())
157+
}
158+
159+
async fn load(self, h: &mut impl Loader) -> Result<()> {
160+
for entry in self.entries {
161+
h.push(entry).await?;
162+
}
163+
164+
Ok(())
165+
}
166+
}
167+
168+
#[cfg(test)]
169+
mod tests {
170+
use super::*;
171+
use crate::import::tests::TestLoader;
172+
use sqlx::SqlitePool;
173+
174+
async fn setup_test_db() -> Result<SqlitePool> {
175+
let pool = SqlitePool::connect(":memory:").await?;
176+
177+
// Create mcfly commands table with real schema including session_id
178+
sqlx::query(
179+
r#"
180+
CREATE TABLE commands (
181+
id INTEGER PRIMARY KEY AUTOINCREMENT,
182+
cmd TEXT NOT NULL,
183+
session_id TEXT NOT NULL,
184+
when_run INTEGER NOT NULL,
185+
exit_code INTEGER NOT NULL,
186+
dir TEXT NOT NULL
187+
)
188+
"#,
189+
)
190+
.execute(&pool)
191+
.await?;
192+
193+
// Insert test data with timestamps, exit codes, and session IDs
194+
sqlx::query("INSERT INTO commands (cmd, session_id, when_run, exit_code, dir) VALUES (?, ?, ?, ?, ?)")
195+
.bind("ls -la")
196+
.bind("session1")
197+
.bind(1672574400) // 2023-01-01 12:00:00 UTC
198+
.bind(0)
199+
.bind("/home/user")
200+
.execute(&pool)
201+
.await?;
202+
203+
sqlx::query("INSERT INTO commands (cmd, session_id, when_run, exit_code, dir) VALUES (?, ?, ?, ?, ?)")
204+
.bind("cd /tmp")
205+
.bind("session1")
206+
.bind(1672574410) // 10 seconds later
207+
.bind(0)
208+
.bind("/home/user")
209+
.execute(&pool)
210+
.await?;
211+
212+
sqlx::query("INSERT INTO commands (cmd, session_id, when_run, exit_code, dir) VALUES (?, ?, ?, ?, ?)")
213+
.bind("false") // command that fails
214+
.bind("session2")
215+
.bind(1672574420) // 20 seconds later
216+
.bind(1)
217+
.bind("/tmp")
218+
.execute(&pool)
219+
.await?;
220+
221+
Ok(pool)
222+
}
223+
224+
#[tokio::test]
225+
async fn test_mcfly_db_import() -> Result<()> {
226+
let pool = setup_test_db().await?;
227+
let mcfly = Mcfly::from_pool(pool).await?;
228+
let mut loader = TestLoader::default();
229+
230+
mcfly.load(&mut loader).await?;
231+
232+
// Should import all commands from commands table
233+
assert_eq!(loader.buf.len(), 3);
234+
235+
// Check first command
236+
assert_eq!(loader.buf[0].command, "ls -la");
237+
assert_eq!(loader.buf[0].cwd.as_str(), "/home/user");
238+
assert_eq!(loader.buf[0].exit, 0);
239+
240+
// Check last command (failed command)
241+
assert_eq!(loader.buf[2].command, "false");
242+
assert_eq!(loader.buf[2].cwd.as_str(), "/tmp");
243+
assert_eq!(loader.buf[2].exit, 1);
244+
245+
Ok(())
246+
}
247+
248+
#[tokio::test]
249+
async fn test_mcfly_db_with_missing_fields() -> Result<()> {
250+
let pool = SqlitePool::connect(":memory:").await?;
251+
252+
// Create commands table with session_id field
253+
sqlx::query(
254+
r#"
255+
CREATE TABLE commands (
256+
id INTEGER PRIMARY KEY AUTOINCREMENT,
257+
cmd TEXT NOT NULL,
258+
session_id TEXT NOT NULL,
259+
when_run INTEGER NOT NULL,
260+
exit_code INTEGER NOT NULL,
261+
dir TEXT NOT NULL
262+
)
263+
"#,
264+
)
265+
.execute(&pool)
266+
.await?;
267+
268+
sqlx::query("INSERT INTO commands (cmd, session_id, when_run, exit_code, dir) VALUES (?, ?, ?, ?, ?)")
269+
.bind("pwd")
270+
.bind("session3")
271+
.bind(1672574400)
272+
.bind(0)
273+
.bind("/home/test")
274+
.execute(&pool)
275+
.await?;
276+
277+
let mcfly = Mcfly::from_pool(pool).await?;
278+
let mut loader = TestLoader::default();
279+
280+
mcfly.load(&mut loader).await?;
281+
282+
assert_eq!(loader.buf.len(), 1);
283+
assert_eq!(loader.buf[0].command, "pwd");
284+
assert_eq!(loader.buf[0].cwd.as_str(), "/home/test");
285+
286+
Ok(())
287+
}
288+
289+
#[tokio::test]
290+
async fn test_empty_mcfly_db() -> Result<()> {
291+
let pool = SqlitePool::connect(":memory:").await?;
292+
293+
// Create empty commands table with session_id field
294+
sqlx::query(
295+
r#"
296+
CREATE TABLE commands (
297+
id INTEGER PRIMARY KEY AUTOINCREMENT,
298+
cmd TEXT NOT NULL,
299+
session_id TEXT NOT NULL,
300+
when_run INTEGER NOT NULL,
301+
exit_code INTEGER NOT NULL,
302+
dir TEXT NOT NULL
303+
)
304+
"#,
305+
)
306+
.execute(&pool)
307+
.await?;
308+
309+
let mcfly = Mcfly::from_pool(pool).await?;
310+
let mut loader = TestLoader::default();
311+
312+
mcfly.load(&mut loader).await?;
313+
314+
assert_eq!(loader.buf.len(), 0);
315+
316+
Ok(())
317+
}
318+
}

crates/atuin-client/src/import/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ use crate::history::History;
1010

1111
pub mod bash;
1212
pub mod fish;
13+
pub mod mcfly;
1314
pub mod nu;
1415
pub mod nu_histdb;
1516
pub mod replxx;

crates/atuin/src/command/client/import.rs

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
use std::env;
2+
use std::path::PathBuf;
23

34
use async_trait::async_trait;
45
use clap::Parser;
@@ -9,8 +10,9 @@ use atuin_client::{
910
database::Database,
1011
history::History,
1112
import::{
12-
Importer, Loader, bash::Bash, fish::Fish, nu::Nu, nu_histdb::NuHistDb, replxx::Replxx,
13-
resh::Resh, xonsh::Xonsh, xonsh_sqlite::XonshSqlite, zsh::Zsh, zsh_histdb::ZshHistDb,
13+
Importer, Loader, bash::Bash, fish::Fish, mcfly::Mcfly, nu::Nu, nu_histdb::NuHistDb,
14+
replxx::Replxx, resh::Resh, xonsh::Xonsh, xonsh_sqlite::XonshSqlite, zsh::Zsh,
15+
zsh_histdb::ZshHistDb,
1416
},
1517
};
1618

@@ -40,6 +42,12 @@ pub enum Cmd {
4042
Xonsh,
4143
/// Import history from xonsh sqlite db
4244
XonshSqlite,
45+
/// Import history from mcfly
46+
Mcfly {
47+
/// Path to mcfly database file
48+
#[arg(long, short)]
49+
file: Option<PathBuf>,
50+
},
4351
}
4452

4553
const BATCH_SIZE: usize = 100;
@@ -119,6 +127,7 @@ impl Cmd {
119127
Self::NuHistDb => import::<NuHistDb, DB>(db).await,
120128
Self::Xonsh => import::<Xonsh, DB>(db).await,
121129
Self::XonshSqlite => import::<XonshSqlite, DB>(db).await,
130+
Self::Mcfly { file } => import_mcfly::<DB>(db, file.as_deref()).await,
122131
}
123132
}
124133
}
@@ -172,3 +181,22 @@ async fn import<I: Importer + Send, DB: Database>(db: &DB) -> Result<()> {
172181
println!("Import complete!");
173182
Ok(())
174183
}
184+
185+
async fn import_mcfly<DB: Database>(db: &DB, file_path: Option<&std::path::Path>) -> Result<()> {
186+
println!("Importing history from mcfly");
187+
188+
let mut importer = if let Some(path) = file_path {
189+
println!("Using mcfly database: {}", path.display());
190+
Mcfly::from_file(path).await?
191+
} else {
192+
Mcfly::new().await?
193+
};
194+
195+
let len = importer.entries().await?;
196+
let mut loader = HistoryImporter::new(db, len);
197+
importer.load(&mut loader).await?;
198+
loader.flush().await?;
199+
200+
println!("Import complete!");
201+
Ok(())
202+
}

0 commit comments

Comments
 (0)