From 827e427ddae3828eeac9733c37929d7328f45e16 Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Sat, 18 Jul 2026 23:15:23 +0200 Subject: [PATCH] feat(studio): annotations for HTML sources via sidecar file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The studio's flagship review feature was inoperative for HTML scenarios (annotations were explicitly skipped). Now: - foo.html annotations live in foo.annotations.json, same object format as the JSON scenarios' annotations field; lazily created, deleted when empty, corrupt sidecars error loudly and are never overwritten; the HTML source itself is never modified - studio load/hot-reload and rustmotion's load_input merge the sidecar into raw['annotations'] so the panel, info, still, render and validate all see the same scenario (validation.rs hooked up during integration review — both loaders stay coherent) - append/delete on HTML sources carry the PR #30 guarantees (visible write_error, generation bump, no silent failures) - apply-annotations skill documents where annotations live per source Known v1 limit: the sidecar itself is not watched; external edits show up at the next HTML reload. --- .claude/skills/apply-annotations/SKILL.md | 13 +- .../rustmotion-cli/src/commands/validation.rs | 10 +- .../src/editor/annotations.rs | 104 ++++++--- crates/rustmotion-studio/src/scenario/mod.rs | 2 + .../rustmotion-studio/src/scenario/model.rs | 12 +- .../rustmotion-studio/src/scenario/sidecar.rs | 203 ++++++++++++++++++ crates/rustmotion/src/loader.rs | 111 +++++++++- 7 files changed, 404 insertions(+), 51 deletions(-) create mode 100644 crates/rustmotion-studio/src/scenario/sidecar.rs diff --git a/.claude/skills/apply-annotations/SKILL.md b/.claude/skills/apply-annotations/SKILL.md index dc21177..9e1bd50 100644 --- a/.claude/skills/apply-annotations/SKILL.md +++ b/.claude/skills/apply-annotations/SKILL.md @@ -5,17 +5,22 @@ description: Apply pending studio annotations from a Rustmotion scenario — for # Apply Annotations -Use this when a Rustmotion scenario has an `annotations` array (created in the studio's "Leave a comment for the agent" box) to apply. +Use this when a Rustmotion scenario has annotations (created in the studio's "Leave a comment for the agent" box) to apply. + +## Where annotations live + +- **JSON scenarios** (`foo.json`): in the scenario file's top-level `annotations` array. +- **HTML scenarios** (`foo.html` / `foo.htm`): in a sidecar file next to the source — `foo.annotations.json` — holding `{"annotations": [...]}` with exactly the same annotation object format. The HTML file itself never contains annotations. Always check for the sidecar when the source is HTML. ## Process -1. Read the scenario file's `annotations` array. For each entry with `status: "open"`: +1. Read the `annotations` array — from the scenario JSON, or from `.annotations.json` for HTML sources. For each entry with `status: "open"`: - `target.pointer` is an RFC 6901 JSON Pointer to the element (e.g. `/scenes/2/children/5`). - `note` is the change request. - `frame` / `view` / `scene` give the moment in the video; `target.kind` is the component type (`text`, `card`, …). -2. For each open annotation, **apply the requested change** by editing the element at `target.pointer` — usually a property under its `style` object — interpreting `note`. Make the smallest edit that satisfies the note. +2. For each open annotation, **apply the requested change** by editing the element at `target.pointer` — usually a property under its `style` object — interpreting `note`. Make the smallest edit that satisfies the note. For HTML sources, the pointer addresses the **transpiled** scenario structure; apply the change in the HTML source (inline `style` attribute of the corresponding element). 3. After each edit, run `rustmotion validate -f ` (schema + geometry). Both passes must succeed. If geometry fails (e.g. `unwrappable_text_overflow`), adjust (keep `wrap: true`, lower a `font-size`, …) and re-validate. -4. Set the annotation's `status` to `"resolved"` (do not delete it — the studio panel and `validate --fix` can strip resolved ones later). +4. Set the annotation's `status` to `"resolved"` **in the same place you found it** — the scenario JSON, or the `.annotations.json` sidecar for HTML sources (do not delete it — the studio panel and `validate --fix` can strip resolved ones later). 5. Report a short summary: which annotations were applied and what changed. ## Rules diff --git a/crates/rustmotion-cli/src/commands/validation.rs b/crates/rustmotion-cli/src/commands/validation.rs index df8d0d1..50f9419 100644 --- a/crates/rustmotion-cli/src/commands/validation.rs +++ b/crates/rustmotion-cli/src/commands/validation.rs @@ -94,9 +94,15 @@ pub fn load(source: ValidationSource<'_>) -> Result { source: e, })?; // HTML input is transpiled to the scenario JSON first, then validated - // through the identical JSON pipeline below. + // through the identical JSON pipeline below. Sidecar annotations + // are merged so validate/render see the same scenario as the + // studio and `load_input`. let s = if rustmotion::loader::is_html_path(path) { - let value = rustmotion::loader::html_to_scenario_json(&s)?; + let mut value = rustmotion::loader::html_to_scenario_json(&s)?; + let annotations = rustmotion::loader::load_html_annotations_sidecar(path)?; + if !annotations.is_empty() { + value["annotations"] = serde_json::Value::Array(annotations); + } serde_json::to_string(&value).map_err(RustmotionError::from)? } else { s diff --git a/crates/rustmotion-studio/src/editor/annotations.rs b/crates/rustmotion-studio/src/editor/annotations.rs index 4322335..dca6124 100644 --- a/crates/rustmotion-studio/src/editor/annotations.rs +++ b/crates/rustmotion-studio/src/editor/annotations.rs @@ -5,7 +5,10 @@ use crate::{ button::{Button, ButtonSize, ButtonVariant}, textarea::{Textarea, TextareaVariant}, }, - scenario::{append_annotation, remove_annotation, Shared}, + scenario::{ + append_annotation, append_sidecar_annotation, remove_annotation, remove_sidecar_annotation, + Shared, + }, }; /// Write `content` to `path`, returning a user-facing error string on failure. @@ -90,31 +93,49 @@ pub fn AnnotationBox(pointer: String, kind: String, current: Signal) -> Ele "view": view, "scene": scene, "target": { "pointer": pointer, "kind": kind } }); - let updated = append_annotation(raw, ann); - // Annotations live in the scenario JSON; HTML sources have no place to - // store them, so skip the write rather than overwrite the HTML. - if let Some(path) = path { - if !rustmotion::loader::is_html_path(&path) { - match serde_json::to_string_pretty(&updated) { - Ok(t) => { - let write_result = write_file(&path, &t); - let mut m = shared.lock().unwrap_or_else(|e| e.into_inner()); - match write_result { - Ok(()) => { - m.write_error = None; - note.set(String::new()); - } - Err(e) => { - m.write_error = Some(e); - m.generation = m.generation.wrapping_add(1); - } + let Some(path) = path else { + return; + }; + if rustmotion::loader::is_html_path(&path) { + // HTML sources: annotations live in the `.annotations.json` + // sidecar; the HTML file itself is never modified. + let result = append_sidecar_annotation(&path, ann.clone()); + let mut m = shared.lock().unwrap_or_else(|e| e.into_inner()); + match result { + Ok(()) => { + m.write_error = None; + // The watcher doesn't observe the sidecar, so reflect + // the change in the in-memory raw directly. + m.raw = append_annotation(std::mem::take(&mut m.raw), ann); + m.generation = m.generation.wrapping_add(1); + note.set(String::new()); + } + Err(e) => { + m.write_error = Some(e); + m.generation = m.generation.wrapping_add(1); + } + } + } else { + let updated = append_annotation(raw, ann); + match serde_json::to_string_pretty(&updated) { + Ok(t) => { + let write_result = write_file(&path, &t); + let mut m = shared.lock().unwrap_or_else(|e| e.into_inner()); + match write_result { + Ok(()) => { + m.write_error = None; + note.set(String::new()); + } + Err(e) => { + m.write_error = Some(e); + m.generation = m.generation.wrapping_add(1); } } - Err(e) => { - let mut m = shared.lock().unwrap_or_else(|e| e.into_inner()); - m.write_error = Some(format!("json: {e}")); - m.generation = m.generation.wrapping_add(1); - } + } + Err(e) => { + let mut m = shared.lock().unwrap_or_else(|e| e.into_inner()); + m.write_error = Some(format!("json: {e}")); + m.generation = m.generation.wrapping_add(1); } } } @@ -142,24 +163,41 @@ pub fn AnnotationBox(pointer: String, kind: String, current: Signal) -> Ele } } -/// Remove an annotation by id from the scenario file (the watcher reloads). +/// Remove an annotation by id. JSON sources rewrite the scenario file (the +/// watcher reloads); HTML sources rewrite the annotations sidecar (deleted +/// when it becomes empty) and update the in-memory raw directly. fn delete_annotation(shared: &Shared, id: &str) { let (path, raw) = { let m = shared.lock().unwrap_or_else(|e| e.into_inner()); (m.path.clone(), m.raw.clone()) }; - let updated = remove_annotation(raw, id); - if let Some(path) = path { - if !rustmotion::loader::is_html_path(&path) { - let write_result = serde_json::to_string_pretty(&updated) - .map_err(|e| format!("json: {e}")) - .and_then(|t| write_file(&path, &t)); - if let Err(e) = write_result { - let mut m = shared.lock().unwrap_or_else(|e2| e2.into_inner()); + let Some(path) = path else { + return; + }; + if rustmotion::loader::is_html_path(&path) { + let result = remove_sidecar_annotation(&path, id); + let mut m = shared.lock().unwrap_or_else(|e| e.into_inner()); + match result { + Ok(()) => { + m.write_error = None; + m.raw = remove_annotation(std::mem::take(&mut m.raw), id); + m.generation = m.generation.wrapping_add(1); + } + Err(e) => { m.write_error = Some(e); m.generation = m.generation.wrapping_add(1); } } + } else { + let updated = remove_annotation(raw, id); + let write_result = serde_json::to_string_pretty(&updated) + .map_err(|e| format!("json: {e}")) + .and_then(|t| write_file(&path, &t)); + if let Err(e) = write_result { + let mut m = shared.lock().unwrap_or_else(|e2| e2.into_inner()); + m.write_error = Some(e); + m.generation = m.generation.wrapping_add(1); + } } } diff --git a/crates/rustmotion-studio/src/scenario/mod.rs b/crates/rustmotion-studio/src/scenario/mod.rs index 7a9a18f..6c543f1 100644 --- a/crates/rustmotion-studio/src/scenario/mod.rs +++ b/crates/rustmotion-studio/src/scenario/mod.rs @@ -3,12 +3,14 @@ mod edit; mod model; +mod sidecar; pub use edit::{ append_annotation, list_annotations, read_field, read_style_object, remove_annotation, set_field, set_style, }; pub use model::{empty_scenario, Shared, StudioModel}; +pub use sidecar::{append_sidecar_annotation, remove_sidecar_annotation}; /// Which top-level view is shown (library home vs. the editor). #[derive(Clone, Copy, PartialEq)] diff --git a/crates/rustmotion-studio/src/scenario/model.rs b/crates/rustmotion-studio/src/scenario/model.rs index c682372..c34a1a1 100644 --- a/crates/rustmotion-studio/src/scenario/model.rs +++ b/crates/rustmotion-studio/src/scenario/model.rs @@ -31,14 +31,20 @@ impl StudioModel { error: Option, path: Option, ) -> Self { - // For HTML sources the raw JSON is the transpiled scenario, so the - // inspector can read/resolve element properties by pointer. + // For HTML sources the raw JSON is the transpiled scenario, plus the + // annotations sidecar merged in so `list_annotations` and the comments + // panel work unchanged; the inspector reads element props by pointer. let raw = path .as_ref() .and_then(|p| { let s = std::fs::read_to_string(p).ok()?; if rustmotion::loader::is_html_path(p) { - rustmotion::loader::html_to_scenario_json(&s).ok() + let raw = rustmotion::loader::html_to_scenario_json(&s).ok()?; + // A corrupt sidecar already failed the scenario load in the + // loader (error banner); a read failure here can only be a + // race, so fall back to the bare transpile. + let annotations = super::sidecar::read_sidecar(p).unwrap_or_default(); + Some(super::sidecar::merge_annotations(raw, annotations)) } else { serde_json::from_str(&s).ok() } diff --git a/crates/rustmotion-studio/src/scenario/sidecar.rs b/crates/rustmotion-studio/src/scenario/sidecar.rs new file mode 100644 index 0000000..691b0f4 --- /dev/null +++ b/crates/rustmotion-studio/src/scenario/sidecar.rs @@ -0,0 +1,203 @@ +//! Annotations sidecar for HTML-dialect scenarios. +//! +//! HTML sources can't carry an `annotations` array like JSON scenarios do, so +//! the studio persists comments in a sidecar file next to the source: +//! `foo.html` → `foo.annotations.json`, holding `{"annotations": [...]}` with +//! exactly the same annotation object format as the JSON scenarios' field. +//! +//! The HTML file itself is never touched by annotations. The sidecar is +//! created lazily on the first comment and deleted when the last one is +//! removed. A present-but-corrupt sidecar is always an error — it is never +//! silently overwritten. + +use std::path::{Path, PathBuf}; + +use serde_json::Value; + +/// Sidecar path for an HTML source: `foo.html` → `foo.annotations.json`. +pub fn sidecar_path(html_path: &Path) -> PathBuf { + html_path.with_extension("annotations.json") +} + +/// Merge sidecar annotations into a scenario raw value by appending them to +/// `raw["annotations"]` (created if absent). Pure; used at model load so +/// `list_annotations` and the comments panel work unchanged for HTML sources. +pub fn merge_annotations(mut raw: Value, annotations: Vec) -> 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 +} + +/// Read and parse the sidecar for an HTML source. A missing sidecar is +/// `Ok(vec![])`; an unreadable or malformed one is `Err(reason)` so callers +/// surface it instead of clobbering the file. +pub fn read_sidecar(html_path: &Path) -> Result, 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())) +} + +/// Append one annotation to the sidecar, creating the file lazily on first +/// use. Refuses to overwrite a corrupt sidecar (returns its parse error). +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) +} + +/// Remove the annotation with `id` from the sidecar. Deletes the file when it +/// becomes empty (no ghost `{"annotations": []}` files). +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) + } +} + +/// Serialize `{"annotations": [...]}` (pretty) to the sidecar path. +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; + + /// Unique per-test HTML path in the OS temp dir (no tempfile dependency). + fn temp_html(tag: &str) -> PathBuf { + let dir = std::env::temp_dir(); + let p = dir.join(format!("rm_sidecar_{}_{}.html", tag, std::process::id())); + // Clean any leftover sidecar from a previous crashed run. + 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"); + + // Existing annotations are preserved, sidecar ones appended after. + 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")); + // Exactly one top-level key: the same `annotations` field JSON + // scenarios use. + 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"); + + // Removing the last annotation deletes the file (no ghost sidecar). + remove_sidecar_annotation(&html, "a2").unwrap(); + assert!(!sidecar_path(&html).exists()); + + // Removing from a missing sidecar is a no-op, not an error. + 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()); + + // The corrupt content is still intact — nothing clobbered it. + let text = std::fs::read_to_string(sidecar_path(&html)).unwrap(); + assert_eq!(text, "{ not json"); + + // A sidecar without an `annotations` array is also an error. + std::fs::write(sidecar_path(&html), r#"{ "foo": 1 }"#).unwrap(); + assert!(read_sidecar(&html).is_err()); + + let _ = std::fs::remove_file(sidecar_path(&html)); + } +} diff --git a/crates/rustmotion/src/loader.rs b/crates/rustmotion/src/loader.rs index c211d7a..a876d3d 100644 --- a/crates/rustmotion/src/loader.rs +++ b/crates/rustmotion/src/loader.rs @@ -38,19 +38,63 @@ pub fn load_scenario_from_source( } /// Load a scenario authored in the HTML/CSS dialect: transpile to the scenario -/// JSON value, deserialize into `Scenario`, then resolve includes — reusing the -/// exact same pipeline as the JSON loader. +/// JSON value, merge the annotations sidecar (if any), deserialize into +/// `Scenario`, then resolve includes — reusing the exact same pipeline as the +/// JSON loader. pub fn load_scenario_from_html(input: &PathBuf) -> Result { let html = std::fs::read_to_string(input).map_err(|e| RustmotionError::FileRead { path: input.display().to_string(), source: e, })?; - let value = rustmotion_html::html_to_scenario_value(&html) + let mut value = rustmotion_html::html_to_scenario_value(&html) .map_err(|e| RustmotionError::HtmlParse(e.to_string()))?; + let annotations = load_html_annotations_sidecar(input)?; + if !annotations.is_empty() { + if let Some(obj) = value.as_object_mut() { + let arr = obj + .entry("annotations") + .or_insert_with(|| serde_json::Value::Array(vec![])); + if let serde_json::Value::Array(a) = arr { + a.extend(annotations); + } + } + } let scenario: Scenario = serde_json::from_value(value).map_err(RustmotionError::from)?; include::resolve_includes(scenario, &include::IncludeSource::File(input.clone())) } +/// Read the annotations sidecar next to an HTML-dialect source: for +/// `foo.html`, `foo.annotations.json` holding `{"annotations": [...]}` (same +/// annotation object format as JSON scenarios' `annotations` field; the studio +/// writes it because HTML sources can't carry the array inline). A missing +/// sidecar is fine (empty); a present-but-invalid one is an error — never +/// silently ignored. +pub fn load_html_annotations_sidecar(input: &std::path::Path) -> Result> { + let sidecar = input.with_extension("annotations.json"); + let text = match std::fs::read_to_string(&sidecar) { + Ok(t) => t, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(vec![]), + Err(e) => { + return Err(RustmotionError::FileRead { + path: sidecar.display().to_string(), + source: e, + }) + } + }; + let doc: serde_json::Value = serde_json::from_str(&text).map_err(|e| { + RustmotionError::from(format!("annotations sidecar {}: {e}", sidecar.display())) + })?; + doc.get("annotations") + .and_then(|a| a.as_array()) + .cloned() + .ok_or_else(|| { + RustmotionError::from(format!( + "annotations sidecar {}: missing \"annotations\" array", + sidecar.display() + )) + }) +} + /// Dispatch by file extension: `.html`/`.htm` use the HTML transpiler, everything /// else uses the JSON loader. Single entry point for all CLI commands. pub fn load_input(input: &PathBuf) -> Result { @@ -92,16 +136,20 @@ mod html_tests { use super::*; use std::io::Write; - #[test] - fn loads_html_scenario_into_resolved() { - let html = r##" + const HTML: &str = r##"

Hi

"##; - let dir = std::env::temp_dir(); - let path = dir.join("rm_html_loader_test.html"); + + fn write_html(name: &str) -> std::path::PathBuf { + let path = std::env::temp_dir().join(format!("{name}_{}.html", std::process::id())); let mut f = std::fs::File::create(&path).unwrap(); - f.write_all(html.as_bytes()).unwrap(); + f.write_all(HTML.as_bytes()).unwrap(); + path + } + #[test] + fn loads_html_scenario_into_resolved() { + let path = write_html("rm_html_loader_test"); let resolved = load_input(&path).expect("html loads"); assert_eq!(resolved.video.width, 1920); assert_eq!(resolved.views.len(), 1); @@ -109,4 +157,49 @@ mod html_tests { assert_eq!(resolved.views[0].scenes[0].duration, 4.0); let _ = std::fs::remove_file(&path); } + + #[test] + fn html_load_merges_annotations_sidecar() { + let path = write_html("rm_html_sidecar_merge"); + let sidecar = path.with_extension("annotations.json"); + std::fs::write( + &sidecar, + r##"{ "annotations": [ { "id": "an_1", "note": "smaller", "status": "open", + "frame": 3, "target": { "pointer": "/scenes/0/children/0", "kind": "text" } } ] }"##, + ) + .unwrap(); + + let annotations = load_html_annotations_sidecar(&path).expect("sidecar loads"); + assert_eq!(annotations.len(), 1); + assert_eq!(annotations[0]["id"], "an_1"); + // The full pipeline (transpile + merge + deserialize) accepts it too. + load_input(&path).expect("html with sidecar loads"); + + let _ = std::fs::remove_file(&path); + let _ = std::fs::remove_file(&sidecar); + } + + #[test] + fn corrupt_annotations_sidecar_fails_the_load() { + let path = write_html("rm_html_sidecar_corrupt"); + let sidecar = path.with_extension("annotations.json"); + std::fs::write(&sidecar, "{ not json").unwrap(); + + assert!(load_html_annotations_sidecar(&path).is_err()); + let err = load_input(&path).expect_err("corrupt sidecar must fail the load"); + assert!( + err.to_string().contains("annotations sidecar"), + "error should name the sidecar, got: {err}" + ); + + let _ = std::fs::remove_file(&path); + let _ = std::fs::remove_file(&sidecar); + } + + #[test] + fn missing_annotations_sidecar_is_empty() { + let path = write_html("rm_html_sidecar_missing"); + assert!(load_html_annotations_sidecar(&path).unwrap().is_empty()); + let _ = std::fs::remove_file(&path); + } }