Skip to content

Commit 24a84b0

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 24a84b0

File tree

3 files changed

+362
-2
lines changed

3 files changed

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

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;

0 commit comments

Comments
 (0)