1use 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 {
28 input: PathBuf,
29 #[arg(short, long, default_value = "60")]
30 frames: usize,
31 #[arg(short, long)]
33 dt: Option<f64>,
34 #[arg(short, long, default_value = "scenes.json")]
35 output: PathBuf,
36 },
37
38 Render {
41 input: PathBuf,
42 #[arg(long, conflicts_with = "frames")]
43 frame: Option<i64>,
44 #[arg(long, conflicts_with = "frame")]
45 frames: Option<usize>,
46 #[arg(long)]
48 dt: Option<f64>,
49 #[arg(long, default_value = "512")]
50 width: u32,
51 #[arg(long, default_value = "512")]
52 height: u32,
53 #[arg(short, long)]
55 out: Option<PathBuf>,
56 #[arg(long)]
58 out_dir: Option<PathBuf>,
59 #[arg(long, default_value = "frame")]
60 prefix: String,
61 #[arg(long, default_value = "white")]
63 background: String,
64 },
65
66 Pack {
68 input: PathBuf,
69 #[arg(short, long)]
70 output: PathBuf,
71 },
72
73 Unpack {
75 input: PathBuf,
76 #[arg(short, long)]
77 output: PathBuf,
78 },
79
80 Info {
82 input: PathBuf,
83 #[arg(long)]
85 json: bool,
86 },
87
88 Validate {
90 input: PathBuf,
91 #[arg(long)]
92 fix: bool,
93 #[arg(long)]
95 deep: bool,
96 #[arg(long, requires = "deep")]
98 json: bool,
99 #[arg(long, requires = "deep")]
101 warnings_as_errors: bool,
102 },
103
104 Diff {
106 a: PathBuf,
107 b: PathBuf,
108 #[arg(long)]
110 fail_on_diff: bool,
111 },
112
113 New {
115 output: PathBuf,
116 #[arg(long, default_value = "ellipse")]
118 template: String,
119 },
120
121 Templates {},
123
124 Play {
126 input: PathBuf,
127 #[arg(short, long, default_value = "5.0")]
128 duration: f64,
129 },
130
131 ExportLottie {
133 input: PathBuf,
134 #[arg(short, long)]
135 output: PathBuf,
136 #[arg(long)]
138 strict: bool,
139 },
140
141 ImportLottie {
143 input: PathBuf,
144 #[arg(short, long)]
145 output: PathBuf,
146 #[arg(long)]
148 strict: bool,
149 },
150
151 ExportSvg {
153 input: PathBuf,
154 #[arg(short, long)]
155 output: PathBuf,
156 #[arg(long, default_value = "0")]
157 frame: f64,
158 #[arg(long)]
160 strict: bool,
161 },
162
163 ImportSvg {
165 input: PathBuf,
166 #[arg(short, long)]
167 output: PathBuf,
168 #[arg(long)]
170 strict: bool,
171 },
172
173 Completions { shell: clap_complete::Shell },
175}
176
177pub fn run() -> Result<()> {
178 let cli = Cli::parse();
179 dispatch(cli.command)
180}
181
182pub fn run_from<I, T>(args: I) -> Result<()>
184where
185 I: IntoIterator<Item = T>,
186 T: Into<std::ffi::OsString> + Clone,
187{
188 let cli = Cli::try_parse_from(args)?;
189 dispatch(cli.command)
190}
191
192fn dispatch(command: Commands) -> Result<()> {
193 match command {
194 Commands::Bake {
195 input,
196 frames,
197 dt,
198 output,
199 } => cmd_bake(input, frames, dt, output),
200 Commands::Render {
201 input,
202 frame,
203 frames,
204 dt,
205 width,
206 height,
207 out,
208 out_dir,
209 prefix,
210 background,
211 } => cmd_render(
212 input, frame, frames, dt, width, height, out, out_dir, prefix, background,
213 ),
214 Commands::Pack { input, output } => cmd_pack(input, output),
215 Commands::Unpack { input, output } => cmd_unpack(input, output),
216 Commands::Info { input, json } => cmd_info(input, json),
217 Commands::Validate {
218 input,
219 fix,
220 deep,
221 json,
222 warnings_as_errors,
223 } => cmd_validate(input, fix, deep, json, warnings_as_errors),
224 Commands::Diff { a, b, fail_on_diff } => cmd_diff(a, b, fail_on_diff),
225 Commands::New { output, template } => cmd_new(output, template),
226 Commands::Templates {} => cmd_templates(),
227 Commands::Play { input, duration } => cmd_play(input, duration),
228 Commands::ExportLottie {
229 input,
230 output,
231 strict,
232 } => cmd_export_lottie(input, output, strict),
233 Commands::ImportLottie {
234 input,
235 output,
236 strict,
237 } => cmd_import_lottie(input, output, strict),
238 Commands::ExportSvg {
239 input,
240 output,
241 frame,
242 strict,
243 } => cmd_export_svg(input, output, frame, strict),
244 Commands::ImportSvg {
245 input,
246 output,
247 strict,
248 } => cmd_import_svg(input, output, strict),
249 Commands::Completions { shell } => {
250 let mut cmd = Cli::command();
251 let name = cmd.get_name().to_string();
252 clap_complete::generate(shell, &mut cmd, name, &mut std::io::stdout());
253 Ok(())
254 }
255 }
256}
257
258fn cmd_bake(input: PathBuf, frames: usize, dt: Option<f64>, output: PathBuf) -> Result<()> {
259 let file = load_file(&input).with_context(|| format!("failed to load {}", input.display()))?;
260 let mut player = Player::new(file)
261 .with_context(|| format!("failed to open player for {}", input.display()))?;
262 let dt = dt.unwrap_or_else(|| default_dt_for(&player));
263 let scenes = player.bake(frames, dt);
264 let json = serde_json::to_string_pretty(&scenes)?;
265 atomic_write(&output, json.as_bytes())?;
266 println!("Baked {frames} frames -> {}", output.display());
267 Ok(())
268}
269
270#[allow(clippy::too_many_arguments)]
271fn cmd_render(
272 input: PathBuf,
273 frame: Option<i64>,
274 frames: Option<usize>,
275 dt: Option<f64>,
276 width: u32,
277 height: u32,
278 out: Option<PathBuf>,
279 out_dir: Option<PathBuf>,
280 prefix: String,
281 background: String,
282) -> Result<()> {
283 if frame.is_none() && frames.is_none() {
284 bail!("specify either --frame N or --frames N");
285 }
286
287 let bg = parse_background(&background)?;
288 let mut player = Player::new(load_file(&input)?)
289 .with_context(|| format!("failed to open player for {}", input.display()))?;
290 let comp_size = player.project.document.compositions[player.project.document.main].size;
291 let view = export_view(comp_size, width, height);
292 let bg_clear = bg.map(|[r, g, b, a]| {
293 [
294 r as f64 / 255.0,
295 g as f64 / 255.0,
296 b as f64 / 255.0,
297 a as f64 / 255.0,
298 ]
299 });
300
301 let mut bridge = SceneRenderer::new();
302 let mut gpu = pollster::block_on(OffscreenRenderer::new(width, height, 4))?;
303 gpu.sync_document_images(&player.project.document)?;
304
305 match (frame, frames) {
306 (Some(f), None) => {
307 player.scrub(f as f64);
308 let png = rasterize_png(&mut bridge, &mut gpu, player.scene(), &view, bg_clear)?;
309 let out = out.ok_or_else(|| anyhow!("--out is required with --frame"))?;
310 atomic_write(&out, &png)?;
311 println!("Rendered frame {f} -> {}", out.display());
312 Ok(())
313 }
314 (None, Some(n)) => {
315 let out_dir = out_dir.ok_or_else(|| anyhow!("--out-dir is required with --frames"))?;
316 std::fs::create_dir_all(&out_dir)?;
317 let dt = dt.unwrap_or_else(|| default_dt_for(&player));
318 let scenes = player.bake(n, dt);
319 for (i, scene) in scenes.iter().enumerate() {
320 let png = rasterize_png(&mut bridge, &mut gpu, scene, &view, bg_clear)?;
321 let path = out_dir.join(format!("{prefix}_{i:05}.png"));
322 atomic_write(&path, &png)?;
323 }
324 println!("Rendered {n} frames -> {}", out_dir.display());
325 Ok(())
326 }
327 (None, None) => unreachable!("guard at top of cmd_render"),
328 (Some(_), Some(_)) => unreachable!("clap conflicts_with prevents this"),
329 }
330}
331
332fn export_view(comp_size: (u32, u32), out_w: u32, out_h: u32) -> ViewTransform {
334 let (cw, ch) = (comp_size.0 as f64, comp_size.1 as f64);
335 if cw <= 0.0 || ch <= 0.0 || out_w == 0 || out_h == 0 {
336 return ViewTransform::identity();
337 }
338 let scale = (out_w as f64 / cw).min(out_h as f64 / ch);
339 let ox = (out_w as f64 - cw * scale) * 0.5;
340 let oy = (out_h as f64 - ch * scale) * 0.5;
341 ViewTransform {
342 scale,
343 offset: glam::DVec2::new(ox, oy),
344 }
345}
346
347fn rasterize_png(
348 bridge: &mut SceneRenderer,
349 gpu: &mut OffscreenRenderer,
350 scene: &renamite_model::Scene,
351 view: &ViewTransform,
352 bg: Option<[f64; 4]>,
353) -> Result<Vec<u8>> {
354 let prepared = bridge.prepare(scene, view);
355 let mut repose = repose_core::Scene::default();
356 bridge.append_repose_scene(&prepared, &mut repose);
357 gpu.render_png(&repose, bg)
358}
359
360fn parse_background(s: &str) -> Result<Option<[u8; 4]>> {
361 match s {
362 "transparent" | "none" => Ok(None),
363 "white" => Ok(Some([255, 255, 255, 255])),
364 "black" => Ok(Some([0, 0, 0, 255])),
365 hex => {
366 let hex = hex.trim_start_matches('#');
367 let bytes = match hex.len() {
368 6 => [
369 u8::from_str_radix(&hex[0..2], 16)?,
370 u8::from_str_radix(&hex[2..4], 16)?,
371 u8::from_str_radix(&hex[4..6], 16)?,
372 255,
373 ],
374 8 => [
375 u8::from_str_radix(&hex[0..2], 16)?,
376 u8::from_str_radix(&hex[2..4], 16)?,
377 u8::from_str_radix(&hex[4..6], 16)?,
378 u8::from_str_radix(&hex[6..8], 16)?,
379 ],
380 _ => bail!(
381 "invalid background '{s}': expected 'transparent', 'white', 'black', or hex RRGGBB[AA]"
382 ),
383 };
384 Ok(Some(bytes))
385 }
386 }
387}
388
389fn cmd_pack(input: PathBuf, output: PathBuf) -> Result<()> {
390 let text = std::fs::read_to_string(&input)?;
391 let mut file: RenFile = renamite_io_ren::open(&text)?;
392 file.normalize();
393 atomic_write(&output, &renamite_io_ren::save_binary(&file)?)?;
394 println!("Packed {} -> {}", input.display(), output.display());
395 Ok(())
396}
397
398fn cmd_unpack(input: PathBuf, output: PathBuf) -> Result<()> {
399 let bytes = std::fs::read(&input)?;
400 let mut file: RenFile = renamite_io_ren::open_binary(&bytes)?;
401 file.normalize();
402 atomic_write(&output, renamite_io_ren::save(&file)?.as_bytes())?;
403 println!("Unpacked {} -> {}", input.display(), output.display());
404 Ok(())
405}
406
407#[derive(Serialize)]
408struct InfoSummary {
409 path: String,
410 name: String,
411 format_version: u32,
412 compositions: usize,
413 nodes: usize,
414 clips: usize,
415 machines: usize,
416 start_machine: Option<String>,
417 main: MainCompInfo,
418}
419
420#[derive(Serialize)]
421struct MainCompInfo {
422 name: String,
423 width: u32,
424 height: u32,
425 fps: f64,
426 in_frame: i64,
427 out_frame: i64,
428}
429
430fn cmd_info(input: PathBuf, json: bool) -> Result<()> {
431 let file = load_file(&input)?;
432 let comp = &file.document.compositions[file.document.main];
433 let summary = InfoSummary {
434 path: input.display().to_string(),
435 name: file.meta.name.clone(),
436 format_version: file.format_version,
437 compositions: file.document.compositions.len(),
438 nodes: file.document.nodes.len(),
439 clips: file.clips.len(),
440 machines: file.machines.len(),
441 start_machine: file
442 .start_machine
443 .and_then(|id| file.machines.get(id))
444 .map(|m| m.name.clone()),
445 main: MainCompInfo {
446 name: comp.name.clone(),
447 width: comp.size.0,
448 height: comp.size.1,
449 fps: comp.rate.fps(),
450 in_frame: comp.range.0.0,
451 out_frame: comp.range.1.0,
452 },
453 };
454
455 if json {
456 println!("{}", serde_json::to_string_pretty(&summary)?);
457 return Ok(());
458 }
459
460 println!("File: {}", summary.path);
461 println!("Name: {}", summary.name);
462 println!("Format: v{}", summary.format_version);
463 println!("Compositions: {}", summary.compositions);
464 println!("Nodes: {}", summary.nodes);
465 println!("Clips: {}", summary.clips);
466 println!("Machines: {}", summary.machines);
467 if let Some(name) = &summary.start_machine {
468 println!("Start machine: {name}");
469 }
470 println!("\nMain composition:");
471 println!(" Name: {}", summary.main.name);
472 println!(" Size: {}x{}", summary.main.width, summary.main.height);
473 println!(" Rate: {:.2} fps", summary.main.fps);
474 println!(
475 " Range: {} - {}",
476 summary.main.in_frame, summary.main.out_frame
477 );
478 Ok(())
479}
480
481fn cmd_validate(
482 input: PathBuf,
483 fix: bool,
484 deep: bool,
485 json: bool,
486 warnings_as_errors: bool,
487) -> Result<()> {
488 let mut file = load_file(&input)?;
489 let before_json = serde_json::to_string(&file)?;
490 file.normalize();
491 file.garbage_collect();
492
493 if deep {
494 let report = renamite_validate::validate(&file);
495
496 if json {
497 println!("{}", serde_json::to_string_pretty(&report)?);
498 } else {
499 for d in &report.diagnostics {
500 println!("{:?}: {}: {}", d.severity, d.path, d.message);
501 }
502 println!(
503 "{} error(s), {} warning(s)",
504 report.error_count(),
505 report.warning_count()
506 );
507 }
508
509 if report.has_errors() || (warnings_as_errors && report.warning_count() > 0) {
510 bail!(
511 "validation failed: {} error(s), {} warning(s)",
512 report.error_count(),
513 report.warning_count()
514 );
515 }
516 return Ok(());
517 }
518
519 if fix {
520 let bytes = save_for_output(&file, &input)?;
521 atomic_write(&input, &bytes)?;
522 println!("Normalized and saved {}", input.display());
523 } else if serde_json::to_string(&file)? == before_json {
524 println!("{} is valid", input.display());
525 } else {
526 bail!("{} needs normalization (use --fix)", input.display());
527 }
528 Ok(())
529}
530
531fn cmd_diff(a: PathBuf, b: PathBuf, fail_on_diff: bool) -> Result<()> {
532 let fa = load_file(&a)?;
533 let fb = load_file(&b)?;
534 let va = serde_json::to_value(&fa)?;
535 let vb = serde_json::to_value(&fb)?;
536
537 let mut diffs = Vec::new();
538 diff_values("", &va, &vb, &mut diffs);
539
540 if diffs.is_empty() {
541 println!("No structural differences.");
542 } else {
543 println!("{} difference(s):", diffs.len());
544 for d in &diffs {
545 println!(" {d}");
546 }
547 if fail_on_diff {
548 bail!(
549 "structural differences found: {} difference(s)",
550 diffs.len()
551 );
552 }
553 }
554 Ok(())
555}
556
557fn diff_values(path: &str, a: &Value, b: &Value, out: &mut Vec<String>) {
560 match (a, b) {
561 (Value::Object(ma), Value::Object(mb)) => {
562 let mut keys: Vec<&String> = ma.keys().chain(mb.keys()).collect();
563 keys.sort();
564 keys.dedup();
565 for k in keys {
566 let sub = if path.is_empty() {
567 k.clone()
568 } else {
569 format!("{path}.{k}")
570 };
571 match (ma.get(k), mb.get(k)) {
572 (Some(av), Some(bv)) => diff_values(&sub, av, bv, out),
573 (Some(_), None) => out.push(format!("- {sub} (removed)")),
574 (None, Some(_)) => out.push(format!("+ {sub} (added)")),
575 (None, None) => unreachable!(),
576 }
577 }
578 }
579 (Value::Array(aa), Value::Array(ba)) => {
580 if aa.len() != ba.len() {
581 out.push(format!("~ {path} (array len {} -> {})", aa.len(), ba.len()));
582 } else {
583 for (i, (av, bv)) in aa.iter().zip(ba.iter()).enumerate() {
584 diff_values(&format!("{path}[{i}]"), av, bv, out);
585 }
586 }
587 }
588 _ => {
589 if a != b {
590 out.push(format!("~ {path}: {a} -> {b}"));
591 }
592 }
593 }
594}
595
596fn cmd_new(output: PathBuf, template: String) -> Result<()> {
597 let name = name_from_path(&output);
598 let mut file = match template.as_str() {
599 "ellipse" => scaffold_ellipse(name.clone()),
601 other => match renamite_examples::parse_template(other) {
602 Some(id) => renamite_examples::build_template(id),
603 None => {
604 let known: Vec<&str> = std::iter::once("ellipse")
605 .chain(renamite_examples::templates().iter().map(|t| t.id.slug()))
606 .collect();
607 bail!(
608 "unknown template '{other}' (expected one of: {})",
609 known.join(", ")
610 )
611 }
612 },
613 };
614 file.meta.name = name;
615
616 let bytes = save_for_output(&file, &output)?;
617 atomic_write(&output, &bytes)?;
618 println!("Created {}", output.display());
619 Ok(())
620}
621
622fn cmd_templates() -> Result<()> {
623 println!("{}", templates_text());
624 Ok(())
625}
626
627fn templates_text() -> String {
628 let mut out =
629 String::from("Available templates (use with `renamite new --template <slug>`):\n");
630 for t in renamite_examples::templates() {
631 out.push_str(&format!(" {:<18} {}\n", t.id.slug(), t.description));
632 }
633 out
634}
635
636fn scaffold_ellipse(name: String) -> RenFile {
637 use renamite_animation::Animated;
638 use renamite_model::{
639 Color, Document, FillRule, Node, NodeKind, Parent, ShapeKind, StyleKind, StylePaint,
640 };
641
642 let mut doc = Document::empty();
643 let comp = doc.main;
644 let (w, h) = doc.compositions[comp].size;
645 let center = glam::DVec2::new(w as f64 / 2.0, h as f64 / 2.0);
646
647 let shape = doc.create_node(Node::new(
648 "Ellipse",
649 NodeKind::Shape(ShapeKind::Ellipse {
650 pos: Animated::new(center),
651 size: Animated::new(glam::DVec2::new(180.0, 180.0)),
652 }),
653 ));
654 let fill = doc.create_node(Node::new(
655 "Fill",
656 NodeKind::Style(StyleKind::Fill {
657 paint: StylePaint::solid(Color::rgba(0.96, 0.42, 0.18, 1.0)),
658 rule: FillRule::NonZero,
659 }),
660 ));
661 doc.attach(shape, Parent::Comp(comp), 0).unwrap();
662 doc.attach(fill, Parent::Comp(comp), 1).unwrap();
663
664 RenFile::new(doc, name)
665}
666
667fn name_from_path(p: &Path) -> String {
668 p.file_stem()
669 .and_then(|s| s.to_str())
670 .unwrap_or("Untitled")
671 .to_string()
672}
673
674fn default_dt_for(player: &Player) -> f64 {
677 let fps = player.rate().fps();
678 if fps > 0.0 && fps.is_finite() {
679 1.0 / fps
680 } else {
681 1.0 / 60.0
682 }
683}
684
685fn cmd_play(input: PathBuf, duration: f64) -> Result<()> {
686 let mut player = Player::new(load_file(&input)?)?;
687 let dt = default_dt_for(&player);
688 let ticks = (duration / dt) as usize;
689
690 println!("Playing {} for {duration:.1}s...", input.display());
691 for _ in 0..ticks {
692 for ev in player.tick(dt) {
693 println!(" {ev}");
694 }
695 }
696 println!("Done. Final head: {:.2}", player.head());
697 Ok(())
698}
699
700fn cmd_export_lottie(input: PathBuf, output: PathBuf, strict: bool) -> Result<()> {
701 let file = load_file(&input)?;
702 let report = renamite_io_lottie::export_project_with_report(
703 &file.document,
704 file.clip_order.len(),
705 file.machine_order.len(),
706 file.start_machine.is_some(),
707 )?;
708 if strict && !report.warnings.is_empty() {
709 for warning in &report.warnings {
710 eprintln!("warning at {}: {}", warning.path, warning.message);
711 }
712 bail!(
713 "Lottie export produced {} compatibility warning(s)",
714 report.warnings.len()
715 );
716 }
717 for warning in &report.warnings {
718 eprintln!("warning at {}: {}", warning.path, warning.message);
719 }
720 atomic_write(&output, &serde_json::to_vec_pretty(&report.value)?)?;
721 println!("Exported {} -> {}", input.display(), output.display());
722 Ok(())
723}
724
725fn cmd_import_lottie(input: PathBuf, output: PathBuf, strict: bool) -> Result<()> {
726 let value: Value = serde_json::from_slice(&std::fs::read(&input)?)?;
727 let report = renamite_io_lottie::import_with_report(&value)?;
728 if strict && !report.warnings.is_empty() {
729 for warning in &report.warnings {
730 eprintln!("warning at {}: {}", warning.path, warning.message);
731 }
732 bail!(
733 "Lottie import produced {} compatibility warning(s)",
734 report.warnings.len()
735 );
736 }
737 for warning in &report.warnings {
738 eprintln!("warning at {}: {}", warning.path, warning.message);
739 }
740 let file = RenFile::new(report.value, name_from_path(&input));
741 match output.extension().and_then(|extension| extension.to_str()) {
742 Some("renb") => {
743 atomic_write(&output, &renamite_io_ren::save_binary(&file)?)?;
744 }
745 _ => {
746 atomic_write(&output, renamite_io_ren::save(&file)?.as_bytes())?;
747 }
748 }
749 println!("Imported {} -> {}", input.display(), output.display());
750 Ok(())
751}
752
753fn cmd_export_svg(input: PathBuf, output: PathBuf, frame: f64, strict: bool) -> Result<()> {
754 let file = load_file(&input)?;
755 let report = renamite_io_svg::export_project_with_report(
756 &file.document,
757 file.document.main,
758 frame,
759 file.clip_order.len(),
760 file.machine_order.len(),
761 file.start_machine.is_some(),
762 )?;
763 if strict && !report.warnings.is_empty() {
764 for warning in &report.warnings {
765 eprintln!("warning at {}: {}", warning.path, warning.message);
766 }
767 bail!(
768 "SVG export produced {} compatibility warning(s)",
769 report.warnings.len()
770 );
771 }
772 for warning in &report.warnings {
773 eprintln!("warning at {}: {}", warning.path, warning.message);
774 }
775 atomic_write(&output, report.value.as_bytes())?;
776 println!(
777 "Exported {} frame {frame} -> {}",
778 input.display(),
779 output.display()
780 );
781 Ok(())
782}
783
784fn cmd_import_svg(input: PathBuf, output: PathBuf, strict: bool) -> Result<()> {
785 let bytes = std::fs::read(&input)?;
786 let report = renamite_io_svg::import_with_report(&bytes)?;
787 if strict && !report.warnings.is_empty() {
788 for warning in &report.warnings {
789 eprintln!("warning at {}: {}", warning.path, warning.message);
790 }
791 bail!(
792 "SVG import produced {} compatibility warning(s)",
793 report.warnings.len()
794 );
795 }
796 for warning in &report.warnings {
797 eprintln!("warning at {}: {}", warning.path, warning.message);
798 }
799 let file = RenFile::new(report.value, name_from_path(&input));
800 match output.extension().and_then(|extension| extension.to_str()) {
801 Some("renb") => {
802 atomic_write(&output, &renamite_io_ren::save_binary(&file)?)?;
803 }
804 _ => {
805 atomic_write(&output, renamite_io_ren::save(&file)?.as_bytes())?;
806 }
807 }
808 println!("Imported {} -> {}", input.display(), output.display());
809 Ok(())
810}
811
812fn load_file(path: &Path) -> Result<RenFile> {
813 let bytes =
814 std::fs::read(path).with_context(|| format!("failed to read {}", path.display()))?;
815
816 if renamite_io_ren::is_binary(&bytes) {
818 return Ok(renamite_io_ren::open_binary(&bytes)?);
819 }
820
821 let text = std::str::from_utf8(&bytes)
822 .with_context(|| format!("{} is neither valid UTF-8 .ren nor .renb", path.display()))?;
823 Ok(renamite_io_ren::open(text)?)
824}
825
826fn atomic_write(path: &Path, bytes: &[u8]) -> Result<()> {
829 let parent = path.parent().filter(|p| !p.as_os_str().is_empty());
830 let tmp = match parent {
831 Some(dir) => {
832 let name = format!(
833 ".{}.tmp-{}",
834 path.file_name()
835 .and_then(|s| s.to_str())
836 .unwrap_or("renamite"),
837 std::process::id()
838 );
839 dir.join(name)
840 }
841 None => std::env::temp_dir().join(format!(
842 ".{}.tmp-{}",
843 path.file_name()
844 .and_then(|s| s.to_str())
845 .unwrap_or("renamite"),
846 std::process::id()
847 )),
848 };
849 std::fs::write(&tmp, bytes).with_context(|| format!("failed to write {}", tmp.display()))?;
850 std::fs::rename(&tmp, path).with_context(|| {
851 let _ = std::fs::remove_file(&tmp);
852 format!(
853 "failed to replace {} (temp {})",
854 path.display(),
855 tmp.display()
856 )
857 })?;
858 Ok(())
859}
860
861fn save_for_output(file: &RenFile, output: &Path) -> Result<Vec<u8>> {
863 match output.extension().and_then(|s| s.to_str()) {
864 Some("renb") => Ok(renamite_io_ren::save_binary(file)?),
865 _ => Ok(renamite_io_ren::save(file)?.into_bytes()),
866 }
867}