use std::{ io::{self, BufReader, Write as _}, mem::forget, path::{Path, PathBuf}, time::Instant, }; use anyhow::Context; use clap::Parser; use fs_err::File; use memofs::Vfs; use rbx_dom_weak::{InstanceBuilder, WeakDom}; use termcolor::{BufferWriter, Color, ColorChoice, ColorSpec, WriteColor}; use crate::{ path_serializer::display_absolute, serve_session::ServeSession, syncback::{syncback_loop, FsSnapshot}, }; use super::{resolve_path, GlobalOptions}; const UNKNOWN_INPUT_KIND_ERR: &str = "Could not detect what kind of file was inputted. \ Expected input file to end in .rbxl, .rbxlx, .rbxm, or .rbxmx."; /// Performs 'syncback' for the provided project, using the `input` file /// given. /// /// Syncback exists to convert Roblox files into a Rojo project automatically. /// It uses the project.json file provided to traverse the Roblox file passed as /// to serialize Instances to the file system in a format that Rojo understands. /// /// To ease programmatic use, this command pipes all normal output to stderr. #[derive(Debug, Parser)] pub struct SyncbackCommand { /// Path to the project to sync back to. #[clap(default_value = "")] pub project: PathBuf, /// Path to the Roblox file to pull Instances from. #[clap(long, short)] pub input: PathBuf, /// If provided, a list all of the files and directories that will be /// added or removed is emitted into stdout. #[clap(long, short)] pub list: bool, /// If provided, syncback will not actually write anything to the file /// system. The command will otherwise run normally. #[clap(long)] pub dry_run: bool, /// If provided, the prompt for writing to the file system is skipped. #[clap(long, short = 'y')] pub non_interactive: bool, /// If provided, forces syncback to use JSON model files instead of binary /// .rbxm files for instances that would otherwise serialize as binary. #[clap(long)] pub dangerously_force_json: bool, } impl SyncbackCommand { pub fn run(&self, global: GlobalOptions) -> anyhow::Result<()> { let path_old = resolve_path(&self.project); let path_new = resolve_path(&self.input); let input_kind = FileKind::from_path(&path_new).context(UNKNOWN_INPUT_KIND_ERR)?; let dom_start_timer = Instant::now(); let dom_new = read_dom(&path_new, input_kind)?; log::debug!( "Finished opening file in {:0.02}s", dom_start_timer.elapsed().as_secs_f32() ); let vfs = Vfs::new_default(); vfs.set_watch_enabled(false); let project_start_timer = Instant::now(); let session_old = ServeSession::new(vfs, path_old.clone())?; log::debug!( "Finished opening project in {:0.02}s", project_start_timer.elapsed().as_secs_f32() ); let mut dom_old = session_old.tree(); log::debug!("Old root: {}", dom_old.inner().root().class); log::debug!("New root: {}", dom_new.root().class); if log::log_enabled!(log::Level::Trace) { log::trace!("Children of old root:"); for child in dom_old.inner().root().children() { let inst = dom_old.get_instance(*child).unwrap(); log::trace!("{} (class: {})", inst.name(), inst.class_name()); } log::trace!("Children of new root:"); for child in dom_new.root().children() { let inst = dom_new.get_by_ref(*child).unwrap(); log::trace!("{} (class: {})", inst.name, inst.class); } } let syncback_timer = Instant::now(); eprintln!("Beginning syncback..."); let snapshot = syncback_loop( session_old.vfs(), &mut dom_old, dom_new, session_old.root_project(), self.dangerously_force_json, )?; log::debug!( "Syncback finished in {:.02}s!", syncback_timer.elapsed().as_secs_f32() ); let base_path = session_old.root_project().folder_location(); if self.list { list_files(&snapshot, global.color.into(), base_path)?; } if !self.dry_run { if !self.non_interactive { eprintln!( "Would write {} files/folders and remove {} files/folders.", snapshot.added_paths().len(), snapshot.removed_paths().len() ); eprint!("Is this okay? (Y/N): "); io::stderr().flush()?; let mut line = String::with_capacity(1); io::stdin().read_line(&mut line)?; line = line.trim().to_lowercase(); if line != "y" { eprintln!("Aborting due to user input!"); return Ok(()); } } eprintln!("Writing to the file system..."); snapshot.write_to_vfs(base_path, session_old.vfs())?; eprintln!("Finished syncback.") } else { eprintln!( "Would write {} files/folders and remove {} files/folders.", snapshot.added_paths().len(), snapshot.removed_paths().len() ); eprintln!("Aborting before writing to file system due to `--dry-run`"); } // It is potentially prohibitively expensive to drop a ServeSession, // and the program is about to exit anyway so we're just going to forget // about it. drop(dom_old); forget(session_old); Ok(()) } } fn read_dom(path: &Path, file_kind: FileKind) -> anyhow::Result { let content = BufReader::new(File::open(path)?); match file_kind { FileKind::Rbxl => rbx_binary::from_reader(content).with_context(|| { format!( "Could not deserialize binary place file at {}", path.display() ) }), FileKind::Rbxlx => rbx_xml::from_reader(content, xml_decode_config()) .with_context(|| format!("Could not deserialize XML place file at {}", path.display())), FileKind::Rbxm => { let temp_tree = rbx_binary::from_reader(content).with_context(|| { format!( "Could not deserialize binary place file at {}", path.display() ) })?; process_model_dom(temp_tree) } FileKind::Rbxmx => { let temp_tree = rbx_xml::from_reader(content, xml_decode_config()).with_context(|| { format!("Could not deserialize XML model file at {}", path.display()) })?; process_model_dom(temp_tree) } } } fn process_model_dom(dom: WeakDom) -> anyhow::Result { let temp_children = dom.root().children(); if temp_children.len() == 1 { let real_root = dom.get_by_ref(temp_children[0]).unwrap(); let mut new_tree = WeakDom::new(InstanceBuilder::new(real_root.class)); for (name, property) in &real_root.properties { new_tree .root_mut() .properties .insert(*name, property.to_owned()); } let children = dom.clone_multiple_into_external(real_root.children(), &mut new_tree); for child in children { new_tree.transfer_within(child, new_tree.root_ref()); } Ok(new_tree) } else { anyhow::bail!( "Rojo does not currently support models with more \ than one Instance at the Root!" ); } } fn xml_decode_config() -> rbx_xml::DecodeOptions<'static> { rbx_xml::DecodeOptions::new().property_behavior(rbx_xml::DecodePropertyBehavior::ReadUnknown) } /// The different kinds of input that Rojo can syncback. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum FileKind { /// An XML model file. Rbxmx, /// An XML place file. Rbxlx, /// A binary model file. Rbxm, /// A binary place file. Rbxl, } impl FileKind { fn from_path(output: &Path) -> Option { let extension = output.extension()?.to_str()?; match extension { "rbxlx" => Some(FileKind::Rbxlx), "rbxmx" => Some(FileKind::Rbxmx), "rbxl" => Some(FileKind::Rbxl), "rbxm" => Some(FileKind::Rbxm), _ => None, } } } fn list_files(snapshot: &FsSnapshot, color: ColorChoice, base_path: &Path) -> io::Result<()> { let no_color = ColorSpec::new(); let mut add_color = ColorSpec::new(); add_color.set_fg(Some(Color::Green)); let mut remove_color = ColorSpec::new(); remove_color.set_fg(Some(Color::Red)); let writer = BufferWriter::stdout(color); let mut buffer = writer.buffer(); let added = snapshot.added_paths(); if !added.is_empty() { buffer.set_color(&add_color)?; for path in added { writeln!( &mut buffer, "Writing {}", display_absolute(path.strip_prefix(base_path).unwrap_or(path)) )?; } } let removed = snapshot.removed_paths(); if !removed.is_empty() { buffer.set_color(&remove_color)?; for path in removed { writeln!( &mut buffer, "Removing {}", display_absolute(path.strip_prefix(base_path).unwrap_or(path)) )?; } } buffer.set_color(&no_color)?; writer.print(&buffer) }