Skip to content

Commit f3ea0cc

Browse files
zampierilucasclaude
andcommitted
feat: add native mcfly import support
Implements a native importer for mcfly shell history, allowing users to migrate their mcfly history to Atuin with a single command: `atuin import mcfly` The implementation: - Uses `mcfly dump` command to export history as JSON - Supports both RFC3339 and Unix timestamp formats - Includes comprehensive tests for various scenarios 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]> Signed-off-by: Lucas Zampieri <[email protected]>
1 parent cb157f7 commit f3ea0cc

File tree

3 files changed

+185
-2
lines changed

3 files changed

+185
-2
lines changed
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
use std::process::Command;
2+
3+
use async_trait::async_trait;
4+
use eyre::{Result, eyre};
5+
use serde::Deserialize;
6+
use time::OffsetDateTime;
7+
8+
use super::{Importer, Loader};
9+
use crate::history::History;
10+
11+
#[derive(Debug, Deserialize)]
12+
struct McflyEntry {
13+
when_run: String,
14+
cmd: String,
15+
}
16+
17+
#[derive(Debug)]
18+
pub struct Mcfly {
19+
entries: Vec<McflyEntry>,
20+
}
21+
22+
fn parse_timestamp(s: &str) -> Result<OffsetDateTime> {
23+
// Try RFC3339 format first (e.g., "2023-01-01T12:00:00Z")
24+
if let Ok(ts) = OffsetDateTime::parse(s, &time::format_description::well_known::Rfc3339) {
25+
return Ok(ts);
26+
}
27+
28+
// Fall back to Unix timestamp (e.g., "1672574410")
29+
let unix_ts = s
30+
.parse::<i64>()
31+
.map_err(|_| eyre!("Failed to parse timestamp: {}", s))?;
32+
33+
OffsetDateTime::from_unix_timestamp(unix_ts).map_err(|_| eyre!("Invalid Unix timestamp: {}", s))
34+
}
35+
36+
#[async_trait]
37+
impl Importer for Mcfly {
38+
const NAME: &'static str = "mcfly";
39+
40+
async fn new() -> Result<Self> {
41+
// Check if mcfly is installed
42+
let output = Command::new("mcfly")
43+
.arg("dump")
44+
.output()
45+
.map_err(|_| eyre!("mcfly not found in PATH. Please ensure mcfly is installed"))?;
46+
47+
if !output.status.success() {
48+
return Err(eyre!(
49+
"Failed to dump mcfly history: {}",
50+
String::from_utf8_lossy(&output.stderr)
51+
));
52+
}
53+
54+
let json_str = String::from_utf8(output.stdout)?;
55+
let entries: Vec<McflyEntry> = serde_json::from_str(&json_str)?;
56+
57+
Ok(Self { entries })
58+
}
59+
60+
async fn entries(&mut self) -> Result<usize> {
61+
Ok(self.entries.len())
62+
}
63+
64+
async fn load(self, h: &mut impl Loader) -> Result<()> {
65+
for entry in self.entries {
66+
let timestamp = parse_timestamp(&entry.when_run)?;
67+
68+
let imported = History::import().timestamp(timestamp).command(entry.cmd);
69+
70+
h.push(imported.build().into()).await?;
71+
}
72+
73+
Ok(())
74+
}
75+
}
76+
77+
#[cfg(test)]
78+
mod test {
79+
use super::{Mcfly, parse_timestamp};
80+
use crate::import::{Importer, tests::TestLoader};
81+
use time::macros::datetime;
82+
83+
#[test]
84+
fn test_parse_timestamp() {
85+
// Test RFC3339 format
86+
let ts1 = parse_timestamp("2023-01-01T12:00:00Z").unwrap();
87+
assert_eq!(ts1, datetime!(2023-01-01 12:00:00 UTC));
88+
89+
// Test Unix timestamp
90+
let ts2 = parse_timestamp("1672574400").unwrap();
91+
assert_eq!(ts2, datetime!(2023-01-01 12:00:00 UTC));
92+
93+
// Test invalid timestamp
94+
assert!(parse_timestamp("invalid").is_err());
95+
assert!(parse_timestamp("999999999999999").is_err()); // Unix timestamp out of range
96+
}
97+
98+
#[tokio::test]
99+
async fn parse_mcfly_history() {
100+
// Create a mock mcfly history with various timestamp formats
101+
let entries = vec![
102+
super::McflyEntry {
103+
when_run: "2023-01-01T12:00:00Z".to_string(),
104+
cmd: "ls -la".to_string(),
105+
},
106+
super::McflyEntry {
107+
when_run: "2023-01-01T12:00:05Z".to_string(),
108+
cmd: "cd /home".to_string(),
109+
},
110+
super::McflyEntry {
111+
when_run: "1672574410".to_string(), // Unix timestamp: 2023-01-01T12:00:10Z
112+
cmd: "echo hello".to_string(),
113+
},
114+
];
115+
116+
let mcfly = Mcfly { entries };
117+
let mut loader = TestLoader::default();
118+
mcfly.load(&mut loader).await.unwrap();
119+
120+
// Verify count and that all entries were imported
121+
assert_eq!(loader.buf.len(), 3);
122+
123+
// Verify timestamps are parsed correctly for both RFC3339 and Unix timestamp formats
124+
assert_eq!(loader.buf[0].timestamp, datetime!(2023-01-01 12:00:00 UTC));
125+
assert_eq!(loader.buf[1].timestamp, datetime!(2023-01-01 12:00:05 UTC));
126+
assert_eq!(loader.buf[2].timestamp, datetime!(2023-01-01 12:00:10 UTC));
127+
128+
// Since mcfly doesn't transform commands, just verify they're imported as-is
129+
let commands: Vec<&str> = loader.buf.iter().map(|h| h.command.as_str()).collect();
130+
assert_eq!(commands, vec!["ls -la", "cd /home", "echo hello"]);
131+
132+
// Verify timestamps are in order
133+
assert!(
134+
loader
135+
.buf
136+
.windows(2)
137+
.all(|w| w[0].timestamp <= w[1].timestamp)
138+
);
139+
}
140+
141+
#[tokio::test]
142+
async fn parse_mcfly_with_invalid_timestamp() {
143+
let entries = vec![
144+
super::McflyEntry {
145+
when_run: "2023-01-01T12:00:00Z".to_string(),
146+
cmd: "valid command".to_string(),
147+
},
148+
super::McflyEntry {
149+
when_run: "invalid_timestamp".to_string(),
150+
cmd: "command with bad timestamp".to_string(),
151+
},
152+
];
153+
154+
let mcfly = Mcfly { entries };
155+
let mut loader = TestLoader::default();
156+
157+
// Should fail on invalid timestamp
158+
let result = mcfly.load(&mut loader).await;
159+
assert!(result.is_err());
160+
assert!(
161+
result
162+
.unwrap_err()
163+
.to_string()
164+
.contains("Failed to parse timestamp")
165+
);
166+
}
167+
168+
#[tokio::test]
169+
async fn parse_empty_mcfly_history() {
170+
let entries = vec![];
171+
let mcfly = Mcfly { entries };
172+
let mut loader = TestLoader::default();
173+
174+
// Should handle empty history gracefully
175+
mcfly.load(&mut loader).await.unwrap();
176+
assert_eq!(loader.buf.len(), 0);
177+
}
178+
}

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: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,9 @@ use atuin_client::{
99
database::Database,
1010
history::History,
1111
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,
12+
Importer, Loader, bash::Bash, fish::Fish, mcfly::Mcfly, nu::Nu, nu_histdb::NuHistDb,
13+
replxx::Replxx, resh::Resh, xonsh::Xonsh, xonsh_sqlite::XonshSqlite, zsh::Zsh,
14+
zsh_histdb::ZshHistDb,
1415
},
1516
};
1617

@@ -40,6 +41,8 @@ pub enum Cmd {
4041
Xonsh,
4142
/// Import history from xonsh sqlite db
4243
XonshSqlite,
44+
/// Import history from mcfly
45+
Mcfly,
4346
}
4447

4548
const BATCH_SIZE: usize = 100;
@@ -119,6 +122,7 @@ impl Cmd {
119122
Self::NuHistDb => import::<NuHistDb, DB>(db).await,
120123
Self::Xonsh => import::<Xonsh, DB>(db).await,
121124
Self::XonshSqlite => import::<XonshSqlite, DB>(db).await,
125+
Self::Mcfly => import::<Mcfly, DB>(db).await,
122126
}
123127
}
124128
}

0 commit comments

Comments
 (0)