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:
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user