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+ }
0 commit comments