use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use bytes::Bytes;
use irontide_core::{FastResumeData, Id20, Id32, InfoDict, InfoHashes, Magnet, TorrentMetaV1};
#[derive(Debug, thiserror::Error)]
pub enum ResumeFileError {
#[error("bencode error: {0}")]
Bencode(#[from] irontide_bencode::Error),
#[error("I/O error: {0}")]
Io(#[from] io::Error),
}
pub fn serialize_resume(data: &FastResumeData) -> Result<Vec<u8>, ResumeFileError> {
irontide_bencode::to_bytes(data).map_err(ResumeFileError::from)
}
pub fn deserialize_resume(data: &[u8]) -> Result<FastResumeData, ResumeFileError> {
irontide_bencode::from_bytes(data).map_err(ResumeFileError::from)
}
pub fn atomic_write(path: &Path, data: &[u8]) -> io::Result<()> {
let tmp_path = path.with_extension("resume.tmp");
fs::write(&tmp_path, data)?;
fs::rename(&tmp_path, path)?;
Ok(())
}
#[must_use]
pub fn resume_file_path(dir: &Path, info_hash: &Id20) -> PathBuf {
dir.join("torrents")
.join(format!("{}.resume", hex::encode(info_hash.as_bytes())))
}
#[must_use]
pub fn scan_resume_dir(dir: &Path) -> Vec<PathBuf> {
let torrents_dir = dir.join("torrents");
let Ok(entries) = fs::read_dir(&torrents_dir) else {
return Vec::new();
};
entries
.filter_map(|entry| {
let entry = entry.ok()?;
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) == Some("resume") {
Some(path)
} else {
None
}
})
.collect()
}
#[must_use]
pub fn default_resume_dir() -> PathBuf {
if let Ok(state_home) = std::env::var("XDG_STATE_HOME")
&& !state_home.is_empty()
{
return PathBuf::from(state_home).join("irontide");
}
if let Ok(home) = std::env::var("HOME") {
return PathBuf::from(home)
.join(".local")
.join("state")
.join("irontide");
}
PathBuf::from(".local/state/irontide")
}
pub fn delete_resume_file(dir: &Path, info_hash: &Id20) -> io::Result<()> {
let path = resume_file_path(dir, info_hash);
fs::remove_file(path)
}
#[must_use]
pub fn reconstruct_torrent_meta(rd: &FastResumeData) -> Option<TorrentMetaV1> {
let info_bytes = rd.info.as_ref()?;
let info: InfoDict = irontide_bencode::from_bytes(info_bytes).ok()?;
let info_hash = Id20::from_bytes(&rd.info_hash).ok()?;
let announce = rd.trackers.first().and_then(|tier| tier.first()).cloned();
let announce_list = if rd.trackers.is_empty() {
None
} else {
Some(rd.trackers.clone())
};
Some(TorrentMetaV1 {
info_hash,
announce,
announce_list,
comment: None,
created_by: None,
creation_date: None,
info,
url_list: rd.url_seeds.clone(),
httpseeds: rd.http_seeds.clone(),
info_bytes: Some(Bytes::from(info_bytes.clone())),
ssl_cert: None,
})
}
#[must_use]
pub fn reconstruct_magnet(rd: &FastResumeData) -> Option<Magnet> {
let v1 = Id20::from_bytes(&rd.info_hash).ok()?;
let v2 = rd
.info_hash2
.as_ref()
.and_then(|ih2| Id32::from_bytes(ih2).ok());
let info_hashes = InfoHashes { v1: Some(v1), v2 };
let display_name = if rd.name.is_empty() {
None
} else {
Some(rd.name.clone())
};
let trackers = rd
.trackers
.iter()
.flat_map(|tier| tier.iter().cloned())
.collect();
Some(Magnet {
info_hashes,
display_name,
trackers,
peers: Vec::new(),
selected_files: None,
})
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
fn sample_resume_data() -> FastResumeData {
let mut data =
FastResumeData::new(vec![0xAB; 20], "test-torrent".into(), "/downloads".into());
data.total_uploaded = 1024;
data.total_downloaded = 2048;
data.active_time = 300;
data.added_time = 1_700_000_000;
data.pieces = vec![0xFF; 8];
data
}
#[test]
fn bencode_round_trip() {
let original = sample_resume_data();
let bytes = serialize_resume(&original).expect("serialize should succeed");
let decoded = deserialize_resume(&bytes).expect("deserialize should succeed");
assert_eq!(original, decoded);
}
#[test]
fn empty_resume_data_round_trip() {
let original = FastResumeData::new(vec![0x00; 20], "empty".into(), "/tmp".into());
let bytes = serialize_resume(&original).expect("serialize should succeed");
let decoded = deserialize_resume(&bytes).expect("deserialize should succeed");
assert_eq!(original, decoded);
}
#[test]
fn web_seed_stats_round_trip() {
let mut original = sample_resume_data();
let stats = irontide_core::WebSeedStats {
url: "http://seed.example.com/file".into(),
state: irontide_core::WebSeedState::Active,
downloaded_bytes: 1024 * 1024,
last_rate_bps: 524_288,
last_error: Some("flaked once".into()),
consecutive_failures: 0,
last_attempt_unix_secs: 1_700_000_000,
next_retry_unix_secs: None,
};
original.web_seed_stats.insert(stats.url.clone(), stats);
let bytes = serialize_resume(&original).expect("serialize");
let decoded = deserialize_resume(&bytes).expect("deserialize");
assert_eq!(original, decoded);
assert_eq!(decoded.web_seed_stats.len(), 1);
let recovered = decoded
.web_seed_stats
.get("http://seed.example.com/file")
.expect("entry");
assert_eq!(recovered.downloaded_bytes, 1024 * 1024);
assert_eq!(recovered.state, irontide_core::WebSeedState::Active);
assert_eq!(recovered.last_error.as_deref(), Some("flaked once"));
}
#[test]
fn empty_web_seed_stats_skipped_on_serialize() {
let original = sample_resume_data();
assert!(original.web_seed_stats.is_empty(), "default has empty map");
let bytes = serialize_resume(&original).expect("serialize");
let needle = b"web_seed_stats";
assert!(
!bytes.windows(needle.len()).any(|w| w == needle),
"empty web_seed_stats key must be skipped on serialize"
);
}
#[test]
fn legacy_resume_file_loads_with_empty_web_seed_stats() {
let original = sample_resume_data();
let bytes = serialize_resume(&original).expect("serialize");
let decoded = deserialize_resume(&bytes).expect("deserialize");
assert!(decoded.web_seed_stats.is_empty());
}
#[test]
fn atomic_write_no_tmp_remains() {
let dir = TempDir::new().expect("failed to create temp dir");
let target = dir.path().join("test.resume");
atomic_write(&target, b"hello world").expect("atomic_write should succeed");
let contents = fs::read(&target).expect("should read target");
assert_eq!(contents, b"hello world");
let tmp_path = target.with_extension("resume.tmp");
assert!(
!tmp_path.exists(),
".tmp file should not remain after write"
);
}
#[test]
fn scan_resume_dir_filters_extensions() {
let dir = TempDir::new().expect("failed to create temp dir");
let torrents = dir.path().join("torrents");
fs::create_dir_all(&torrents).expect("failed to create torrents dir");
fs::write(torrents.join("aabb.resume"), b"r1").expect("write");
fs::write(torrents.join("ccdd.resume"), b"r2").expect("write");
fs::write(torrents.join("eeff.dat"), b"d1").expect("write");
fs::write(torrents.join("0011.resume.tmp"), b"t1").expect("write");
fs::write(torrents.join("notes.txt"), b"n1").expect("write");
let mut found = scan_resume_dir(dir.path());
found.sort();
assert_eq!(found.len(), 2);
assert!(found[0].ends_with("aabb.resume") || found[0].ends_with("ccdd.resume"));
assert!(found[1].ends_with("aabb.resume") || found[1].ends_with("ccdd.resume"));
for path in &found {
assert_eq!(
path.extension().and_then(|e| e.to_str()),
Some("resume"),
"only .resume files should be returned"
);
}
}
}