use crate::UploadError;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UploadState {
pub upload_url: String,
pub uploaded_bytes: u64,
pub total_size: u64,
pub file_path: PathBuf,
pub title: String,
pub workspace: String,
}
impl UploadState {
pub fn resume_dir() -> Result<PathBuf, UploadError> {
let config_dir = dirs::config_dir()
.ok_or_else(|| UploadError::Config("Cannot determine config directory".into()))?;
Ok(config_dir.join("youtube-uploader").join("resume"))
}
fn state_filename(&self) -> String {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
self.file_path.hash(&mut hasher);
self.title.hash(&mut hasher);
format!("{:016x}.json", hasher.finish())
}
pub fn save(&self) -> Result<PathBuf, UploadError> {
let dir = Self::resume_dir()?;
std::fs::create_dir_all(&dir)
.map_err(|e| UploadError::Config(format!("Failed to create resume directory: {e}")))?;
let path = dir.join(self.state_filename());
let json = serde_json::to_string_pretty(self)
.map_err(|e| UploadError::Config(format!("Failed to serialize resume state: {e}")))?;
std::fs::write(&path, json)
.map_err(|e| UploadError::Config(format!("Failed to write resume state: {e}")))?;
Ok(path)
}
pub fn load_for_file(file_path: &Path) -> Result<Option<Self>, UploadError> {
let dir = Self::resume_dir()?;
if !dir.exists() {
return Ok(None);
}
for entry in std::fs::read_dir(&dir)
.map_err(|e| UploadError::Config(format!("Failed to read resume directory: {e}")))?
{
let entry = entry
.map_err(|e| UploadError::Config(format!("Failed to read directory entry: {e}")))?;
let path = entry.path();
if path.extension().is_some_and(|ext| ext == "json") {
let content = std::fs::read_to_string(&path).map_err(|e| {
UploadError::Config(format!("Failed to read resume state: {e}"))
})?;
if let Ok(state) = serde_json::from_str::<Self>(&content)
&& state.file_path == file_path
{}
}
}
Ok(None)
}
pub fn delete(&self) -> Result<(), UploadError> {
let dir = Self::resume_dir()?;
let path = dir.join(self.state_filename());
if path.exists() {
std::fs::remove_file(&path)
.map_err(|e| UploadError::Config(format!("Failed to delete resume state: {e}")))?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_upload_state_serialization_roundtrip() {
let state = UploadState {
upload_url: "https://storage.googleapis.com/upload/abc123".to_string(),
uploaded_bytes: 8_388_608,
total_size: 25_000_000,
file_path: PathBuf::from("/tmp/video.mp4"),
title: "Test Video".to_string(),
workspace: "youtube".to_string(),
};
let json = serde_json::to_string(&state).unwrap();
let deserialized: UploadState = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.upload_url, state.upload_url);
assert_eq!(deserialized.uploaded_bytes, state.uploaded_bytes);
assert_eq!(deserialized.total_size, state.total_size);
assert_eq!(deserialized.file_path, state.file_path);
assert_eq!(deserialized.title, state.title);
assert_eq!(deserialized.workspace, state.workspace);
}
#[test]
fn test_upload_state_filename_is_deterministic() {
let state = UploadState {
upload_url: "https://example.com".to_string(),
uploaded_bytes: 0,
total_size: 100,
file_path: PathBuf::from("/tmp/video.mp4"),
title: "Test".to_string(),
workspace: "youtube".to_string(),
};
let name1 = state.state_filename();
let name2 = state.state_filename();
assert_eq!(name1, name2);
assert!(name1.ends_with(".json"));
}
#[test]
fn test_upload_state_different_files_different_names() {
let state1 = UploadState {
upload_url: String::new(),
uploaded_bytes: 0,
total_size: 100,
file_path: PathBuf::from("/tmp/video1.mp4"),
title: "Video 1".to_string(),
workspace: "youtube".to_string(),
};
let state2 = UploadState {
upload_url: String::new(),
uploaded_bytes: 0,
total_size: 100,
file_path: PathBuf::from("/tmp/video2.mp4"),
title: "Video 2".to_string(),
workspace: "youtube".to_string(),
};
assert_ne!(state1.state_filename(), state2.state_filename());
}
#[test]
fn test_resume_dir_is_under_config() {
let dir = UploadState::resume_dir().unwrap();
assert!(dir.to_string_lossy().contains("youtube-uploader"));
assert!(dir.to_string_lossy().contains("resume"));
}
#[test]
fn test_upload_state_save_and_load_roundtrip() {
let dir = std::env::temp_dir().join("vu_test_resume_roundtrip");
std::fs::create_dir_all(&dir).unwrap();
let state = UploadState {
upload_url: "https://storage.googleapis.com/upload/test".to_string(),
uploaded_bytes: 1_048_576,
total_size: 10_000_000,
file_path: PathBuf::from(dir.join("video.mp4")),
title: "Resume Test".to_string(),
workspace: "youtube".to_string(),
};
let saved_path = state.save().unwrap();
assert!(saved_path.exists());
let content = std::fs::read_to_string(&saved_path).unwrap();
let loaded: UploadState = serde_json::from_str(&content).unwrap();
assert_eq!(loaded.upload_url, state.upload_url);
assert_eq!(loaded.uploaded_bytes, state.uploaded_bytes);
assert_eq!(loaded.total_size, state.total_size);
assert_eq!(loaded.title, state.title);
state.delete().unwrap();
assert!(!saved_path.exists());
std::fs::remove_dir_all(&dir).ok();
}
}