Skip to main content

librojo/cli/
syncback.rs

1use std::{
2    io::{self, BufReader, Write as _},
3    mem::forget,
4    path::{Path, PathBuf},
5    time::Instant,
6};
7
8use anyhow::Context;
9use clap::Parser;
10use fs_err::File;
11use memofs::Vfs;
12use rbx_dom_weak::{InstanceBuilder, WeakDom};
13use termcolor::{BufferWriter, Color, ColorChoice, ColorSpec, WriteColor};
14
15use crate::{
16    path_serializer::display_absolute,
17    serve_session::ServeSession,
18    syncback::{syncback_loop, FsSnapshot},
19};
20
21use super::{resolve_path, GlobalOptions};
22
23const UNKNOWN_INPUT_KIND_ERR: &str = "Could not detect what kind of file was inputted. \
24                                       Expected input file to end in .rbxl, .rbxlx, .rbxm, or .rbxmx.";
25
26/// Performs 'syncback' for the provided project, using the `input` file
27/// given.
28///
29/// Syncback exists to convert Roblox files into a Rojo project automatically.
30/// It uses the project.json file provided to traverse the Roblox file passed as
31/// to serialize Instances to the file system in a format that Rojo understands.
32///
33/// To ease programmatic use, this command pipes all normal output to stderr.
34#[derive(Debug, Parser)]
35pub struct SyncbackCommand {
36    /// Path to the project to sync back to.
37    #[clap(default_value = "")]
38    pub project: PathBuf,
39
40    /// Path to the Roblox file to pull Instances from.
41    #[clap(long, short)]
42    pub input: PathBuf,
43
44    /// If provided, a list all of the files and directories that will be
45    /// added or removed is emitted into stdout.
46    #[clap(long, short)]
47    pub list: bool,
48
49    /// If provided, syncback will not actually write anything to the file
50    /// system. The command will otherwise run normally.
51    #[clap(long)]
52    pub dry_run: bool,
53
54    /// If provided, the prompt for writing to the file system is skipped.
55    #[clap(long, short = 'y')]
56    pub non_interactive: bool,
57}
58
59impl SyncbackCommand {
60    pub fn run(&self, global: GlobalOptions) -> anyhow::Result<()> {
61        let path_old = resolve_path(&self.project)?;
62        let path_new = resolve_path(&self.input)?;
63
64        let input_kind = FileKind::from_path(&path_new).context(UNKNOWN_INPUT_KIND_ERR)?;
65        let dom_start_timer = Instant::now();
66        let dom_new = read_dom(&path_new, input_kind)?;
67        log::debug!(
68            "Finished opening file in {:0.02}s",
69            dom_start_timer.elapsed().as_secs_f32()
70        );
71
72        let vfs = Vfs::new_default()?;
73        vfs.set_watch_enabled(false);
74
75        let project_start_timer = Instant::now();
76        let session_old = ServeSession::new(vfs, path_old.clone())?;
77        log::debug!(
78            "Finished opening project in {:0.02}s",
79            project_start_timer.elapsed().as_secs_f32()
80        );
81
82        let mut dom_old = session_old.tree();
83
84        log::debug!("Old root: {}", dom_old.inner().root().class);
85        log::debug!("New root: {}", dom_new.root().class);
86
87        if log::log_enabled!(log::Level::Trace) {
88            log::trace!("Children of old root:");
89            for child in dom_old.inner().root().children() {
90                let inst = dom_old.get_instance(*child).unwrap();
91                log::trace!("{} (class: {})", inst.name(), inst.class_name());
92            }
93            log::trace!("Children of new root:");
94            for child in dom_new.root().children() {
95                let inst = dom_new.get_by_ref(*child).unwrap();
96                log::trace!("{} (class: {})", inst.name, inst.class);
97            }
98        }
99
100        let syncback_timer = Instant::now();
101        eprintln!("Beginning syncback...");
102        let snapshot = syncback_loop(
103            session_old.vfs(),
104            &mut dom_old,
105            dom_new,
106            session_old.root_project(),
107        )?;
108        log::debug!(
109            "Syncback finished in {:.02}s!",
110            syncback_timer.elapsed().as_secs_f32()
111        );
112
113        let base_path = session_old.root_project().folder_location();
114        if self.list {
115            list_files(&snapshot, global.color.into(), base_path)?;
116        }
117
118        if !self.dry_run {
119            if !self.non_interactive {
120                eprintln!(
121                    "Would write {} files/folders and remove {} files/folders.",
122                    snapshot.added_paths().len(),
123                    snapshot.removed_paths().len()
124                );
125                eprint!("Is this okay? (Y/N): ");
126                io::stderr().flush()?;
127                let mut line = String::with_capacity(1);
128                io::stdin().read_line(&mut line)?;
129                line = line.trim().to_lowercase();
130                if line != "y" {
131                    eprintln!("Aborting due to user input!");
132                    return Ok(());
133                }
134            }
135            eprintln!("Writing to the file system...");
136            snapshot.write_to_vfs(base_path, session_old.vfs())?;
137            eprintln!("Finished syncback.")
138        } else {
139            eprintln!(
140                "Would write {} files/folders and remove {} files/folders.",
141                snapshot.added_paths().len(),
142                snapshot.removed_paths().len()
143            );
144            eprintln!("Aborting before writing to file system due to `--dry-run`");
145        }
146
147        // It is potentially prohibitively expensive to drop a ServeSession,
148        // and the program is about to exit anyway so we're just going to forget
149        // about it.
150        drop(dom_old);
151        forget(session_old);
152
153        Ok(())
154    }
155}
156
157fn read_dom(path: &Path, file_kind: FileKind) -> anyhow::Result<WeakDom> {
158    let content = BufReader::new(File::open(path)?);
159    match file_kind {
160        FileKind::Rbxl => rbx_binary::from_reader(content).with_context(|| {
161            format!(
162                "Could not deserialize binary place file at {}",
163                path.display()
164            )
165        }),
166        FileKind::Rbxlx => rbx_xml::from_reader(content, xml_decode_config())
167            .with_context(|| format!("Could not deserialize XML place file at {}", path.display())),
168        FileKind::Rbxm => {
169            let temp_tree = rbx_binary::from_reader(content).with_context(|| {
170                format!(
171                    "Could not deserialize binary place file at {}",
172                    path.display()
173                )
174            })?;
175
176            process_model_dom(temp_tree)
177        }
178        FileKind::Rbxmx => {
179            let temp_tree =
180                rbx_xml::from_reader(content, xml_decode_config()).with_context(|| {
181                    format!("Could not deserialize XML model file at {}", path.display())
182                })?;
183            process_model_dom(temp_tree)
184        }
185    }
186}
187
188fn process_model_dom(dom: WeakDom) -> anyhow::Result<WeakDom> {
189    let temp_children = dom.root().children();
190    if temp_children.len() == 1 {
191        let real_root = dom.get_by_ref(temp_children[0]).unwrap();
192        let mut new_tree = WeakDom::new(InstanceBuilder::new(real_root.class));
193        for (name, property) in &real_root.properties {
194            new_tree
195                .root_mut()
196                .properties
197                .insert(*name, property.to_owned());
198        }
199
200        let children = dom.clone_multiple_into_external(real_root.children(), &mut new_tree);
201        for child in children {
202            new_tree.transfer_within(child, new_tree.root_ref());
203        }
204        Ok(new_tree)
205    } else {
206        anyhow::bail!(
207            "Rojo does not currently support models with more \
208        than one Instance at the Root!"
209        );
210    }
211}
212
213fn xml_decode_config() -> rbx_xml::DecodeOptions<'static> {
214    rbx_xml::DecodeOptions::new().property_behavior(rbx_xml::DecodePropertyBehavior::ReadUnknown)
215}
216
217/// The different kinds of input that Rojo can syncback.
218#[derive(Debug, Clone, Copy, PartialEq, Eq)]
219enum FileKind {
220    /// An XML model file.
221    Rbxmx,
222
223    /// An XML place file.
224    Rbxlx,
225
226    /// A binary model file.
227    Rbxm,
228
229    /// A binary place file.
230    Rbxl,
231}
232
233impl FileKind {
234    fn from_path(output: &Path) -> Option<FileKind> {
235        let extension = output.extension()?.to_str()?;
236
237        match extension {
238            "rbxlx" => Some(FileKind::Rbxlx),
239            "rbxmx" => Some(FileKind::Rbxmx),
240            "rbxl" => Some(FileKind::Rbxl),
241            "rbxm" => Some(FileKind::Rbxm),
242            _ => None,
243        }
244    }
245}
246
247fn list_files(snapshot: &FsSnapshot, color: ColorChoice, base_path: &Path) -> io::Result<()> {
248    let no_color = ColorSpec::new();
249    let mut add_color = ColorSpec::new();
250    add_color.set_fg(Some(Color::Green));
251    let mut remove_color = ColorSpec::new();
252    remove_color.set_fg(Some(Color::Red));
253
254    let writer = BufferWriter::stdout(color);
255    let mut buffer = writer.buffer();
256
257    let added = snapshot.added_paths();
258    if !added.is_empty() {
259        buffer.set_color(&add_color)?;
260        for path in added {
261            writeln!(
262                &mut buffer,
263                "Writing {}",
264                display_absolute(path.strip_prefix(base_path).unwrap_or(path))
265            )?;
266        }
267    }
268    let removed = snapshot.removed_paths();
269    if !removed.is_empty() {
270        buffer.set_color(&remove_color)?;
271        for path in removed {
272            writeln!(
273                &mut buffer,
274                "Removing {}",
275                display_absolute(path.strip_prefix(base_path).unwrap_or(path))
276            )?;
277        }
278    }
279    buffer.set_color(&no_color)?;
280
281    writer.print(&buffer)
282}