feat(rust): port state.py, globalstore.py and manifest appending

- src/state.rs: per-run state.json (status/attempts/error/files/updated),
  corrupt-file -> *.json.bak restart, atomic tmp+rename save, mark() parity
  (attempts increments, None fields never clear existing values)
- src/globalstore.rs: cross-run urls.json at XDG_DATA_HOME or
  ~/.local/share/dlphelper/urls.json, disabled-store no-op
- src/manifest.rs: append_manifest as compact JSONL, UTF-8
- unknown/extra JSON fields are preserved on round-trip so both versions can
  read each other's artifacts (schema contract per AGENTS.md)
- 14 unit tests
This commit is contained in:
2026-10-09 13:27:59 +02:00
parent 271fb3bf68
commit 59a89fa06a
8 changed files with 1381 additions and 4 deletions
+193
View File
@@ -0,0 +1,193 @@
//! Cross-run, cross-channel record of completed URLs — Rust port of
//! `version-python/dlphelper/globalstore.py`.
//!
//! state.json only knows about one OUT_DIR; this store (default
//! `~/.local/share/dlphelper/urls.json`) records every successfully
//! downloaded URL once, forever, regardless of OUT_DIR — so the same gofile
//! link posted in a second channel is not downloaded again.
use std::collections::BTreeMap;
use std::fs;
use std::path::PathBuf;
use log::warn;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::util::{now_local_iso, save_sorted};
/// Per-URL record in urls.json: `{"status", "updated", "channel", "host",
/// "file_count", ...}`. Only fields actually set are written (the python
/// version never writes `attempts`/`files` here).
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct GlobalRecord {
#[serde(default)]
pub status: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub updated: Option<String>,
/// channel / host / file_count and any unknown fields.
#[serde(flatten, default)]
pub extra: BTreeMap<String, Value>,
}
impl GlobalRecord {
pub fn is_done(&self) -> bool {
self.status == "ok"
}
}
/// Default location: `$XDG_DATA_HOME/dlphelper/urls.json`, else
/// `~/.local/share/dlphelper/urls.json`. An empty XDG_DATA_HOME falls back to
/// home, matching the python version.
pub fn default_global_path() -> Option<PathBuf> {
if let Ok(xdg) = std::env::var("XDG_DATA_HOME") {
if !xdg.is_empty() {
return Some(PathBuf::from(xdg).join("dlphelper").join("urls.json"));
}
}
dirs::home_dir().map(|home| {
home.join(".local")
.join("share")
.join("dlphelper")
.join("urls.json")
})
}
/// JSON file mapping url -> record. A `None` path disables the store
/// (all writes become no-ops), like passing `gstore=None` in python.
pub struct GlobalStore {
pub path: Option<PathBuf>,
pub records: BTreeMap<String, GlobalRecord>,
}
impl GlobalStore {
pub fn disabled() -> Self {
Self {
path: None,
records: BTreeMap::new(),
}
}
/// Load urls.json; a corrupt or unreadable file is moved to `*.json.bak`
/// and we start fresh (same behavior as the python version).
pub fn load(path: impl Into<PathBuf>) -> Self {
let path = path.into();
if path.exists() {
let parsed = fs::read_to_string(&path).ok().and_then(|text| {
serde_json::from_str::<BTreeMap<String, GlobalRecord>>(&text).ok()
});
if let Some(records) = parsed {
return Self {
path: Some(path),
records,
};
}
let backup = path.with_extension("json.bak");
warn!(
"Corrupt global store {} ; backed up to {}",
path.display(),
backup.display()
);
let _ = fs::rename(&path, &backup);
}
Self {
path: Some(path),
records: BTreeMap::new(),
}
}
pub fn is_done(&self, url: &str) -> bool {
self.records.get(url).is_some_and(GlobalRecord::is_done)
}
/// Record a URL as done (plus attribution fields when known), then save.
/// No-ops when the store is disabled.
pub fn mark(
&mut self,
url: &str,
channel: Option<&str>,
host: Option<&str>,
file_count: Option<u64>,
) -> anyhow::Result<()> {
if self.path.is_none() {
return Ok(());
}
let rec = self.records.entry(url.to_string()).or_default();
rec.status = "ok".to_string();
if let Some(c) = channel {
rec.extra.insert("channel".into(), Value::from(c));
}
if let Some(h) = host {
rec.extra.insert("host".into(), Value::from(h));
}
if let Some(n) = file_count {
rec.extra.insert("file_count".into(), Value::from(n));
}
rec.updated = Some(now_local_iso());
self.save()
}
pub fn save(&self) -> anyhow::Result<()> {
match &self.path {
Some(path) => save_sorted(path, &self.records),
None => Ok(()),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn disabled_store_is_a_noop() {
let mut gs = GlobalStore::disabled();
gs.mark("u", Some("c"), Some("gofile"), Some(2)).unwrap();
assert!(gs.records.is_empty());
}
#[test]
fn mark_persists_and_reloads() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("urls.json");
{
let mut gs = GlobalStore::load(&path);
gs.mark("u1", Some("chan"), Some("gofile"), Some(3))
.unwrap();
gs.mark("u2", None, None, None).unwrap();
}
let gs = GlobalStore::load(&path);
assert!(gs.is_done("u1"));
assert!(gs.is_done("u2"));
assert!(!gs.is_done("u3"));
let v: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(v["u1"]["status"], "ok");
assert_eq!(v["u1"]["channel"], "chan");
assert_eq!(v["u1"]["host"], "gofile");
assert_eq!(v["u1"]["file_count"], 3);
// no attempts/files keys here — that's state.json's job
assert!(v["u1"].get("attempts").is_none());
assert!(v["u2"].get("channel").is_none());
}
#[test]
fn corrupt_store_is_backed_up() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("urls.json");
fs::write(&path, "{not json").unwrap();
let gs = GlobalStore::load(&path);
assert!(gs.records.is_empty());
assert!(dir.path().join("urls.json.bak").exists());
}
#[test]
fn default_path_honors_empty_xdg_fallback() {
// Only assert shape; don't touch the process env (tests run in parallel).
if default_global_path().is_none() {
panic!("home dir should be discoverable in tests");
}
}
}
+7 -1
View File
@@ -1,3 +1,9 @@
mod globalstore;
mod manifest;
mod state;
mod util;
fn main() {
println!("dlphelper {version} — Rust rewrite scaffold; modules are ported step by step, see version-rust/README.md", version = env!("CARGO_PKG_VERSION"));
env_logger::init();
println!("dlphelper {}", env!("CARGO_PKG_VERSION"));
}
+101
View File
@@ -0,0 +1,101 @@
//! manifest.jsonl — one compact JSON line per downloaded file — Rust port of
//! `append_manifest` from `version-python/dlphelper/state.py`.
//!
//! Row layout (all attribution fields optional except channel/url):
//! `{"channel", "url", "host", "message_id", "timestamp", "author", "path",
//! "size", ...}` — unknown extra fields are preserved on round-trip.
use std::collections::BTreeMap;
use std::fs::OpenOptions;
use std::io::Write;
use std::path::Path;
use anyhow::Context as _;
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ManifestRow {
pub channel: String,
pub url: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub host: Option<String>,
/// Message ids from Discord exports may be numeric or string.
#[serde(skip_serializing_if = "Option::is_none")]
pub message_id: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub timestamp: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub author: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub size: Option<u64>,
/// Any additional fields merged in by the caller.
#[serde(flatten, default)]
pub extra: BTreeMap<String, Value>,
}
/// Append one compact JSON line (UTF-8, like python `ensure_ascii=False`).
pub fn append_manifest(manifest_path: impl AsRef<Path>, row: &ManifestRow) -> anyhow::Result<()> {
let path = manifest_path.as_ref();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("creating {}", parent.display()))?;
}
let mut fh = OpenOptions::new()
.create(true)
.append(true)
.open(path)
.with_context(|| format!("opening {}", path.display()))?;
fh.write_all(serde_json::to_string(row)?.as_bytes())?;
fh.write_all(b"\n")?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn row(path: &str) -> ManifestRow {
ManifestRow {
channel: "123".into(),
url: "https://gofile.io/d/abc".into(),
host: Some("gofile".into()),
message_id: Some(Value::from(987654)),
timestamp: Some("2026-10-09T13:00:00".into()),
author: Some("tester".into()),
path: Some(path.into()),
size: Some(4567),
extra: BTreeMap::new(),
}
}
#[test]
fn appends_jsonl_and_creates_parents() {
let dir = tempfile::tempdir().unwrap();
let manifest = dir.path().join("a").join("b").join("manifest.jsonl");
append_manifest(&manifest, &row("/out/x.7z")).unwrap();
append_manifest(&manifest, &row("/out/y.7z")).unwrap();
let text = fs::read_to_string(&manifest).unwrap();
let lines: Vec<&str> = text.lines().collect();
assert_eq!(lines.len(), 2);
let second: ManifestRow = serde_json::from_str(lines[1]).unwrap();
assert_eq!(second.path.as_deref(), Some("/out/y.7z"));
assert_eq!(second.channel, "123");
assert_eq!(second.message_id, Some(Value::from(987654)));
}
#[test]
fn utf8_is_not_ascii_escaped() {
let dir = tempfile::tempdir().unwrap();
let manifest = dir.path().join("manifest.jsonl");
let mut r = row("/out/x.7z");
r.author = Some("Zähni".into());
append_manifest(&manifest, &r).unwrap();
let text = fs::read_to_string(&manifest).unwrap();
assert!(text.contains("Zähni"));
}
}
+234
View File
@@ -0,0 +1,234 @@
//! Per-run persistent state (state.json) — Rust port of
//! `version-python/dlphelper/state.py`. Makes runs resumable and failures
//! trackable: re-runs skip URLs with status "ok", failed URLs are retried.
//!
//! Schema contract with the python version:
//!
//! ```json
//! { "<url>": { "status": "ok" | "failed" | "error" | ...,
//! "attempts": 3, "error": "...", "updated": "2026-10-09T13:00:00",
//! "files": [{"path": "...", "size": 123}] } }
//! ```
//!
//! Unknown fields written by other tools survive load+save (serde flatten),
//! so both versions can consume each other's files.
use std::collections::BTreeMap;
use std::fs;
use std::path::PathBuf;
use log::warn;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::util::{now_local_iso, save_sorted};
/// One downloaded file as recorded in `state.json`.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct FileEntry {
pub path: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub size: Option<u64>,
/// Unknown extra fields, preserved for schema compatibility.
#[serde(flatten, default)]
pub extra: BTreeMap<String, Value>,
}
/// Per-URL record in state.json.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Record {
/// "pending" | "ok" | "failed" | "error" — open set, like the python version.
#[serde(default)]
pub status: String,
#[serde(default)]
pub attempts: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub files: Vec<FileEntry>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub updated: Option<String>,
#[serde(flatten, default)]
pub extra: BTreeMap<String, Value>,
}
impl Record {
pub fn is_done(&self) -> bool {
self.status == "ok"
}
}
/// JSON-backed state store for one run directory.
pub struct State {
pub path: PathBuf,
pub records: BTreeMap<String, Record>,
}
impl State {
/// Load state.json; a corrupt or unreadable file is moved to `*.json.bak`
/// and we start fresh (same behavior as the python version).
pub fn load(path: impl Into<PathBuf>) -> Self {
let path = path.into();
if path.exists() {
let parsed = fs::read_to_string(&path)
.ok()
.and_then(|text| serde_json::from_str::<BTreeMap<String, Record>>(&text).ok());
if let Some(records) = parsed {
return Self { path, records };
}
let backup = path.with_extension("json.bak");
warn!(
"Corrupt state file {} ; moving to {} and starting fresh",
path.display(),
backup.display()
);
let _ = fs::rename(&path, &backup);
}
Self {
path,
records: BTreeMap::new(),
}
}
pub fn is_done(&self, url: &str) -> bool {
self.records.get(url).is_some_and(Record::is_done)
}
/// The URLs from `urls` whose status is not "ok".
pub fn pending<'a>(&self, urls: &'a [String]) -> Vec<&'a str> {
urls.iter()
.map(String::as_str)
.filter(|u| !self.is_done(u))
.collect()
}
/// All URLs whose record is not "ok".
pub fn failures(&self) -> Vec<String> {
self.records
.iter()
.filter(|(_, r)| !r.is_done())
.map(|(u, _)| u.clone())
.collect()
}
/// Create-or-get the record for `url`, bump `attempts`, stamp `updated`,
/// apply `f`, then save — mirrors python `mark(url, **fields)`.
///
/// Parity note: the python version skips `None` fields, so a value set by
/// an earlier mark (e.g. an old error message) is *not* cleared by a later
/// mark that doesn't mention it. The closure decides what to set; nothing
/// is cleared implicitly.
pub fn mark(&mut self, url: &str, f: impl FnOnce(&mut Record)) -> anyhow::Result<&Record> {
let fresh = !self.records.contains_key(url);
let rec = self.records.entry(url.to_string()).or_default();
if fresh && rec.status.is_empty() {
rec.status = "pending".to_string();
}
f(rec);
rec.attempts += 1;
rec.updated = Some(now_local_iso());
self.save()?;
Ok(self.records.get(url).expect("entry just inserted"))
}
/// Atomic save: write `*.json.tmp`, then rename over state.json.
pub fn save(&self) -> anyhow::Result<()> {
save_sorted(&self.path, &self.records)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn mark_creates_record_and_increments_attempts() {
let dir = tempfile::tempdir().unwrap();
let mut st = State::load(dir.path().join("state.json"));
st.mark("http://x/1", |r| r.status = "ok".into()).unwrap();
let rec = &st.records["http://x/1"];
assert_eq!(rec.status, "ok");
assert_eq!(rec.attempts, 1);
assert_eq!(rec.updated.as_deref().map(str::len), Some(19));
assert!(st.is_done("http://x/1"));
assert!(st.failures().is_empty());
}
#[test]
fn failed_then_ok_keeps_old_error_like_python() {
let dir = tempfile::tempdir().unwrap();
let mut st = State::load(dir.path().join("state.json"));
st.mark("u", |r| {
r.status = "failed".into();
r.error = Some("boom".into());
})
.unwrap();
// python: mark(url, status="ok", error=None) does NOT clear the error
st.mark("u", |r| r.status = "ok".into()).unwrap();
let rec = &st.records["u"];
assert_eq!(rec.error.as_deref(), Some("boom"));
assert_eq!(rec.attempts, 2);
assert_eq!(st.failures(), Vec::<String>::new());
}
#[test]
fn pending_and_failures() {
let dir = tempfile::tempdir().unwrap();
let mut st = State::load(dir.path().join("state.json"));
st.mark("a", |r| r.status = "ok".into()).unwrap();
st.mark("b", |r| r.status = "failed".into()).unwrap();
st.mark("c", |_r| {}).unwrap();
let urls = vec!["a".into(), "b".into(), "c".into()];
assert_eq!(st.pending(&urls), vec!["b", "c"]);
assert_eq!(st.failures(), vec!["b".to_string(), "c".to_string()]);
}
#[test]
fn corrupt_file_is_backed_up_and_restarted() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("state.json");
fs::write(&path, "not json{").unwrap();
let st = State::load(&path);
assert!(st.records.is_empty());
assert!(dir.path().join("state.json.bak").exists());
assert!(!path.exists());
}
#[test]
fn unknown_fields_survive_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("state.json");
fs::write(
&path,
r#"{"http://x/1": {"status": "ok", "attempts": 3, "custom": 42}}"#,
)
.unwrap();
let st = State::load(&path);
assert!(st.is_done("http://x/1"));
st.save().unwrap();
let v: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(v["http://x/1"]["custom"], 42);
assert_eq!(v["http://x/1"]["attempts"], 3);
}
#[test]
fn files_are_recorded_and_persisted() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("state.json");
let mut st = State::load(&path);
st.mark("u", |r| {
r.status = "ok".into();
r.files.push(FileEntry {
path: "/out/x.7z".into(),
size: Some(1234),
extra: BTreeMap::new(),
});
})
.unwrap();
let reloaded = State::load(&path);
assert_eq!(reloaded.records["u"].files.len(), 1);
assert_eq!(reloaded.records["u"].files[0].size, Some(1234));
}
}
+62
View File
@@ -0,0 +1,62 @@
//! Small shared helpers: timestamps and the atomic sorted-JSON save used by
//! both state stores (mirrors `json.dumps(..., indent=2, sort_keys=True)` +
//! tmp-file rename from the python version).
use std::fs;
use std::path::Path;
use chrono::Local;
use serde::Serialize;
/// Local time in the exact `"%Y-%m-%dT%H:%M:%S"` format the python version writes.
pub fn now_local_iso() -> String {
Local::now().format("%Y-%m-%dT%H:%M:%S").to_string()
}
/// Write `value` pretty-printed, then atomically rename over `path`.
/// The caller must keep keys in a `BTreeMap` so the output is key-sorted,
/// like the python version's `sort_keys=True`.
pub fn save_sorted(path: &Path, value: &impl Serialize) -> anyhow::Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let tmp = path.with_extension("json.tmp");
fs::write(&tmp, serde_json::to_string_pretty(value)?)?;
fs::rename(&tmp, path)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeMap;
#[test]
fn timestamp_format_matches_python() {
let t = now_local_iso();
assert_eq!(t.len(), 19, "expected YYYY-MM-DDTHH:MM:SS, got {t}");
let bytes = t.as_bytes();
for (i, b) in bytes.iter().enumerate() {
let is_sep = matches!(i, 4 | 7 | 10 | 13 | 16);
if is_sep {
assert!(!b.is_ascii_digit(), "expected separator at {i} in {t}");
} else {
assert!(b.is_ascii_digit(), "expected digit at {i} in {t}");
}
}
}
#[test]
fn save_sorted_is_atomic_and_sorted() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("sub").join("x.json");
let mut map = BTreeMap::new();
map.insert("b", 2);
map.insert("a", 1);
save_sorted(&path, &map).unwrap();
let text = fs::read_to_string(&path).unwrap();
assert!(text.find("\"a\"").unwrap() < text.find("\"b\"").unwrap());
// tmp file was renamed away
assert!(!dir.path().join("sub").join("x.json.tmp").exists());
}
}