use std::fs::{self, File};
use std::io::{self, Read, Write};
use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};
const EXCLUDED_DIRS: &[&str] = &["profile", "var"];
const EXCLUDED_FILES: &[&str] = &["updates2.dau", "updates.txt", "developer_options.txt"];
#[derive(Debug, Clone)]
struct FileEntry {
path: String,
md5: String,
size: u64,
modified: SystemTime,
}
fn format_datetime(time: SystemTime) -> String {
let secs = time
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let sec = secs % 60;
let min = (secs / 60) % 60;
let hour = (secs / 3600) % 24;
let days = secs / 86400;
let (year, month, day) = days_to_ymd(days as u32);
format!(
"{:04}-{:02}-{:02}T{:02}:{:02}:{:02}",
year, month, day, hour, min, sec
)
}
fn is_leap(year: u32) -> bool {
(year.is_multiple_of(4) && !year.is_multiple_of(100)) || year.is_multiple_of(400)
}
fn days_to_ymd(mut days: u32) -> (u32, u32, u32) {
let mut year = 1970u32;
loop {
let y_days = if is_leap(year) { 366 } else { 365 };
if days < y_days {
break;
}
days -= y_days;
year += 1;
}
let month_days = [
31u32,
if is_leap(year) { 29 } else { 28 },
31,
30,
31,
30,
31,
31,
30,
31,
30,
31,
];
let mut month = 1u32;
for &md in &month_days {
if days < md {
break;
}
days -= md;
month += 1;
}
(year, month, days + 1)
}
pub(crate) fn generate_update_files(root_dir: &Path) -> io::Result<usize> {
let entries = collect_files(root_dir)?;
let count = entries.len();
if entries.is_empty() {
return Ok(0);
}
generate_updates_txt(root_dir, &entries)?;
let ghost_master = root_dir.join("ghost/master");
if ghost_master.is_dir() {
fs::copy(
root_dir.join("updates.txt"),
ghost_master.join("updates.txt"),
)?;
}
Ok(count)
}
fn collect_files(root_dir: &Path) -> io::Result<Vec<FileEntry>> {
let mut entries = Vec::new();
collect_files_recursive(root_dir, root_dir, &mut entries)?;
entries.sort_by(|a, b| a.path.cmp(&b.path));
Ok(entries)
}
fn collect_files_recursive(
root_dir: &Path,
current_dir: &Path,
entries: &mut Vec<FileEntry>,
) -> io::Result<()> {
let read_dir = match fs::read_dir(current_dir) {
Ok(rd) => rd,
Err(_) => return Ok(()),
};
for entry in read_dir.flatten() {
let file_type = entry.file_type()?;
if file_type.is_symlink() {
continue;
}
let path = entry.path();
let name = entry.file_name();
if file_type.is_dir() {
if EXCLUDED_DIRS.iter().any(|&d| d == name) {
continue;
}
collect_files_recursive(root_dir, &path, entries)?;
} else if file_type.is_file() {
if EXCLUDED_FILES.iter().any(|&f| f == name) {
continue;
}
let relative_path = path
.strip_prefix(root_dir)
.map_err(|e| io::Error::other(e.to_string()))?
.to_string_lossy()
.replace('\\', "/");
let metadata = fs::metadata(&path)?;
entries.push(FileEntry {
path: relative_path,
md5: calculate_md5(&path)?,
size: metadata.len(),
modified: metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH),
});
}
}
Ok(())
}
fn calculate_md5(path: &Path) -> io::Result<String> {
let mut file = File::open(path)?;
let mut context = md5::Context::new();
let mut buffer = [0u8; 8192];
loop {
let bytes_read = file.read(&mut buffer)?;
if bytes_read == 0 {
break;
}
context.consume(&buffer[..bytes_read]);
}
let digest = context.finalize();
Ok(format!("{:032x}", digest))
}
fn generate_updates_txt(root_dir: &Path, entries: &[FileEntry]) -> io::Result<()> {
let output_path = root_dir.join("updates.txt");
let mut file = File::create(&output_path)?;
file.write_all(b"charset,UTF-8\r\n")?;
for entry in entries {
let date = format_datetime(entry.modified);
let record = format!(
"file,{}\x01{}\x01size={}\x01date={}\x01\r\n",
entry.path, entry.md5, entry.size, date
);
file.write_all(record.as_bytes())?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_calculate_md5() {
let temp = TempDir::new().unwrap();
let test_file = temp.path().join("test.txt");
fs::write(&test_file, "Hello, World!").unwrap();
let md5 = calculate_md5(&test_file).unwrap();
assert_eq!(md5, "65a8e27d8879283831b664bd8b7f0ad4");
}
#[test]
fn test_collect_files_excludes_update_files() {
let temp = TempDir::new().unwrap();
fs::write(temp.path().join("test.txt"), "content").unwrap();
fs::write(temp.path().join("updates2.dau"), "should be excluded").unwrap();
fs::write(temp.path().join("updates.txt"), "should be excluded").unwrap();
let profile_dir = temp.path().join("profile");
fs::create_dir(&profile_dir).unwrap();
fs::write(profile_dir.join("user.txt"), "user data").unwrap();
let entries = collect_files(temp.path()).unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].path, "test.txt");
}
#[test]
fn test_generate_update_files() {
let temp = TempDir::new().unwrap();
let ghost_dir = temp.path().join("ghost/master");
fs::create_dir_all(&ghost_dir).unwrap();
fs::write(ghost_dir.join("descript.txt"), "test content").unwrap();
let shell_dir = temp.path().join("shell/master");
fs::create_dir_all(&shell_dir).unwrap();
fs::write(shell_dir.join("surface0.png"), "fake png").unwrap();
let count = generate_update_files(temp.path()).unwrap();
assert_eq!(count, 2);
assert!(!temp.path().join("updates2.dau").exists());
assert!(temp.path().join("updates.txt").exists());
let content = fs::read_to_string(temp.path().join("updates.txt")).unwrap();
assert!(content.starts_with("charset,UTF-8\r\n"));
assert!(content.contains("file,ghost/master/descript.txt"));
assert!(content.contains("file,shell/master/surface0.png"));
assert!(content.contains("size="));
assert!(content.contains("date="));
}
#[test]
fn test_format_datetime() {
assert_eq!(format_datetime(UNIX_EPOCH), "1970-01-01T00:00:00");
let t = UNIX_EPOCH + std::time::Duration::from_secs(1704067200);
assert_eq!(format_datetime(t), "2024-01-01T00:00:00");
}
#[test]
fn test_format_datetime_leap_year_branches() {
let t = UNIX_EPOCH + std::time::Duration::from_secs(1_709_210_096);
assert_eq!(format_datetime(t), "2024-02-29T12:34:56");
let t = UNIX_EPOCH + std::time::Duration::from_secs(951_782_400);
assert_eq!(format_datetime(t), "2000-02-29T00:00:00");
let t = UNIX_EPOCH + std::time::Duration::from_secs(4_107_542_400);
assert_eq!(format_datetime(t), "2100-03-01T00:00:00");
let t = UNIX_EPOCH + std::time::Duration::from_secs(1_704_067_199);
assert_eq!(format_datetime(t), "2023-12-31T23:59:59");
}
#[test]
fn test_generate_update_files_empty_dir_creates_nothing() {
let temp = TempDir::new().unwrap();
let count = generate_update_files(temp.path()).unwrap();
assert_eq!(count, 0);
assert!(!temp.path().join("updates.txt").exists());
}
#[test]
fn test_generate_update_files_nonexistent_root_returns_zero() {
let temp = TempDir::new().unwrap();
let missing = temp.path().join("no_such_dir");
let count = generate_update_files(&missing).unwrap();
assert_eq!(count, 0);
}
#[test]
fn test_collect_files_excludes_var_dir() {
let temp = TempDir::new().unwrap();
fs::write(temp.path().join("keep.txt"), "keep").unwrap();
let var_dir = temp.path().join("var");
fs::create_dir(&var_dir).unwrap();
fs::write(var_dir.join("state.txt"), "state").unwrap();
let entries = collect_files(temp.path()).unwrap();
let paths: Vec<&str> = entries.iter().map(|e| e.path.as_str()).collect();
assert_eq!(paths, vec!["keep.txt"]);
}
#[test]
fn test_collect_files_excludes_developer_options() {
let temp = TempDir::new().unwrap();
fs::write(temp.path().join("keep.txt"), "keep").unwrap();
fs::write(temp.path().join("developer_options.txt"), "dev only").unwrap();
let entries = collect_files(temp.path()).unwrap();
let paths: Vec<&str> = entries.iter().map(|e| e.path.as_str()).collect();
assert_eq!(paths, vec!["keep.txt"]);
}
#[test]
fn test_updates_txt_entries_sorted_by_path() {
let temp = TempDir::new().unwrap();
fs::write(temp.path().join("zeta.txt"), "z").unwrap();
fs::write(temp.path().join("alpha.txt"), "a").unwrap();
let sub = temp.path().join("pkg");
fs::create_dir_all(&sub).unwrap();
fs::write(sub.join("inner.txt"), "i").unwrap();
fs::write(temp.path().join("pkg.txt"), "p").unwrap();
generate_update_files(temp.path()).unwrap();
let content = fs::read_to_string(temp.path().join("updates.txt")).unwrap();
let file_paths: Vec<&str> = content
.lines()
.filter(|l| l.starts_with("file,"))
.map(|l| {
l.strip_prefix("file,")
.unwrap()
.split('\x01')
.next()
.unwrap()
})
.collect();
assert_eq!(
file_paths,
vec!["alpha.txt", "pkg.txt", "pkg/inner.txt", "zeta.txt"]
);
}
#[test]
fn test_calculate_md5_empty_file() {
let temp = TempDir::new().unwrap();
let empty = temp.path().join("empty.bin");
fs::write(&empty, b"").unwrap();
assert_eq!(
calculate_md5(&empty).unwrap(),
"d41d8cd98f00b204e9800998ecf8427e"
);
}
#[test]
fn test_calculate_md5_multi_chunk_matches_one_shot() {
let temp = TempDir::new().unwrap();
let big = temp.path().join("big.bin");
let data: Vec<u8> = (0..20_000u32).map(|i| (i % 251) as u8).collect();
fs::write(&big, &data).unwrap();
let expected = format!("{:032x}", md5::compute(&data));
assert_eq!(calculate_md5(&big).unwrap(), expected);
}
#[test]
fn test_updates_txt_soh_delimiters() {
let temp = TempDir::new().unwrap();
fs::write(temp.path().join("test.txt"), "hello").unwrap();
generate_update_files(temp.path()).unwrap();
let bytes = fs::read(temp.path().join("updates.txt")).unwrap();
let content = String::from_utf8(bytes).unwrap();
assert!(content.starts_with("charset,UTF-8\r\n"));
let file_line = content.lines().find(|l| l.starts_with("file,")).unwrap();
let soh_count = file_line.bytes().filter(|&b| b == 0x01).count();
assert!(
soh_count >= 3,
"file line should have at least 3 SOH delimiters, got {soh_count}: {file_line:?}"
);
}
#[test]
fn test_updates_txt_copied_to_ghost_master() {
let temp = TempDir::new().unwrap();
let ghost_dir = temp.path().join("ghost/master");
fs::create_dir_all(&ghost_dir).unwrap();
fs::write(ghost_dir.join("descript.txt"), "desc").unwrap();
generate_update_files(temp.path()).unwrap();
assert!(temp.path().join("updates.txt").exists());
assert!(
ghost_dir.join("updates.txt").exists(),
"updates.txt should be copied to ghost/master/"
);
let root_content = fs::read_to_string(temp.path().join("updates.txt")).unwrap();
let copy_content = fs::read_to_string(ghost_dir.join("updates.txt")).unwrap();
assert_eq!(root_content, copy_content);
}
#[test]
fn test_updates_txt_excludes_self_deploy_dir() {
let temp = TempDir::new().unwrap();
let ghost_master = temp.path().join("ghost/master");
fs::create_dir_all(&ghost_master).unwrap();
fs::write(ghost_master.join("descript.txt"), "desc").unwrap();
fs::create_dir_all(ghost_master.join("dic")).unwrap();
fs::write(ghost_master.join("dic/foo.pasta"), "foo").unwrap();
let self_deploy = ghost_master.join("profile/pasta/pasta_scripts");
fs::create_dir_all(&self_deploy).unwrap();
fs::write(self_deploy.join("main.lua"), "-- framework script").unwrap();
fs::write(self_deploy.join(".md5"), "deadbeef").unwrap();
let count = generate_update_files(temp.path()).unwrap();
let content = fs::read_to_string(temp.path().join("updates.txt")).unwrap();
assert!(
content.contains("file,ghost/master/descript.txt"),
"descript.txt should be listed: {content:?}"
);
assert!(
content.contains("file,ghost/master/dic/foo.pasta"),
"dic/foo.pasta should be listed: {content:?}"
);
assert!(
!content.contains("profile"),
"no profile/ path must appear in updates.txt: {content:?}"
);
assert!(
!content.contains("main.lua"),
"self-deploy script must not be listed: {content:?}"
);
assert!(
!content.contains(".md5"),
".md5 marker must not be listed: {content:?}"
);
assert_eq!(count, 2, "only the 2 normal files should be counted");
}
#[test]
fn test_collect_files_excludes_updates_in_subdirs() {
let temp = TempDir::new().unwrap();
let sub = temp.path().join("ghost/master");
fs::create_dir_all(&sub).unwrap();
fs::write(sub.join("descript.txt"), "desc").unwrap();
fs::write(sub.join("updates.txt"), "should be excluded").unwrap();
fs::write(sub.join("updates2.dau"), "should be excluded").unwrap();
let entries = collect_files(temp.path()).unwrap();
let paths: Vec<&str> = entries.iter().map(|e| e.path.as_str()).collect();
assert!(
paths.contains(&"ghost/master/descript.txt"),
"descript.txt should be included"
);
assert!(
!paths.iter().any(|p| p.contains("updates.txt")),
"updates.txt should be excluded from listing"
);
assert!(
!paths.iter().any(|p| p.contains("updates2.dau")),
"updates2.dau should be excluded from listing"
);
}
}