Skip to main content

pfx_run/
archive.rs

1use chrono::{DateTime, FixedOffset, Local};
2use fs2::FileExt;
3use serde::{Deserialize, Serialize};
4use serde_json::{Value, json};
5use sha2::{Digest, Sha256};
6use std::fs;
7use std::io::{ErrorKind, Read, Write};
8use std::path::{Component, Path, PathBuf};
9
10#[derive(Clone, Copy, Debug, PartialEq, Eq)]
11pub enum State {
12    Archived,
13    Queued,
14    Conflict,
15    Refused,
16    Skipped,
17}
18
19#[derive(Debug)]
20enum Fail {
21    Conflict(String),
22    Retry(String),
23}
24
25impl Fail {
26    fn message(&self) -> &str {
27        match self {
28            Self::Conflict(message) | Self::Retry(message) => message,
29        }
30    }
31}
32
33impl From<String> for Fail {
34    fn from(message: String) -> Self {
35        Self::Retry(message)
36    }
37}
38
39impl From<&str> for Fail {
40    fn from(message: &str) -> Self {
41        Self::Retry(message.into())
42    }
43}
44
45impl State {
46    pub fn name(self) -> &'static str {
47        match self {
48            Self::Archived => "archived",
49            Self::Queued => "queued",
50            Self::Conflict => "conflict",
51            Self::Refused => "refused",
52            Self::Skipped => "skipped",
53        }
54    }
55}
56
57#[derive(Serialize, Deserialize)]
58struct Pending {
59    manifest: PathBuf,
60    folder: PathBuf,
61    root: PathBuf,
62}
63
64#[derive(Clone, Deserialize, Serialize, PartialEq, Eq)]
65struct CatalogFile {
66    name: String,
67    bytes: u64,
68    sha256: String,
69}
70
71#[derive(Clone, Deserialize, Serialize, PartialEq, Eq)]
72struct Catalog {
73    v: u8,
74    tool: String,
75    product: String,
76    shot: String,
77    version: String,
78    caller: String,
79    made: String,
80    path: String,
81    files: Vec<CatalogFile>,
82}
83
84pub fn local_made() -> String {
85    Local::now().to_rfc3339()
86}
87
88fn hash_io(path: &Path) -> std::io::Result<String> {
89    let mut file = fs::File::open(path)?;
90    let mut digest = Sha256::new();
91    let mut buffer = [0u8; 65536];
92    loop {
93        let read = file.read(&mut buffer)?;
94        if read == 0 {
95            break;
96        }
97        digest.update(&buffer[..read]);
98    }
99    Ok(hex::encode(digest.finalize()))
100}
101
102fn hash(path: &Path) -> Result<String, String> {
103    hash_io(path).map_err(|e| format!("{}: {e}", path.display()))
104}
105
106fn safe(part: &str) -> bool {
107    !part.is_empty()
108        && part != "."
109        && part != ".."
110        && part
111            .chars()
112            .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
113}
114
115const BUILT_ROOT: Option<&str> = option_env!("PFX_ARCHIVE_ROOT");
116
117fn root_online(root: &Path) -> bool {
118    if root.as_os_str().is_empty() || !root.exists() {
119        return false;
120    }
121    let Some(built) = BUILT_ROOT.filter(|built| root == Path::new(built)) else {
122        return true;
123    };
124    if !cfg!(target_os = "linux") {
125        return true;
126    }
127    fs::read_to_string("/proc/self/mountinfo").is_ok_and(|text| {
128        text.lines()
129            .any(|line| line.split_whitespace().nth(4) == Some(built))
130    })
131}
132
133pub fn folder(manifest: &Value) -> Result<PathBuf, String> {
134    let root = archive_root();
135    folder_at(manifest, &root)
136}
137
138fn root_in(env: &impl Fn(&str) -> Option<std::ffi::OsString>) -> Option<PathBuf> {
139    env("PITO_ARCHIVE")
140        .filter(|root| !root.is_empty())
141        .map(PathBuf::from)
142        .or_else(|| BUILT_ROOT.map(PathBuf::from))
143}
144
145fn archive_root() -> PathBuf {
146    root_in(&|key| std::env::var_os(key)).unwrap_or_default()
147}
148
149const TOOL: &str = "pfx";
150
151fn masters(root: &Path) -> PathBuf {
152    root.join("Masters")
153}
154
155fn catalog_file(root: &Path) -> PathBuf {
156    masters(root).join("catalog.jsonl")
157}
158
159pub const SAMPLES: &[&str] = &["checker", "sample"];
160
161pub fn is_tag(version: &str) -> bool {
162    let Some(rest) = version.strip_prefix('v') else {
163        return false;
164    };
165    let (numbers, pre) = match rest.split_once('-') {
166        Some((numbers, pre)) => (numbers, Some(pre)),
167        None => (rest, None),
168    };
169    let parts: Vec<&str> = numbers.split('.').collect();
170    parts.len() == 3
171        && parts
172            .iter()
173            .all(|part| !part.is_empty() && part.chars().all(|c| c.is_ascii_digit()))
174        && pre.is_none_or(|pre| {
175            pre.split('.').all(|part| {
176                !part.is_empty() && part.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
177            })
178        })
179}
180
181pub fn pinned(manifest: &Value) -> bool {
182    manifest["pinned"]
183        .as_bool()
184        .unwrap_or_else(|| manifest["version"].as_str().is_some_and(is_tag))
185}
186
187fn has_master(manifest: &Value) -> bool {
188    manifest["files"].as_array().is_some_and(|files| {
189        files.iter().any(|item| {
190            item["file"].as_str().is_some_and(|name| {
191                name.ends_with(".master.mp4")
192                    || name.ends_with(".master.png")
193                    || name.ends_with(".alpha.master.mov")
194            })
195        })
196    })
197}
198
199fn under_tmp(root: &Path) -> bool {
200    root.components().any(|part| part.as_os_str() == "tmp")
201}
202
203pub fn skip_reason(manifest: &Value, no_archive: bool) -> Option<&'static str> {
204    skip_reason_in(manifest, no_archive, |key| std::env::var_os(key))
205}
206
207fn run_reason(env: &impl Fn(&str) -> Option<std::ffi::OsString>) -> Option<&'static str> {
208    if env("PFX_DIRECT").is_some_and(|value| value == "1")
209        || env("PITO_ARCHIVE").is_some_and(|root| under_tmp(Path::new(&root)))
210    {
211        return Some("test");
212    }
213    if env("RIDE_APP").is_some_and(|value| !value.is_empty()) {
214        return Some("ride");
215    }
216    None
217}
218
219fn skip_reason_in(
220    manifest: &Value,
221    no_archive: bool,
222    env: impl Fn(&str) -> Option<std::ffi::OsString>,
223) -> Option<&'static str> {
224    if no_archive {
225        return Some("no-archive");
226    }
227    if let Some(reason) = run_reason(&env) {
228        return Some(reason);
229    }
230    if root_in(&env).is_none() {
231        return Some("no-root");
232    }
233    if manifest["product"]
234        .as_str()
235        .is_some_and(|product| SAMPLES.contains(&product))
236    {
237        return Some("sample");
238    }
239    if !has_master(manifest) {
240        return Some("frames-only");
241    }
242    if !pinned(manifest) {
243        return Some("unpinned");
244    }
245    None
246}
247
248pub fn reason(manifest_path: &Path, no_archive: bool) -> Option<&'static str> {
249    let manifest: Value = fs::read(manifest_path)
250        .ok()
251        .and_then(|bytes| serde_json::from_slice(&bytes).ok())
252        .unwrap_or(Value::Null);
253    skip_reason(&manifest, no_archive)
254}
255
256fn caller(value: Option<&str>) -> String {
257    let mut clean = String::new();
258    for character in value.unwrap_or("").chars() {
259        let lower = character.to_ascii_lowercase();
260        if lower.is_ascii_lowercase() || lower.is_ascii_digit() {
261            clean.push(lower);
262        } else if !clean.is_empty() && !clean.ends_with('-') {
263            clean.push('-');
264        }
265    }
266    let clean = clean.trim_end_matches('-');
267    if clean.is_empty() {
268        "manual".into()
269    } else {
270        clean.into()
271    }
272}
273
274#[cfg(not(test))]
275fn invoker() -> Option<String> {
276    std::env::var("PITO_INVOKER").ok()
277}
278
279#[cfg(test)]
280fn invoker() -> Option<String> {
281    None
282}
283
284fn folder_at(manifest: &Value, root: &Path) -> Result<PathBuf, String> {
285    folder_with_caller(manifest, root, invoker().as_deref())
286}
287
288fn folder_with_caller(
289    manifest: &Value,
290    root: &Path,
291    invoker: Option<&str>,
292) -> Result<PathBuf, String> {
293    let product = manifest["product"]
294        .as_str()
295        .ok_or("archive: missing product")?;
296    let kind = manifest["kind"].as_str().ok_or("archive: missing kind")?;
297    let shot = manifest["shot"].as_str().unwrap_or(kind);
298    let version = manifest["version"]
299        .as_str()
300        .ok_or("archive: missing version")?;
301    let made = manifest["made"].as_str().ok_or("archive: missing made")?;
302    leaf_under(root, product, shot, version, made, &caller(invoker))
303}
304
305fn leaf_under(
306    root: &Path,
307    product: &str,
308    shot: &str,
309    version: &str,
310    made: &str,
311    caller: &str,
312) -> Result<PathBuf, String> {
313    for (what, part) in [
314        ("product", product),
315        ("shot", shot),
316        ("version", version),
317        ("caller", caller),
318    ] {
319        if !safe(part) {
320            return Err(format!("the {what} {part:?} is not a safe folder name"));
321        }
322    }
323    let date = DateTime::parse_from_rfc3339(made).map_err(|e| format!("archive made: {e}"))?;
324    Ok(masters(root)
325        .join(TOOL)
326        .join(product)
327        .join(shot)
328        .join(format!(
329            "{}_{}_{}_{}",
330            date.format("%Y-%m-%d"),
331            date.format("%H%M%S"),
332            version,
333            caller
334        )))
335}
336
337fn selected(name: &str, still: bool) -> bool {
338    name.ends_with(".master.mp4")
339        || name.ends_with(".alpha.master.mov")
340        || name.ends_with(".master.png")
341        || name.ends_with(".poster.png")
342        || name.contains(".cutouts/")
343        || (still && !name.contains('/') && name.ends_with(".png"))
344}
345
346fn files(manifest: &Value) -> Result<Vec<(PathBuf, String)>, String> {
347    let mut out = Vec::new();
348    let listed = manifest["files"]
349        .as_array()
350        .ok_or("archive: missing files")?;
351    let still = listed.iter().any(|item| {
352        item["file"]
353            .as_str()
354            .is_some_and(|name| !name.contains('/') && name.ends_with(".master.png"))
355    });
356    for item in listed {
357        let name = item["file"].as_str().ok_or("archive: file has no name")?;
358        if !selected(name, still) {
359            continue;
360        }
361        let path = Path::new(name);
362        if path
363            .components()
364            .any(|part| !matches!(part, Component::Normal(_)))
365        {
366            return Err(format!("archive: unsafe file {name}"));
367        }
368        let checksum = item["sha256"]
369            .as_str()
370            .ok_or("archive: file has no sha256")?;
371        out.push((path.to_path_buf(), checksum.to_owned()));
372    }
373    Ok(out)
374}
375
376fn write_manifest(path: &Path, manifest: &Value) -> Result<(), String> {
377    fs::write(
378        path,
379        serde_json::to_vec_pretty(manifest).map_err(|e| e.to_string())?,
380    )
381    .map_err(|e| format!("{}: {e}", path.display()))
382}
383
384fn leaf_name(root: &Path, folder: &Path) -> String {
385    folder
386        .strip_prefix(masters(root))
387        .unwrap_or(folder)
388        .to_string_lossy()
389        .into_owned()
390}
391
392fn catalog_of(manifest: &Value) -> Result<Option<Catalog>, String> {
393    match &manifest["archive"]["catalog"] {
394        Value::Null => Ok(None),
395        Value::String(line) => serde_json::from_str(line)
396            .map(Some)
397            .map_err(|e| format!("archive catalog: {e}")),
398        object => serde_json::from_value(object.clone())
399            .map(Some)
400            .map_err(|e| format!("archive catalog: {e}")),
401    }
402}
403
404fn set_failure(manifest: &mut Value, root: &Path, folder: &Path, state: State, error: &str) {
405    set_state(manifest, root, folder, state);
406    manifest["archive"]["error"] = json!(error);
407}
408
409fn set_state(manifest: &mut Value, root: &Path, folder: &Path, state: State) {
410    let catalog = match catalog_of(manifest) {
411        Ok(Some(catalog)) => serde_json::to_value(catalog).unwrap_or(Value::Null),
412        _ => manifest["archive"]["catalog"].clone(),
413    };
414    manifest["archive"] =
415        json!({"leaf": leaf_name(root, folder), "state": state.name(), "catalog": catalog});
416}
417
418fn instant(value: &Value) -> Result<DateTime<FixedOffset>, String> {
419    let text = value.as_str().ok_or("archive: missing made")?;
420    DateTime::parse_from_rfc3339(text).map_err(|e| format!("archive made: {e}"))
421}
422
423fn numbered(base: &Path, number: usize) -> Result<PathBuf, String> {
424    if number < 2 {
425        return Ok(base.to_path_buf());
426    }
427    Ok(base.with_file_name(format!(
428        "{}-{number}",
429        base.file_name()
430            .ok_or("archive: invalid leaf")?
431            .to_string_lossy()
432    )))
433}
434
435fn same_render(leaf: &Path, manifest: &Value) -> Result<bool, Fail> {
436    let at = |why: &dyn std::fmt::Display| format!("{}: {why}", leaf.display());
437    let conflict = |why: &dyn std::fmt::Display| {
438        Fail::Conflict(format!("archive conflict at {}: {why}", leaf.display()))
439    };
440    let bytes = match fs::read(leaf.join("manifest.json")) {
441        Ok(bytes) => bytes,
442        Err(e) if matches!(e.kind(), ErrorKind::NotFound | ErrorKind::NotADirectory) => {
443            return Ok(false);
444        }
445        Err(e) => return Err(Fail::Retry(at(&e))),
446    };
447    let old: Value = serde_json::from_slice(&bytes).map_err(|e| conflict(&e))?;
448    let theirs = instant(&old["made"]).map_err(|e| conflict(&e))?;
449    if theirs != instant(&manifest["made"]).map_err(|e| conflict(&e))? {
450        return Ok(false);
451    }
452    let ours = files(manifest)?;
453    for (name, _) in &ours {
454        match fs::metadata(leaf.join(name)) {
455            Ok(meta) if !meta.is_file() => return Ok(false),
456            Ok(_) => {}
457            Err(e) if e.kind() == ErrorKind::NotFound => return Ok(false),
458            Err(e) => return Err(Fail::Retry(at(&e))),
459        }
460    }
461    for (name, checksum) in &ours {
462        match hash_io(&leaf.join(name)) {
463            Ok(found) if found == *checksum => {}
464            Ok(_) => {
465                return Err(conflict(&format!(
466                    "{} differs from this render's manifest",
467                    name.display()
468                )));
469            }
470            Err(e) => return Err(Fail::Retry(at(&e))),
471        }
472    }
473    Ok(true)
474}
475
476fn resolve_leaf(base: &Path, manifest: &Value) -> Result<(PathBuf, bool), Fail> {
477    let mut number = 1;
478    loop {
479        let candidate = numbered(base, number)?;
480        if !candidate
481            .try_exists()
482            .map_err(|e| format!("{}: {e}", candidate.display()))?
483        {
484            return Ok((candidate, false));
485        }
486        if same_render(&candidate, manifest)? {
487            return Ok((candidate, true));
488        }
489        number += 1;
490    }
491}
492
493fn held_or_conflict(destination: &Path, checksum: &str) -> Result<bool, Fail> {
494    if hash(destination)? == checksum {
495        Ok(false)
496    } else {
497        Err(Fail::Conflict(format!(
498            "archive conflict at {}",
499            destination.display()
500        )))
501    }
502}
503
504fn put(source: &Path, destination: &Path, checksum: &str) -> Result<bool, Fail> {
505    if destination.exists() {
506        return held_or_conflict(destination, checksum);
507    }
508    let parent = destination.parent().ok_or("archive: no parent")?;
509    fs::create_dir_all(parent).map_err(|e| e.to_string())?;
510    let name = destination
511        .file_name()
512        .ok_or("archive: no file name")?
513        .to_string_lossy();
514    let partial = parent.join(format!(".{name}.partial"));
515    if partial.exists() {
516        fs::remove_file(&partial).map_err(|e| e.to_string())?;
517    }
518    fs::copy(source, &partial).map_err(|e| format!("{}: {e}", partial.display()))?;
519    if hash(&partial)? != checksum {
520        fs::remove_file(&partial).ok();
521        return Err(format!("archive checksum mismatch for {}", source.display()).into());
522    }
523    place(&partial, destination, checksum)
524}
525
526fn link_unsupported(error: &std::io::Error) -> bool {
527    if error.kind() == ErrorKind::Unsupported {
528        return true;
529    }
530    #[cfg(target_os = "linux")]
531    {
532        matches!(error.raw_os_error(), Some(libc::ENOSYS | libc::EOPNOTSUPP))
533    }
534    #[cfg(not(target_os = "linux"))]
535    {
536        false
537    }
538}
539
540fn place(partial: &Path, destination: &Path, checksum: &str) -> Result<bool, Fail> {
541    match fs::hard_link(partial, destination) {
542        Ok(()) => {
543            fs::remove_file(partial).map_err(|e| format!("{}: {e}", partial.display()))?;
544            return Ok(true);
545        }
546        Err(error) if error.kind() == ErrorKind::AlreadyExists => {
547            fs::remove_file(partial).ok();
548            return held_or_conflict(destination, checksum);
549        }
550        Err(error) if link_unsupported(&error) => {}
551        Err(error) => {
552            fs::remove_file(partial).ok();
553            return Err(format!("{}: {error}", destination.display()).into());
554        }
555    }
556    if destination.exists() {
557        fs::remove_file(partial).ok();
558        return held_or_conflict(destination, checksum);
559    }
560    fs::rename(partial, destination).map_err(|e| format!("{}: {e}", destination.display()))?;
561    Ok(true)
562}
563
564fn copy_files(source: &Path, destination: &Path, manifest: &Value) -> Result<(), Fail> {
565    for (name, checksum) in files(manifest)? {
566        let from = source.join(&name);
567        if hash(&from)? != checksum {
568            return Err(format!("archive source checksum mismatch for {}", from.display()).into());
569        }
570        put(&from, &destination.join(&name), &checksum)?;
571    }
572    Ok(())
573}
574
575fn catalog_for(
576    manifest: &Value,
577    root: &Path,
578    folder: &Path,
579    source: &Path,
580) -> Result<Catalog, String> {
581    let mut entries = Vec::new();
582    for (name, sha256) in files(manifest)? {
583        let from = source.join(&name);
584        if hash(&from)? != sha256 {
585            return Err(format!(
586                "archive source checksum mismatch for {}",
587                from.display()
588            ));
589        }
590        entries.push(CatalogFile {
591            name: name.to_string_lossy().into_owned(),
592            bytes: fs::metadata(&from).map_err(|e| e.to_string())?.len(),
593            sha256,
594        });
595    }
596    let path = folder
597        .strip_prefix(masters(root))
598        .map_err(|e| e.to_string())?;
599    let parts: Vec<_> = path.components().collect();
600    Ok(Catalog {
601        v: 1,
602        tool: TOOL.into(),
603        product: manifest["product"]
604            .as_str()
605            .ok_or("archive: missing product")?
606            .into(),
607        shot: parts
608            .get(2)
609            .ok_or("archive: missing shot")?
610            .as_os_str()
611            .to_string_lossy()
612            .into_owned(),
613        version: manifest["version"]
614            .as_str()
615            .ok_or("archive: missing version")?
616            .into(),
617        caller: parts
618            .get(3)
619            .and_then(|_| folder.file_name())
620            .and_then(|n| n.to_str())
621            .and_then(|n| n.rsplit('_').next())
622            .ok_or("archive: missing caller")?
623            .into(),
624        made: manifest["made"]
625            .as_str()
626            .ok_or("archive: missing made")?
627            .into(),
628        path: path.to_string_lossy().into_owned(),
629        files: entries,
630    })
631}
632
633fn lock_path() -> Result<PathBuf, String> {
634    let runtime = match std::env::var_os("XDG_RUNTIME_DIR") {
635        Some(dir) => PathBuf::from(dir),
636        None => {
637            use std::os::unix::fs::MetadataExt;
638            let uid = fs::metadata("/proc/self")
639                .map_err(|e| format!("archive: no XDG_RUNTIME_DIR and no /proc/self: {e}"))?
640                .uid();
641            PathBuf::from(format!("/run/user/{uid}"))
642        }
643    };
644    Ok(runtime.join("pito-archive/catalog.lock"))
645}
646
647fn with_lock<T>(path: &Path, action: impl FnOnce() -> Result<T, Fail>) -> Result<T, Fail> {
648    fs::create_dir_all(path.parent().ok_or("archive: lock has no parent")?)
649        .map_err(|e| e.to_string())?;
650    let file = fs::OpenOptions::new()
651        .create(true)
652        .write(true)
653        .truncate(false)
654        .open(path)
655        .map_err(|e| e.to_string())?;
656    file.lock_exclusive().map_err(|e| e.to_string())?;
657    let result = action();
658    FileExt::unlock(&file).map_err(|e| e.to_string())?;
659    result
660}
661
662fn append_catalog_unlocked(root: &Path, catalog: &Catalog) -> Result<(), String> {
663    let mut line = serde_json::to_vec(catalog).map_err(|e| e.to_string())?;
664    line.push(b'\n');
665    let mut file = fs::OpenOptions::new()
666        .create(true)
667        .append(true)
668        .open(catalog_file(root))
669        .map_err(|e| e.to_string())?;
670    let written = file.write(&line).map_err(|e| e.to_string())?;
671    if written != line.len() {
672        return Err("archive: short catalog write".into());
673    }
674    Ok(())
675}
676
677fn catalog_present(root: &Path, catalog: &Catalog) -> Result<bool, String> {
678    let text = match fs::read_to_string(catalog_file(root)) {
679        Ok(text) => text,
680        Err(error) if error.kind() == ErrorKind::NotFound => return Ok(false),
681        Err(error) => return Err(error.to_string()),
682    };
683    let line = serde_json::to_string(catalog).map_err(|e| e.to_string())?;
684    Ok(text.lines().any(|item| item == line))
685}
686
687fn partial_folder(folder: &Path) -> Result<PathBuf, String> {
688    Ok(folder.with_file_name(format!(
689        "{}.partial",
690        folder
691            .file_name()
692            .ok_or("archive: invalid leaf")?
693            .to_string_lossy()
694    )))
695}
696
697fn stage_leaf(source: &Path, folder: &Path, manifest: &Value) -> Result<PathBuf, Fail> {
698    let partial = partial_folder(folder)?;
699    if partial.exists() {
700        fs::remove_dir_all(&partial).map_err(|e| e.to_string())?;
701    }
702    fs::create_dir_all(&partial).map_err(|e| e.to_string())?;
703    let result = (|| -> Result<PathBuf, Fail> {
704        copy_files(source, &partial, manifest)?;
705        let manifest_bytes = serde_json::to_vec_pretty(manifest).map_err(|e| e.to_string())?;
706        fs::write(partial.join("manifest.json"), &manifest_bytes).map_err(|e| e.to_string())?;
707        let manifest_sha = hex::encode(Sha256::digest(&manifest_bytes));
708        if hash(&partial.join("manifest.json"))? != manifest_sha {
709            return Err("archive manifest checksum mismatch".into());
710        }
711        let saved: Value = serde_json::from_slice(
712            &fs::read(partial.join("manifest.json")).map_err(|e| e.to_string())?,
713        )
714        .map_err(|e| e.to_string())?;
715        if saved != *manifest {
716            return Err("archive manifest verification failed".into());
717        }
718        Ok(partial.clone())
719    })();
720    if result.is_err() && partial.exists() {
721        fs::remove_dir_all(partial).map_err(|e| e.to_string())?;
722    }
723    result
724}
725
726fn taken(folder: &Path) -> Fail {
727    Fail::Retry(format!(
728        "{} was taken by another render meanwhile",
729        folder.display()
730    ))
731}
732
733fn finalize_leaf(partial: &Path, folder: &Path, manifest: &Value) -> Result<bool, Fail> {
734    if folder
735        .try_exists()
736        .map_err(|e| format!("{}: {e}", folder.display()))?
737    {
738        return if same_render(folder, manifest)? {
739            Ok(false)
740        } else {
741            Err(taken(folder))
742        };
743    }
744    fs::rename(partial, folder).map_err(|e| format!("{}: {e}", folder.display()))?;
745    Ok(true)
746}
747
748fn publish_leaf(source: &Path, folder: &Path, manifest: &Value) -> Result<bool, Fail> {
749    if folder
750        .try_exists()
751        .map_err(|e| format!("{}: {e}", folder.display()))?
752    {
753        return if same_render(folder, manifest)? {
754            Ok(false)
755        } else {
756            Err(taken(folder))
757        };
758    }
759    let partial = stage_leaf(source, folder, manifest)?;
760    let result = finalize_leaf(&partial, folder, manifest);
761    if partial.exists() {
762        fs::remove_dir_all(&partial).map_err(|e| e.to_string())?;
763    }
764    result
765}
766
767fn publish(
768    entry: &Path,
769    base: &Path,
770    root: &Path,
771    manifest: &Value,
772) -> Result<(Value, bool), Fail> {
773    let mut catalog = catalog_of(manifest)?.ok_or("archive: missing catalog")?;
774    let (leaf, held) = resolve_leaf(base, manifest)?;
775    catalog.path = leaf_name(root, &leaf);
776    let mut staged = manifest.clone();
777    staged["archive"]["catalog"] = serde_json::to_value(&catalog).map_err(|e| e.to_string())?;
778    set_state(&mut staged, root, &leaf, State::Archived);
779    let added = if held {
780        false
781    } else {
782        publish_leaf(entry, &leaf, &staged)?
783    };
784    if added || !catalog_present(root, &catalog)? {
785        append_catalog_unlocked(root, &catalog)?;
786    }
787    Ok((staged, added))
788}
789
790fn clone_or_copy(from: &Path, to: &Path) -> std::io::Result<()> {
791    #[cfg(target_os = "linux")]
792    {
793        let source = fs::File::open(from)?;
794        let target = fs::File::create(to)?;
795        if rustix::fs::ioctl_ficlone(&target, &source).is_ok() {
796            return Ok(());
797        }
798    }
799    fs::copy(from, to).map(|_| ())
800}
801
802fn queue_path(manifest: &Path, folder: &Path, cache: &Path) -> PathBuf {
803    let key = hex::encode(Sha256::digest(
804        format!("{}:{}", manifest.display(), folder.display()).as_bytes(),
805    ));
806    cache.join("archive-queue").join(&key[..24])
807}
808
809fn queue(
810    manifest_path: &Path,
811    folder: &Path,
812    root: &Path,
813    manifest: &Value,
814    cache: &Path,
815) -> Result<(), String> {
816    let directory = queue_path(manifest_path, folder, cache);
817    fs::create_dir_all(&directory).map_err(|e| e.to_string())?;
818    let source = manifest_path
819        .parent()
820        .ok_or("archive: manifest has no folder")?;
821    for (name, checksum) in files(manifest)? {
822        let from = source.join(&name);
823        let to = directory.join(&name);
824        if to.is_file() && hash(&to)? == checksum {
825            continue;
826        }
827        fs::create_dir_all(to.parent().ok_or("archive: no parent")?).map_err(|e| e.to_string())?;
828        if to.exists() {
829            fs::remove_file(&to).map_err(|e| e.to_string())?;
830        }
831        clone_or_copy(&from, &to).map_err(|e| format!("{}: {e}", to.display()))?;
832        if hash(&to)? != checksum {
833            return Err(format!(
834                "archive queue checksum mismatch for {}",
835                to.display()
836            ));
837        }
838    }
839    write_manifest(&directory.join("manifest.json"), manifest)?;
840    let pending = Pending {
841        manifest: manifest_path.to_path_buf(),
842        folder: folder.to_path_buf(),
843        root: root.to_path_buf(),
844    };
845    fs::write(
846        directory.join("pending.json"),
847        serde_json::to_vec(&pending).map_err(|e| e.to_string())?,
848    )
849    .map_err(|e| e.to_string())
850}
851
852pub fn archive(manifest_path: &Path, no_archive: bool) -> State {
853    let root = archive_root();
854    if let Some(reason) = reason(manifest_path, no_archive) {
855        record_skip(manifest_path, &root, reason);
856        return State::Skipped;
857    }
858    let cache = match crate::ffmpeg::cache() {
859        Ok(cache) => cache,
860        Err(message) => {
861            eprintln!("pfx: archive cache unavailable: {message}");
862            return State::Queued;
863        }
864    };
865    let lock = match lock_path() {
866        Ok(lock) => lock,
867        Err(message) => {
868            eprintln!("pfx: archive lock unavailable: {message}");
869            return State::Queued;
870        }
871    };
872    archive_in(manifest_path, no_archive, &root, &cache, &lock)
873}
874
875fn record_skip(manifest_path: &Path, root: &Path, reason: &str) {
876    let attempt = || -> Result<(), String> {
877        let mut manifest: Value =
878            serde_json::from_slice(&fs::read(manifest_path).map_err(|e| e.to_string())?)
879                .map_err(|e| e.to_string())?;
880        let folder = folder_at(&manifest, root)
881            .ok()
882            .filter(|_| !root.as_os_str().is_empty());
883        manifest["archive"] = match folder {
884            Some(folder) => {
885                json!({"leaf": leaf_name(root, &folder), "state": State::Skipped.name(), "reason": reason})
886            }
887            None => json!({"state": State::Skipped.name(), "reason": reason}),
888        };
889        write_manifest(manifest_path, &manifest)
890    };
891    if let Err(message) = attempt() {
892        eprintln!("pfx: could not record skipped archive: {message}");
893    }
894}
895
896fn archive_in(
897    manifest_path: &Path,
898    no_archive: bool,
899    root: &Path,
900    cache: &Path,
901    lock: &Path,
902) -> State {
903    if no_archive {
904        record_skip(manifest_path, root, "no-archive");
905        return State::Skipped;
906    }
907    let attempt = || -> Result<State, String> {
908        let mut manifest: Value =
909            serde_json::from_slice(&fs::read(manifest_path).map_err(|e| e.to_string())?)
910                .map_err(|e| e.to_string())?;
911        if manifest["made"].is_null() {
912            manifest["made"] = json!(local_made());
913        }
914        let folder = match folder_at(&manifest, root) {
915            Ok(folder) => folder,
916            Err(error) => {
917                manifest["archive"] = json!({"state": State::Refused.name(), "error": error});
918                write_manifest(manifest_path, &manifest)?;
919                eprintln!("pfx: archive refused: {error}");
920                return Ok(State::Refused);
921            }
922        };
923        let source = manifest_path
924            .parent()
925            .ok_or("archive: manifest has no folder")?;
926        let catalog = catalog_for(&manifest, root, &folder, source)?;
927        manifest["archive"]["catalog"] =
928            serde_json::to_value(&catalog).map_err(|e| e.to_string())?;
929        set_state(&mut manifest, root, &folder, State::Queued);
930        write_manifest(manifest_path, &manifest)?;
931        queue(manifest_path, &folder, root, &manifest, cache)?;
932        if !root_online(root) {
933            return Ok(State::Queued);
934        }
935        let entry = queue_path(manifest_path, &folder, cache);
936        match with_lock(lock, || publish(&entry, &folder, root, &manifest)) {
937            Ok((archived, added)) => {
938                write_manifest(manifest_path, &archived)?;
939                fs::remove_dir_all(&entry).map_err(|e| e.to_string())?;
940                Ok(if added {
941                    State::Archived
942                } else {
943                    State::Skipped
944                })
945            }
946            Err(Fail::Conflict(message)) => {
947                set_failure(&mut manifest, root, &folder, State::Conflict, &message);
948                write_manifest(manifest_path, &manifest)?;
949                fs::remove_dir_all(&entry).map_err(|e| e.to_string())?;
950                eprintln!("pfx: {message}");
951                Ok(State::Conflict)
952            }
953            Err(Fail::Retry(message)) => {
954                set_state(&mut manifest, root, &folder, State::Queued);
955                write_manifest(manifest_path, &manifest)?;
956                eprintln!("pfx: archive queued: {message}");
957                Ok(State::Queued)
958            }
959        }
960    };
961    match attempt() {
962        Ok(state) => state,
963        Err(message) => {
964            eprintln!("pfx: archive queued, but could not save queue: {message}");
965            State::Queued
966        }
967    }
968}
969
970pub struct Import {
971    pub app: String,
972    pub shot: String,
973    pub still: bool,
974    pub version: String,
975    pub files: Vec<PathBuf>,
976}
977
978pub fn import(import: &Import) -> Result<(PathBuf, State), String> {
979    let manifest_path = import_manifest(import, &local_made())?;
980    let state = archive(&manifest_path, false);
981    Ok((manifest_path, state))
982}
983
984fn commit(version: &str) -> bool {
985    version.len() == 40 && version.chars().all(|c| c.is_ascii_hexdigit())
986}
987
988fn import_manifest(import: &Import, made: &str) -> Result<PathBuf, String> {
989    for (what, part) in [("app", &import.app), ("shot", &import.shot)] {
990        if !safe(part) {
991            return Err(format!("the {what} {part:?} is not a safe folder name"));
992        }
993    }
994    if !is_tag(&import.version) && !commit(&import.version) {
995        return Err(format!(
996            "--rev {} is neither a tag vX.Y.Z (or vX.Y.Z-rc.N) nor a full commit: only pinned masters are archived",
997            import.version
998        ));
999    }
1000    let folder = |path: &Path| match path.parent() {
1001        Some(parent) if !parent.as_os_str().is_empty() => parent.to_path_buf(),
1002        _ => PathBuf::from("."),
1003    };
1004    let first = import.files.first().ok_or("no files to import")?;
1005    let source = folder(first);
1006    let kind = if import.still { "still" } else { "clip" };
1007    let mut listed = Vec::new();
1008    let mut master = false;
1009    for path in &import.files {
1010        if folder(path) != source {
1011            return Err(format!(
1012                "every file sits in one folder: {} is not in {}",
1013                path.display(),
1014                source.display()
1015            ));
1016        }
1017        let name = path
1018            .file_name()
1019            .and_then(|name| name.to_str())
1020            .ok_or_else(|| format!("{}: not a file name", path.display()))?;
1021        let bytes = fs::metadata(path)
1022            .map_err(|e| format!("{}: {e}", path.display()))?
1023            .len();
1024        master |= if import.still {
1025            name.ends_with(".master.png")
1026        } else {
1027            name.ends_with(".master.mp4") || name.ends_with(".alpha.master.mov")
1028        };
1029        listed.push(json!({"file": name, "bytes": bytes, "sha256": hash(path)?}));
1030    }
1031    if !master {
1032        let wanted = if import.still {
1033            "<name>.master.png"
1034        } else {
1035            "<name>.master.mp4"
1036        };
1037        return Err(format!("a {kind} import needs {wanted} among its files"));
1038    }
1039    let manifest = json!({
1040        "tool": {"name": TOOL, "version": env!("CARGO_PKG_VERSION")},
1041        "kind": kind,
1042        "product": import.app,
1043        "app": import.app,
1044        "shot": import.shot,
1045        "version": import.version,
1046        "pinned": true,
1047        "made": made,
1048        "imported": true,
1049        "files": listed,
1050    });
1051    let path = source.join(format!("{}.{kind}.import.json", import.shot));
1052    write_manifest(&path, &manifest)?;
1053    Ok(path)
1054}
1055
1056pub fn retry() -> Result<(usize, usize), String> {
1057    retry_in(&crate::ffmpeg::cache()?, &lock_path()?)
1058}
1059
1060pub fn retry_at_startup() -> Result<(usize, usize), String> {
1061    startup_in(|key| std::env::var_os(key), retry)
1062}
1063
1064fn startup_in(
1065    env: impl Fn(&str) -> Option<std::ffi::OsString>,
1066    retry: impl FnOnce() -> Result<(usize, usize), String>,
1067) -> Result<(usize, usize), String> {
1068    if run_reason(&env).is_some() {
1069        return Ok((0, 0));
1070    }
1071    retry()
1072}
1073
1074fn rewrite_local(path: &Path, queued: &Value, manifest: &Value) -> Result<(), String> {
1075    let Ok(bytes) = fs::read(path) else {
1076        return Ok(());
1077    };
1078    let Ok(local) = serde_json::from_slice::<Value>(&bytes) else {
1079        return Ok(());
1080    };
1081    let (Ok(theirs), Ok(ours)) = (instant(&local["made"]), instant(&queued["made"])) else {
1082        return Ok(());
1083    };
1084    if theirs == ours {
1085        write_manifest(path, manifest)?;
1086    }
1087    Ok(())
1088}
1089
1090fn read_json(path: &Path) -> Result<Value, String> {
1091    serde_json::from_slice(&fs::read(path).map_err(|e| format!("{}: {e}", path.display()))?)
1092        .map_err(|e| format!("{}: {e}", path.display()))
1093}
1094
1095fn retry_in(cache: &Path, lock: &Path) -> Result<(usize, usize), String> {
1096    let queue_root = cache.join("archive-queue");
1097    if !queue_root.exists() {
1098        return Ok((0, 0));
1099    }
1100    let mut completed = 0;
1101    let mut pending_count = 0;
1102    for entry in fs::read_dir(&queue_root).map_err(|e| e.to_string())? {
1103        let entry = entry.map_err(|e| e.to_string())?;
1104        if !entry.path().is_dir() {
1105            continue;
1106        }
1107        let result = (|| -> Result<(), Fail> {
1108            let pending: Pending = serde_json::from_slice(
1109                &fs::read(entry.path().join("pending.json")).map_err(|e| e.to_string())?,
1110            )
1111            .map_err(|e| e.to_string())?;
1112            let manifest = read_json(&entry.path().join("manifest.json"))?;
1113            if !root_online(&pending.root) {
1114                return Err(format!("{} is offline", pending.root.display()).into());
1115            }
1116            let (archived, _) = with_lock(lock, || {
1117                publish(&entry.path(), &pending.folder, &pending.root, &manifest)
1118            })?;
1119            Ok(rewrite_local(&pending.manifest, &manifest, &archived)?)
1120        })();
1121        match result {
1122            Ok(()) => {
1123                fs::remove_dir_all(entry.path()).map_err(|e| e.to_string())?;
1124                completed += 1;
1125            }
1126            Err(Fail::Conflict(message)) => {
1127                eprintln!("pfx: archive retry: {message}");
1128                let pending: Pending = serde_json::from_slice(
1129                    &fs::read(entry.path().join("pending.json")).map_err(|e| e.to_string())?,
1130                )
1131                .map_err(|e| e.to_string())?;
1132                let manifest = read_json(&entry.path().join("manifest.json"))?;
1133                let mut conflicted = manifest.clone();
1134                set_failure(
1135                    &mut conflicted,
1136                    &pending.root,
1137                    &pending.folder,
1138                    State::Conflict,
1139                    &message,
1140                );
1141                rewrite_local(&pending.manifest, &manifest, &conflicted)?;
1142                fs::remove_dir_all(entry.path()).map_err(|e| e.to_string())?;
1143                completed += 1;
1144            }
1145            Err(fail) => {
1146                eprintln!("pfx: archive retry: {}", fail.message());
1147                pending_count += 1;
1148            }
1149        }
1150    }
1151    Ok((completed, pending_count))
1152}
1153
1154fn filed(catalog: &mut Catalog, masters: &Path, manifest: &Path) {
1155    let Some(leaf) = manifest
1156        .parent()
1157        .and_then(|leaf| leaf.strip_prefix(masters).ok())
1158    else {
1159        return;
1160    };
1161    if let Some(tool) = leaf.components().next() {
1162        catalog.tool = tool.as_os_str().to_string_lossy().into_owned();
1163    }
1164    catalog.path = leaf.to_string_lossy().into_owned();
1165}
1166
1167fn collect_catalogs(
1168    masters: &Path,
1169    path: &Path,
1170    entries: &mut Vec<(DateTime<FixedOffset>, Catalog)>,
1171) -> Result<(), String> {
1172    for item in fs::read_dir(path).map_err(|e| format!("{}: {e}", path.display()))? {
1173        let item = item.map_err(|e| e.to_string())?;
1174        let item_path = item.path();
1175        if item.file_type().map_err(|e| e.to_string())?.is_dir() {
1176            if !item.file_name().to_string_lossy().ends_with(".partial") {
1177                collect_catalogs(masters, &item_path, entries)?;
1178            }
1179        } else if item.file_name().to_string_lossy() == "manifest.json" {
1180            let manifest = read_json(&item_path)?;
1181            let named = |why: String| format!("{}: {why}", item_path.display());
1182            if let Some(mut catalog) = catalog_of(&manifest).map_err(&named)? {
1183                let made = instant(&json!(catalog.made)).map_err(&named)?;
1184                filed(&mut catalog, masters, &item_path);
1185                entries.push((made, catalog));
1186            }
1187        }
1188    }
1189    Ok(())
1190}
1191
1192pub fn reindex() -> Result<usize, String> {
1193    let root = archive_root();
1194    if root.as_os_str().is_empty() {
1195        return Err("archive: no root; set PITO_ARCHIVE".into());
1196    }
1197    reindex_in(&root, &lock_path()?)
1198}
1199
1200fn reindex_in(root: &Path, lock: &Path) -> Result<usize, String> {
1201    if !root_online(root) {
1202        return Err(format!("archive: {} is offline", root.display()));
1203    }
1204    with_lock(lock, || {
1205        let masters = masters(root);
1206        fs::create_dir_all(&masters).map_err(|e| format!("{}: {e}", masters.display()))?;
1207        let mut entries = Vec::new();
1208        collect_catalogs(&masters, &masters, &mut entries)?;
1209        entries.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.path.cmp(&b.1.path)));
1210        let partial = masters.join("catalog.jsonl.partial");
1211        let mut file = fs::File::create(&partial).map_err(|e| e.to_string())?;
1212        for (_, entry) in &entries {
1213            serde_json::to_writer(&mut file, entry).map_err(|e| e.to_string())?;
1214            file.write_all(b"\n").map_err(|e| e.to_string())?;
1215        }
1216        file.sync_all().map_err(|e| e.to_string())?;
1217        fs::rename(&partial, catalog_file(root)).map_err(|e| e.to_string())?;
1218        Ok(entries.len())
1219    })
1220    .map_err(|fail| fail.message().to_owned())
1221}
1222
1223#[cfg(test)]
1224mod tests {
1225    use super::*;
1226    fn reason_with(
1227        manifest: &Value,
1228        no_archive: bool,
1229        pairs: &[(&str, &str)],
1230    ) -> Option<&'static str> {
1231        let owned: Vec<(String, String)> = pairs
1232            .iter()
1233            .map(|(key, value)| ((*key).to_string(), (*value).to_string()))
1234            .collect();
1235        skip_reason_in(manifest, no_archive, move |key| {
1236            owned
1237                .iter()
1238                .find(|(name, _)| name == key)
1239                .map(|(_, value)| std::ffi::OsString::from(value))
1240        })
1241    }
1242
1243    fn pinned_master(product: &str) -> Value {
1244        json!({
1245            "product": product,
1246            "version": "aabbccddeeff",
1247            "pinned": true,
1248            "files": [{"file": "alpha-clip-a-1280x720-60fps.master.mp4", "sha256": "00"}],
1249        })
1250    }
1251
1252    #[test]
1253    fn only_a_pinned_master_outside_tests_and_rides_is_archived() {
1254        let master = pinned_master("alpha");
1255        let root = ("PITO_ARCHIVE", "/srv/archive");
1256        assert_eq!(reason_with(&master, false, &[root]), None);
1257        assert_eq!(
1258            reason_with(&master, false, &[]),
1259            BUILT_ROOT.map_or(Some("no-root"), |_| None)
1260        );
1261        assert_eq!(reason_with(&master, true, &[root]), Some("no-archive"));
1262        assert_eq!(
1263            reason_with(&master, false, &[("PFX_DIRECT", "1"), root]),
1264            Some("test")
1265        );
1266        assert_eq!(
1267            reason_with(&master, false, &[("PITO_ARCHIVE", "/work/app/tmp/archive")]),
1268            Some("test")
1269        );
1270        assert_eq!(
1271            reason_with(&master, false, &[("RIDE_APP", "beta"), root]),
1272            Some("ride")
1273        );
1274        assert_eq!(reason_with(&master, false, &[("RIDE_APP", ""), root]), None);
1275        assert_eq!(
1276            reason_with(&pinned_master("checker"), false, &[root]),
1277            Some("sample")
1278        );
1279        assert_eq!(
1280            reason_with(&pinned_master("sample"), false, &[root]),
1281            Some("sample")
1282        );
1283        let mut unpinned = master.clone();
1284        unpinned["pinned"] = json!(false);
1285        assert_eq!(reason_with(&unpinned, false, &[root]), Some("unpinned"));
1286        let mut frames = master.clone();
1287        frames["files"] = json!([{"file": "frames/00000.png", "sha256": "00"}]);
1288        assert_eq!(reason_with(&frames, false, &[root]), Some("frames-only"));
1289    }
1290
1291    #[test]
1292    fn an_older_manifest_is_pinned_only_by_a_tag() {
1293        assert!(pinned(&json!({"version": "v1.2.3"})));
1294        assert!(!pinned(&json!({"version": "aabbccddeeff"})));
1295        assert!(!pinned(&json!({"version": "aabbccddeeff-dirty"})));
1296        assert!(!pinned(&json!({"version": "vpreview"})));
1297        assert!(pinned(&json!({"version": "aabbccddeeff", "pinned": true})));
1298    }
1299
1300    #[test]
1301    fn a_pre_release_tag_is_a_tag() {
1302        for tag in ["v1.0.0-rc.1", "v1.0.0-alpha", "v0.27.0-beta.2.x-y"] {
1303            assert!(is_tag(tag), "{tag}");
1304        }
1305        for not in [
1306            "v1.0.0-",
1307            "v1.0.0-rc..1",
1308            "v1.0-rc.1",
1309            "v1.0.0-rc_1",
1310            "1.0.0-rc.1",
1311        ] {
1312            assert!(!is_tag(not), "{not}");
1313        }
1314    }
1315
1316    #[test]
1317    fn verified_partial_is_linked_without_clobbering_an_existing_file() {
1318        let dir = Path::new(env!("CARGO_MANIFEST_DIR"))
1319            .join("../../tmp")
1320            .join(format!("archive-link-test-{}", std::process::id()));
1321        fs::create_dir_all(&dir).unwrap();
1322        let partial = dir.join(".master.mp4.partial");
1323        let final_path = dir.join("master.mp4");
1324        fs::write(&partial, b"first").unwrap();
1325        assert!(place(&partial, &final_path, &hash(&partial).unwrap()).unwrap());
1326        assert!(!partial.exists());
1327        fs::write(&partial, b"second").unwrap();
1328        assert!(place(&partial, &final_path, &hash(&partial).unwrap()).is_err());
1329        assert_eq!(fs::read(&final_path).unwrap(), b"first");
1330        assert!(!partial.exists());
1331        fs::remove_dir_all(dir).unwrap();
1332    }
1333
1334    #[test]
1335    fn leaf_is_staged_before_one_rename() {
1336        let base = Path::new(env!("CARGO_MANIFEST_DIR"))
1337            .join("../../tmp")
1338            .join(format!("archive-stage-test-{}", std::process::id()));
1339        let source = base.join("source");
1340        let folder = base.join("archive/leaf");
1341        fs::create_dir_all(&source).unwrap();
1342        fs::write(source.join("scene.master.mp4"), b"master").unwrap();
1343        let manifest = json!({"files": [{"file": "scene.master.mp4", "sha256": hash(&source.join("scene.master.mp4")).unwrap()}]});
1344        let partial = stage_leaf(&source, &folder, &manifest).unwrap();
1345        assert!(partial.join("scene.master.mp4").exists());
1346        assert!(partial.join("manifest.json").exists());
1347        assert!(!folder.exists());
1348        assert!(finalize_leaf(&partial, &folder, &manifest).unwrap());
1349        assert!(!partial.exists());
1350        assert!(folder.join("manifest.json").exists());
1351        fs::remove_dir_all(base).unwrap();
1352    }
1353
1354    #[test]
1355    fn a_still_archives_its_png_deliveries_beside_its_master() {
1356        let base = Path::new(env!("CARGO_MANIFEST_DIR"))
1357            .join("../../tmp")
1358            .join(format!("archive-still-{}", std::process::id()));
1359        let source = base.join("source");
1360        let root = base.join("archive");
1361        let cache = base.join("cache");
1362        let lock = base.join("runtime/catalog.lock");
1363        fs::create_dir_all(&source).unwrap();
1364        fs::create_dir_all(&root).unwrap();
1365        let names = [
1366            "sample-still-shapes-1920x1080.master.png",
1367            "sample-still-shapes-1920x1080.png",
1368            "sample-still-shapes-960x540.png",
1369            "sample-still-shapes-1920x1080-web-320.png",
1370            "sample-still-shapes-1920x1080-web-320@2x.png",
1371            "sample-still-shapes-1920x1080.linear.exr",
1372        ];
1373        let mut listed = Vec::new();
1374        for (i, name) in names.iter().enumerate() {
1375            let path = source.join(name);
1376            fs::write(&path, format!("bytes {i}")).unwrap();
1377            listed.push(json!({"file": name, "sha256": hash(&path).unwrap()}));
1378        }
1379        let manifest_path = source.join("sample-still-shapes-1920x1080.json");
1380        let manifest = json!({
1381            "product": "neutral", "kind": "still", "shot": "shapes", "version": "v1",
1382            "made": "2026-10-05T19:00:00+02:00",
1383            "files": listed,
1384        });
1385        write_manifest(&manifest_path, &manifest).unwrap();
1386        assert_eq!(
1387            archive_in(&manifest_path, false, &root, &cache, &lock),
1388            State::Archived
1389        );
1390        let leaf = root.join("Masters/pfx/neutral/shapes/2026-10-05_190000_v1_manual");
1391        for name in &names[..5] {
1392            assert_eq!(
1393                hash(&leaf.join(name)).unwrap(),
1394                hash(&source.join(name)).unwrap(),
1395                "{name}"
1396            );
1397        }
1398        assert!(!leaf.join(names[5]).exists());
1399        let catalog: Catalog = serde_json::from_str(
1400            fs::read_to_string(root.join("Masters/catalog.jsonl"))
1401                .unwrap()
1402                .trim(),
1403        )
1404        .unwrap();
1405        let mut caught: Vec<&str> = catalog.files.iter().map(|f| f.name.as_str()).collect();
1406        caught.sort_unstable();
1407        let mut wanted = names[..5].to_vec();
1408        wanted.sort_unstable();
1409        assert_eq!(caught, wanted);
1410        let clip = json!({"files": [
1411            {"file": "c.master.mp4", "sha256": "0"},
1412            {"file": "c.poster.png", "sha256": "0"},
1413            {"file": "c-640.png", "sha256": "0"},
1414            {"file": "c.frames/00000.png", "sha256": "0"}
1415        ]});
1416        let kept: Vec<PathBuf> = files(&clip).unwrap().into_iter().map(|(p, _)| p).collect();
1417        assert_eq!(
1418            kept,
1419            [PathBuf::from("c.master.mp4"), PathBuf::from("c.poster.png")]
1420        );
1421        fs::remove_dir_all(&base).ok();
1422    }
1423
1424    #[test]
1425    fn copies_verifies_skips_conflicts_and_retries() {
1426        let base = Path::new(env!("CARGO_MANIFEST_DIR"))
1427            .join("../../tmp")
1428            .join(format!("archive-test-{}", std::process::id()));
1429        let source = base.join("source");
1430        let root = base.join("archive");
1431        let cache = base.join("cache");
1432        let lock = base.join("runtime/catalog.lock");
1433        fs::create_dir_all(&source).unwrap();
1434        fs::create_dir_all(&root).unwrap();
1435        let master = source.join("clip.master.mp4");
1436        fs::write(&master, b"first master").unwrap();
1437        let manifest_path = source.join("clip.json");
1438        let mut manifest = json!({
1439            "product": "alpha", "kind": "clip", "shot": "checker", "version": "v1",
1440            "made": "2026-10-02T09:14:00+02:00",
1441            "files": [{"file": "clip.master.mp4", "sha256": hash(&master).unwrap()}]
1442        });
1443        write_manifest(&manifest_path, &manifest).unwrap();
1444        assert_eq!(
1445            archive_in(&manifest_path, true, &root, &cache, &lock),
1446            State::Skipped
1447        );
1448        let skipped: Value = serde_json::from_slice(&fs::read(&manifest_path).unwrap()).unwrap();
1449        assert_eq!(skipped["archive"]["state"], "skipped");
1450        assert!(!root.join("Masters").exists());
1451        assert_eq!(
1452            archive_in(&manifest_path, false, &root, &cache, &lock),
1453            State::Archived
1454        );
1455        let target = root.join("Masters/pfx/alpha/checker/2026-10-02_091400_v1_manual");
1456        assert_eq!(
1457            hash(&target.join("clip.master.mp4")).unwrap(),
1458            hash(&master).unwrap()
1459        );
1460        assert!(target.join("manifest.json").exists());
1461        assert!(
1462            !target
1463                .with_file_name("2026-10-02_091400_v1_manual.partial")
1464                .exists()
1465        );
1466        let first_catalog = fs::read(root.join("Masters/catalog.jsonl")).unwrap();
1467        assert_eq!(
1468            first_catalog.iter().filter(|byte| **byte == b'\n').count(),
1469            1
1470        );
1471        assert_eq!(
1472            archive_in(&manifest_path, false, &root, &cache, &lock),
1473            State::Skipped
1474        );
1475        assert_eq!(
1476            fs::read(root.join("Masters/catalog.jsonl")).unwrap(),
1477            first_catalog
1478        );
1479        fs::write(&master, b"different master").unwrap();
1480        manifest["files"][0]["sha256"] = json!(hash(&master).unwrap());
1481        manifest["made"] = json!("2026-10-02T09:14:00.500+02:00");
1482        write_manifest(&manifest_path, &manifest).unwrap();
1483        assert_eq!(
1484            archive_in(&manifest_path, false, &root, &cache, &lock),
1485            State::Archived
1486        );
1487        let second = target.with_file_name("2026-10-02_091400_v1_manual-2");
1488        assert_eq!(
1489            fs::read(second.join("clip.master.mp4")).unwrap(),
1490            b"different master"
1491        );
1492        assert_eq!(
1493            fs::read(target.join("clip.master.mp4")).unwrap(),
1494            b"first master"
1495        );
1496        let both = fs::read_to_string(root.join("Masters/catalog.jsonl")).unwrap();
1497        assert_eq!(both.lines().count(), 2);
1498        assert!(both.starts_with(&String::from_utf8(first_catalog.clone()).unwrap()));
1499        let offline = base.join("offline");
1500        manifest["version"] = json!("v2");
1501        write_manifest(&manifest_path, &manifest).unwrap();
1502        assert_eq!(
1503            archive_in(&manifest_path, false, &offline, &cache, &lock),
1504            State::Queued
1505        );
1506        let queued: Value = serde_json::from_slice(&fs::read(&manifest_path).unwrap()).unwrap();
1507        assert_eq!(queued["archive"]["state"], "queued");
1508        fs::create_dir_all(&offline).unwrap();
1509        assert_eq!(retry_in(&cache, &lock).unwrap(), (1, 0));
1510        let completed: Value = serde_json::from_slice(&fs::read(&manifest_path).unwrap()).unwrap();
1511        assert_eq!(completed["archive"]["state"], "archived");
1512        assert_eq!(
1513            fs::read(
1514                offline
1515                    .join("Masters/pfx/alpha/checker/2026-10-02_091400_v2_manual/clip.master.mp4")
1516            )
1517            .unwrap(),
1518            b"different master"
1519        );
1520        let replay = root.join("Masters/prec/gamma/tour/2026-10-02_1015_v1_gamma");
1521        fs::create_dir_all(&replay).unwrap();
1522        let replay_catalog = json!({
1523            "v": 1, "tool": "prec", "product": "gamma", "shot": "tour",
1524            "version": "v1", "caller": "gamma", "made": "2026-10-02T10:15:00+02:00",
1525            "path": "prec/gamma/tour/2026-10-02_1015_v1_gamma",
1526            "files": [{"name": "tour.master.mp4", "bytes": 3, "sha256": "abc"}]
1527        });
1528        write_manifest(
1529            &replay.join("manifest.json"),
1530            &json!({"archive": {"catalog": replay_catalog}}),
1531        )
1532        .unwrap();
1533        let replay_line = format!(
1534            "{}\n",
1535            serde_json::to_string(&serde_json::from_value::<Catalog>(replay_catalog).unwrap())
1536                .unwrap()
1537        );
1538        fs::write(root.join("Masters/catalog.jsonl"), b"stale\n").unwrap();
1539        assert_eq!(reindex_in(&root, &lock).unwrap(), 3);
1540        assert_eq!(
1541            fs::read_to_string(root.join("Masters/catalog.jsonl")).unwrap(),
1542            format!("{both}{replay_line}")
1543        );
1544        fs::remove_dir_all(base).unwrap();
1545    }
1546
1547    struct Fixture {
1548        base: PathBuf,
1549        source: PathBuf,
1550        root: PathBuf,
1551        cache: PathBuf,
1552        lock: PathBuf,
1553        master: PathBuf,
1554        manifest_path: PathBuf,
1555        manifest: Value,
1556    }
1557
1558    impl Fixture {
1559        fn new(label: &str) -> Fixture {
1560            let base = Path::new(env!("CARGO_MANIFEST_DIR"))
1561                .join("../../tmp")
1562                .join(format!("archive-{label}-{}", std::process::id()));
1563            let source = base.join("source");
1564            let root = base.join("archive");
1565            fs::create_dir_all(&source).unwrap();
1566            fs::create_dir_all(&root).unwrap();
1567            let master = source.join("clip.master.mp4");
1568            fs::write(&master, b"first master").unwrap();
1569            let manifest_path = source.join("clip.json");
1570            let manifest = json!({
1571                "product": "alpha", "kind": "clip", "shot": "checker", "version": "v1",
1572                "made": "2026-10-02T09:14:00+02:00",
1573                "files": [{"file": "clip.master.mp4", "sha256": hash(&master).unwrap()}]
1574            });
1575            write_manifest(&manifest_path, &manifest).unwrap();
1576            Fixture {
1577                cache: base.join("cache"),
1578                lock: base.join("runtime/catalog.lock"),
1579                base,
1580                source,
1581                root,
1582                master,
1583                manifest_path,
1584                manifest,
1585            }
1586        }
1587
1588        fn leaf(&self) -> PathBuf {
1589            self.root
1590                .join("Masters/pfx/alpha/checker/2026-10-02_091400_v1_manual")
1591        }
1592
1593        fn entries(&self) -> usize {
1594            fs::read_dir(self.cache.join("archive-queue"))
1595                .map(|items| items.count())
1596                .unwrap_or(0)
1597        }
1598
1599        fn manifest_state(&self) -> String {
1600            let saved: Value =
1601                serde_json::from_slice(&fs::read(&self.manifest_path).unwrap()).unwrap();
1602            saved["archive"]["state"].as_str().unwrap().to_owned()
1603        }
1604
1605        fn queue_now(&self) -> PathBuf {
1606            let mut queued = self.manifest.clone();
1607            let catalog = catalog_for(&queued, &self.root, &self.leaf(), &self.source).unwrap();
1608            queued["archive"]["catalog"] = serde_json::to_value(&catalog).unwrap();
1609            set_state(&mut queued, &self.root, &self.leaf(), State::Queued);
1610            write_manifest(&self.manifest_path, &queued).unwrap();
1611            queue(
1612                &self.manifest_path,
1613                &self.leaf(),
1614                &self.root,
1615                &queued,
1616                &self.cache,
1617            )
1618            .unwrap();
1619            queue_path(&self.manifest_path, &self.leaf(), &self.cache)
1620        }
1621    }
1622
1623    impl Drop for Fixture {
1624        fn drop(&mut self) {
1625            fs::remove_dir_all(&self.base).ok();
1626        }
1627    }
1628
1629    #[test]
1630    fn a_successful_archive_leaves_no_queue_entry() {
1631        let fixture = Fixture::new("queue-clean");
1632        assert_eq!(
1633            archive_in(
1634                &fixture.manifest_path,
1635                false,
1636                &fixture.root,
1637                &fixture.cache,
1638                &fixture.lock
1639            ),
1640            State::Archived
1641        );
1642        assert_eq!(fixture.entries(), 0);
1643        assert_eq!(fixture.manifest_state(), "archived");
1644        assert_eq!(
1645            fs::read(fixture.leaf().join("clip.master.mp4")).unwrap(),
1646            b"first master"
1647        );
1648    }
1649
1650    #[test]
1651    fn a_conflict_removes_the_queue_entry() {
1652        let fixture = Fixture::new("queue-conflict");
1653        let leaf = fixture.leaf();
1654        fs::create_dir_all(&leaf).unwrap();
1655        fs::write(leaf.join("clip.master.mp4"), b"another master").unwrap();
1656        fs::write(leaf.join("manifest.json"), b"{}").unwrap();
1657        assert_eq!(
1658            archive_in(
1659                &fixture.manifest_path,
1660                false,
1661                &fixture.root,
1662                &fixture.cache,
1663                &fixture.lock
1664            ),
1665            State::Conflict
1666        );
1667        assert_eq!(fixture.entries(), 0);
1668        assert_eq!(fixture.manifest_state(), "conflict");
1669        assert_eq!(
1670            fs::read(leaf.join("clip.master.mp4")).unwrap(),
1671            b"another master"
1672        );
1673    }
1674
1675    #[test]
1676    fn a_copy_interrupted_after_queueing_is_finished_by_retry() {
1677        let fixture = Fixture::new("queue-killed");
1678        let entry = fixture.queue_now();
1679        assert!(entry.join("pending.json").is_file());
1680        assert!(!fixture.leaf().exists());
1681        assert_eq!(fixture.manifest_state(), "queued");
1682        assert_eq!(retry_in(&fixture.cache, &fixture.lock).unwrap(), (1, 0));
1683        assert_eq!(fixture.entries(), 0);
1684        assert_eq!(fixture.manifest_state(), "archived");
1685        assert_eq!(
1686            fs::read(fixture.leaf().join("clip.master.mp4")).unwrap(),
1687            b"first master"
1688        );
1689        let catalog = fs::read_to_string(fixture.root.join("Masters/catalog.jsonl")).unwrap();
1690        assert_eq!(catalog.lines().count(), 1);
1691    }
1692
1693    #[test]
1694    fn a_copy_that_fails_stays_queued_until_retry_finishes_it() {
1695        let fixture = Fixture::new("queue-failed");
1696        let blocker = partial_folder(&fixture.leaf()).unwrap();
1697        fs::create_dir_all(blocker.parent().unwrap()).unwrap();
1698        fs::write(&blocker, b"in the way").unwrap();
1699        assert_eq!(
1700            archive_in(
1701                &fixture.manifest_path,
1702                false,
1703                &fixture.root,
1704                &fixture.cache,
1705                &fixture.lock
1706            ),
1707            State::Queued
1708        );
1709        assert_eq!(fixture.entries(), 1);
1710        assert_eq!(fixture.manifest_state(), "queued");
1711        fs::remove_file(&blocker).unwrap();
1712        assert_eq!(retry_in(&fixture.cache, &fixture.lock).unwrap(), (1, 0));
1713        assert_eq!(fixture.entries(), 0);
1714        assert!(fixture.leaf().join("manifest.json").is_file());
1715    }
1716
1717    #[test]
1718    fn a_source_rewritten_after_queueing_does_not_change_the_queued_copy() {
1719        let fixture = Fixture::new("queue-rewrite");
1720        let entry = fixture.queue_now();
1721        fs::write(&fixture.master, b"rewritten in place").unwrap();
1722        assert_eq!(
1723            fs::read(entry.join("clip.master.mp4")).unwrap(),
1724            b"first master"
1725        );
1726        assert_eq!(retry_in(&fixture.cache, &fixture.lock).unwrap(), (1, 0));
1727        assert_eq!(
1728            fs::read(fixture.leaf().join("clip.master.mp4")).unwrap(),
1729            b"first master"
1730        );
1731    }
1732
1733    #[cfg(unix)]
1734    #[test]
1735    fn the_queued_copy_is_never_a_hard_link() {
1736        use std::os::unix::fs::MetadataExt;
1737        let fixture = Fixture::new("queue-links");
1738        let entry = fixture.queue_now();
1739        let queued = fs::metadata(entry.join("clip.master.mp4")).unwrap();
1740        let original = fs::metadata(&fixture.master).unwrap();
1741        assert_eq!(queued.nlink(), 1);
1742        assert_eq!(original.nlink(), 1);
1743        assert_ne!(queued.ino(), original.ino());
1744    }
1745
1746    #[test]
1747    fn leaf_uses_made_offset_and_sanitized_caller() {
1748        let root = Path::new("tmp/archive");
1749        let manifest = json!({"product": "beta", "kind": "still", "shot": "page", "version": "v1.2.3", "made": "2026-10-02T00:30:00+02:00"});
1750        assert_eq!(
1751            folder_with_caller(&manifest, root, Some("PITO Invoker!2")).unwrap(),
1752            root.join("Masters/pfx/beta/page/2026-10-02_003000_v1.2.3_pito-invoker-2")
1753        );
1754        assert_eq!(
1755            folder_with_caller(&manifest, root, None).unwrap(),
1756            root.join("Masters/pfx/beta/page/2026-10-02_003000_v1.2.3_manual")
1757        );
1758    }
1759
1760    #[test]
1761    fn concurrent_catalog_appends_are_whole_lines() {
1762        let base = Path::new(env!("CARGO_MANIFEST_DIR"))
1763            .join("../../tmp")
1764            .join(format!("archive-lock-test-{}", std::process::id()));
1765        fs::create_dir_all(masters(&base)).unwrap();
1766        let lock = base.join("runtime/catalog.lock");
1767        let template = Catalog {
1768            v: 1,
1769            tool: "pfx".into(),
1770            product: "alpha".into(),
1771            shot: "scene".into(),
1772            version: "v1".into(),
1773            caller: "manual".into(),
1774            made: "2026-10-02T09:14:00+02:00".into(),
1775            path: "a".into(),
1776            files: vec![],
1777        };
1778        std::thread::scope(|scope| {
1779            for index in 0..2 {
1780                let mut item = template.clone();
1781                item.path = format!("leaf-{index}");
1782                let root = &base;
1783                let lock = &lock;
1784                scope.spawn(move || {
1785                    with_lock(lock, || Ok(append_catalog_unlocked(root, &item)?)).unwrap()
1786                });
1787            }
1788        });
1789        let lines = fs::read_to_string(base.join("Masters/catalog.jsonl")).unwrap();
1790        assert_eq!(lines.lines().count(), 2);
1791        let paths: Vec<String> = lines
1792            .lines()
1793            .map(|line| serde_json::from_str::<Catalog>(line).unwrap().path)
1794            .collect();
1795        assert!(paths.contains(&"leaf-0".into()));
1796        assert!(paths.contains(&"leaf-1".into()));
1797        fs::remove_dir_all(base).unwrap();
1798    }
1799
1800    impl Fixture {
1801        fn archive(&self) -> State {
1802            archive_in(
1803                &self.manifest_path,
1804                false,
1805                &self.root,
1806                &self.cache,
1807                &self.lock,
1808            )
1809        }
1810
1811        fn rerender(&mut self, contents: &[u8], made: &str) {
1812            fs::write(&self.master, contents).unwrap();
1813            self.manifest["made"] = json!(made);
1814            self.manifest["files"][0]["sha256"] = json!(hash(&self.master).unwrap());
1815            write_manifest(&self.manifest_path, &self.manifest).unwrap();
1816        }
1817
1818        fn saved(&self) -> Value {
1819            serde_json::from_slice(&fs::read(&self.manifest_path).unwrap()).unwrap()
1820        }
1821
1822        fn catalog_paths(&self) -> Vec<String> {
1823            fs::read_to_string(self.root.join("Masters/catalog.jsonl"))
1824                .unwrap_or_default()
1825                .lines()
1826                .map(|line| serde_json::from_str::<Catalog>(line).unwrap().path)
1827                .collect()
1828        }
1829    }
1830
1831    fn leaf_manifest(catalog: Value, extra: Value) -> Value {
1832        let mut manifest = json!({"archive": {"catalog": catalog}});
1833        for (key, value) in extra.as_object().unwrap() {
1834            manifest["archive"][key] = value.clone();
1835        }
1836        manifest
1837    }
1838
1839    fn line(tool: &str, made: &str, path: &str) -> Value {
1840        json!({
1841            "v": 1, "tool": tool, "product": "beta", "shot": "tour", "version": "v1",
1842            "caller": "gamma", "made": made, "path": path,
1843            "files": [{"name": "tour.master.mp4", "bytes": 3, "sha256": "abc"}]
1844        })
1845    }
1846
1847    fn put_leaf(root: &Path, path: &str, manifest: &Value) {
1848        let leaf = masters(root).join(path);
1849        fs::create_dir_all(&leaf).unwrap();
1850        write_manifest(&leaf.join("manifest.json"), manifest).unwrap();
1851    }
1852
1853    #[test]
1854    fn reindex_reads_both_tools_and_both_shapes_sorted_by_instant_then_path() {
1855        let fixture = Fixture::new("reindex-mix");
1856        let root = &fixture.root;
1857        let early = line(
1858            "prec",
1859            "2026-10-02T10:00:00+05:00",
1860            "prec/beta/tour/2026-10-02_1000_v1_gamma",
1861        );
1862        let late_a = line(
1863            "pfx",
1864            "2026-10-02T09:00:00+00:00",
1865            "pfx/beta/tour/2026-10-02_090000_v1_gamma",
1866        );
1867        let late_b = line(
1868            "prec",
1869            "2026-10-02T09:00:00+00:00",
1870            "prec/beta/tour/2026-10-02_090000_v1_gamma",
1871        );
1872        let newest = line(
1873            "pito-engine",
1874            "2026-10-03T08:15:00.123456789+02:00",
1875            "pito-engine/beta/tour/2026-10-03_0815_v1_gamma",
1876        );
1877        let string = |value: &Value| json!(serde_json::to_string(value).unwrap());
1878        put_leaf(
1879            root,
1880            "prec/beta/tour/2026-10-02_1000_v1_gamma",
1881            &leaf_manifest(string(&early), json!({"leaf": "x"})),
1882        );
1883        put_leaf(
1884            root,
1885            "prec/beta/tour/2026-10-02_090000_v1_gamma",
1886            &leaf_manifest(string(&late_b), json!({"folder": "x"})),
1887        );
1888        put_leaf(
1889            root,
1890            "pfx/beta/tour/2026-10-02_090000_v1_gamma",
1891            &leaf_manifest(late_a.clone(), json!({"leaf": "x"})),
1892        );
1893        put_leaf(
1894            root,
1895            "pfx/beta/tour/2026-10-03_0815_v1_gamma",
1896            &leaf_manifest(newest, json!({"folder": "/old/absolute/x"})),
1897        );
1898        put_leaf(
1899            root,
1900            "pfx/beta/tour/skipped",
1901            &json!({"archive": {"state": "skipped", "reason": "unpinned"}}),
1902        );
1903        fs::create_dir_all(root.join("Masters/pfx/beta/tour/half.partial")).unwrap();
1904        fs::write(
1905            root.join("Masters/pfx/beta/tour/half.partial/manifest.json"),
1906            b"not json",
1907        )
1908        .unwrap();
1909        assert_eq!(reindex_in(root, &fixture.lock).unwrap(), 4);
1910        let order: Vec<(String, String)> = fs::read_to_string(root.join("Masters/catalog.jsonl"))
1911            .unwrap()
1912            .lines()
1913            .map(|line| {
1914                let catalog = serde_json::from_str::<Catalog>(line).unwrap();
1915                (catalog.tool, catalog.path)
1916            })
1917            .collect();
1918        assert_eq!(
1919            order,
1920            [
1921                ("prec", "prec/beta/tour/2026-10-02_1000_v1_gamma"),
1922                ("pfx", "pfx/beta/tour/2026-10-02_090000_v1_gamma"),
1923                ("prec", "prec/beta/tour/2026-10-02_090000_v1_gamma"),
1924                ("pfx", "pfx/beta/tour/2026-10-03_0815_v1_gamma"),
1925            ]
1926            .map(|(tool, path)| (tool.to_owned(), path.to_owned()))
1927        );
1928        let first = fs::read_to_string(root.join("Masters/catalog.jsonl")).unwrap();
1929        assert_eq!(reindex_in(root, &fixture.lock).unwrap(), 4);
1930        assert_eq!(
1931            fs::read_to_string(root.join("Masters/catalog.jsonl")).unwrap(),
1932            first
1933        );
1934    }
1935
1936    #[test]
1937    fn an_unreadable_manifest_fails_the_reindex_by_name() {
1938        let fixture = Fixture::new("reindex-bad");
1939        let good = line("pfx", "2026-10-02T09:00:00+00:00", "pfx/beta/tour/ok");
1940        put_leaf(
1941            &fixture.root,
1942            "pfx/beta/tour/ok",
1943            &leaf_manifest(good, json!({})),
1944        );
1945        fs::write(fixture.root.join("Masters/catalog.jsonl"), b"kept\n").unwrap();
1946        let broken = fixture.root.join("Masters/prec/beta/tour/broken");
1947        fs::create_dir_all(&broken).unwrap();
1948        fs::write(broken.join("manifest.json"), b"{ not json").unwrap();
1949        let error = reindex_in(&fixture.root, &fixture.lock).unwrap_err();
1950        assert!(
1951            error.contains("prec/beta/tour/broken/manifest.json"),
1952            "{error}"
1953        );
1954        assert_eq!(
1955            fs::read(fixture.root.join("Masters/catalog.jsonl")).unwrap(),
1956            b"kept\n"
1957        );
1958        fs::write(
1959            broken.join("manifest.json"),
1960            b"{\"archive\": {\"catalog\": 7}}",
1961        )
1962        .unwrap();
1963        let error = reindex_in(&fixture.root, &fixture.lock).unwrap_err();
1964        assert!(error.contains("broken/manifest.json"), "{error}");
1965    }
1966
1967    #[test]
1968    fn a_catalog_is_read_as_an_object_or_as_a_string() {
1969        let entry = line("pfx", "2026-10-02T09:00:00+00:00", "a/b/c/d");
1970        let object = json!({"archive": {"catalog": entry.clone()}});
1971        let string = json!({"archive": {"catalog": serde_json::to_string(&entry).unwrap()}});
1972        assert_eq!(
1973            catalog_of(&object).unwrap().unwrap().path,
1974            catalog_of(&string).unwrap().unwrap().path
1975        );
1976        assert!(catalog_of(&json!({})).unwrap().is_none());
1977    }
1978
1979    #[test]
1980    fn two_renders_in_the_same_second_get_their_own_leaves() {
1981        let mut fixture = Fixture::new("same-second");
1982        fixture.rerender(b"first master", "2026-10-02T09:14:00.100+02:00");
1983        assert_eq!(fixture.archive(), State::Archived);
1984        fixture.rerender(b"second master", "2026-10-02T09:14:00.900+02:00");
1985        assert_eq!(fixture.archive(), State::Archived);
1986        fixture.rerender(b"third master", "2026-10-02T09:14:00.950+02:00");
1987        assert_eq!(fixture.archive(), State::Archived);
1988        let base = "pfx/alpha/checker/2026-10-02_091400_v1_manual";
1989        assert_eq!(
1990            fixture.catalog_paths(),
1991            [base.to_string(), format!("{base}-2"), format!("{base}-3")]
1992        );
1993        for (suffix, contents) in [
1994            ("", &b"first master"[..]),
1995            ("-2", b"second master"),
1996            ("-3", b"third master"),
1997        ] {
1998            let leaf = masters(&fixture.root).join(format!("{base}{suffix}"));
1999            assert_eq!(fs::read(leaf.join("clip.master.mp4")).unwrap(), contents);
2000        }
2001        let saved = fixture.saved();
2002        assert_eq!(saved["archive"]["leaf"], format!("{base}-3"));
2003        assert_eq!(saved["archive"]["catalog"]["path"], format!("{base}-3"));
2004        assert_eq!(saved["archive"]["catalog"]["caller"], "manual");
2005        assert_eq!(saved["archive"]["state"], "archived");
2006        assert!(saved["archive"]["folder"].is_null());
2007        let leaf_manifest: Value = serde_json::from_slice(
2008            &fs::read(masters(&fixture.root).join(format!("{base}-3/manifest.json"))).unwrap(),
2009        )
2010        .unwrap();
2011        assert_eq!(leaf_manifest, saved);
2012        assert_eq!(fixture.entries(), 0);
2013    }
2014
2015    #[test]
2016    fn a_free_gap_is_taken_before_a_higher_suffix() {
2017        let mut fixture = Fixture::new("gap");
2018        fixture.rerender(b"first master", "2026-10-02T09:14:00.100+02:00");
2019        assert_eq!(fixture.archive(), State::Archived);
2020        fixture.rerender(b"second master", "2026-10-02T09:14:00.200+02:00");
2021        assert_eq!(fixture.archive(), State::Archived);
2022        fixture.rerender(b"third master", "2026-10-02T09:14:00.300+02:00");
2023        assert_eq!(fixture.archive(), State::Archived);
2024        let base = fixture.leaf();
2025        fs::remove_dir_all(base.with_file_name("2026-10-02_091400_v1_manual-2")).unwrap();
2026        fixture.rerender(b"fourth master", "2026-10-02T09:14:00.400+02:00");
2027        assert_eq!(fixture.archive(), State::Archived);
2028        assert!(
2029            fixture.saved()["archive"]["leaf"]
2030                .as_str()
2031                .unwrap()
2032                .ends_with("_manual-2")
2033        );
2034    }
2035
2036    #[test]
2037    fn a_retry_of_an_archived_render_leaves_it_alone() {
2038        let fixture = Fixture::new("retry-archived");
2039        assert_eq!(fixture.archive(), State::Archived);
2040        let catalog = fs::read(fixture.root.join("Masters/catalog.jsonl")).unwrap();
2041        let leaf_manifest = fs::read(fixture.leaf().join("manifest.json")).unwrap();
2042        fixture.queue_now();
2043        assert_eq!(retry_in(&fixture.cache, &fixture.lock).unwrap(), (1, 0));
2044        assert_eq!(fixture.entries(), 0);
2045        assert_eq!(
2046            fs::read(fixture.root.join("Masters/catalog.jsonl")).unwrap(),
2047            catalog
2048        );
2049        assert_eq!(
2050            fs::read(fixture.leaf().join("manifest.json")).unwrap(),
2051            leaf_manifest
2052        );
2053        assert!(
2054            !fixture
2055                .leaf()
2056                .with_file_name("2026-10-02_091400_v1_manual-2")
2057                .exists()
2058        );
2059        assert_eq!(fixture.manifest_state(), "archived");
2060    }
2061
2062    #[test]
2063    fn a_retry_of_a_render_missing_from_the_catalog_adds_its_line_once() {
2064        let fixture = Fixture::new("retry-catalog");
2065        assert_eq!(fixture.archive(), State::Archived);
2066        fs::remove_file(fixture.root.join("Masters/catalog.jsonl")).unwrap();
2067        fixture.queue_now();
2068        assert_eq!(retry_in(&fixture.cache, &fixture.lock).unwrap(), (1, 0));
2069        assert_eq!(fixture.catalog_paths().len(), 1);
2070    }
2071
2072    #[test]
2073    fn a_retry_never_rewrites_a_newer_local_manifest() {
2074        let mut fixture = Fixture::new("retry-newer");
2075        fixture.queue_now();
2076        fixture.rerender(b"newer master", "2026-10-02T09:20:00+02:00");
2077        let newer = fs::read(&fixture.manifest_path).unwrap();
2078        assert_eq!(retry_in(&fixture.cache, &fixture.lock).unwrap(), (1, 0));
2079        assert_eq!(fs::read(&fixture.manifest_path).unwrap(), newer);
2080        assert_eq!(fixture.entries(), 0);
2081        assert_eq!(
2082            fs::read(fixture.leaf().join("clip.master.mp4")).unwrap(),
2083            b"first master"
2084        );
2085        assert_eq!(fixture.catalog_paths().len(), 1);
2086    }
2087
2088    #[test]
2089    fn a_retry_rewrites_the_local_manifest_of_its_own_render() {
2090        let fixture = Fixture::new("retry-own");
2091        fixture.queue_now();
2092        assert_eq!(fixture.manifest_state(), "queued");
2093        assert_eq!(retry_in(&fixture.cache, &fixture.lock).unwrap(), (1, 0));
2094        assert_eq!(fixture.manifest_state(), "archived");
2095        assert_eq!(
2096            fixture.saved()["archive"]["leaf"],
2097            "pfx/alpha/checker/2026-10-02_091400_v1_manual"
2098        );
2099    }
2100
2101    #[test]
2102    fn a_test_or_ride_run_does_not_retry_the_queue_at_startup() {
2103        let fixture = Fixture::new("startup");
2104        fixture.queue_now();
2105        let run = |pairs: &'static [(&'static str, &'static str)]| {
2106            startup_in(
2107                move |key| {
2108                    pairs
2109                        .iter()
2110                        .find(|(name, _)| *name == key)
2111                        .map(|(_, value)| std::ffi::OsString::from(value))
2112                },
2113                || retry_in(&fixture.cache, &fixture.lock),
2114            )
2115            .unwrap()
2116        };
2117        for pairs in [
2118            &[("PFX_DIRECT", "1")][..],
2119            &[("PITO_ARCHIVE", "/work/app/tmp/archive")],
2120            &[("RIDE_APP", "alpha")],
2121        ] {
2122            assert_eq!(run(pairs), (0, 0));
2123            assert_eq!(fixture.entries(), 1);
2124            assert!(!fixture.leaf().exists());
2125        }
2126        assert_eq!(run(&[("PITO_ARCHIVE", "/srv/archive")]), (1, 0));
2127        assert_eq!(fixture.entries(), 0);
2128        assert!(fixture.leaf().join("manifest.json").is_file());
2129    }
2130
2131    #[test]
2132    fn an_older_hhmm_leaf_is_read_and_left_alone() {
2133        let fixture = Fixture::new("old-leaf");
2134        let old = fixture
2135            .root
2136            .join("Masters/pfx/alpha/checker/2026-10-02_0914_v1_manual");
2137        fs::create_dir_all(&old).unwrap();
2138        fs::write(old.join("clip.master.mp4"), b"first master").unwrap();
2139        let entry = catalog_for(&fixture.manifest, &fixture.root, &old, &fixture.source).unwrap();
2140        let mut stored = fixture.manifest.clone();
2141        stored["archive"] = json!({
2142            "folder": old,
2143            "state": "archived",
2144            "catalog": serde_json::to_string(&entry).unwrap(),
2145        });
2146        write_manifest(&old.join("manifest.json"), &stored).unwrap();
2147        assert_eq!(reindex_in(&fixture.root, &fixture.lock).unwrap(), 1);
2148        assert_eq!(
2149            fixture.catalog_paths(),
2150            ["pfx/alpha/checker/2026-10-02_0914_v1_manual"]
2151        );
2152        assert_eq!(fixture.archive(), State::Archived);
2153        assert_eq!(fixture.catalog_paths().len(), 2);
2154        assert_eq!(fixture.entries(), 0);
2155        assert!(old.join("clip.master.mp4").is_file());
2156    }
2157
2158    #[test]
2159    fn a_leaf_that_cannot_be_checked_is_a_conflict() {
2160        let fixture = Fixture::new("leaf-checks");
2161        assert_eq!(fixture.archive(), State::Archived);
2162        fs::write(fixture.leaf().join("clip.master.mp4"), b"damaged").unwrap();
2163        fixture.queue_now();
2164        assert_eq!(retry_in(&fixture.cache, &fixture.lock).unwrap(), (1, 0));
2165        assert_eq!(fixture.manifest_state(), "conflict");
2166        assert_eq!(fixture.catalog_paths().len(), 1);
2167    }
2168
2169    #[test]
2170    fn an_unsafe_name_is_refused() {
2171        let root = Path::new("tmp/archive");
2172        let made = "2026-10-02T09:14:00+02:00";
2173        for (product, shot, version, caller) in [
2174            ("alpha", "checker", "release/1.0", "manual"),
2175            ("..", "checker", "v1", "manual"),
2176            ("alpha", "", "v1", "manual"),
2177            ("alpha", "checker", "v1", "a b"),
2178            ("stu/dio", "checker", "v1", "manual"),
2179            ("alpha", ".", "v1", "manual"),
2180        ] {
2181            assert!(leaf_under(root, product, shot, version, made, caller).is_err());
2182        }
2183        for (key, value) in [
2184            ("product", "../alpha"),
2185            ("shot", "a b"),
2186            ("version", "release/1.0"),
2187        ] {
2188            let mut fixture = Fixture::new("refused");
2189            fixture.manifest[key] = json!(value);
2190            write_manifest(&fixture.manifest_path, &fixture.manifest).unwrap();
2191            assert_eq!(fixture.archive(), State::Refused);
2192            let saved = fixture.saved();
2193            assert_eq!(saved["archive"]["state"], "refused");
2194            assert!(saved["archive"]["leaf"].is_null());
2195            assert!(saved["archive"]["error"].as_str().unwrap().contains(value));
2196            assert!(!fixture.root.join("Masters").exists());
2197            assert_eq!(fixture.entries(), 0);
2198        }
2199    }
2200
2201    #[test]
2202    fn a_transient_read_error_leaves_the_render_queued_and_a_retry_archives_it() {
2203        let fixture = Fixture::new("transient");
2204        assert_eq!(fixture.archive(), State::Archived);
2205        let stored = fixture.leaf().join("manifest.json");
2206        let aside = fixture.base.join("manifest.aside");
2207        fs::rename(&stored, &aside).unwrap();
2208        fs::create_dir(&stored).unwrap();
2209        assert_eq!(fixture.archive(), State::Queued);
2210        assert_eq!(fixture.manifest_state(), "queued");
2211        assert_eq!(fixture.entries(), 1);
2212        fs::remove_dir(&stored).unwrap();
2213        fs::rename(&aside, &stored).unwrap();
2214        assert_eq!(retry_in(&fixture.cache, &fixture.lock).unwrap(), (1, 0));
2215        assert_eq!(fixture.manifest_state(), "archived");
2216        assert_eq!(fixture.entries(), 0);
2217        assert_eq!(fixture.catalog_paths().len(), 1);
2218        let siblings = fixture.leaf().parent().unwrap().to_path_buf();
2219        assert_eq!(fs::read_dir(siblings).unwrap().count(), 1);
2220    }
2221
2222    #[test]
2223    fn a_read_error_on_a_retry_keeps_the_entry_pending() {
2224        let fixture = Fixture::new("transient-retry");
2225        assert_eq!(fixture.archive(), State::Archived);
2226        let stored = fixture.leaf().join("manifest.json");
2227        let aside = fixture.base.join("manifest.aside");
2228        fs::rename(&stored, &aside).unwrap();
2229        fs::create_dir(&stored).unwrap();
2230        fixture.queue_now();
2231        assert_eq!(retry_in(&fixture.cache, &fixture.lock).unwrap(), (0, 1));
2232        assert_eq!(fixture.entries(), 1);
2233        assert_eq!(fixture.manifest_state(), "queued");
2234        fs::remove_dir(&stored).unwrap();
2235        fs::rename(&aside, &stored).unwrap();
2236        assert_eq!(retry_in(&fixture.cache, &fixture.lock).unwrap(), (1, 0));
2237        assert_eq!(fixture.manifest_state(), "archived");
2238    }
2239
2240    #[test]
2241    fn a_real_sha256_mismatch_is_still_a_conflict() {
2242        let fixture = Fixture::new("mismatch");
2243        assert_eq!(fixture.archive(), State::Archived);
2244        fs::write(fixture.leaf().join("clip.master.mp4"), b"damaged").unwrap();
2245        assert_eq!(fixture.archive(), State::Conflict);
2246        assert_eq!(fixture.manifest_state(), "conflict");
2247        assert!(
2248            fixture.saved()["archive"]["error"]
2249                .as_str()
2250                .unwrap()
2251                .contains("clip.master.mp4")
2252        );
2253        assert_eq!(fixture.entries(), 0);
2254        assert_eq!(
2255            fs::read(fixture.leaf().join("clip.master.mp4")).unwrap(),
2256            b"damaged"
2257        );
2258    }
2259
2260    #[test]
2261    fn a_leaf_manifest_that_cannot_be_this_render_is_a_conflict() {
2262        let fixture = Fixture::new("foreign-manifest");
2263        let leaf = fixture.leaf();
2264        fs::create_dir_all(&leaf).unwrap();
2265        fs::write(leaf.join("manifest.json"), b"not json").unwrap();
2266        assert_eq!(fixture.archive(), State::Conflict);
2267        assert_eq!(fixture.entries(), 0);
2268        assert_eq!(fixture.catalog_paths().len(), 0);
2269    }
2270
2271    #[test]
2272    fn an_imported_master_lands_in_the_app_s_leaf_with_its_manifest_and_catalog_line() {
2273        let base = Path::new(env!("CARGO_MANIFEST_DIR"))
2274            .join("../../tmp")
2275            .join(format!("archive-import-{}", std::process::id()));
2276        let source = base.join("source");
2277        let root = base.join("archive");
2278        fs::create_dir_all(&source).unwrap();
2279        fs::create_dir_all(masters(&root)).unwrap();
2280        let master = source.join("desk-3840x2160.master.png");
2281        let web = source.join("desk-1920x1080.png");
2282        fs::write(&master, b"master").unwrap();
2283        fs::write(&web, b"web").unwrap();
2284        let import = |version: &str, still: bool, files: Vec<PathBuf>| Import {
2285            app: "splicer".into(),
2286            shot: "desk".into(),
2287            still,
2288            version: version.into(),
2289            files,
2290        };
2291        let made = "2026-10-12T05:00:00+02:00";
2292        let refused = |import: Import| import_manifest(&import, made).unwrap_err();
2293        assert!(refused(import("aabbccddeeff", true, vec![master.clone()])).contains("pinned"));
2294        assert!(refused(import("v1.2.3", true, vec![web.clone()])).contains(".master.png"));
2295        assert!(refused(import("v1.2.3", false, vec![master.clone()])).contains(".master.mp4"));
2296        let elsewhere = base.join("other.master.png");
2297        fs::write(&elsewhere, b"other").unwrap();
2298        assert!(
2299            refused(import("v1.2.3", true, vec![master.clone(), elsewhere])).contains("one folder")
2300        );
2301        let manifest_path = import_manifest(
2302            &import("v1.2.3", true, vec![master.clone(), web.clone()]),
2303            made,
2304        )
2305        .unwrap();
2306        let state = archive_in(
2307            &manifest_path,
2308            false,
2309            &root,
2310            &base.join("cache"),
2311            &base.join("runtime/catalog.lock"),
2312        );
2313        assert_eq!(state, State::Archived);
2314        let leaf = masters(&root).join("pfx/splicer/desk/2026-10-12_050000_v1.2.3_manual");
2315        for name in [
2316            "manifest.json",
2317            "desk-3840x2160.master.png",
2318            "desk-1920x1080.png",
2319        ] {
2320            assert!(leaf.join(name).is_file(), "{name}");
2321        }
2322        let catalog = fs::read_to_string(catalog_file(&root)).unwrap();
2323        let line: Catalog = serde_json::from_str(catalog.trim()).unwrap();
2324        assert_eq!(
2325            (
2326                line.product.as_str(),
2327                line.shot.as_str(),
2328                line.version.as_str(),
2329                line.files.len()
2330            ),
2331            ("splicer", "desk", "v1.2.3", 2)
2332        );
2333        let _ = fs::remove_dir_all(&base);
2334    }
2335}