rustmotion 0.8.0

A CLI tool that renders motion design videos from JSON scenarios. No browser, no Node.js — just a single Rust binary.
Documentation
use std::path::{Path, PathBuf};

use serde_json::Value;

pub fn sidecar_path(html_path: &Path) -> PathBuf {
    html_path.with_extension("annotations.json")
}

pub fn merge_annotations(mut raw: Value, annotations: Vec<Value>) -> Value {
    if annotations.is_empty() {
        return raw;
    }
    if let Some(obj) = raw.as_object_mut() {
        let arr = obj
            .entry("annotations")
            .or_insert_with(|| Value::Array(vec![]));
        if let Value::Array(a) = arr {
            a.extend(annotations);
        }
    }
    raw
}

pub fn read_sidecar(html_path: &Path) -> Result<Vec<Value>, String> {
    let p = sidecar_path(html_path);
    let text = match std::fs::read_to_string(&p) {
        Ok(t) => t,
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(vec![]),
        Err(e) => return Err(format!("read {}: {e}", p.display())),
    };
    let doc: Value =
        serde_json::from_str(&text).map_err(|e| format!("parse {}: {e}", p.display()))?;
    doc.get("annotations")
        .and_then(|a| a.as_array())
        .cloned()
        .ok_or_else(|| format!("{}: missing \"annotations\" array", p.display()))
}

pub fn append_sidecar_annotation(html_path: &Path, annotation: Value) -> Result<(), String> {
    let mut annotations = read_sidecar(html_path)?;
    annotations.push(annotation);
    write_sidecar(html_path, &annotations)
}

pub fn remove_sidecar_annotation(html_path: &Path, id: &str) -> Result<(), String> {
    let mut annotations = read_sidecar(html_path)?;
    annotations.retain(|x| x.get("id").and_then(|v| v.as_str()) != Some(id));
    if annotations.is_empty() {
        let p = sidecar_path(html_path);
        match std::fs::remove_file(&p) {
            Ok(()) => Ok(()),
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
            Err(e) => Err(format!("remove {}: {e}", p.display())),
        }
    } else {
        write_sidecar(html_path, &annotations)
    }
}

fn write_sidecar(html_path: &Path, annotations: &[Value]) -> Result<(), String> {
    let doc = serde_json::json!({ "annotations": annotations });
    let text = serde_json::to_string_pretty(&doc).map_err(|e| format!("json: {e}"))?;
    let p = sidecar_path(html_path);
    std::fs::write(&p, text).map_err(|e| format!("write {}: {e}", p.display()))
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    fn temp_html(tag: &str) -> PathBuf {
        let dir = std::env::temp_dir();
        let p = dir.join(format!("rm_sidecar_{}_{}.html", tag, std::process::id()));
        let _ = std::fs::remove_file(sidecar_path(&p));
        p
    }

    fn ann(id: &str) -> Value {
        json!({
            "id": id, "note": "make it smaller", "status": "open", "frame": 12,
            "view": 0, "scene": 0,
            "target": { "pointer": "/scenes/0/children/1", "kind": "text" }
        })
    }

    #[test]
    fn sidecar_path_derives_next_to_source() {
        assert_eq!(
            sidecar_path(Path::new("/work/demo/foo.html")),
            PathBuf::from("/work/demo/foo.annotations.json")
        );
        assert_eq!(
            sidecar_path(Path::new("/work/demo/bar.htm")),
            PathBuf::from("/work/demo/bar.annotations.json")
        );
    }

    #[test]
    fn merge_appends_into_raw_annotations() {
        let raw = json!({ "video": { "width": 1, "height": 1 }, "scenes": [] });
        let merged = merge_annotations(raw, vec![ann("a1"), ann("a2")]);
        let arr = merged["annotations"].as_array().unwrap();
        assert_eq!(arr.len(), 2);
        assert_eq!(arr[0]["id"], "a1");

        let raw = json!({ "annotations": [ { "id": "pre" } ] });
        let merged = merge_annotations(raw, vec![ann("post")]);
        let arr = merged["annotations"].as_array().unwrap();
        assert_eq!(arr.len(), 2);
        assert_eq!(arr[0]["id"], "pre");
        assert_eq!(arr[1]["id"], "post");
    }

    #[test]
    fn missing_sidecar_reads_as_empty() {
        let html = temp_html("missing");
        assert_eq!(read_sidecar(&html), Ok(vec![]));
    }

    #[test]
    fn append_creates_sidecar_with_expected_format() {
        let html = temp_html("append");
        append_sidecar_annotation(&html, ann("an_1")).unwrap();

        let text = std::fs::read_to_string(sidecar_path(&html)).unwrap();
        let doc: Value = serde_json::from_str(&text).unwrap();
        let arr = doc["annotations"].as_array().unwrap();
        assert_eq!(arr.len(), 1);
        assert_eq!(arr[0], ann("an_1"));
        assert_eq!(doc.as_object().unwrap().len(), 1);

        let _ = std::fs::remove_file(sidecar_path(&html));
    }

    #[test]
    fn remove_keeps_remaining_and_deletes_when_empty() {
        let html = temp_html("remove");
        append_sidecar_annotation(&html, ann("a1")).unwrap();
        append_sidecar_annotation(&html, ann("a2")).unwrap();

        remove_sidecar_annotation(&html, "a1").unwrap();
        let left = read_sidecar(&html).unwrap();
        assert_eq!(left.len(), 1);
        assert_eq!(left[0]["id"], "a2");

        remove_sidecar_annotation(&html, "a2").unwrap();
        assert!(!sidecar_path(&html).exists());

        assert_eq!(remove_sidecar_annotation(&html, "a2"), Ok(()));
    }

    #[test]
    fn corrupt_sidecar_errors_and_is_never_overwritten() {
        let html = temp_html("corrupt");
        std::fs::write(sidecar_path(&html), "{ not json").unwrap();

        assert!(read_sidecar(&html).is_err());
        assert!(append_sidecar_annotation(&html, ann("x")).is_err());
        assert!(remove_sidecar_annotation(&html, "x").is_err());

        let text = std::fs::read_to_string(sidecar_path(&html)).unwrap();
        assert_eq!(text, "{ not json");

        std::fs::write(sidecar_path(&html), r#"{ "foo": 1 }"#).unwrap();
        assert!(read_sidecar(&html).is_err());

        let _ = std::fs::remove_file(sidecar_path(&html));
    }
}