cargo_mobile2/bicycle/
mod.rs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
//! `bicycle` is [`handlebars`] with wheels. 🚴🏽‍♀️

#![forbid(unsafe_code)]
#![allow(dead_code)]

mod json_map;
mod traverse;

pub use self::{json_map::*, traverse::*};
pub use handlebars::{self, HelperDef};

use handlebars::Handlebars;
use std::{
    fmt::{self, Debug},
    fs,
    io::{self, Read, Write},
    iter,
    path::{Component, Path, PathBuf, Prefix},
};
use thiserror::Error;

pub type CustomEscapeFn = &'static (dyn Fn(&str) -> String + 'static + Send + Sync);

/// Specifies how to escape template variables prior to rendering.
pub enum EscapeFn {
    /// The default setting. Doesn't change the variables at all.
    None,
    /// Escape anything that looks like HTML. This is recommended when rendering HTML templates with user-provided data.
    Html,
    /// Escape using a custom function.
    Custom(CustomEscapeFn),
}

impl Debug for EscapeFn {
    fn fmt(&self, fmtr: &mut fmt::Formatter) -> fmt::Result {
        fmtr.pad(match self {
            Self::None => "None",
            Self::Html => "Html",
            Self::Custom(_) => "Custom(..)",
        })
    }
}

impl Default for EscapeFn {
    fn default() -> Self {
        Self::None
    }
}

impl From<CustomEscapeFn> for EscapeFn {
    fn from(custom: CustomEscapeFn) -> Self {
        Self::Custom(custom)
    }
}

/// An error encountered when rendering a template.
#[derive(Debug, Error)]
pub enum RenderingError {
    #[error("Failed to render template: {0}")]
    RenderingFailed(#[from] Box<handlebars::RenderError>),
}

/// An error encountered when processing an [`Action`].
#[derive(Debug, Error)]
pub enum ProcessingError {
    /// Failed to traverse files.
    #[error("Failed to traverse templates at {src:?}: {cause}")]
    Traversal {
        src: PathBuf,
        #[source]
        cause: TraversalError<RenderingError>,
    },
    /// Failed to create directory.
    #[error("Failed to create directory at {dest:?}: {cause}")]
    DirectoryCreation {
        dest: PathBuf,
        #[source]
        cause: io::Error,
    },
    /// Failed to copy file.
    #[error("Failed to copy file {src:?} to {dest:?}: {cause}")]
    FileCopy {
        src: PathBuf,
        dest: PathBuf,
        #[source]
        cause: io::Error,
    },
    /// Failed to open or read input file.
    #[error("Failed to read template at {src:?}: {cause}")]
    TemplateRead {
        src: PathBuf,
        #[source]
        cause: io::Error,
    },
    /// Failed to render template.
    #[error("Failed to render template at {src:?}: {cause}")]
    TemplateRender {
        src: PathBuf,
        #[source]
        cause: RenderingError,
    },
    /// Failed to create or write output file.
    #[error("Failed to write template from {src:?} to {dest:?}: {cause}")]
    TemplateWrite {
        src: PathBuf,
        dest: PathBuf,
        #[source]
        cause: io::Error,
    },
}

#[derive(Debug)]
pub struct Bicycle {
    handlebars: Handlebars<'static>,
    base_data: JsonMap,
}

impl Default for Bicycle {
    fn default() -> Self {
        Self::new(Default::default(), iter::empty(), Default::default())
    }
}

impl Bicycle {
    /// Creates a new `Bicycle` instance, using the provided arguments to
    /// configure the underlying [`handlebars::Handlebars`] instance.
    ///
    /// For info on `helpers`, consult the [`handlebars` docs](../handlebars/index.html#custom-helper).
    ///
    /// `base_data` is data that will be available for all invocations of all methods on this instance.
    ///
    /// # Examples
    /// ```
    /// use cargo_mobile2::bicycle::{
    ///     handlebars::{handlebars_helper, HelperDef},
    ///     Bicycle, EscapeFn, JsonMap,
    /// };
    /// use std::collections::HashMap;
    ///
    /// // An escape function that just replaces spaces with an angry emoji...
    /// fn spaces_make_me_very_mad(raw: &str) -> String {
    ///     raw.replace(' ', "😡")
    /// }
    ///
    /// // A helper to reverse strings.
    /// handlebars_helper!(reverse: |s: str|
    ///     // This doesn't correctly account for graphemes, so
    ///     // use a less naïve implementation for real apps.
    ///     s.chars().rev().collect::<String>()
    /// );
    ///
    /// // You could just as well use a [`Vec`] of tuples, or in this case,
    /// // [`std::iter::once`].
    /// let mut helpers = HashMap::<_, Box<dyn HelperDef + Send + Sync>>::new();
    /// helpers.insert("reverse", Box::new(reverse));
    ///
    /// let bike = Bicycle::new(
    ///     EscapeFn::Custom(&spaces_make_me_very_mad),
    ///     helpers,
    ///     JsonMap::default(),
    /// );
    /// ```
    pub fn new<'helper_name>(
        escape_fn: EscapeFn,
        helpers: impl iter::IntoIterator<
            Item = (
                &'helper_name str,
                Box<dyn HelperDef + Send + Sync + 'static>,
            ),
        >,
        base_data: JsonMap,
    ) -> Self {
        let mut handlebars = Handlebars::new();
        handlebars.set_strict_mode(true);
        match escape_fn {
            EscapeFn::Custom(escape_fn) => handlebars.register_escape_fn(escape_fn),
            EscapeFn::None => handlebars.register_escape_fn(handlebars::no_escape),
            EscapeFn::Html => handlebars.register_escape_fn(handlebars::html_escape),
        }
        for (name, helper) in helpers {
            handlebars.register_helper(name, helper);
        }
        Self {
            handlebars,
            base_data,
        }
    }

    /// Renders a template.
    ///
    /// Use `insert_data` to define any variables needed for the template.
    ///
    /// # Examples
    /// ```
    /// use cargo_mobile2::bicycle::Bicycle;
    ///
    /// let bike = Bicycle::default();
    /// let rendered = bike.render("Hello {{name}}!", |map| {
    ///     map.insert("name", "Shinji");
    /// }).unwrap();
    /// assert_eq!(rendered, "Hello Shinji!");
    /// ```
    pub fn render(
        &self,
        template: &str,
        insert_data: impl FnOnce(&mut JsonMap),
    ) -> Result<String, RenderingError> {
        let mut data = self.base_data.clone();
        insert_data(&mut data);
        self.handlebars
            .render_template(template, &data.0)
            .map_err(Box::new)
            .map_err(Into::into)
    }

    /// Executes an [`Action`].
    ///
    /// - [`Action::CreateDirectory`] is executed with the same semantics as `mkdir -p`:
    ///   any missing parent directories are also created, and creation succeeds even if
    ///   the directory already exists. Failure results in a [`ProcessingError::DirectoryCreationFailed`].
    /// - [`Action::CopyFile`] is executed with the same semantics as `cp`:
    ///   if the destination file already exists, it will be overwritted with a copy of
    ///   the source file. Failure results in a [`ProcessingError::FileCopyFailed`].
    /// - [`Action::WriteTemplate`] is executed by reading the source file,
    ///   rendering the contents as a template (using `insert_data` to pass
    ///   any required values to the underlying [`Bicycle::render`] call),
    ///   and then finally writing the result to the destination file. The destination
    ///   file will be overwritten if it already exists. Failure for each step results
    ///   in [`ProcessingError::TemplateReadFailed`], [`ProcessingError::TemplateRenderFailed`],
    ///   and [`ProcessingError::TemplateWriteFailed`], respectively.
    pub fn process_action(
        &self,
        action: &Action,
        insert_data: impl Fn(&mut JsonMap),
    ) -> Result<(), ProcessingError> {
        log::info!("{:#?}", action);
        match action {
            Action::CreateDirectory { dest } => {
                fs::create_dir_all(dest).map_err(|cause| ProcessingError::DirectoryCreation {
                    dest: dest.clone(),
                    cause,
                })?;
            }
            Action::CopyFile { src, dest } => {
                fs::copy(src, dest).map_err(|cause| ProcessingError::FileCopy {
                    src: src.clone(),
                    dest: dest.clone(),
                    cause,
                })?;
            }
            Action::WriteTemplate { src, dest } => {
                let mut template = String::new();
                fs::File::open(src)
                    .and_then(|mut file| file.read_to_string(&mut template))
                    .map_err(|cause| ProcessingError::TemplateRead {
                        src: src.clone(),
                        cause,
                    })?;
                let rendered = self.render(&template, insert_data).map_err(|cause| {
                    ProcessingError::TemplateRender {
                        src: src.clone(),
                        cause,
                    }
                })?;
                fs::File::create(dest)
                    .and_then(|mut file| file.write_all(rendered.as_bytes()))
                    .map_err(|cause| ProcessingError::TemplateWrite {
                        src: src.clone(),
                        dest: dest.clone(),
                        cause,
                    })?;
            }
        }
        Ok(())
    }

    /// Iterates over `actions`, passing each item to [`Bicycle::process_action`].
    pub fn process_actions<'iter_item>(
        &self,
        actions: impl iter::Iterator<Item = &'iter_item Action>,
        insert_data: impl Fn(&mut JsonMap),
    ) -> Result<(), ProcessingError> {
        for action in actions {
            self.process_action(action, &insert_data)?;
        }
        Ok(())
    }

    /// A convenience method that calls [`traverse`](traverse()) and passes the
    /// output to [`Bicycle::process_actions`]. Uses [`Bicycle::transform_path`]
    /// as the `transform_path` argument and `DEFAULT_TEMPLATE_EXT` ("hbs") as
    /// the `template_ext` argument to [`traverse`](traverse()).
    pub fn process(
        &self,
        src: impl AsRef<Path>,
        dest: impl AsRef<Path>,
        insert_data: impl Fn(&mut JsonMap),
    ) -> Result<(), ProcessingError> {
        self.filter_and_process(src, dest, insert_data, |_| true)
    }

    /// A convenience method that does the same work as [`Bicycle::process`],
    /// but applies a filter predicate to each action prior to processing it.
    pub fn filter_and_process(
        &self,
        src: impl AsRef<Path>,
        dest: impl AsRef<Path>,
        insert_data: impl Fn(&mut JsonMap),
        mut filter: impl FnMut(&Action) -> bool,
    ) -> Result<(), ProcessingError> {
        let src = src.as_ref();
        traverse(
            src,
            dest,
            |path| self.transform_path(path, &insert_data),
            DEFAULT_TEMPLATE_EXT,
        )
        .map_err(|cause| ProcessingError::Traversal {
            src: src.to_owned(),
            cause,
        })
        .and_then(|actions| {
            self.process_actions(actions.iter().filter(|action| filter(action)), insert_data)
        })
    }

    /// Renders a path string itself as a template.
    /// Intended to be used as the `transform_path` argument to [`traverse`](traverse()).
    pub fn transform_path(
        &self,
        path: &Path,
        insert_data: impl FnOnce(&mut JsonMap),
    ) -> Result<PathBuf, RenderingError> {
        // On Windows, backslash is the path separator, and passing that
        // to handlebars, will make it think that "path\to\{{something}}"
        // is an escaped sequence and won't render correctly, so we need to
        // disassemble the path into its compoenents and build it backup
        // but use forward slash as a separator
        let path_str = dunce::simplified(path)
            .components()
            .map(|c| c.as_os_str().to_str().unwrap())
            .collect::<Vec<_>>()
            .join("/");
        // This is naïve, but optimistically isn't a problem in practice.
        if path_str.contains("{{") {
            self.render(&path_str, insert_data)
                .map(PathBuf::from)
                .map(|p| p.components().collect::<PathBuf>())
                .map(|p| {
                    if let Some(Component::Prefix(prefix)) = p.components().next() {
                        if let Prefix::Disk(_) = prefix.kind() {
                            return p
                                .to_str()
                                .map(|s| format!("\\\\?\\{}", s))
                                .map(PathBuf::from)
                                .unwrap_or_else(|| p);
                        }
                    }
                    p
                })
        } else {
            Ok(path.to_owned())
        }
    }
}