use crate::error::{Result, RustmotionError};
use crate::schema::{ResolvedScenario, Scenario};
use crate::{expand, include, variables};
use std::path::PathBuf;
pub fn load_scenario(input: &PathBuf) -> Result<ResolvedScenario> {
load_scenario_with_vars(input, None)
}
pub fn load_scenario_with_vars(
input: &PathBuf,
overrides: Option<&std::collections::HashMap<String, serde_json::Value>>,
) -> Result<ResolvedScenario> {
let json_str = std::fs::read_to_string(input).map_err(|e| RustmotionError::FileRead {
path: input.display().to_string(),
source: e,
})?;
let mut json_value: serde_json::Value =
serde_json::from_str(&json_str).map_err(RustmotionError::from)?;
let label = input.display().to_string();
variables::apply_variables(&mut json_value, overrides, &label)?;
expand::expand_directives(&mut json_value, &label)?;
if let Some(dir) = input.parent() {
{
crate::assets::rebase_relative_paths(&mut json_value, dir);
}
}
let scenario: Scenario = serde_json::from_value(json_value).map_err(RustmotionError::from)?;
include::resolve_includes(scenario, &include::IncludeSource::File(input.clone()))
}
pub fn load_scenario_from_source(
input: Option<&PathBuf>,
json: Option<&str>,
) -> Result<ResolvedScenario> {
load_scenario_from_source_with_vars(input, json, None)
}
pub fn load_scenario_from_source_with_vars(
input: Option<&PathBuf>,
json: Option<&str>,
overrides: Option<&std::collections::HashMap<String, serde_json::Value>>,
) -> Result<ResolvedScenario> {
match (input, json) {
(Some(_), Some(_)) => Err(RustmotionError::ConflictingInput),
(Some(path), None) => load_scenario_with_vars(path, overrides),
(None, Some(json_str)) => {
let mut json_value: serde_json::Value =
serde_json::from_str(json_str).map_err(RustmotionError::from)?;
variables::apply_variables(&mut json_value, overrides, "<inline>")?;
expand::expand_directives(&mut json_value, "<inline>")?;
let scenario: Scenario =
serde_json::from_value(json_value).map_err(RustmotionError::from)?;
include::resolve_includes(scenario, &include::IncludeSource::Inline)
}
(None, None) => Err(RustmotionError::MissingInput),
}
}
pub fn load_scenario_from_html(input: &PathBuf) -> Result<ResolvedScenario> {
load_scenario_from_html_with_vars(input, None)
}
pub fn load_scenario_from_html_with_vars(
input: &PathBuf,
overrides: Option<&std::collections::HashMap<String, serde_json::Value>>,
) -> Result<ResolvedScenario> {
let html = std::fs::read_to_string(input).map_err(|e| RustmotionError::FileRead {
path: input.display().to_string(),
source: e,
})?;
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 label = input.display().to_string();
variables::apply_variables(&mut value, overrides, &label)?;
expand::expand_directives(&mut value, &label)?;
if let Some(dir) = input.parent() {
crate::assets::rebase_relative_paths(&mut value, dir);
}
let scenario: Scenario = serde_json::from_value(value).map_err(RustmotionError::from)?;
include::resolve_includes(scenario, &include::IncludeSource::File(input.clone()))
}
pub fn load_html_annotations_sidecar(input: &std::path::Path) -> Result<Vec<serde_json::Value>> {
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()
))
})
}
pub fn load_input(input: &PathBuf) -> Result<ResolvedScenario> {
load_input_with_vars(input, None)
}
pub fn load_input_with_vars(
input: &PathBuf,
overrides: Option<&std::collections::HashMap<String, serde_json::Value>>,
) -> Result<ResolvedScenario> {
match input.extension().and_then(|e| e.to_str()) {
Some("html") | Some("htm") => load_scenario_from_html_with_vars(input, overrides),
_ => load_scenario_with_vars(input, overrides),
}
}
pub fn html_to_scenario_json(html: &str) -> Result<serde_json::Value> {
rustmotion_html::html_to_scenario_value(html)
.map_err(|e| RustmotionError::HtmlParse(e.to_string()))
}
pub fn is_html_path(path: &std::path::Path) -> bool {
matches!(
path.extension().and_then(|e| e.to_str()),
Some("html") | Some("htm")
)
}
pub fn set_html_inline_style(html: &str, pointer: &str, prop: &str, value: &str) -> Option<String> {
rustmotion_html::set_inline_style(html, pointer, prop, value)
}
pub fn set_html_text_content(html: &str, pointer: &str, text: &str) -> Option<String> {
rustmotion_html::set_text_content(html, pointer, text)
}
pub fn set_html_attribute(html: &str, pointer: &str, name: &str, value: &str) -> Option<String> {
rustmotion_html::set_attribute(html, pointer, name, value)
}
pub fn remove_html_inline_style(html: &str, pointer: &str, prop: &str) -> Option<String> {
rustmotion_html::remove_inline_style(html, pointer, prop)
}
#[cfg(test)]
mod html_tests {
use super::*;
use std::io::Write;
const HTML: &str = r##"<rustmotion width="1920" height="1080" fps="30" background="#0f172a">
<scene duration="4"><h1 style="font-size:96; color:#ffffff">Hi</h1></scene>
</rustmotion>"##;
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();
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);
assert_eq!(resolved.views[0].scenes.len(), 1);
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");
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);
}
#[test]
fn html_var_in_text_substituted_via_load_input_with_vars() {
let html = r##"<rustmotion width="320" height="240" fps="30">
<scene duration="1"><h1 style="font-size:24; color:#ffffff">Hello $greeting</h1></scene>
</rustmotion>"##;
let path = {
let p =
std::env::temp_dir().join(format!("rm_html_var_subst_{}.html", std::process::id()));
let mut f = std::fs::File::create(&p).unwrap();
std::io::Write::write_all(&mut f, html.as_bytes()).unwrap();
p
};
let mut overrides = std::collections::HashMap::new();
overrides.insert("greeting".to_string(), serde_json::json!("World"));
let resolved =
load_input_with_vars(&path, Some(&overrides)).expect("html var substitution");
let content = &resolved.views[0].scenes[0].children[0];
let _ = content;
assert_eq!(resolved.video.width, 320);
let _ = std::fs::remove_file(&path);
}
}
#[cfg(test)]
mod vars_tests {
use super::*;
use std::io::Write;
#[test]
fn json_override_number_preserves_type() {
let json_str = serde_json::json!({
"config": {
"width_px": { "type": "number", "default": 100 }
},
"video": { "width": "$width_px", "height": 100, "fps": 30 },
"scenes": [{ "duration": 0.1, "children": [] }]
})
.to_string();
let path = {
let p = std::env::temp_dir().join(format!("rm_var_num_{}.json", std::process::id()));
let mut f = std::fs::File::create(&p).unwrap();
f.write_all(json_str.as_bytes()).unwrap();
p
};
let mut overrides = std::collections::HashMap::new();
overrides.insert("width_px".to_string(), serde_json::json!(200));
let resolved = load_input_with_vars(&path, Some(&overrides)).expect("loads with override");
assert_eq!(
resolved.video.width, 200,
"override number must be applied as number"
);
let _ = std::fs::remove_file(&path);
}
#[test]
fn unknown_override_with_config_errors_actionably() {
let json_str = serde_json::json!({
"config": {
"color": { "type": "string", "default": "#000" }
},
"video": { "width": 100, "height": 100, "fps": 30 },
"scenes": [{ "duration": 0.1, "children": [] }]
})
.to_string();
let path = {
let p =
std::env::temp_dir().join(format!("rm_var_unknown_{}.json", std::process::id()));
let mut f = std::fs::File::create(&p).unwrap();
f.write_all(json_str.as_bytes()).unwrap();
p
};
let mut overrides = std::collections::HashMap::new();
overrides.insert("not_declared".to_string(), serde_json::json!("x"));
let err =
load_input_with_vars(&path, Some(&overrides)).expect_err("unknown var must error");
assert!(
err.to_string().contains("not_declared"),
"error must name the unknown variable, got: {err}"
);
let _ = std::fs::remove_file(&path);
}
#[test]
fn override_wins_over_default() {
let json_str = serde_json::json!({
"config": {
"title": { "type": "string", "default": "Default Title" }
},
"video": { "width": 100, "height": 100, "fps": 30 },
"scenes": [{ "duration": 0.1, "children": [
{ "type": "text", "content": "$title" }
]}]
})
.to_string();
let path = {
let p = std::env::temp_dir().join(format!("rm_var_prec_{}.json", std::process::id()));
let mut f = std::fs::File::create(&p).unwrap();
f.write_all(json_str.as_bytes()).unwrap();
p
};
let mut overrides = std::collections::HashMap::new();
overrides.insert("title".to_string(), serde_json::json!("Override Title"));
let resolved = load_input_with_vars(&path, Some(&overrides)).expect("loads");
assert_eq!(resolved.views[0].scenes[0].duration, 0.1);
let _ = std::fs::remove_file(&path);
}
}