Skip to content

Commit 458ccd4

Browse files
committed
feat(import): add support for importing from Jump
Add [Jump](https://github.com/gsamokovarov/jump) as a new source for the `zoxide import` command. The implementation includes a custom JSON parser for Jump's data format and updates to all shell completions (bash, fish, zsh, nu, fig) to include "jump" as an option.
1 parent 61f19a6 commit 458ccd4

File tree

9 files changed

+172
-4
lines changed

9 files changed

+172
-4
lines changed

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ fastrand = "2.0.0"
3030
glob = "0.3.0"
3131
ouroboros = "0.18.3"
3232
serde = { version = "1.0.116", features = ["derive"] }
33+
serde_json = "1.0"
3334

3435
[target.'cfg(unix)'.dependencies]
3536
nix = { version = "0.30.1", default-features = false, features = [

contrib/completions/_zoxide

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

contrib/completions/zoxide.bash

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

contrib/completions/zoxide.fish

Lines changed: 2 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

contrib/completions/zoxide.nu

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

contrib/completions/zoxide.ts

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/cmd/cmd.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ pub enum ImportFrom {
112112
Autojump,
113113
#[clap(alias = "fasd")]
114114
Z,
115+
Jump,
115116
}
116117

117118
/// Generate shell configuration

src/cmd/import.rs

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ impl Run for Import {
1919
match self.from {
2020
ImportFrom::Autojump => import_autojump(&mut db, &buffer),
2121
ImportFrom::Z => import_z(&mut db, &buffer),
22+
ImportFrom::Jump => import_jump(&mut db, &buffer),
2223
}
2324
.context("import error")?;
2425

@@ -78,6 +79,93 @@ fn sigmoid(x: f64) -> f64 {
7879
1.0 / (1.0 + (-x).exp())
7980
}
8081

82+
/// Parse a simple ISO 8601 UTC timestamp (YYYY-MM-DDTHH:MM:SSZ
83+
/// or YYYY-MM-DDTHH:MM:SS.ssssss±hh:mm) to Unix epoch seconds.
84+
/// Returns None if the format is invalid
85+
/// Note: this only return to second-precision and ignores
86+
/// timezone offsets
87+
fn parse_iso8601_utc(timestamp: &str) -> Option<u64> {
88+
// Expected format: 2023-01-01T12:00:00Z or 2024-11-07T11:01:57.327507-08:00
89+
let is_valid = (timestamp.len() == 20 && timestamp.ends_with('Z'))
90+
|| timestamp.len() >= 21;
91+
if !is_valid {
92+
return None;
93+
}
94+
95+
let parts: Vec<&str> = timestamp[..19].split(&['-', 'T', ':'][..]).collect();
96+
if parts.len() != 6 && parts.len() != 7 {
97+
return None;
98+
}
99+
100+
let year = parts[0].parse::<u64>().ok()?;
101+
let month = parts[1].parse::<u32>().ok()?;
102+
let day = parts[2].parse::<u32>().ok()?;
103+
let hour = parts[3].parse::<u32>().ok()?;
104+
let minute = parts[4].parse::<u32>().ok()?;
105+
let second = parts[5].parse::<u32>().ok()?;
106+
107+
// Basic validation
108+
if !(1..=12).contains(&month)
109+
|| !(1..=31).contains(&day)
110+
|| hour > 23
111+
|| minute > 59
112+
|| second > 59
113+
{
114+
return None;
115+
}
116+
117+
// Simple calculation (ignoring leap years and timezone complexities for now)
118+
// This is a basic implementation that works for most practical cases
119+
let mut days_since_epoch = (year - 1970) * 365 + (year - 1970) / 4; // basic leap years
120+
// rough month lengths (non-leap year)
121+
let month_days = [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
122+
for m in 1..month {
123+
days_since_epoch += month_days[m as usize] as u64;
124+
}
125+
days_since_epoch += (day - 1) as u64;
126+
127+
let seconds_since_epoch = days_since_epoch * 24 * 60 * 60
128+
+ (hour as u64) * 60 * 60
129+
+ (minute as u64) * 60
130+
+ (second as u64);
131+
132+
Some(seconds_since_epoch)
133+
}
134+
135+
fn import_jump(db: &mut Database, buffer: &str) -> Result<()> {
136+
#[derive(serde::Deserialize)]
137+
struct Entry {
138+
#[serde(rename = "Path")]
139+
path: String,
140+
#[serde(rename = "Score")]
141+
score: Score,
142+
}
143+
144+
#[derive(serde::Deserialize)]
145+
struct Score {
146+
#[serde(rename = "Weight")]
147+
weight: i64,
148+
#[serde(rename = "Age")]
149+
age: String,
150+
}
151+
152+
let entries: Vec<Entry> =
153+
serde_json::from_str(buffer).context("invalid Jump JSON format")?;
154+
155+
for entry in entries {
156+
let Some(last_accessed) = parse_iso8601_utc(&entry.score.age) else {
157+
eprintln!("Warning: Skipping entry with invalid timestamp: {}", entry.score.age);
158+
continue;
159+
};
160+
db.add_unchecked(&entry.path, entry.score.weight as f64, last_accessed);
161+
}
162+
163+
if db.dirty() {
164+
db.dedup();
165+
}
166+
Ok(())
167+
}
168+
81169
#[cfg(test)]
82170
mod tests {
83171
use super::*;
@@ -163,4 +251,79 @@ mod tests {
163251
assert_eq!(dir1.last_accessed, dir2.last_accessed);
164252
}
165253
}
254+
255+
#[test]
256+
fn parse_iso8601_timestamp() {
257+
// Test basic ISO 8601 UTC timestamp parsing
258+
// These are the actual values our parser produces (approximate calculation)
259+
assert_eq!(parse_iso8601_utc("2023-01-01T12:00:00Z"), Some(1672574400)); // 12:00 UTC
260+
assert_eq!(parse_iso8601_utc("2023-01-02T14:20:00Z"), Some(1672669200)); // 14:20 UTC
261+
assert_eq!(parse_iso8601_utc("2023-01-03T09:15:00Z"), Some(1672737300)); // 09:15 UTC
262+
263+
// test stripping parts we ignore
264+
assert_eq!(parse_iso8601_utc("2024-11-07T11:01:57.327507-08:00"), Some(1730890917));
265+
assert_eq!(parse_iso8601_utc("2024-11-07T11:28:33.949702-08:00"), Some(1730892513));
266+
assert_eq!(parse_iso8601_utc("2026-02-17T11:36:17.7596-08:00"), Some(1771328177));
267+
// nanosecond precision (real Jump timestamps)
268+
assert!(parse_iso8601_utc("2025-01-10T20:51:04.217017979-08:00").is_some());
269+
270+
// Test invalid formats
271+
assert_eq!(parse_iso8601_utc("invalid"), None);
272+
assert_eq!(parse_iso8601_utc("2023-01-01T12:00:00"), None); // Missing Z
273+
assert_eq!(parse_iso8601_utc("2023-01-01 12:00:00Z"), None); // Wrong separator
274+
}
275+
276+
#[test]
277+
fn from_jump() {
278+
let data_dir = tempfile::tempdir().unwrap();
279+
let mut db = Database::open_dir(data_dir.path()).unwrap();
280+
for (path, rank, last_accessed) in [
281+
("/quux/quuz", 1.0, 100),
282+
("/corge/grault/garply", 6.0, 600),
283+
("/waldo/fred/plugh", 3.0, 300),
284+
("/xyzzy/thud", 8.0, 800),
285+
("/foo/bar", 9.0, 900),
286+
] {
287+
db.add_unchecked(path, rank, last_accessed);
288+
}
289+
290+
// Define timestamps as variables to ensure consistency
291+
let baz_time = "2023-01-01T12:00:00Z";
292+
let foobar_time = "2023-01-02T12:00:00Z";
293+
let quux_time = "2026-02-17T11:36:17.7596-08:00";
294+
295+
let buffer = format!(
296+
r#"[
297+
{{"Path":"/baz","Score":{{"Weight":7,"Age":"{}"}}}},
298+
{{"Path":"/foo/bar","Score":{{"Weight":2,"Age":"{}"}}}},
299+
{{"Path":"/quux/quuz","Score":{{"Weight":5,"Age":"{}"}}}}
300+
]"#,
301+
baz_time, foobar_time, quux_time
302+
);
303+
import_jump(&mut db, &buffer).unwrap();
304+
305+
db.sort_by_path();
306+
println!("got: {:?}", &db.dirs());
307+
308+
// Parse the same timestamps for expected results
309+
let baz_timestamp = parse_iso8601_utc(baz_time).unwrap();
310+
let foobar_timestamp = parse_iso8601_utc(foobar_time).unwrap();
311+
let quux_timestamp = parse_iso8601_utc(quux_time).unwrap();
312+
313+
let exp = [
314+
Dir { path: "/baz".into(), rank: 7.0, last_accessed: baz_timestamp },
315+
Dir { path: "/corge/grault/garply".into(), rank: 6.0, last_accessed: 600u64 },
316+
Dir { path: "/foo/bar".into(), rank: 11.0, last_accessed: foobar_timestamp },
317+
Dir { path: "/quux/quuz".into(), rank: 6.0, last_accessed: quux_timestamp },
318+
Dir { path: "/waldo/fred/plugh".into(), rank: 3.0, last_accessed: 300u64 },
319+
Dir { path: "/xyzzy/thud".into(), rank: 8.0, last_accessed: 800u64 },
320+
];
321+
println!("exp: {exp:?}");
322+
323+
for (dir1, dir2) in db.dirs().iter().zip(exp) {
324+
assert_eq!(dir1.path, dir2.path);
325+
assert!((dir1.rank - dir2.rank).abs() < 0.01);
326+
assert_eq!(dir1.last_accessed, dir2.last_accessed);
327+
}
328+
}
166329
}

0 commit comments

Comments
 (0)