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!("structural differences found: {} difference(s)", diffs.len());
544        }
545    }
546    Ok(())
547}
548
549/// Minimal recursive structural diff. Object keys are compared by name;
550/// arrays of differing length are reported wholesale (no element alignment).
551fn diff_values(path: &str, a: &Value, b: &Value, out: &mut Vec<String>) {
552    match (a, b) {
553        (Value::Object(ma), Value::Object(mb)) => {
554            let mut keys: Vec<&String> = ma.keys().chain(mb.keys()).collect();
555            keys.sort();
556            keys.dedup();
557            for k in keys {
558                let sub = if path.is_empty() {
559                    k.clone()
560                } else {
561                    format!("{path}.{k}")
562                };
563                match (ma.get(k), mb.get(k)) {
564                    (Some(av), Some(bv)) => diff_values(&sub, av, bv, out),
565                    (Some(_), None) => out.push(format!("- {sub} (removed)")),
566                    (None, Some(_)) => out.push(format!("+ {sub} (added)")),
567                    (None, None) => unreachable!(),
568                }
569            }
570        }
571        (Value::Array(aa), Value::Array(ba)) => {
572            if aa.len() != ba.len() {
573                out.push(format!("~ {path} (array len {} -> {})", aa.len(), ba.len()));
574            } else {
575                for (i, (av, bv)) in aa.iter().zip(ba.iter()).enumerate() {
576                    diff_values(&format!("{path}[{i}]"), av, bv, out);
577                }
578            }
579        }
580        _ => {
581            if a != b {
582                out.push(format!("~ {path}: {a} -> {b}"));
583            }
584        }
585    }
586}
587
588fn cmd_new(output: PathBuf, template: String) -> Result<()> {
589    let name = name_from_path(&output);
590    let mut file = match template.as_str() {
591        // Legacy alias predating the renamite-examples template set.
592        "ellipse" => scaffold_ellipse(name.clone()),
593        other => match renamite_examples::parse_template(other) {
594            Some(id) => renamite_examples::build_template(id),
595            None => {
596                let known: Vec<&str> = std::iter::once("ellipse")
597                    .chain(renamite_examples::templates().iter().map(|t| t.id.slug()))
598                    .collect();
599                bail!(
600                    "unknown template '{other}' (expected one of: {})",
601                    known.join(", ")
602                )
603            }
604        },
605    };
606    file.meta.name = name;
607
608    let ext = output.extension().and_then(|s| s.to_str()).unwrap_or("ren");
609    match ext {
610        "renb" => std::fs::write(&output, renamite_io_ren::save_binary(&file)?)?,
611        _ => std::fs::write(&output, renamite_io_ren::save(&file)?)?,
612    }
613    println!("Created {}", output.display());
614    Ok(())
615}
616
617fn cmd_templates() -> Result<()> {
618    println!("{}", templates_text());
619    Ok(())
620}
621
622fn templates_text() -> String {
623    let mut out =
624        String::from("Available templates (use with `renamite new --template <slug>`):\n");
625    for t in renamite_examples::templates() {
626        out.push_str(&format!("  {:<18} {}\n", t.id.slug(), t.description));
627    }
628    out
629}
630
631fn scaffold_ellipse(name: String) -> RenFile {
632    use renamite_animation::Animated;
633    use renamite_model::{
634        Color, Document, FillRule, Node, NodeKind, Parent, ShapeKind, StyleKind, StylePaint,
635    };
636
637    let mut doc = Document::empty();
638    let comp = doc.main;
639    let (w, h) = doc.compositions[comp].size;
640    let center = glam::DVec2::new(w as f64 / 2.0, h as f64 / 2.0);
641
642    let shape = doc.create_node(Node::new(
643        "Ellipse",
644        NodeKind::Shape(ShapeKind::Ellipse {
645            pos: Animated::new(center),
646            size: Animated::new(glam::DVec2::new(180.0, 180.0)),
647        }),
648    ));
649    let fill = doc.create_node(Node::new(
650        "Fill",
651        NodeKind::Style(StyleKind::Fill {
652            paint: StylePaint::solid(Color::rgba(0.96, 0.42, 0.18, 1.0)),
653            rule: FillRule::NonZero,
654        }),
655    ));
656    doc.attach(shape, Parent::Comp(comp), 0).unwrap();
657    doc.attach(fill, Parent::Comp(comp), 1).unwrap();
658
659    RenFile::new(doc, name)
660}
661
662fn name_from_path(p: &Path) -> String {
663    p.file_stem()
664        .and_then(|s| s.to_str())
665        .unwrap_or("Untitled")
666        .to_string()
667}
668
669fn cmd_play(input: PathBuf, duration: f64) -> Result<()> {
670    let mut player = Player::new(load_file(&input)?)?;
671    let dt = 1.0 / 60.0;
672    let ticks = (duration / dt) as usize;
673
674    println!("Playing {} for {duration:.1}s...", input.display());
675    for _ in 0..ticks {
676        for ev in player.tick(dt) {
677            println!("  {ev}");
678        }
679    }
680    println!("Done. Final head: {:.2}", player.head());
681    Ok(())
682}
683
684fn cmd_export_lottie(input: PathBuf, output: PathBuf, strict: bool) -> Result<()> {
685    let file = load_file(&input)?;
686    let report = renamite_io_lottie::export_with_report(&file.document)?;
687    if strict && !report.warnings.is_empty() {
688        for warning in &report.warnings {
689            eprintln!("warning at {}: {}", warning.path, warning.message);
690        }
691        bail!(
692            "Lottie export produced {} compatibility warning(s)",
693            report.warnings.len()
694        );
695    }
696    for warning in &report.warnings {
697        eprintln!("warning at {}: {}", warning.path, warning.message);
698    }
699    std::fs::write(&output, serde_json::to_vec_pretty(&report.value)?)?;
700    println!("Exported {} -> {}", input.display(), output.display());
701    Ok(())
702}
703
704fn cmd_import_lottie(input: PathBuf, output: PathBuf, strict: bool) -> Result<()> {
705    let value: Value = serde_json::from_slice(&std::fs::read(&input)?)?;
706    let report = renamite_io_lottie::import_with_report(&value)?;
707    if strict && !report.warnings.is_empty() {
708        for warning in &report.warnings {
709            eprintln!("warning at {}: {}", warning.path, warning.message);
710        }
711        bail!(
712            "Lottie import produced {} compatibility warning(s)",
713            report.warnings.len()
714        );
715    }
716    for warning in &report.warnings {
717        eprintln!("warning at {}: {}", warning.path, warning.message);
718    }
719    let file = RenFile::new(report.value, name_from_path(&input));
720    match output.extension().and_then(|extension| extension.to_str()) {
721        Some("renb") => {
722            std::fs::write(&output, renamite_io_ren::save_binary(&file)?)?;
723        }
724        _ => {
725            std::fs::write(&output, renamite_io_ren::save(&file)?)?;
726        }
727    }
728    println!("Imported {} -> {}", input.display(), output.display());
729    Ok(())
730}
731
732fn cmd_export_svg(input: PathBuf, output: PathBuf, frame: f64, strict: bool) -> Result<()> {
733    let file = load_file(&input)?;
734    let report = renamite_io_svg::export_with_report(&file.document, file.document.main, frame)?;
735    if strict && !report.warnings.is_empty() {
736        for warning in &report.warnings {
737            eprintln!("warning at {}: {}", warning.path, warning.message);
738        }
739        bail!(
740            "SVG export produced {} compatibility warning(s)",
741            report.warnings.len()
742        );
743    }
744    for warning in &report.warnings {
745        eprintln!("warning at {}: {}", warning.path, warning.message);
746    }
747    std::fs::write(&output, report.value)?;
748    println!(
749        "Exported {} frame {frame} -> {}",
750        input.display(),
751        output.display()
752    );
753    Ok(())
754}
755
756fn cmd_import_svg(input: PathBuf, output: PathBuf, strict: bool) -> Result<()> {
757    let bytes = std::fs::read(&input)?;
758    let report = renamite_io_svg::import_with_report(&bytes)?;
759    if strict && !report.warnings.is_empty() {
760        for warning in &report.warnings {
761            eprintln!("warning at {}: {}", warning.path, warning.message);
762        }
763        bail!(
764            "SVG import produced {} compatibility warning(s)",
765            report.warnings.len()
766        );
767    }
768    for warning in &report.warnings {
769        eprintln!("warning at {}: {}", warning.path, warning.message);
770    }
771    let file = RenFile::new(report.value, name_from_path(&input));
772    match output.extension().and_then(|extension| extension.to_str()) {
773        Some("renb") => {
774            std::fs::write(&output, renamite_io_ren::save_binary(&file)?)?;
775        }
776        _ => {
777            std::fs::write(&output, renamite_io_ren::save(&file)?)?;
778        }
779    }
780    println!("Imported {} -> {}", input.display(), output.display());
781    Ok(())
782}
783
784fn load_file(path: &Path) -> Result<RenFile> {
785    let bytes =
786        std::fs::read(path).with_context(|| format!("failed to read {}", path.display()))?;
787
788    // Magic wins over extension so extension-less / mislabeled .renb still open.
789    if renamite_io_ren::is_binary(&bytes) {
790        return Ok(renamite_io_ren::open_binary(&bytes)?);
791    }
792
793    let text = std::str::from_utf8(&bytes)
794        .with_context(|| format!("{} is neither valid UTF-8 .ren nor .renb", path.display()))?;
795    Ok(renamite_io_ren::open(text)?)
796}
797
798#[cfg(test)]
799mod tests {
800    use super::*;
801    use serde_json::json;
802
803    #[test]
804    fn parses_named_colors() {
805        assert_eq!(parse_background("transparent").unwrap(), None);
806        assert_eq!(
807            parse_background("white").unwrap(),
808            Some([255, 255, 255, 255])
809        );
810        assert_eq!(parse_background("black").unwrap(), Some([0, 0, 0, 255]));
811    }
812
813    #[test]
814    fn parses_hex_with_and_without_alpha() {
815        assert_eq!(parse_background("#ff0000").unwrap(), Some([255, 0, 0, 255]));
816        assert_eq!(
817            parse_background("00ff0080").unwrap(),
818            Some([0, 255, 0, 0x80])
819        );
820    }
821
822    #[test]
823    fn rejects_garbage_background() {
824        assert!(parse_background("not-a-color").is_err());
825        assert!(parse_background("#ff00").is_err());
826    }
827
828    #[test]
829    fn diff_detects_added_removed_changed() {
830        let a = json!({ "x": 1, "y": 2, "obj": { "same": 1 } });
831        let b = json!({ "x": 5, "z": 3, "obj": { "same": 1 } });
832        let mut diffs = Vec::new();
833        diff_values("", &a, &b, &mut diffs);
834        assert!(diffs.iter().any(|d| d.contains("~ x: 1 -> 5")));
835        assert!(diffs.iter().any(|d| d.contains("- y (removed)")));
836        assert!(diffs.iter().any(|d| d.contains("+ z (added)")));
837        assert!(!diffs.iter().any(|d| d.contains("obj")));
838    }
839
840    #[test]
841    fn diff_reports_array_length_change_wholesale() {
842        let a = json!({ "arr": [1, 2, 3] });
843        let b = json!({ "arr": [1, 2] });
844        let mut diffs = Vec::new();
845        diff_values("", &a, &b, &mut diffs);
846        assert_eq!(diffs, vec!["~ arr (array len 3 -> 2)"]);
847    }
848
849    #[test]
850    fn render_rejects_neither_frame_nor_frames() {
851        let err = run_from(["renamite", "render", "x.ren"]).unwrap_err();
852        assert!(err.to_string().contains("--frame"));
853    }
854
855    #[test]
856    fn render_rejects_both_frame_and_frames() {
857        let result = Cli::try_parse_from([
858            "renamite", "render", "x.ren", "--frame", "1", "--frames", "10",
859        ]);
860        assert!(result.is_err(), "clap must reject mutually exclusive flags");
861    }
862
863    #[test]
864    fn new_ellipse_roundtrips_through_pack_and_open() {
865        let dir = tempfile::tempdir().unwrap();
866        let ren_path = dir.path().join("scene.ren");
867        cmd_new(ren_path.clone(), "ellipse".into()).unwrap();
868
869        let file = load_file(&ren_path).unwrap();
870        assert_eq!(file.document.nodes.len(), 2); // shape + fill
871        assert_eq!(file.meta.name, "scene");
872
873        let renb_path = dir.path().join("scene.renb");
874        cmd_pack(ren_path, renb_path.clone()).unwrap();
875        let repacked = load_file(&renb_path).unwrap();
876        assert_eq!(repacked.document.nodes.len(), 2);
877    }
878
879    #[test]
880    fn play_and_render_accept_renb() {
881        let dir = tempfile::tempdir().unwrap();
882        let ren = dir.path().join("scene.ren");
883        let renb = dir.path().join("scene.renb");
884        cmd_new(ren.clone(), "ellipse".into()).unwrap();
885        cmd_pack(ren, renb.clone()).unwrap();
886        assert!(Player::new(load_file(&renb).unwrap()).is_ok());
887    }
888
889    #[test]
890    fn new_blank_has_no_nodes() {
891        let dir = tempfile::tempdir().unwrap();
892        let path = dir.path().join("empty.ren");
893        cmd_new(path.clone(), "blank".into()).unwrap();
894        let file = load_file(&path).unwrap();
895        assert_eq!(file.document.nodes.len(), 0);
896    }
897
898    #[test]
899    fn new_rejects_unknown_template() {
900        let dir = tempfile::tempdir().unwrap();
901        let path = dir.path().join("x.ren");
902        assert!(cmd_new(path, "not-a-template".into()).is_err());
903    }
904
905    #[test]
906    fn templates_lists_every_builtin_slug() {
907        let text = templates_text();
908        for t in renamite_examples::templates() {
909            assert!(
910                text.contains(t.id.slug()),
911                "templates output must mention {}",
912                t.id.slug()
913            );
914        }
915    }
916
917    #[test]
918    fn parse_template_accepts_slugs_and_display_names() {
919        use renamite_examples::TemplateId;
920        for id in TemplateId::all() {
921            assert_eq!(renamite_examples::parse_template(id.slug()), Some(*id));
922            assert_eq!(
923                renamite_examples::parse_template(id.display_name()),
924                Some(*id)
925            );
926            assert_eq!(
927                renamite_examples::parse_template(&id.slug().to_uppercase()),
928                Some(*id),
929                "template lookup must be case-insensitive"
930            );
931        }
932        assert_eq!(renamite_examples::parse_template("nope"), None);
933    }
934
935    #[test]
936    fn new_with_each_template_roundtrips() {
937        use renamite_examples::TemplateId;
938        for id in TemplateId::all() {
939            let dir = tempfile::tempdir().unwrap();
940            let path = dir.path().join(format!("{}.ren", id.slug()));
941            cmd_new(path.clone(), id.slug().into()).unwrap();
942
943            let loaded = load_file(&path).unwrap();
944            let mut expected = renamite_examples::build_template(*id);
945            expected.meta.name = id.slug().to_string();
946            assert_eq!(
947                serde_json::to_value(&loaded).unwrap(),
948                serde_json::to_value(&expected).unwrap(),
949                "template {} must survive save->load roundtrip",
950                id.slug()
951            );
952
953            let renb = dir.path().join(format!("{}.renb", id.slug()));
954            cmd_pack(path, renb.clone()).unwrap();
955            let packed = load_file(&renb).unwrap();
956            assert_eq!(
957                serde_json::to_value(&packed).unwrap(),
958                serde_json::to_value(&expected).unwrap(),
959                "template {} must survive binary pack->unpack roundtrip",
960                id.slug()
961            );
962        }
963    }
964
965    #[test]
966    #[ignore]
967    fn render_single_frame_writes_valid_png() {
968        let dir = tempfile::tempdir().unwrap();
969        let ren = dir.path().join("scene.ren");
970        cmd_new(ren.clone(), "ellipse".into()).unwrap();
971
972        let png = dir.path().join("out.png");
973        cmd_render(
974            ren,
975            Some(0),
976            None,
977            1.0 / 60.0,
978            64,
979            64,
980            Some(png.clone()),
981            None,
982            "frame".into(),
983            "white".into(),
984        )
985        .unwrap();
986
987        let bytes = std::fs::read(&png).unwrap();
988        assert_eq!(
989            &bytes[..8],
990            &[0x89, b'P', b'N', b'G', b'\r', b'\n', 0x1a, b'\n']
991        );
992    }
993
994    #[test]
995    #[ignore]
996    fn render_sequence_writes_numbered_files() {
997        let dir = tempfile::tempdir().unwrap();
998        let ren = dir.path().join("scene.ren");
999        cmd_new(ren.clone(), "ellipse".into()).unwrap();
1000
1001        let out_dir = dir.path().join("frames");
1002        cmd_render(
1003            ren,
1004            None,
1005            Some(3),
1006            1.0 / 60.0,
1007            32,
1008            32,
1009            None,
1010            Some(out_dir.clone()),
1011            "f".into(),
1012            "transparent".into(),
1013        )
1014        .unwrap();
1015
1016        for i in 0..3 {
1017            assert!(out_dir.join(format!("f_{i:05}.png")).exists());
1018        }
1019    }
1020
1021    #[test]
1022    fn diff_of_identical_files_is_empty() {
1023        let dir = tempfile::tempdir().unwrap();
1024        let a = dir.path().join("a.ren");
1025        let b = dir.path().join("b.ren");
1026        cmd_new(a.clone(), "ellipse".into()).unwrap();
1027        std::fs::copy(&a, &b).unwrap();
1028
1029        let fa = load_file(&a).unwrap();
1030        let fb = load_file(&b).unwrap();
1031        let mut diffs = Vec::new();
1032        diff_values(
1033            "",
1034            &serde_json::to_value(&fa).unwrap(),
1035            &serde_json::to_value(&fb).unwrap(),
1036            &mut diffs,
1037        );
1038        assert!(diffs.is_empty());
1039    }
1040
1041    #[test]
1042    fn validate_reports_clean_file_without_writing() {
1043        let dir = tempfile::tempdir().unwrap();
1044        let path = dir.path().join("scene.ren");
1045        cmd_new(path.clone(), "ellipse".into()).unwrap();
1046
1047        let before = std::fs::read(&path).unwrap();
1048        cmd_validate(path.clone(), false, false, false, false).unwrap(); // no --fix: must not rewrite
1049        let after = std::fs::read(&path).unwrap();
1050        assert_eq!(before, after);
1051    }
1052
1053    #[test]
1054    fn validate_deep_reports_clean_ellipse_template() {
1055        let dir = tempfile::tempdir().unwrap();
1056        let path = dir.path().join("scene.ren");
1057        cmd_new(path.clone(), "ellipse".into()).unwrap();
1058
1059        let file = load_file(&path).unwrap();
1060        let report = renamite_validate::validate(&file);
1061        assert_eq!(report.error_count(), 0);
1062        assert_eq!(report.warning_count(), 0);
1063    }
1064
1065    #[test]
1066    fn import_svg_then_export_roundtrips() {
1067        let dir = tempfile::tempdir().unwrap();
1068        let svg = dir.path().join("in.svg");
1069        let ren = dir.path().join("out.ren");
1070        let out_svg = dir.path().join("out.svg");
1071        std::fs::write(
1072            &svg,
1073            r##"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
1074                <rect x="10" y="10" width="50" height="30" fill="#ff0000"/>
1075            </svg>"##,
1076        )
1077        .unwrap();
1078
1079        cmd_import_svg(svg.clone(), ren.clone(), false).unwrap();
1080        let file = load_file(&ren).unwrap();
1081        assert_eq!(
1082            file.document.compositions[file.document.main].size,
1083            (100, 100)
1084        );
1085
1086        cmd_export_svg(ren.clone(), out_svg.clone(), 0.0, false).unwrap();
1087        let text = std::fs::read_to_string(&out_svg).unwrap();
1088        assert!(text.contains("<svg"));
1089        assert!(text.contains("fill=\"#FF0000\""));
1090    }
1091
1092    #[test]
1093    fn import_svg_strict_fails_on_filter_warning() {
1094        let dir = tempfile::tempdir().unwrap();
1095        let svg = dir.path().join("in.svg");
1096        let ren = dir.path().join("out.ren");
1097        std::fs::write(
1098            &svg,
1099            r##"<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10">
1100                <filter id="b"><feGaussianBlur stdDeviation="2"/></filter>
1101                <g filter="url(#b)"><rect x="0" y="0" width="10" height="10"/></g>
1102            </svg>"##,
1103        )
1104        .unwrap();
1105
1106        assert!(cmd_import_svg(svg.clone(), ren.clone(), true).is_err());
1107    }
1108
1109    #[test]
1110    fn export_svg_missing_composition_is_err() {
1111        let dir = tempfile::tempdir().unwrap();
1112        let ren = dir.path().join("out.ren");
1113        let svg = dir.path().join("out.svg");
1114        std::fs::write(&ren, r##"<svg xmlns="http://www.w3.org/2000/svg"/>"##).unwrap();
1115        assert!(cmd_export_svg(ren, svg, 0.0, false).is_err());
1116    }
1117}