cargo_scaffold/
lib.rs

1#![doc = include_str!("../README.md")]
2mod git;
3mod helpers;
4
5use std::{
6    collections::BTreeMap,
7    env,
8    fs::{self, File},
9    io::{Read, Write},
10    path::{Path, PathBuf},
11    process::Command,
12    string::ToString,
13};
14
15use anyhow::{anyhow, Context, Result};
16use clap::Parser;
17use console::{Emoji, Style};
18use dialoguer::{Confirm, Input, MultiSelect, Select};
19use fs::OpenOptions;
20use globset::{Glob, GlobSetBuilder};
21use handlebars::Handlebars;
22use helpers::ForRangHelper;
23use serde::{Deserialize, Serialize};
24use walkdir::WalkDir;
25
26pub use toml::Value;
27pub const SCAFFOLD_FILENAME: &str = ".scaffold.toml";
28
29#[derive(Serialize, Deserialize)]
30pub struct ScaffoldDescription {
31    template: TemplateDescription,
32    #[serde(default)]
33    parameters: BTreeMap<String, Parameter>,
34    hooks: Option<Hooks>,
35    #[serde(skip)]
36    target_dir: Option<PathBuf>,
37    #[serde(skip)]
38    template_path: PathBuf,
39    #[serde(skip)]
40    force: bool,
41    #[serde(skip)]
42    append: bool,
43    #[serde(skip)]
44    project_name: Option<String>,
45    #[serde(skip)]
46    default_parameters: BTreeMap<String, Value>,
47}
48
49#[derive(Debug, Serialize, Deserialize, Clone)]
50pub struct TemplateDescription {
51    exclude: Option<Vec<String>>,
52    disable_templating: Option<Vec<String>>,
53    notes: Option<String>,
54}
55
56#[derive(Debug, Serialize, Deserialize, Clone)]
57pub struct Parameter {
58    message: String,
59    #[serde(default)]
60    required: bool,
61    r#type: ParameterType,
62    default: Option<Value>,
63    values: Option<Vec<Value>>,
64    tags: Option<Vec<String>>,
65}
66
67#[derive(Debug, Default, Serialize, Deserialize, Clone)]
68pub struct Hooks {
69    pre: Option<Vec<String>>,
70    post: Option<Vec<String>>,
71}
72
73#[derive(Debug, Serialize, Deserialize, Clone)]
74#[serde(rename_all = "lowercase")]
75pub enum ParameterType {
76    String,
77    Integer,
78    Float,
79    Boolean,
80    Select,
81    MultiSelect,
82}
83
84/// Opts: The options for scaffolding.
85///
86/// This structure can be generated using the `parse` or `parse_from` method (when used in Cli) or
87/// can be generated as `default` and then the required values can be updated.
88///
89/// Usage: If generated in a 'cli' binary
90///
91/// ```no_run
92/// # use cargo_scaffold::{Opts, ScaffoldDescription};
93/// # use clap::Parser;
94/// # use anyhow::Result;
95///
96/// # fn main() -> Result<()> {
97///
98///     let opts = Opts::parse_from(vec!["scaffold", "/path/to/template"]);
99///     ScaffoldDescription::new(opts)?.scaffold()
100/// # }
101///
102/// ```
103///
104/// Usage: When generated as a library
105///
106/// ```no_run
107/// # use cargo_scaffold::{Opts, ScaffoldDescription};
108/// # use clap::Parser;
109/// # use anyhow::Result;
110///
111/// # fn main() -> Result<()> {
112///
113///     let mut opts = Opts::default()
114///         .project_name("testlib")
115///         .template_path("https://github.com/Cosmian/mpc_rust_template.git");
116///
117///     ScaffoldDescription::new(opts)?.scaffold()
118/// # }
119///
120/// ```
121#[derive(Parser, Debug, Default)]
122#[command(author, version, about, long_about=None)]
123pub struct Opts {
124    /// Specifiy your template location
125    #[arg(name = "template", required = true)]
126    template_path: PathBuf,
127
128    /// Specifiy your template location in the repository if it's not located at the root of your repository
129    #[arg(name = "repository_template_path", short = 'r', long = "path")]
130    repository_template_path: Option<PathBuf>,
131
132    /// Full commit hash, tag or branch from which the template is cloned
133    /// (i.e.: "deed14dcbf17ba87f6659ea05755cf94cb1464ab" or "v0.5.0" or "main")
134    #[arg(name = "git_ref", short = 't', long = "git_ref")]
135    git_ref: Option<String>,
136
137    /// Specify the name of your generated project (and so skip the prompt asking for it)
138    #[arg(name = "name", short = 'n', long = "name")]
139    project_name: Option<String>,
140
141    /// Specifiy the target directory
142    #[arg(name = "target_directory", short = 'd', long = "target_directory")]
143    target_dir: Option<PathBuf>,
144
145    /// Override target directory if it exists
146    #[arg(short = 'f', long = "force")]
147    force: bool,
148
149    /// Append files in the target directory, create directory with the project name if it doesn't already exist but doesn't overwrite existing file (use force for that kind of usage)
150    #[arg(short = 'a', long = "append")]
151    append: bool,
152
153    /// Ignored, kept for backwards compatibility [DEPRECATED]
154    #[arg(short = 'p', long = "passphrase")]
155    passphrase_needed: bool,
156
157    /// Specify if your private SSH key is located in another location than $HOME/.ssh/id_rsa
158    #[arg(short = 'k', long = "private_key_path")]
159    private_key_path: Option<PathBuf>,
160
161    /// Supply parameters via the command line in <name>=<value> format
162    #[arg(long = "param")]
163    parameters: Vec<String>,
164}
165
166impl Opts {
167    /// Builder function for the `Opts` structure
168    pub fn builder<T: Into<PathBuf>>(template_path: T) -> Self {
169        Self::default().template_path(template_path)
170    }
171
172    /// Set the template path for the structure.
173    pub fn template_path<T: Into<PathBuf>>(mut self, path: T) -> Self {
174        let _ = std::mem::replace(&mut self.template_path, path.into());
175        self
176    }
177
178    /// Set the template path inside the repository
179    pub fn repository_template_path<T: Into<PathBuf>>(mut self, path: T) -> Self {
180        let _ = self.repository_template_path.replace(path.into());
181        self
182    }
183
184    /// Set the git reference
185    pub fn git_ref<T: Into<String>>(mut self, gitref: T) -> Self {
186        let _ = self.git_ref.replace(gitref.into());
187        self
188    }
189
190    /// Set the project name
191    pub fn project_name<T: Into<String>>(mut self, name: T) -> Self {
192        let _ = self.project_name.replace(name.into());
193        self
194    }
195
196    /// Set the target directory
197    pub fn target_dir<T: Into<PathBuf>>(mut self, target_dir: T) -> Self {
198        let _ = self.target_dir.replace(target_dir.into());
199        self
200    }
201
202    /// Force generating to the target directory if exists
203    pub fn force(mut self, force: bool) -> Self {
204        self.force = force;
205        self
206    }
207
208    /// Append generating to the target directory if exists
209    pub fn append(mut self, append: bool) -> Self {
210        self.append = append;
211        self
212    }
213
214    /// Is Passphrase needed (prompt user)
215    pub fn passphrase_needed(mut self, needed: bool) -> Self {
216        self.passphrase_needed = needed;
217        self
218    }
219
220    /// Set the private key path
221    pub fn private_key_path<T: Into<PathBuf>>(mut self, private_key_path: T) -> Self {
222        let _ = self.private_key_path.replace(private_key_path.into());
223        self
224    }
225
226    /// Set the parameters (supplied as `vec!["key1=value1", "key2=value2"]`).
227    pub fn parameters<T: Into<String>>(mut self, params: Vec<T>) -> Self {
228        let _ = std::mem::replace(
229            &mut self.parameters,
230            params
231                .into_iter()
232                .map(|x| x.into())
233                .collect::<Vec<String>>(),
234        );
235        self
236    }
237}
238
239impl ScaffoldDescription {
240    pub fn new(opts: Opts) -> Result<Self> {
241        let mut default_parameters = BTreeMap::new();
242        for param in opts.parameters {
243            let split = param.splitn(2, '=').collect::<Vec<_>>();
244            if split.len() != 2 {
245                return Err(anyhow!("invalid argument: {}", param));
246            }
247            default_parameters.insert(split[0].to_string(), Value::String(split[1].to_string()));
248        }
249        if let Some(ref name) = opts.project_name {
250            default_parameters.insert("name".to_string(), Value::String(name.to_string()));
251        }
252
253        let mut template_path = opts.template_path.to_string_lossy().to_string();
254        let mut scaffold_desc: ScaffoldDescription = {
255            if template_path.ends_with(".git") {
256                let tmp_dir = env::temp_dir().join(format!("{:x}", md5::compute(&template_path)));
257                if tmp_dir.exists() {
258                    fs::remove_dir_all(&tmp_dir)?;
259                }
260                fs::create_dir_all(&tmp_dir)?;
261                git::clone(
262                    &template_path,
263                    opts.git_ref.as_deref(),
264                    &tmp_dir,
265                    opts.private_key_path.as_deref(),
266                )?;
267                template_path = match opts.repository_template_path {
268                    Some(sub_path) => tmp_dir.join(sub_path).to_string_lossy().to_string(),
269                    None => tmp_dir.to_string_lossy().to_string(),
270                };
271            }
272            let mut scaffold_file =
273                File::open(PathBuf::from(&template_path).join(SCAFFOLD_FILENAME))
274                    .with_context(|| format!("cannot open .scaffold.toml in {}", template_path))?;
275            let mut scaffold_desc_str = String::new();
276            scaffold_file.read_to_string(&mut scaffold_desc_str)?;
277            toml::from_str(&scaffold_desc_str)?
278        };
279
280        scaffold_desc.target_dir = opts.target_dir;
281        scaffold_desc.force = opts.force;
282        scaffold_desc.template_path = PathBuf::from(template_path);
283        scaffold_desc.project_name = opts.project_name;
284        scaffold_desc.append = opts.append;
285        scaffold_desc.default_parameters = default_parameters;
286
287        Ok(scaffold_desc)
288    }
289
290    pub fn name(&self) -> Option<String> {
291        self.project_name.clone()
292    }
293
294    fn create_dir(&self, name: &str) -> Result<PathBuf> {
295        let mut dir_path = self
296            .target_dir
297            .clone()
298            .unwrap_or_else(|| env::current_dir().unwrap_or_else(|_| ".".into()));
299
300        let cyan = Style::new().cyan();
301        if self.target_dir.is_none() {
302            dir_path = dir_path.join(name);
303        }
304        if dir_path.exists() {
305            if !self.force && !self.append {
306                return Err(anyhow!(
307                    "cannot create {} because it already exists",
308                    dir_path.to_string_lossy()
309                ));
310            } else if self.force {
311                println!(
312                    "{} {}",
313                    Emoji("🔄", ""),
314                    cyan.apply_to("Override directory…"),
315                );
316                fs::remove_dir_all(&dir_path).with_context(|| "Cannot remove directory")?;
317            } else if self.append {
318                println!(
319                    "{} {}",
320                    Emoji("🔄", ""),
321                    cyan.apply_to(format!(
322                        "Append to directory {}…",
323                        dir_path.to_string_lossy()
324                    )),
325                );
326            }
327        } else {
328            println!(
329                "{} {}",
330                Emoji("🔄", ""),
331                cyan.apply_to(format!(
332                    "Creating directory {}…",
333                    dir_path.to_string_lossy()
334                )),
335            );
336        }
337        fs::create_dir_all(&dir_path).with_context(|| "Cannot create directory")?;
338        let path = fs::canonicalize(dir_path).with_context(|| "Cannot canonicalize path")?;
339
340        Ok(path)
341    }
342
343    /// Launch prompt to the user to ask for different parameters
344    pub fn fetch_parameters_value(&self) -> Result<BTreeMap<String, Value>> {
345        use std::collections::btree_map::Entry;
346
347        let mut parameters: BTreeMap<String, Value> = self.default_parameters.clone();
348        for (parameter_name, parameter) in &self.parameters {
349            if let Entry::Vacant(entry) = parameters.entry(parameter_name.clone()) {
350                entry.insert(parameter.to_value_interactive()?);
351            }
352        }
353
354        if let Entry::Vacant(entry) = parameters.entry("name".to_string()) {
355            let value = Parameter {
356                message: "What is the name of your generated project ?".to_string(),
357                required: true,
358                r#type: ParameterType::String,
359                default: None,
360                values: None,
361                tags: None,
362            }
363            .to_value_interactive()?;
364            entry.insert(value);
365        };
366
367        Ok(parameters)
368    }
369
370    /// Scaffold the project with the template
371    pub fn scaffold(&self) -> Result<()> {
372        let mut parameters = self.default_parameters.clone();
373        parameters.append(&mut self.fetch_parameters_value()?);
374        self.internal_scaffold(parameters)
375    }
376
377    /// Scaffold the project with the given parameters defined in the .scaffold.toml without prompting any inputs
378    /// It's a non-interactive mode
379    pub fn scaffold_with_parameters(&self, mut parameters: BTreeMap<String, Value>) -> Result<()> {
380        let mut default_parameters = self.default_parameters.clone();
381        if let Some(name) = &self.project_name {
382            parameters.insert("name".to_string(), Value::String(name.clone()));
383        } else {
384            return Err(anyhow!("project_name must be set"));
385        }
386
387        default_parameters.append(&mut parameters);
388        self.internal_scaffold(default_parameters)
389    }
390
391    fn internal_scaffold(&self, mut parameters: BTreeMap<String, Value>) -> Result<()> {
392        let excludes = match &self.template.exclude {
393            Some(exclude) => {
394                let mut builder = GlobSetBuilder::new();
395                for ex in exclude {
396                    builder.add(Glob::new(ex.trim_start_matches("./"))?);
397                }
398
399                builder.build()?
400            }
401            None => GlobSetBuilder::new().build()?,
402        };
403        let disable_templating = match &self.template.disable_templating {
404            Some(exclude) => {
405                let mut builder = GlobSetBuilder::new();
406                for ex in exclude {
407                    builder.add(Glob::new(ex.trim_start_matches("./"))?);
408                }
409
410                builder.build()?
411            }
412            None => GlobSetBuilder::new().build()?,
413        };
414
415        let name = parameters
416            .get("name")
417            .expect("project name must have been set. qed")
418            .as_str()
419            .expect("project name must be a string")
420            .to_string();
421        let dir_path = self.create_dir(&name)?;
422        parameters.insert(
423            "target_dir".to_string(),
424            Value::String(dir_path.to_str().unwrap_or_default().to_string()),
425        );
426
427        let mut template_engine = Handlebars::new();
428        template_engine.set_strict_mode(false);
429        #[cfg(feature = "helpers")]
430        handlebars_misc_helpers::setup_handlebars(&mut template_engine);
431        template_engine.register_helper("forRange", Box::new(ForRangHelper));
432
433        // pre-hooks
434        if let Some(Hooks {
435            pre: Some(commands),
436            ..
437        }) = &self.hooks
438        {
439            if !commands.is_empty() {
440                let cyan = Style::new().cyan();
441                println!(
442                    "{} {}",
443                    Emoji("🤖", ""),
444                    cyan.apply_to("Triggering pre-hooks…"),
445                );
446            }
447            let commands = commands
448                .iter()
449                .map(|c| template_engine.render_template(c, &parameters).ok())
450                .map(|v| v.unwrap())
451                .collect::<Vec<String>>();
452
453            self.run_hooks(&dir_path, &commands)?;
454        }
455
456        // List entries inside directory
457        let entries = WalkDir::new(&self.template_path)
458            .into_iter()
459            .filter_entry(|entry| {
460                // Do not include git files
461                if entry
462                    .path()
463                    .components()
464                    .any(|c| c == std::path::Component::Normal(".git".as_ref()))
465                {
466                    return false;
467                }
468
469                if entry.depth() == 1 && entry.file_name() == SCAFFOLD_FILENAME {
470                    return false;
471                }
472
473                !excludes.is_match(
474                    entry
475                        .path()
476                        .strip_prefix(&self.template_path)
477                        .unwrap_or_else(|_| entry.path()),
478                )
479            });
480
481        let cyan = Style::new().cyan();
482        println!("{} {}", Emoji("🔄", ""), cyan.apply_to("Templating files…"),);
483        for entry in entries {
484            let entry = entry.map_err(|e| anyhow!("cannot read entry : {}", e))?;
485            let entry_path = entry.path().strip_prefix(&self.template_path)?;
486
487            if entry_path == PathBuf::from("") {
488                continue;
489            }
490            if entry.file_type().is_dir() {
491                if entry.path().to_str() == Some(".") {
492                    continue;
493                }
494
495                let entry_path = render_path(&template_engine, entry_path, &parameters)?;
496
497                let dir_path_to_create = dir_path.join(&entry_path);
498                if dir_path_to_create.exists() && self.force {
499                    fs::remove_dir_all(&dir_path_to_create)
500                        .with_context(|| "Cannot remove directory")?;
501                }
502                if dir_path_to_create.exists() && self.append {
503                    continue;
504                }
505                fs::create_dir(dir_path.join(entry_path))
506                    .map_err(|e| anyhow!("cannot create dir : {}", e))?;
507                continue;
508            }
509
510            let filename = entry.path();
511            let mut content = Vec::new();
512            {
513                let mut file =
514                    File::open(filename).map_err(|e| anyhow!("cannot open file : {}", e))?;
515                // TODO add the ability to read a non string file
516                file.read_to_end(&mut content)
517                    .map_err(|e| anyhow!("cannot read file {filename:?} : {}", e))?;
518            }
519            let (path, content) = if disable_templating.is_match(entry_path) {
520                (dir_path.join(entry_path), content)
521            } else {
522                let content = std::str::from_utf8(&content)
523                    .map_err(|_| anyhow!("invalid UTF-8 in {entry_path:?}, consider disabling templating for this file"))?;
524                let rendered_content = template_engine
525                    .render_template(content, &parameters)
526                    .map_err(|e| anyhow!("cannot render template {entry_path:?} : {}", e))?;
527
528                let rendered_path =
529                    render_path(&template_engine, &dir_path.join(entry_path), &parameters)?;
530                (rendered_path, rendered_content.into_bytes())
531            };
532
533            let filename_path = PathBuf::from(&path);
534            // We skip the file if the file already exist and if we are in an append mode
535            if filename_path.exists() && !self.force && self.append {
536                continue;
537            }
538
539            let permissions = entry
540                .metadata()
541                .map_err(|e| anyhow!("cannot get metadata for path : {}", e))?
542                .permissions();
543
544            let mut file = OpenOptions::new().write(true).create(true).open(&path)?;
545            file.set_permissions(permissions)
546                .map_err(|e| anyhow!("cannot set permission to file {:?} : {}", path, e))?;
547            file.write_all(&content)
548                .map_err(|e| anyhow!("cannot create file : {}", e))?;
549        }
550
551        let green = Style::new().green();
552        println!(
553            "{} Your project {} has been generated successfuly {}",
554            Emoji("✅", ""),
555            green.apply_to(name),
556            Emoji("🚀", "")
557        );
558
559        let yellow = Style::new().yellow();
560        println!(
561            "\n{}\n",
562            yellow.apply_to("-----------------------------------------------------"),
563        );
564
565        if let Some(notes) = &self.template.notes {
566            let rendered_notes = template_engine
567                .render_template(notes, &parameters)
568                .map_err(|e| anyhow!("cannot render template for path : {}", e))?;
569            println!("{}", rendered_notes);
570            println!(
571                "\n{}\n",
572                yellow.apply_to("-----------------------------------------------------"),
573            );
574        }
575
576        // post-hooks
577        if let Some(Hooks {
578            post: Some(commands),
579            ..
580        }) = &self.hooks
581        {
582            if !commands.is_empty() {
583                let cyan = Style::new().cyan();
584                println!(
585                    "{} {}",
586                    Emoji("🤖", ""),
587                    cyan.apply_to("Triggering post-hooks…"),
588                );
589                let commands = commands
590                    .iter()
591                    .map(|c| template_engine.render_template(c, &parameters).ok())
592                    .map(|v| v.unwrap())
593                    .collect::<Vec<String>>();
594
595                self.run_hooks(&dir_path, &commands)?;
596            }
597        }
598
599        Ok(())
600    }
601
602    fn run_hooks(&self, project_path: &Path, commands: &[String]) -> Result<()> {
603        let initial_path = std::env::current_dir()?;
604        // move to project directory
605        std::env::set_current_dir(project_path).map_err(|e| {
606            anyhow!(
607                "cannot change directory to project path {:?}: {}",
608                &project_path,
609                e
610            )
611        })?;
612        // run commands
613        let magenta = Style::new().magenta();
614        for cmd in commands {
615            println!("{} {}", Emoji("✨", ""), magenta.apply_to(cmd));
616            ScaffoldDescription::run_cmd(cmd)?;
617        }
618        // move back to initial path
619        std::env::set_current_dir(&initial_path).map_err(|e| {
620            anyhow!(
621                "cannot move back to original path {:?}: {}",
622                &initial_path,
623                e
624            )
625        })?;
626        Ok(())
627    }
628
629    pub fn run_cmd(cmd: &str) -> Result<()> {
630        let mut command = ScaffoldDescription::setup_cmd(cmd)?;
631        let mut child = command.spawn().expect("cannot execute command");
632        child.wait().expect("failed to wait on child process");
633        Ok(())
634    }
635
636    pub fn setup_cmd(cmd: &str) -> Result<Command> {
637        let splitted_cmd =
638            shell_words::split(cmd).map_err(|e| anyhow!("cannot split command line : {}", e))?;
639        if splitted_cmd.is_empty() {
640            anyhow::bail!("command argument is invalid: empty after splitting");
641        }
642        let mut command = Command::new(&splitted_cmd[0]);
643        if splitted_cmd.len() > 1 {
644            command.args(&splitted_cmd[1..]);
645        }
646        Ok(command)
647    }
648}
649
650fn render_path(
651    template_engine: &Handlebars,
652    path: &Path,
653    parameters: &BTreeMap<String, Value>,
654) -> Result<PathBuf> {
655    // The backslash character used as path separator on windows is an escape character for handlebars.
656    // Avoid passing it to the template renderer by expanding each path component individually.
657    // This also prevents strange patterns where template placeholders span across single folder/file names.
658    let mut output = PathBuf::new();
659    for component in path.components() {
660        match component {
661            std::path::Component::Normal(component) => {
662                let component = component
663                    .to_str()
664                    .ok_or_else(|| anyhow!("invalid Unicode path: {path:?}"))?;
665                let rendered = template_engine
666                    .render_template(component, parameters)
667                    .map_err(|e| anyhow!("cannot render template for path {path:?} : {}", e))?;
668                output.push(rendered);
669            }
670            component => output.push(component),
671        };
672    }
673    Ok(output)
674}
675
676impl Parameter {
677    fn to_value_interactive(&self) -> Result<toml::Value> {
678        let value = match self.r#type {
679            ParameterType::String => {
680                Value::String(Input::new().with_prompt(&self.message).interact()?)
681            }
682            ParameterType::Float => {
683                Value::Float(Input::<f64>::new().with_prompt(&self.message).interact()?)
684            }
685            ParameterType::Integer => {
686                Value::Integer(Input::<i64>::new().with_prompt(&self.message).interact()?)
687            }
688            ParameterType::Boolean => {
689                Value::Boolean(Confirm::new().with_prompt(&self.message).interact()?)
690            }
691            ParameterType::Select => {
692                let idx_selected = Select::new()
693                    .items(
694                        self.values
695                            .as_ref()
696                            .expect("cannot make a select parameter with empty values"),
697                    )
698                    .with_prompt(&self.message)
699                    .default(0)
700                    .interact()?;
701                self.values
702                    .as_ref()
703                    .expect("cannot make a select parameter with empty values")
704                    .get(idx_selected)
705                    .unwrap()
706                    .clone()
707            }
708            ParameterType::MultiSelect => {
709                let idxs_selected = MultiSelect::new()
710                    .items(
711                        self.values
712                            .as_ref()
713                            .expect("cannot make a select parameter with empty values"),
714                    )
715                    .with_prompt(&self.message)
716                    .interact()?;
717                let values = idxs_selected
718                    .into_iter()
719                    .map(|idx| {
720                        self.values
721                            .as_ref()
722                            .expect("cannot make a select parameter with empty values")
723                            .get(idx)
724                            .unwrap()
725                            .clone()
726                    })
727                    .collect();
728
729                Value::Array(values)
730            }
731        };
732        Ok(value)
733    }
734}
735
736#[cfg(test)]
737mod tests {
738    use crate::{render_path, BTreeMap, Handlebars};
739
740    use super::{Opts, ScaffoldDescription};
741    use std::fs::{remove_file, File};
742    use std::io::Write;
743    use std::path::Path;
744    use std::process::{Command, Stdio};
745
746    #[test]
747    #[cfg(windows)]
748    fn windows_paths_interpolation_works() {
749        // this isn't completely a scaffold test.
750        // This is us making sure we don't regress with the interpolation
751        let template_engine = Handlebars::new();
752
753        let mut parameters = BTreeMap::new();
754        parameters.insert("snake_name".to_string(), "tracing".to_string().into());
755
756        let path = Path::new(
757            "\\\\?\\C:\\Users\\Ignition\\AppData\\Local\\Temp\\router_scaffoldXwTZ11\\src\\plugins\\{{snake_name}}.rs"
758        );
759        let res = render_path(&template_engine, path, &parameters).unwrap();
760
761        assert_eq!(Path::new("\\\\?\\C:\\Users\\Ignition\\AppData\\Local\\Temp\\router_scaffoldXwTZ11\\src\\plugins\\tracing.rs"), res);
762    }
763
764    #[test]
765    #[cfg(unix)]
766    fn unix_paths_interpolation_works() {
767        // this isn't completely a scaffold test.
768        // This is us making sure we don't regress with the interpolation
769        let template_engine = Handlebars::new();
770
771        let mut parameters = BTreeMap::new();
772        parameters.insert("snake_name".to_string(), "tracing".to_string().into());
773
774        let path = Path::new("/tmp/router_scaffoldXwTZ11/src/plugins/{{snake_name}}.rs");
775        let res = render_path(&template_engine, path, &parameters).unwrap();
776
777        assert_eq!(
778            Path::new("/tmp/router_scaffoldXwTZ11/src/plugins/tracing.rs"),
779            res
780        );
781    }
782
783    #[test]
784    #[cfg(unix)]
785    fn unix_paths_dirs_work() {
786        // this isn't completely a scaffold test.
787        // This is us making sure we don't regress with the interpolation
788        let template_engine = Handlebars::new();
789
790        let mut parameters = BTreeMap::new();
791        parameters.insert("snake_name".to_string(), "tracing".to_string().into());
792        parameters.insert("directory_name".to_string(), "example".to_string().into());
793
794        let path = Path::new(
795            "/tmp/router_scaffoldXwTZ11/src/plugins/{{directory_name}}/{{snake_name}}.rs",
796        );
797        let res = render_path(&template_engine, path, &parameters).unwrap();
798
799        assert_eq!(
800            Path::new("/tmp/router_scaffoldXwTZ11/src/plugins/example/tracing.rs"),
801            res
802        );
803    }
804
805    #[test]
806    fn split_and_run_cmd() {
807        let mut command = ScaffoldDescription::setup_cmd("ls -alh .").unwrap();
808        command.stdout(Stdio::null());
809        let mut child = command.spawn().expect("cannot execute command");
810        child.wait().expect("failed to wait on child process");
811
812        let mut command = ScaffoldDescription::setup_cmd("/bin/bash -c ls").unwrap();
813        command.stdout(Stdio::null());
814        let mut child = command.spawn().expect("cannot execute command");
815        child.wait().expect("failed to wait on child process");
816    }
817
818    #[test]
819    fn split_and_run_script() {
820        let script_name = "./test.sh";
821        let cmd = format!("/bin/bash -c {}", script_name);
822        {
823            let mut file = File::create(script_name).unwrap();
824            file.write_all(b"#!/bin/bash\nls .\nfree").unwrap();
825            Command::new("chmod")
826                .arg("+x")
827                .arg(script_name)
828                .output()
829                .expect("can't set execute perm on script file");
830        }
831        let mut command = ScaffoldDescription::setup_cmd(&cmd).unwrap();
832        command.stdout(Stdio::null());
833        let mut child = command.spawn().expect("cannot execute command");
834        child.wait().expect("failed to wait on child process");
835        // uncomment to see output of script execution
836        // std::io::stdout().write_all(&_output.stdout).unwrap();
837        remove_file(script_name).unwrap();
838    }
839
840    #[test]
841    fn test_build_opts_works() {
842        let opts = Opts::builder("/path/to/template");
843        assert_eq!(
844            opts.template_path,
845            std::path::PathBuf::from("/path/to/template")
846        );
847
848        // Test projct name can be set
849        assert!(opts.project_name.is_none());
850        let opts = opts.project_name("project");
851        assert_eq!(opts.project_name, Some("project".to_string()));
852
853        // Test template can be set
854        assert_eq!(
855            opts.template_path,
856            std::path::PathBuf::from("/path/to/template")
857        );
858        let opts = opts.template_path("/path/to/new-template");
859        assert_eq!(
860            opts.template_path,
861            std::path::PathBuf::from("/path/to/new-template")
862        );
863
864        // Test repository template path can be set.
865        assert!(opts.repository_template_path.is_none());
866        let opts = opts.repository_template_path("somepath");
867        assert_eq!(
868            opts.repository_template_path,
869            Some(std::path::PathBuf::from("somepath"))
870        );
871
872        // Test git_ref can be set
873        assert!(opts.git_ref.is_none());
874        let opts = opts.git_ref("main");
875        assert_eq!(opts.git_ref, Some("main".to_string()));
876
877        // Test target_dir
878        assert!(opts.target_dir.is_none());
879        let opts = opts.target_dir("target");
880        assert_eq!(opts.target_dir, Some(std::path::PathBuf::from("target")));
881
882        // Test append, force, passphrase_needed
883        assert!(!opts.append);
884        assert!(!opts.force);
885        assert!(!opts.passphrase_needed);
886        let opts = opts.append(true).force(true).passphrase_needed(true);
887        assert!(opts.append);
888        assert!(opts.force);
889        assert!(opts.passphrase_needed);
890
891        // Test private_key_path can be set
892        assert!(opts.private_key_path.is_none());
893        let opts = opts.private_key_path(".ssh/id_rsa");
894        assert_eq!(
895            opts.private_key_path,
896            Some(std::path::PathBuf::from(".ssh/id_rsa"))
897        );
898
899        // Test parameters can be set
900        assert!(opts.parameters.is_empty());
901        let opts = opts.parameters(vec!["key1=value1"]);
902        assert_eq!(opts.parameters, vec!["key1=value1".to_string()]);
903    }
904}