Skip to main content

renamite_cli/
lib.rs

1//! renamite CLI - implemented as a library so every command is directly
2//! testable (no subprocess spawning). `main.rs` only calls [`run`].
3
4use anyhow::{Context, Result, anyhow, bail};
5use clap::{CommandFactory, Parser, Subcommand};
6use renamite_behavior_common::ViewTransform;
7use renamite_io_ren::RenFile;
8use renamite_player::Player;
9use renamite_render_bridge::SceneRenderer;
10use renamite_render_offscreen::OffscreenRenderer;
11use serde::Serialize;
12use serde_json::Value;
13use std::path::{Path, PathBuf};
14
15#[derive(Parser)]
16#[command(name = "renamite")]
17#[command(about = "Runtime and tooling for .ren animations")]
18#[command(version, author)]
19pub struct Cli {
20    #[command(subcommand)]
21    pub command: Commands,
22}
23
24#[derive(Subcommand)]
25pub enum Commands {
26    /// Bake animation frames to JSON scenes (golden tests / export)
27    Bake {
28        input: PathBuf,
29        #[arg(short, long, default_value = "60")]
30        frames: usize,
31        #[arg(short, long, default_value = "0.016666667")]
32        dt: f64,
33        #[arg(short, long, default_value = "scenes.json")]
34        output: PathBuf,
35    },
36
37    /// Rasterize to PNG via the Repose WGPU renderer: a single frame
38    /// (--frame) or a sequence (--frames)
39    Render {
40        input: PathBuf,
41        #[arg(long, conflicts_with = "frames")]
42        frame: Option<i64>,
43        #[arg(long, conflicts_with = "frame")]
44        frames: Option<usize>,
45        #[arg(long, default_value = "0.016666667")]
46        dt: f64,
47        #[arg(long, default_value = "512")]
48        width: u32,
49        #[arg(long, default_value = "512")]
50        height: u32,
51        /// Single-frame output path
52        #[arg(short, long)]
53        out: Option<PathBuf>,
54        /// Sequence output directory
55        #[arg(long)]
56        out_dir: Option<PathBuf>,
57        #[arg(long, default_value = "frame")]
58        prefix: String,
59        /// "transparent", "white", "black", or hex RRGGBB[AA]
60        #[arg(long, default_value = "white")]
61        background: String,
62    },
63
64    /// Pack .ren -> binary .renb
65    Pack {
66        input: PathBuf,
67        #[arg(short, long)]
68        output: PathBuf,
69    },
70
71    /// Unpack .renb -> pretty .ren
72    Unpack {
73        input: PathBuf,
74        #[arg(short, long)]
75        output: PathBuf,
76    },
77
78    /// Show project info
79    Info {
80        input: PathBuf,
81        /// Emit a machine-readable JSON summary
82        #[arg(long)]
83        json: bool,
84    },
85
86    /// Validate + normalize (optionally fix in place, or deep-validate)
87    Validate {
88        input: PathBuf,
89        #[arg(long)]
90        fix: bool,
91        /// Run deep structural validation and print diagnostics
92        #[arg(long)]
93        deep: bool,
94        /// Emit the deep validation report as JSON
95        #[arg(long, requires = "deep")]
96        json: bool,
97        /// Exit 1 on warnings as well as errors in deep mode
98        #[arg(long, requires = "deep")]
99        warnings_as_errors: bool,
100    },
101
102    /// Structural diff between two .ren/.renb files
103    Diff {
104        a: PathBuf,
105        b: PathBuf,
106        /// Exit with status 1 if any differences are found
107        #[arg(long)]
108        fail_on_diff: bool,
109    },
110
111    /// Scaffold a new .ren project
112    New {
113        output: PathBuf,
114        /// Template slug, e.g. "blank", "bouncing-ball" (run `renamite templates`)
115        #[arg(long, default_value = "ellipse")]
116        template: String,
117    },
118
119    /// List built-in project templates
120    Templates {},
121
122    /// Headless playback (prints machine events)
123    Play {
124        input: PathBuf,
125        #[arg(short, long, default_value = "5.0")]
126        duration: f64,
127    },
128
129    /// Export a .ren/.renb project to Lottie JSON.
130    ExportLottie {
131        input: PathBuf,
132        #[arg(short, long)]
133        output: PathBuf,
134        /// Fail if the exporter emitted compatibility warnings.
135        #[arg(long)]
136        strict: bool,
137    },
138
139    /// Convert Lottie JSON to a Renamite project.
140    ImportLottie {
141        input: PathBuf,
142        #[arg(short, long)]
143        output: PathBuf,
144        /// Fail if unsupported objects were skipped.
145        #[arg(long)]
146        strict: bool,
147    },
148
149    /// Export a .ren/.renb project to a static SVG frame snapshot.
150    ExportSvg {
151        input: PathBuf,
152        #[arg(short, long)]
153        output: PathBuf,
154        #[arg(long, default_value = "0")]
155        frame: f64,
156        /// Fail if the exporter emitted compatibility warnings.
157        #[arg(long)]
158        strict: bool,
159    },
160
161    /// Convert an SVG file to a Renamite project.
162    ImportSvg {
163        input: PathBuf,
164        #[arg(short, long)]
165        output: PathBuf,
166        /// Fail if unsupported objects were skipped.
167        #[arg(long)]
168        strict: bool,
169    },
170
171    /// Generate shell completions
172    Completions { shell: clap_complete::Shell },
173}
174
175pub fn run() -> Result<()> {
176    let cli = Cli::parse();
177    dispatch(cli.command)
178}
179
180/// Exposed for tests: parse from an explicit argv, bypassing `std::env::args`.
181pub fn run_from<I, T>(args: I) -> Result<()>
182where
183    I: IntoIterator<Item = T>,
184    T: Into<std::ffi::OsString> + Clone,
185{
186    let cli = Cli::try_parse_from(args)?;
187    dispatch(cli.command)
188}
189
190fn dispatch(command: Commands) -> Result<()> {
191    match command {
192        Commands::Bake {
193            input,
194            frames,
195            dt,
196            output,
197        } => cmd_bake(input, frames, dt, output),
198        Commands::Render {
199            input,
200            frame,
201            frames,
202            dt,
203            width,
204            height,
205            out,
206            out_dir,
207            prefix,
208            background,
209        } => cmd_render(
210            input, frame, frames, dt, width, height, out, out_dir, prefix, background,
211        ),
212        Commands::Pack { input, output } => cmd_pack(input, output),
213        Commands::Unpack { input, output } => cmd_unpack(input, output),
214        Commands::Info { input, json } => cmd_info(input, json),
215        Commands::Validate {
216            input,
217            fix,
218            deep,
219            json,
220            warnings_as_errors,
221        } => cmd_validate(input, fix, deep, json, warnings_as_errors),
222        Commands::Diff { a, b, fail_on_diff } => cmd_diff(a, b, fail_on_diff),
223        Commands::New { output, template } => cmd_new(output, template),
224        Commands::Templates {} => cmd_templates(),
225        Commands::Play { input, duration } => cmd_play(input, duration),
226        Commands::ExportLottie {
227            input,
228            output,
229            strict,
230        } => cmd_export_lottie(input, output, strict),
231        Commands::ImportLottie {
232            input,
233            output,
234            strict,
235        } => cmd_import_lottie(input, output, strict),
236        Commands::ExportSvg {
237            input,
238            output,
239            frame,
240            strict,
241        } => cmd_export_svg(input, output, frame, strict),
242        Commands::ImportSvg {
243            input,
244            output,
245            strict,
246        } => cmd_import_svg(input, output, strict),
247        Commands::Completions { shell } => {
248            let mut cmd = Cli::command();
249            let name = cmd.get_name().to_string();
250            clap_complete::generate(shell, &mut cmd, name, &mut std::io::stdout());
251            Ok(())
252        }
253    }
254}
255
256fn cmd_bake(input: PathBuf, frames: usize, dt: f64, output: PathBuf) -> Result<()> {
257    let file = load_file(&input).with_context(|| format!("failed to load {}", input.display()))?;
258    let mut player = Player::new(file)
259        .with_context(|| format!("failed to open player for {}", input.display()))?;
260    let scenes = player.bake(frames, dt);
261    let json = serde_json::to_string_pretty(&scenes)?;
262    std::fs::write(&output, json)?;
263    println!("Baked {frames} frames -> {}", output.display());
264    Ok(())
265}
266
267#[allow(clippy::too_many_arguments)]
268fn cmd_render(
269    input: PathBuf,
270    frame: Option<i64>,
271    frames: Option<usize>,
272    dt: f64,
273    width: u32,
274    height: u32,
275    out: Option<PathBuf>,
276    out_dir: Option<PathBuf>,
277    prefix: String,
278    background: String,
279) -> Result<()> {
280    if frame.is_none() && frames.is_none() {
281        bail!("specify either --frame N or --frames N");
282    }
283
284    let bg = parse_background(&background)?;
285    let mut player = Player::new(load_file(&input)?)
286        .with_context(|| format!("failed to open player for {}", input.display()))?;
287    let comp_size = player.project.document.compositions[player.project.document.main].size;
288    let view = export_view(comp_size, width, height);
289    let bg_clear = bg.map(|[r, g, b, a]| {
290        [
291            r as f64 / 255.0,
292            g as f64 / 255.0,
293            b as f64 / 255.0,
294            a as f64 / 255.0,
295        ]
296    });
297
298    let mut bridge = SceneRenderer::new();
299    let mut gpu = pollster::block_on(OffscreenRenderer::new(width, height, 4))?;
300    gpu.sync_document_images(&player.project.document)?;
301
302    match (frame, frames) {
303        (Some(f), None) => {
304            player.scrub(f as f64);
305            let png = rasterize_png(&mut bridge, &mut gpu, player.scene(), &view, bg_clear)?;
306            let out = out.ok_or_else(|| anyhow!("--out is required with --frame"))?;
307            std::fs::write(&out, png)?;
308            println!("Rendered frame {f} -> {}", out.display());
309            Ok(())
310        }
311        (None, Some(n)) => {
312            let out_dir = out_dir.ok_or_else(|| anyhow!("--out-dir is required with --frames"))?;
313            std::fs::create_dir_all(&out_dir)?;
314            let scenes = player.bake(n, dt);
315            for (i, scene) in scenes.iter().enumerate() {
316                let png = rasterize_png(&mut bridge, &mut gpu, scene, &view, bg_clear)?;
317                let path = out_dir.join(format!("{prefix}_{i:05}.png"));
318                std::fs::write(&path, png)?;
319            }
320            println!("Rendered {n} frames -> {}", out_dir.display());
321            Ok(())
322        }
323        (None, None) => unreachable!("guard at top of cmd_render"),
324        (Some(_), Some(_)) => unreachable!("clap conflicts_with prevents this"),
325    }
326}
327
328/// World -> pixel "contain" fit for the main composition.
329fn export_view(comp_size: (u32, u32), out_w: u32, out_h: u32) -> ViewTransform {
330    let (cw, ch) = (comp_size.0 as f64, comp_size.1 as f64);
331    if cw <= 0.0 || ch <= 0.0 || out_w == 0 || out_h == 0 {
332        return ViewTransform::identity();
333    }
334    let scale = (out_w as f64 / cw).min(out_h as f64 / ch);
335    let ox = (out_w as f64 - cw * scale) * 0.5;
336    let oy = (out_h as f64 - ch * scale) * 0.5;
337    ViewTransform {
338        scale,
339        offset: glam::DVec2::new(ox, oy),
340    }
341}
342
343fn rasterize_png(
344    bridge: &mut SceneRenderer,
345    gpu: &mut OffscreenRenderer,
346    scene: &renamite_model::Scene,
347    view: &ViewTransform,
348    bg: Option<[f64; 4]>,
349) -> Result<Vec<u8>> {
350    let prepared = bridge.prepare(scene, view);
351    let mut repose = repose_core::Scene::default();
352    bridge.append_repose_scene(&prepared, &mut repose);
353    gpu.render_png(&repose, bg)
354}
355
356fn parse_background(s: &str) -> Result<Option<[u8; 4]>> {
357    match s {
358        "transparent" | "none" => Ok(None),
359        "white" => Ok(Some([255, 255, 255, 255])),
360        "black" => Ok(Some([0, 0, 0, 255])),
361        hex => {
362            let hex = hex.trim_start_matches('#');
363            let bytes = match hex.len() {
364                6 => [
365                    u8::from_str_radix(&hex[0..2], 16)?,
366                    u8::from_str_radix(&hex[2..4], 16)?,
367                    u8::from_str_radix(&hex[4..6], 16)?,
368                    255,
369                ],
370                8 => [
371                    u8::from_str_radix(&hex[0..2], 16)?,
372                    u8::from_str_radix(&hex[2..4], 16)?,
373                    u8::from_str_radix(&hex[4..6], 16)?,
374                    u8::from_str_radix(&hex[6..8], 16)?,
375                ],
376                _ => bail!(
377                    "invalid background '{s}': expected 'transparent', 'white', 'black', or hex RRGGBB[AA]"
378                ),
379            };
380            Ok(Some(bytes))
381        }
382    }
383}
384
385fn cmd_pack(input: PathBuf, output: PathBuf) -> Result<()> {
386    let text = std::fs::read_to_string(&input)?;
387    let mut file: RenFile = renamite_io_ren::open(&text)?;
388    file.normalize();
389    std::fs::write(&output, renamite_io_ren::save_binary(&file)?)?;
390    println!("Packed {} -> {}", input.display(), output.display());
391    Ok(())
392}
393
394fn cmd_unpack(input: PathBuf, output: PathBuf) -> Result<()> {
395    let bytes = std::fs::read(&input)?;
396    let mut file: RenFile = renamite_io_ren::open_binary(&bytes)?;
397    file.normalize();
398    std::fs::write(&output, renamite_io_ren::save(&file)?)?;
399    println!("Unpacked {} -> {}", input.display(), output.display());
400    Ok(())
401}
402
403#[derive(Serialize)]
404struct InfoSummary {
405    path: String,
406    name: String,
407    format_version: u32,
408    compositions: usize,
409    nodes: usize,
410    clips: usize,
411    machines: usize,
412    start_machine: Option<String>,
413    main: MainCompInfo,
414}
415
416#[derive(Serialize)]
417struct MainCompInfo {
418    name: String,
419    width: u32,
420    height: u32,
421    fps: f64,
422    in_frame: i64,
423    out_frame: i64,
424}
425
426fn cmd_info(input: PathBuf, json: bool) -> Result<()> {
427    let file = load_file(&input)?;
428    let comp = &file.document.compositions[file.document.main];
429    let summary = InfoSummary {
430        path: input.display().to_string(),
431        name: file.meta.name.clone(),
432        format_version: file.format_version,
433        compositions: file.document.compositions.len(),
434        nodes: file.document.nodes.len(),
435        clips: file.clips.len(),
436        machines: file.machines.len(),
437        start_machine: file
438            .start_machine
439            .and_then(|id| file.machines.get(id))
440            .map(|m| m.name.clone()),
441        main: MainCompInfo {
442            name: comp.name.clone(),
443            width: comp.size.0,
444            height: comp.size.1,
445            fps: comp.rate.fps(),
446            in_frame: comp.range.0.0,
447            out_frame: comp.range.1.0,
448        },
449    };
450
451    if json {
452        println!("{}", serde_json::to_string_pretty(&summary)?);
453        return Ok(());
454    }
455
456    println!("File:           {}", summary.path);
457    println!("Name:           {}", summary.name);
458    println!("Format:         v{}", summary.format_version);
459    println!("Compositions:   {}", summary.compositions);
460    println!("Nodes:          {}", summary.nodes);
461    println!("Clips:          {}", summary.clips);
462    println!("Machines:       {}", summary.machines);
463    if let Some(name) = &summary.start_machine {
464        println!("Start machine:  {name}");
465    }
466    println!("\nMain composition:");
467    println!("  Name:  {}", summary.main.name);
468    println!("  Size:  {}x{}", summary.main.width, summary.main.height);
469    println!("  Rate:  {:.2} fps", summary.main.fps);
470    println!(
471        "  Range: {} - {}",
472        summary.main.in_frame, summary.main.out_frame
473    );
474    Ok(())
475}
476
477fn cmd_validate(
478    input: PathBuf,
479    fix: bool,
480    deep: bool,
481    json: bool,
482    warnings_as_errors: bool,
483) -> Result<()> {
484    let mut file = load_file(&input)?;
485    let before_json = serde_json::to_string(&file)?;
486    file.normalize();
487    file.garbage_collect();
488
489    if deep {
490        let report = renamite_validate::validate(&file);
491
492        if json {
493            println!("{}", serde_json::to_string_pretty(&report)?);
494        } else {
495            for d in &report.diagnostics {
496                println!("{:?}: {}: {}", d.severity, d.path, d.message);
497            }
498            println!(
499                "{} error(s), {} warning(s)",
500                report.error_count(),
501                report.warning_count()
502            );
503        }
504
505        if report.has_errors() || (warnings_as_errors && report.warning_count() > 0) {
506            bail!(
507                "validation failed: {} error(s), {} warning(s)",
508                report.error_count(),
509                report.warning_count()
510            );
511        }
512        return Ok(());
513    }
514
515    if fix {
516        std::fs::write(&input, renamite_io_ren::save_binary(&file)?)?;
517        println!("Normalized and saved {}", input.display());
518    } else if serde_json::to_string(&file)? == before_json {
519        println!("{} is valid", input.display());
520    } else {
521        bail!("{} needs normalization (use --fix)", input.display());
522    }
523    Ok(())
524}
525
526fn cmd_diff(a: PathBuf, b: PathBuf, fail_on_diff: bool) -> Result<()> {
527    let fa = load_file(&a)?;
528    let fb = load_file(&b)?;
529    let va = serde_json::to_value(&fa)?;
530    let vb = serde_json::to_value(&fb)?;
531
532    let mut diffs = Vec::new();
533    diff_values("", &va, &vb, &mut diffs);
534
535    if diffs.is_empty() {
536        println!("No structural differences.");
537    } else {
538        println!("{} difference(s):", diffs.len());
539        for d in &diffs {
540            println!("  {d}");
541        }
542        if fail_on_diff {
543            bail!(
544                "structural differences found: {} difference(s)",
545                diffs.len()
546            );
547        }
548    }
549    Ok(())
550}
551
552/// Minimal recursive structural diff. Object keys are compared by name;
553/// arrays of differing length are reported wholesale (no element alignment).
554fn diff_values(path: &str, a: &Value, b: &Value, out: &mut Vec<String>) {
555    match (a, b) {
556        (Value::Object(ma), Value::Object(mb)) => {
557            let mut keys: Vec<&String> = ma.keys().chain(mb.keys()).collect();
558            keys.sort();
559            keys.dedup();
560            for k in keys {
561                let sub = if path.is_empty() {
562                    k.clone()
563                } else {
564                    format!("{path}.{k}")
565                };
566                match (ma.get(k), mb.get(k)) {
567                    (Some(av), Some(bv)) => diff_values(&sub, av, bv, out),
568                    (Some(_), None) => out.push(format!("- {sub} (removed)")),
569                    (None, Some(_)) => out.push(format!("+ {sub} (added)")),
570                    (None, None) => unreachable!(),
571                }
572            }
573        }
574        (Value::Array(aa), Value::Array(ba)) => {
575            if aa.len() != ba.len() {
576                out.push(format!("~ {path} (array len {} -> {})", aa.len(), ba.len()));
577            } else {
578                for (i, (av, bv)) in aa.iter().zip(ba.iter()).enumerate() {
579                    diff_values(&format!("{path}[{i}]"), av, bv, out);
580                }
581            }
582        }
583        _ => {
584            if a != b {
585                out.push(format!("~ {path}: {a} -> {b}"));
586            }
587        }
588    }
589}
590
591fn cmd_new(output: PathBuf, template: String) -> Result<()> {
592    let name = name_from_path(&output);
593    let mut file = match template.as_str() {
594        // Legacy alias predating the renamite-examples template set.
595        "ellipse" => scaffold_ellipse(name.clone()),
596        other => match renamite_examples::parse_template(other) {
597            Some(id) => renamite_examples::build_template(id),
598            None => {
599                let known: Vec<&str> = std::iter::once("ellipse")
600                    .chain(renamite_examples::templates().iter().map(|t| t.id.slug()))
601                    .collect();
602                bail!(
603                    "unknown template '{other}' (expected one of: {})",
604                    known.join(", ")
605                )
606            }
607        },
608    };
609    file.meta.name = name;
610
611    let ext = output.extension().and_then(|s| s.to_str()).unwrap_or("ren");
612    match ext {
613        "renb" => std::fs::write(&output, renamite_io_ren::save_binary(&file)?)?,
614        _ => std::fs::write(&output, renamite_io_ren::save(&file)?)?,
615    }
616    println!("Created {}", output.display());
617    Ok(())
618}
619
620fn cmd_templates() -> Result<()> {
621    println!("{}", templates_text());
622    Ok(())
623}
624
625fn templates_text() -> String {
626    let mut out =
627        String::from("Available templates (use with `renamite new --template <slug>`):\n");
628    for t in renamite_examples::templates() {
629        out.push_str(&format!("  {:<18} {}\n", t.id.slug(), t.description));
630    }
631    out
632}
633
634fn scaffold_ellipse(name: String) -> RenFile {
635    use renamite_animation::Animated;
636    use renamite_model::{
637        Color, Document, FillRule, Node, NodeKind, Parent, ShapeKind, StyleKind, StylePaint,
638    };
639
640    let mut doc = Document::empty();
641    let comp = doc.main;
642    let (w, h) = doc.compositions[comp].size;
643    let center = glam::DVec2::new(w as f64 / 2.0, h as f64 / 2.0);
644
645    let shape = doc.create_node(Node::new(
646        "Ellipse",
647        NodeKind::Shape(ShapeKind::Ellipse {
648            pos: Animated::new(center),
649            size: Animated::new(glam::DVec2::new(180.0, 180.0)),
650        }),
651    ));
652    let fill = doc.create_node(Node::new(
653        "Fill",
654        NodeKind::Style(StyleKind::Fill {
655            paint: StylePaint::solid(Color::rgba(0.96, 0.42, 0.18, 1.0)),
656            rule: FillRule::NonZero,
657        }),
658    ));
659    doc.attach(shape, Parent::Comp(comp), 0).unwrap();
660    doc.attach(fill, Parent::Comp(comp), 1).unwrap();
661
662    RenFile::new(doc, name)
663}
664
665fn name_from_path(p: &Path) -> String {
666    p.file_stem()
667        .and_then(|s| s.to_str())
668        .unwrap_or("Untitled")
669        .to_string()
670}
671
672fn cmd_play(input: PathBuf, duration: f64) -> Result<()> {
673    let mut player = Player::new(load_file(&input)?)?;
674    let dt = 1.0 / 60.0;
675    let ticks = (duration / dt) as usize;
676
677    println!("Playing {} for {duration:.1}s...", input.display());
678    for _ in 0..ticks {
679        for ev in player.tick(dt) {
680            println!("  {ev}");
681        }
682    }
683    println!("Done. Final head: {:.2}", player.head());
684    Ok(())
685}
686
687fn cmd_export_lottie(input: PathBuf, output: PathBuf, strict: bool) -> Result<()> {
688    let file = load_file(&input)?;
689    let report = renamite_io_lottie::export_with_report(&file.document)?;
690    if strict && !report.warnings.is_empty() {
691        for warning in &report.warnings {
692            eprintln!("warning at {}: {}", warning.path, warning.message);
693        }
694        bail!(
695            "Lottie export produced {} compatibility warning(s)",
696            report.warnings.len()
697        );
698    }
699    for warning in &report.warnings {
700        eprintln!("warning at {}: {}", warning.path, warning.message);
701    }
702    std::fs::write(&output, serde_json::to_vec_pretty(&report.value)?)?;
703    println!("Exported {} -> {}", input.display(), output.display());
704    Ok(())
705}
706
707fn cmd_import_lottie(input: PathBuf, output: PathBuf, strict: bool) -> Result<()> {
708    let value: Value = serde_json::from_slice(&std::fs::read(&input)?)?;
709    let report = renamite_io_lottie::import_with_report(&value)?;
710    if strict && !report.warnings.is_empty() {
711        for warning in &report.warnings {
712            eprintln!("warning at {}: {}", warning.path, warning.message);
713        }
714        bail!(
715            "Lottie import produced {} compatibility warning(s)",
716            report.warnings.len()
717        );
718    }
719    for warning in &report.warnings {
720        eprintln!("warning at {}: {}", warning.path, warning.message);
721    }
722    let file = RenFile::new(report.value, name_from_path(&input));
723    match output.extension().and_then(|extension| extension.to_str()) {
724        Some("renb") => {
725            std::fs::write(&output, renamite_io_ren::save_binary(&file)?)?;
726        }
727        _ => {
728            std::fs::write(&output, renamite_io_ren::save(&file)?)?;
729        }
730    }
731    println!("Imported {} -> {}", input.display(), output.display());
732    Ok(())
733}
734
735fn cmd_export_svg(input: PathBuf, output: PathBuf, frame: f64, strict: bool) -> Result<()> {
736    let file = load_file(&input)?;
737    let report = renamite_io_svg::export_with_report(&file.document, file.document.main, frame)?;
738    if strict && !report.warnings.is_empty() {
739        for warning in &report.warnings {
740            eprintln!("warning at {}: {}", warning.path, warning.message);
741        }
742        bail!(
743            "SVG export produced {} compatibility warning(s)",
744            report.warnings.len()
745        );
746    }
747    for warning in &report.warnings {
748        eprintln!("warning at {}: {}", warning.path, warning.message);
749    }
750    std::fs::write(&output, report.value)?;
751    println!(
752        "Exported {} frame {frame} -> {}",
753        input.display(),
754        output.display()
755    );
756    Ok(())
757}
758
759fn cmd_import_svg(input: PathBuf, output: PathBuf, strict: bool) -> Result<()> {
760    let bytes = std::fs::read(&input)?;
761    let report = renamite_io_svg::import_with_report(&bytes)?;
762    if strict && !report.warnings.is_empty() {
763        for warning in &report.warnings {
764            eprintln!("warning at {}: {}", warning.path, warning.message);
765        }
766        bail!(
767            "SVG import produced {} compatibility warning(s)",
768            report.warnings.len()
769        );
770    }
771    for warning in &report.warnings {
772        eprintln!("warning at {}: {}", warning.path, warning.message);
773    }
774    let file = RenFile::new(report.value, name_from_path(&input));
775    match output.extension().and_then(|extension| extension.to_str()) {
776        Some("renb") => {
777            std::fs::write(&output, renamite_io_ren::save_binary(&file)?)?;
778        }
779        _ => {
780            std::fs::write(&output, renamite_io_ren::save(&file)?)?;
781        }
782    }
783    println!("Imported {} -> {}", input.display(), output.display());
784    Ok(())
785}
786
787fn load_file(path: &Path) -> Result<RenFile> {
788    let bytes =
789        std::fs::read(path).with_context(|| format!("failed to read {}", path.display()))?;
790
791    // Magic wins over extension so extension-less / mislabeled .renb still open.
792    if renamite_io_ren::is_binary(&bytes) {
793        return Ok(renamite_io_ren::open_binary(&bytes)?);
794    }
795
796    let text = std::str::from_utf8(&bytes)
797        .with_context(|| format!("{} is neither valid UTF-8 .ren nor .renb", path.display()))?;
798    Ok(renamite_io_ren::open(text)?)
799}
800
801#[cfg(test)]
802mod tests {
803    use super::*;
804    use serde_json::json;
805
806    #[test]
807    fn parses_named_colors() {
808        assert_eq!(parse_background("transparent").unwrap(), None);
809        assert_eq!(
810            parse_background("white").unwrap(),
811            Some([255, 255, 255, 255])
812        );
813        assert_eq!(parse_background("black").unwrap(), Some([0, 0, 0, 255]));
814    }
815
816    #[test]
817    fn parses_hex_with_and_without_alpha() {
818        assert_eq!(parse_background("#ff0000").unwrap(), Some([255, 0, 0, 255]));
819        assert_eq!(
820            parse_background("00ff0080").unwrap(),
821            Some([0, 255, 0, 0x80])
822        );
823    }
824
825    #[test]
826    fn rejects_garbage_background() {
827        assert!(parse_background("not-a-color").is_err());
828        assert!(parse_background("#ff00").is_err());
829    }
830
831    #[test]
832    fn diff_detects_added_removed_changed() {
833        let a = json!({ "x": 1, "y": 2, "obj": { "same": 1 } });
834        let b = json!({ "x": 5, "z": 3, "obj": { "same": 1 } });
835        let mut diffs = Vec::new();
836        diff_values("", &a, &b, &mut diffs);
837        assert!(diffs.iter().any(|d| d.contains("~ x: 1 -> 5")));
838        assert!(diffs.iter().any(|d| d.contains("- y (removed)")));
839        assert!(diffs.iter().any(|d| d.contains("+ z (added)")));
840        assert!(!diffs.iter().any(|d| d.contains("obj")));
841    }
842
843    #[test]
844    fn diff_reports_array_length_change_wholesale() {
845        let a = json!({ "arr": [1, 2, 3] });
846        let b = json!({ "arr": [1, 2] });
847        let mut diffs = Vec::new();
848        diff_values("", &a, &b, &mut diffs);
849        assert_eq!(diffs, vec!["~ arr (array len 3 -> 2)"]);
850    }
851
852    #[test]
853    fn render_rejects_neither_frame_nor_frames() {
854        let err = run_from(["renamite", "render", "x.ren"]).unwrap_err();
855        assert!(err.to_string().contains("--frame"));
856    }
857
858    #[test]
859    fn render_rejects_both_frame_and_frames() {
860        let result = Cli::try_parse_from([
861            "renamite", "render", "x.ren", "--frame", "1", "--frames", "10",
862        ]);
863        assert!(result.is_err(), "clap must reject mutually exclusive flags");
864    }
865
866    #[test]
867    fn new_ellipse_roundtrips_through_pack_and_open() {
868        let dir = tempfile::tempdir().unwrap();
869        let ren_path = dir.path().join("scene.ren");
870        cmd_new(ren_path.clone(), "ellipse".into()).unwrap();
871
872        let file = load_file(&ren_path).unwrap();
873        assert_eq!(file.document.nodes.len(), 2); // shape + fill
874        assert_eq!(file.meta.name, "scene");
875
876        let renb_path = dir.path().join("scene.renb");
877        cmd_pack(ren_path, renb_path.clone()).unwrap();
878        let repacked = load_file(&renb_path).unwrap();
879        assert_eq!(repacked.document.nodes.len(), 2);
880    }
881
882    #[test]
883    fn play_and_render_accept_renb() {
884        let dir = tempfile::tempdir().unwrap();
885        let ren = dir.path().join("scene.ren");
886        let renb = dir.path().join("scene.renb");
887        cmd_new(ren.clone(), "ellipse".into()).unwrap();
888        cmd_pack(ren, renb.clone()).unwrap();
889        assert!(Player::new(load_file(&renb).unwrap()).is_ok());
890    }
891
892    #[test]
893    fn new_blank_has_no_nodes() {
894        let dir = tempfile::tempdir().unwrap();
895        let path = dir.path().join("empty.ren");
896        cmd_new(path.clone(), "blank".into()).unwrap();
897        let file = load_file(&path).unwrap();
898        assert_eq!(file.document.nodes.len(), 0);
899    }
900
901    #[test]
902    fn new_rejects_unknown_template() {
903        let dir = tempfile::tempdir().unwrap();
904        let path = dir.path().join("x.ren");
905        assert!(cmd_new(path, "not-a-template".into()).is_err());
906    }
907
908    #[test]
909    fn templates_lists_every_builtin_slug() {
910        let text = templates_text();
911        for t in renamite_examples::templates() {
912            assert!(
913                text.contains(t.id.slug()),
914                "templates output must mention {}",
915                t.id.slug()
916            );
917        }
918    }
919
920    #[test]
921    fn parse_template_accepts_slugs_and_display_names() {
922        use renamite_examples::TemplateId;
923        for id in TemplateId::all() {
924            assert_eq!(renamite_examples::parse_template(id.slug()), Some(*id));
925            assert_eq!(
926                renamite_examples::parse_template(id.display_name()),
927                Some(*id)
928            );
929            assert_eq!(
930                renamite_examples::parse_template(&id.slug().to_uppercase()),
931                Some(*id),
932                "template lookup must be case-insensitive"
933            );
934        }
935        assert_eq!(renamite_examples::parse_template("nope"), None);
936    }
937
938    #[test]
939    fn new_with_each_template_roundtrips() {
940        use renamite_examples::TemplateId;
941        for id in TemplateId::all() {
942            let dir = tempfile::tempdir().unwrap();
943            let path = dir.path().join(format!("{}.ren", id.slug()));
944            cmd_new(path.clone(), id.slug().into()).unwrap();
945
946            let loaded = load_file(&path).unwrap();
947            let mut expected = renamite_examples::build_template(*id);
948            expected.meta.name = id.slug().to_string();
949            assert_eq!(
950                serde_json::to_value(&loaded).unwrap(),
951                serde_json::to_value(&expected).unwrap(),
952                "template {} must survive save->load roundtrip",
953                id.slug()
954            );
955
956            let renb = dir.path().join(format!("{}.renb", id.slug()));
957            cmd_pack(path, renb.clone()).unwrap();
958            let packed = load_file(&renb).unwrap();
959            assert_eq!(
960                serde_json::to_value(&packed).unwrap(),
961                serde_json::to_value(&expected).unwrap(),
962                "template {} must survive binary pack->unpack roundtrip",
963                id.slug()
964            );
965        }
966    }
967
968    #[test]
969    #[ignore]
970    fn render_single_frame_writes_valid_png() {
971        let dir = tempfile::tempdir().unwrap();
972        let ren = dir.path().join("scene.ren");
973        cmd_new(ren.clone(), "ellipse".into()).unwrap();
974
975        let png = dir.path().join("out.png");
976        cmd_render(
977            ren,
978            Some(0),
979            None,
980            1.0 / 60.0,
981            64,
982            64,
983            Some(png.clone()),
984            None,
985            "frame".into(),
986            "white".into(),
987        )
988        .unwrap();
989
990        let bytes = std::fs::read(&png).unwrap();
991        assert_eq!(
992            &bytes[..8],
993            &[0x89, b'P', b'N', b'G', b'\r', b'\n', 0x1a, b'\n']
994        );
995    }
996
997    #[test]
998    #[ignore]
999    fn render_sequence_writes_numbered_files() {
1000        let dir = tempfile::tempdir().unwrap();
1001        let ren = dir.path().join("scene.ren");
1002        cmd_new(ren.clone(), "ellipse".into()).unwrap();
1003
1004        let out_dir = dir.path().join("frames");
1005        cmd_render(
1006            ren,
1007            None,
1008            Some(3),
1009            1.0 / 60.0,
1010            32,
1011            32,
1012            None,
1013            Some(out_dir.clone()),
1014            "f".into(),
1015            "transparent".into(),
1016        )
1017        .unwrap();
1018
1019        for i in 0..3 {
1020            assert!(out_dir.join(format!("f_{i:05}.png")).exists());
1021        }
1022    }
1023
1024    #[test]
1025    fn diff_of_identical_files_is_empty() {
1026        let dir = tempfile::tempdir().unwrap();
1027        let a = dir.path().join("a.ren");
1028        let b = dir.path().join("b.ren");
1029        cmd_new(a.clone(), "ellipse".into()).unwrap();
1030        std::fs::copy(&a, &b).unwrap();
1031
1032        let fa = load_file(&a).unwrap();
1033        let fb = load_file(&b).unwrap();
1034        let mut diffs = Vec::new();
1035        diff_values(
1036            "",
1037            &serde_json::to_value(&fa).unwrap(),
1038            &serde_json::to_value(&fb).unwrap(),
1039            &mut diffs,
1040        );
1041        assert!(diffs.is_empty());
1042    }
1043
1044    #[test]
1045    fn validate_reports_clean_file_without_writing() {
1046        let dir = tempfile::tempdir().unwrap();
1047        let path = dir.path().join("scene.ren");
1048        cmd_new(path.clone(), "ellipse".into()).unwrap();
1049
1050        let before = std::fs::read(&path).unwrap();
1051        cmd_validate(path.clone(), false, false, false, false).unwrap(); // no --fix: must not rewrite
1052        let after = std::fs::read(&path).unwrap();
1053        assert_eq!(before, after);
1054    }
1055
1056    #[test]
1057    fn validate_deep_reports_clean_ellipse_template() {
1058        let dir = tempfile::tempdir().unwrap();
1059        let path = dir.path().join("scene.ren");
1060        cmd_new(path.clone(), "ellipse".into()).unwrap();
1061
1062        let file = load_file(&path).unwrap();
1063        let report = renamite_validate::validate(&file);
1064        assert_eq!(report.error_count(), 0);
1065        assert_eq!(report.warning_count(), 0);
1066    }
1067
1068    #[test]
1069    fn import_svg_then_export_roundtrips() {
1070        let dir = tempfile::tempdir().unwrap();
1071        let svg = dir.path().join("in.svg");
1072        let ren = dir.path().join("out.ren");
1073        let out_svg = dir.path().join("out.svg");
1074        std::fs::write(
1075            &svg,
1076            r##"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
1077                <rect x="10" y="10" width="50" height="30" fill="#ff0000"/>
1078            </svg>"##,
1079        )
1080        .unwrap();
1081
1082        cmd_import_svg(svg.clone(), ren.clone(), false).unwrap();
1083        let file = load_file(&ren).unwrap();
1084        assert_eq!(
1085            file.document.compositions[file.document.main].size,
1086            (100, 100)
1087        );
1088
1089        cmd_export_svg(ren.clone(), out_svg.clone(), 0.0, false).unwrap();
1090        let text = std::fs::read_to_string(&out_svg).unwrap();
1091        assert!(text.contains("<svg"));
1092        assert!(text.contains("fill=\"#FF0000\""));
1093    }
1094
1095    #[test]
1096    fn import_svg_strict_fails_on_filter_warning() {
1097        let dir = tempfile::tempdir().unwrap();
1098        let svg = dir.path().join("in.svg");
1099        let ren = dir.path().join("out.ren");
1100        std::fs::write(
1101            &svg,
1102            r##"<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10">
1103                <filter id="b"><feGaussianBlur stdDeviation="2"/></filter>
1104                <g filter="url(#b)"><rect x="0" y="0" width="10" height="10"/></g>
1105            </svg>"##,
1106        )
1107        .unwrap();
1108
1109        assert!(cmd_import_svg(svg.clone(), ren.clone(), true).is_err());
1110    }
1111
1112    #[test]
1113    fn export_svg_missing_composition_is_err() {
1114        let dir = tempfile::tempdir().unwrap();
1115        let ren = dir.path().join("out.ren");
1116        let svg = dir.path().join("out.svg");
1117        std::fs::write(&ren, r##"<svg xmlns="http://www.w3.org/2000/svg"/>"##).unwrap();
1118        assert!(cmd_export_svg(ren, svg, 0.0, false).is_err());
1119    }
1120}