substrate_manager/ops/
substrate_new.rs

1use anyhow::Context as _;
2use core::slice;
3use std::ffi::OsStr;
4use std::fs;
5use std::fs::OpenOptions;
6use std::io::Write;
7use std::path::Path;
8use std::path::PathBuf;
9use std::process::Command;
10use strum::Display;
11use toml_edit::value;
12use toml_edit::Array;
13use toml_edit::Document;
14use toml_edit::Item;
15use toml_edit::Table;
16
17use crate::core::manifest::Manifest;
18use crate::templates::load_template_config;
19use crate::util::config::get_package_name;
20use crate::util::config::Config;
21use crate::util::restricted_names;
22use crate::util::to_snake_case;
23use crate::util::SubstrateResult;
24
25#[derive(Debug, Display)]
26pub enum Template {
27    // Chain templates
28    Substrate,
29    Cumulus,
30    Frontier,
31    Canvas,
32    // Contract templates
33    CargoContract,
34    // Path to custom template
35    Custom(String),
36}
37
38#[derive(Debug)]
39pub struct NewOptions {
40    pub template: Template,
41    /// Absolute path to the directory for the new package
42    pub path: PathBuf,
43    pub name: Option<String>,
44}
45
46// TODO: Add digestible error message on failure
47pub fn validate_rust_installation() -> SubstrateResult<()> {
48    let info = String::from_utf8_lossy(
49        &Command::new("rustup")
50            // Workaround for override caused by RUSTUP_TOOLCHAIN
51            .args(vec!["run", "nightly", "rustup", "show"])
52            .output()?
53            .stdout,
54    )
55    .to_string();
56
57    if !info.contains("wasm32-unknown-unknown") || !info.contains("nightly") {
58        println!("\nRust nightly toolchain is not installed. Installing...\n");
59        // Follows installation steps in: https://docs.substrate.io/install/macos/
60        // @TODO: Make sure same steps work for other operating systems.
61        Command::new("rustup")
62            .args(vec!["default", "stable"])
63            .status()?;
64        Command::new("rustup").args(vec!["update"]).status()?;
65        Command::new("rustup")
66            .args(vec!["update", "nightly"])
67            .status()?;
68        Command::new("rustup")
69            .args(vec![
70                "target",
71                "add",
72                "wasm32-unknown-unknown",
73                "--toolchain",
74                "nightly",
75            ])
76            .status()?;
77    }
78
79    Ok(())
80}
81
82fn get_name(opts: &NewOptions) -> SubstrateResult<&str> {
83    if let Some(ref name) = opts.name {
84        return Ok(name);
85    }
86
87    let path = &opts.path;
88    let file_name = path.file_name().ok_or_else(|| {
89        anyhow::format_err!(
90            "cannot auto-detect package name from path {:?} ; use --name to override",
91            path.as_os_str()
92        )
93    })?;
94
95    file_name.to_str().ok_or_else(|| {
96        anyhow::format_err!(
97            "cannot create package with a non-unicode name: {:?}",
98            file_name
99        )
100    })
101}
102
103fn get_parent(opts: &NewOptions) -> SubstrateResult<&str> {
104    let path = &opts.path;
105    let parent = path.parent().ok_or_else(|| {
106        anyhow::format_err!(
107            "cannot auto-detect package parent directory from path {:?}",
108            path.as_os_str()
109        )
110    })?;
111
112    parent.to_str().ok_or_else(|| {
113        anyhow::format_err!(
114            "cannot create package with a non-unicode name: {:?}",
115            parent
116        )
117    })
118}
119
120/// Validates that the path contains valid PATH env characters.
121fn validate_path(path: &Path) -> SubstrateResult<()> {
122    if cargo_util::paths::join_paths(slice::from_ref(&OsStr::new(path)), "").is_err() {
123        let path = path.to_string_lossy();
124        anyhow::bail!(
125            "the path `{path}` contains invalid PATH characters (usually `:`, `;`, or `\"`)\n\
126            It is recommended to use a different name to avoid problems."
127        );
128    }
129    Ok(())
130}
131
132fn validate_name(name: &str, show_name_help: bool) -> SubstrateResult<()> {
133    // If --name is already used to override, no point in suggesting it
134    // again as a fix.
135    let name_help = if show_name_help {
136        "\nIf you need a package name to not match the directory name, consider using --name flag."
137    } else {
138        ""
139    };
140    let bin_help = String::from(name_help);
141
142    restricted_names::validate_package_name(name, "package name", &bin_help)?;
143
144    if restricted_names::is_keyword(name) {
145        anyhow::bail!(
146            "the name `{}` cannot be used as a package name, it is a Rust keyword{}",
147            name,
148            bin_help
149        );
150    }
151    if restricted_names::is_conflicting_artifact_name(name) {
152        anyhow::bail!(
153            "the name `{}` cannot be used as a package name, \
154                it conflicts with cargo's build directory names{}",
155            name,
156            name_help
157        );
158    }
159    if name == "test" {
160        anyhow::bail!(
161            "the name `test` cannot be used as a package name, \
162            it conflicts with Rust's built-in test library{}",
163            bin_help
164        );
165    }
166    if ["core", "std", "alloc", "proc_macro", "proc-macro"].contains(&name) {
167        let warning = format!(
168            "the name `{}` is part of Rust's standard library\n\
169            It is recommended to use a different name to avoid problems.{}",
170            name, bin_help
171        );
172
173        println!("{}", warning);
174    }
175    if restricted_names::is_windows_reserved(name) {
176        if cfg!(windows) {
177            anyhow::bail!(
178                "cannot use name `{}`, it is a reserved Windows filename{}",
179                name,
180                name_help
181            );
182        } else {
183            let warning = format!(
184                "the name `{}` is a reserved Windows filename\n\
185                This package will not work on Windows platforms.",
186                name
187            );
188            println!("{}", warning);
189        }
190    }
191    if restricted_names::is_non_ascii_name(name) {
192        let warning = format!(
193            "the name `{}` contains non-ASCII characters\n\
194            Non-ASCII crate names are not supported by Rust.",
195            name
196        );
197        println!("{}", warning);
198    }
199
200    Ok(())
201}
202
203fn print_start_hacking_message(cwd: &Path, path: &Path) {
204    println!("\nStart hacking by typing:\n");
205    if let Ok(relative_path) = path.strip_prefix(cwd) {
206        println!("cd {}", relative_path.display());
207    } else {
208        println!("cd {}", path.display());
209    }
210    println!("substrate-manager");
211}
212
213pub fn new_chain(opts: &NewOptions, config: &Config) -> SubstrateResult<()> {
214    let path = &opts.path;
215    if path.exists() {
216        anyhow::bail!(
217            "destination `{}` already exists\n\n\
218             Use `substrate init` to initialize the directory",
219            path.display()
220        )
221    }
222
223    validate_path(path)?;
224
225    let name = get_name(opts)?;
226    validate_name(name, opts.name.is_none())?;
227
228    validate_rust_installation()?;
229
230    println!("Creating new chain...\n");
231
232    generate_node_template(&opts.template, &path)?;
233
234    mk_chain(opts, name)?;
235
236    println!("\nCreated chain `{}`!", name);
237    print_start_hacking_message(config.cwd(), path);
238
239    Ok(())
240}
241
242pub fn new_contract(opts: &NewOptions, config: &Config) -> SubstrateResult<()> {
243    let path = &opts.path;
244    if path.exists() {
245        anyhow::bail!(
246            "destination `{}` already exists\n\n\
247             Use `substrate init` to initialize the directory",
248            path.display()
249        )
250    }
251
252    validate_path(path)?;
253
254    let name = get_name(opts)?;
255    validate_name(name, opts.name.is_none())?;
256
257    validate_rust_installation()?;
258    validate_cargo_contract_installation()?;
259
260    println!("Creating new contract...");
261    create_smart_contract(name, &opts.path)?;
262    mk_contract(opts, name)?;
263
264    print_start_hacking_message(config.cwd(), path);
265
266    Ok(())
267}
268
269/// Gets the latest commit id (SHA1) of the repository given by `path`.
270fn get_git_commit_id(path: &Path) -> String {
271    let commit_id_output = Command::new("git")
272        .current_dir(path)
273        .args(["rev-parse", "HEAD"])
274        .output()
275        .expect("git rev-parse failed")
276        .stdout;
277
278    let commit_id = String::from_utf8_lossy(&commit_id_output);
279
280    let commit_id = commit_id.trim().to_string();
281    commit_id
282}
283
284/// Find all `Cargo.toml` files in the given path.
285fn find_cargo_tomls(path: &Path) -> Vec<PathBuf> {
286    let path = format!("{}/**/Cargo.toml", path.display());
287
288    let glob = glob::glob(&path).expect("Generates globbing pattern");
289
290    let mut result = Vec::new();
291    glob.into_iter().for_each(|file| match file {
292        Ok(file) => result.push(file),
293        Err(e) => println!("{:?}", e),
294    });
295
296    if result.is_empty() {
297        panic!("Did not find any `Cargo.toml` files.");
298    }
299
300    result
301}
302
303/// Find all `.rs` files in the given path.
304fn find_rust_files(path: &Path) -> Vec<PathBuf> {
305    let path = format!("{}/**/*.rs", path.display());
306
307    let glob = glob::glob(&path).expect("Generates globbing pattern");
308
309    let mut result = Vec::new();
310    glob.into_iter().for_each(|file| match file {
311        Ok(file) => result.push(file),
312        Err(e) => println!("{:?}", e),
313    });
314
315    if result.is_empty() {
316        panic!("Did not find any `.rs` files.");
317    }
318
319    result
320}
321
322/// Process and replace dependencies in the provided table.
323/// Replaces 'path' dependencies with 'git' dependencies if the path does not exist.
324fn process_and_replace_dependencies(
325    dependencies: &mut Table,
326    remote: &str,
327    commit_id: &str,
328    cargo_toml_path: &Path,
329) {
330    for (_, dep_value) in dependencies.iter_mut() {
331        if let Some(dep_table) = dep_value.as_inline_table_mut() {
332            if let Some(path_value) = dep_table.get("path").and_then(|p| p.as_str()) {
333                let full_path = cargo_toml_path.join(path_value);
334                if !full_path.exists() {
335                    dep_table.remove("path");
336                    dep_table.insert("git", remote.into());
337                    dep_table.insert("rev", commit_id.into());
338                }
339            }
340            *dep_value = value(dep_table.clone());
341        }
342    }
343}
344
345/// Replaces all non-existent remote path dependencie in Cargo.toml files with a git dependency.
346fn replace_path_dependencies_with_git(
347    cargo_toml_path: &Path,
348    remote: &str,
349    commit_id: &str,
350    cargo_toml: &mut Document,
351) {
352    let mut cargo_toml_path = cargo_toml_path.to_path_buf();
353    // remove `Cargo.toml`
354    cargo_toml_path.pop();
355
356    // Process regular dependency tables
357    for &table in &["dependencies", "build-dependencies", "dev-dependencies"] {
358        if let Some(dependencies) = cargo_toml[table].as_table_mut() {
359            process_and_replace_dependencies(dependencies, remote, commit_id, &cargo_toml_path);
360        }
361    }
362
363    // Process workspace dependency table
364    if let Some(workspace_deps) = cargo_toml
365        .get_mut("workspace")
366        .and_then(|w| w["dependencies"].as_table_mut())
367    {
368        process_and_replace_dependencies(workspace_deps, remote, commit_id, &cargo_toml_path);
369    }
370}
371
372/// Update the top level (workspace) `Cargo.toml` file.
373///
374/// - Adds `profile.release` = `panic = unwind`
375/// - Adds `workspace` definition
376fn update_top_level_cargo_toml(
377    cargo_toml: &mut Document,
378    workspace_members: Vec<&PathBuf>,
379    node_template_generated_folder: &Path,
380) {
381    let mut panic_unwind = Table::new();
382    panic_unwind.insert("panic", value("unwind"));
383
384    let mut profile = Table::new();
385    profile.insert("release", Item::Table(panic_unwind));
386
387    cargo_toml.insert("profile", Item::Table(profile));
388
389    let members = workspace_members
390        .iter()
391        .map(|p| {
392            p.strip_prefix(node_template_generated_folder)
393                .expect("Workspace member is a child of the node template path!")
394                .parent()
395                // We get the `Cargo.toml` paths as workspace members, but for the `members` field
396                // we just need the path.
397                .expect("The given path ends with `Cargo.toml` as file name!")
398                .display()
399                .to_string()
400        })
401        .collect::<Array>();
402
403    // let mut members_section = Table::new();
404    // members_section.insert("members", value(members));
405
406    // cargo_toml.insert("workspace", Item::Table(members_section));
407    cargo_toml
408        .as_table_mut()
409        .entry("workspace")
410        .or_insert(toml_edit::table())
411        .as_table_mut()
412        .unwrap()
413        .insert("members", value(members));
414}
415
416pub fn generate_node_template(template: &Template, path: &Path) -> SubstrateResult<()> {
417    let template_config = if let Template::Custom(template_config_path) = template {
418        load_template_config(template_config_path)?
419    } else {
420        load_template_config(&template.to_string())?
421    };
422
423    Command::new("git")
424        .args([
425            "clone",
426            "--filter=blob:none",
427            "--depth",
428            "1",
429            "--sparse",
430            "--branch",
431            &template_config.branch,
432            &template_config.remote,
433            path.as_os_str()
434                .to_str()
435                .expect("invalid characters in path"),
436        ])
437        .status()?;
438
439    // Get commit id before we mutate the repository
440    let commit_id = get_git_commit_id(path);
441
442    Command::new("git")
443        .current_dir(path)
444        .args(["sparse-checkout", "add", &template_config.template_path])
445        .status()?;
446
447    // Remove .git directory and reinitialize it
448    fs::remove_dir_all(path.join(".git"))?;
449
450    if let Ok(entries) = fs::read_dir(path) {
451        for entry in entries {
452            if let Ok(entry) = entry {
453                let entry_path = entry.path();
454
455                if let Some(file_name) = entry_path.file_name().and_then(|n| n.to_str()) {
456                    if entry_path.is_file()
457                        && !file_name.contains("rustfmt.toml")
458                        && !file_name.contains("Cargo")
459                    {
460                        fs::remove_file(entry_path)?;
461                    }
462                }
463            }
464        }
465    }
466
467    let local_template_path = path.join(template_config.template_path);
468
469    for entry in fs::read_dir(&local_template_path)? {
470        let entry = entry?;
471        let entry_path = entry.path();
472        let relative_path = path.join(entry_path.file_name().unwrap());
473        let dest_path = path.join(relative_path);
474
475        fs::rename(&entry_path, &dest_path)?;
476    }
477
478    fs::remove_dir_all(local_template_path)?;
479
480    let top_level_cargo_toml_path = path.join("Cargo.toml");
481    let mut cargo_tomls = find_cargo_tomls(path);
482
483    // Check if top level Cargo.toml exists. If not, create one in the destination
484    if !cargo_tomls.contains(&top_level_cargo_toml_path) {
485        // create the top_level_cargo_toml
486        OpenOptions::new()
487            .create(true)
488            .write(true)
489            .open(&top_level_cargo_toml_path)
490            .expect("Create root level `Cargo.toml` failed.");
491
492        // push into our data structure
493        cargo_tomls.push(PathBuf::from(&top_level_cargo_toml_path));
494    }
495
496    cargo_tomls.iter().for_each(|t| {
497        let mut cargo_toml = Manifest::new(t.to_path_buf());
498        let mut cargo_toml_document = cargo_toml.read_document().expect("Read Cargo.toml failed.");
499        // println!("cargo_toml_document: {:?}", cargo_toml_document);
500        replace_path_dependencies_with_git(
501            t,
502            &template_config.remote,
503            &commit_id,
504            &mut cargo_toml_document,
505        );
506
507        // Check if this is the top level `Cargo.toml`, as this requires some special treatments.
508        if top_level_cargo_toml_path == t.to_path_buf() {
509            // All workspace member `Cargo.toml` file paths.
510            let workspace_members = cargo_tomls
511                .iter()
512                .filter(|p| **p != top_level_cargo_toml_path)
513                .collect();
514
515            update_top_level_cargo_toml(&mut cargo_toml_document, workspace_members, path);
516        }
517
518        cargo_toml
519            .write_document(cargo_toml_document)
520            .expect("Write Cargo.toml failed.");
521    });
522
523    Ok(())
524}
525
526pub fn validate_cargo_contract_installation() -> SubstrateResult<()> {
527    if which::which("cargo-contract").is_err() {
528        // Install cargo-contract
529        Command::new("cargo")
530            .args(["install", "--force", "--locked", "cargo-contract"])
531            .status()?;
532    }
533
534    Ok(())
535}
536
537pub fn create_smart_contract(name: &str, path: &Path) -> SubstrateResult<()> {
538    let path_binding = PathBuf::from("");
539    let parent = path.parent().unwrap_or(&path_binding);
540    // Recursively create project directory and all of its parent directories if they are missing
541    fs::create_dir_all(parent)?;
542
543    let status = Command::new("cargo-contract")
544        .args(["contract", "new", name, "-t", parent.to_str().unwrap()])
545        .status()?;
546
547    // We have to do this in case the package name is different then its root directory name
548    let dir_from_path = path.file_name().unwrap();
549    if name != dir_from_path {
550        fs::rename(parent.join(name), path)?;
551    }
552
553    if !status.success() {
554        return Err(anyhow::anyhow!("failed to create smart contract"));
555    }
556
557    Ok(())
558}
559
560fn replace_occurrence_in_file(file_path: &Path, original: &str, new: &str) -> SubstrateResult<()> {
561    if file_path.exists() {
562        let file = fs::read_to_string(file_path)?;
563        let new_file = file.replace(original, new);
564        let mut file = OpenOptions::new()
565            .write(true)
566            .truncate(true)
567            .open(file_path)?;
568        file.write_all(new_file.as_bytes())?;
569    }
570    Ok(())
571}
572
573pub fn mk_chain(opts: &NewOptions, name: &str) -> SubstrateResult<()> {
574    let node_path = opts.path.join("node");
575    let node_manifest_path = node_path.join("Cargo.toml");
576    let runtime_path = opts.path.join("runtime");
577    let runtime_manifest_path = runtime_path.join("Cargo.toml");
578    let substrate_manifest_path = opts.path.join("Substrate.toml");
579
580    // TODO:
581    // Consider changing package version as well
582    let original_runtime_package_name =
583        get_package_name(&runtime_path)?.expect("Runtime package name exists");
584    let original_runtime_package_name_snake = to_snake_case(&original_runtime_package_name);
585    let original_node_package_name =
586        get_package_name(&node_path)?.expect("Node package version exists");
587    let _original_node_package_name_snake = to_snake_case(&original_node_package_name);
588    let node_package_name = name.to_string() + "-node";
589    let _node_package_name_snake = to_snake_case(&node_package_name);
590    let runtime_package_name = name.to_string() + "-runtime";
591    let runtime_package_name_snake = to_snake_case(&runtime_package_name);
592
593    let mut node_manifest = Manifest::new(node_manifest_path);
594    let mut node_document = node_manifest.read_document()?;
595
596    let mut runtime_manifest = Manifest::new(runtime_manifest_path);
597    let mut runtime_document = runtime_manifest.read_document()?;
598
599    let mut substrate_manifest = Manifest::new(substrate_manifest_path);
600    let mut substrate_document = Document::new();
601
602    // Replaces all occurences of 'node-template' with node name
603    node_document["package"]["name"] = value(&node_package_name);
604    if node_document.get("bin").is_some() {
605        node_document["bin"][0]["name"] = value(&node_package_name);
606    }
607
608    // TODO:
609    // Insert edited runtime_package_name into the right position under 'packages', instead of at
610    // the bottom:
611    // https://docs.rs/toml_edit/latest/toml_edit/struct.Table.html#method.position
612
613    // Replaces all occurences of `node-template-runtime` with runtime name
614    let item = node_document["dependencies"]
615        .as_table_mut()
616        .unwrap()
617        .remove_entry(&original_runtime_package_name)
618        .unwrap()
619        .1;
620    node_document["dependencies"][&runtime_package_name] = item;
621
622    // Deal with the scenario where node inherts from daddy Cargo.toml
623    let mut root_manifest = Manifest::new(opts.path.join("Cargo.toml"));
624    let mut root_document = root_manifest.read_document()?;
625    if let Some(workspace_deps) = root_document["workspace"]["dependencies"].as_table_mut() {
626        if let Some(mut runtime_dep) = workspace_deps.remove_entry(&original_runtime_package_name) {
627            let dep_table = runtime_dep.1.as_inline_table_mut().unwrap();
628            dep_table.remove("git");
629            dep_table.remove("rev");
630            let path = runtime_path.strip_prefix(&opts.path)?;
631            dep_table.insert("path", path.to_str().unwrap().into());
632            root_document["workspace"]["dependencies"][&runtime_package_name] = runtime_dep.1;
633            root_manifest.write_document(root_document)?;
634        }
635    }
636
637    // Iterate over all features and replace original runtime name with new one
638    for feature in node_document["features"].as_table_mut().unwrap().iter_mut() {
639        for arr in feature.1.as_array_mut().unwrap().iter_mut() {
640            if arr
641                .as_str()
642                .unwrap()
643                .contains(&original_runtime_package_name)
644            {
645                *arr = arr
646                    .as_str()
647                    .unwrap()
648                    .replace(&original_runtime_package_name, &runtime_package_name)
649                    .into();
650            }
651        }
652    }
653
654    runtime_document["package"]["name"] = value(&runtime_package_name);
655
656    let node_rust_files = find_rust_files(&node_path);
657    // let runtime_rust_files = find_rust_files(&runtime_path);
658
659    for file in node_rust_files {
660        replace_occurrence_in_file(
661            &file,
662            &original_runtime_package_name_snake,
663            &runtime_package_name_snake,
664        )?;
665    }
666
667    substrate_document.insert("type", value("chain"));
668
669    // Write changes to files
670    node_manifest.write_document(node_document)?;
671    runtime_manifest.write_document(runtime_document)?;
672    substrate_manifest.write_document(substrate_document)?;
673
674    Ok(())
675}
676
677pub fn mk_contract(opts: &NewOptions, _name: &str) -> SubstrateResult<()> {
678    let substrate_manifest_path = opts.path.join("Substrate.toml");
679    let mut substrate_manifest = Manifest::new(substrate_manifest_path);
680    let mut substrate_document = Document::new();
681
682    substrate_document.insert("type", value("contract"));
683    substrate_manifest.write_document(substrate_document)?;
684
685    Ok(())
686}