Skip to main content

dfx_core/extension/manifest/
extension.rs

1use crate::config::model::project_template::ProjectTemplateCategory;
2use crate::config::project_templates::{ProjectTemplate, ProjectTemplateName, ResourceLocation};
3use crate::error::extension::{
4    ConvertExtensionSubcommandIntoClapArgError, ConvertExtensionSubcommandIntoClapCommandError,
5    LoadExtensionManifestError,
6};
7use crate::extension::manager::ExtensionManager;
8use crate::json::structure::{SerdeVec, VersionReqWithJsonSchema, VersionWithJsonSchema};
9use schemars::JsonSchema;
10use serde::{Deserialize, Deserializer, Serialize, Serializer};
11use serde_json::Value;
12use std::path::PathBuf;
13use std::{
14    collections::{BTreeMap, HashMap},
15    path::Path,
16};
17
18pub static MANIFEST_FILE_NAME: &str = "extension.json";
19const DEFAULT_DOWNLOAD_URL_TEMPLATE: &str = "https://github.com/dfinity/dfx-extensions/releases/download/{{tag}}/{{basename}}.{{archive-format}}";
20
21type SubcmdName = String;
22type ArgName = String;
23
24fn should_skip_serializing_project_templates(
25    project_templates: &Option<HashMap<String, ExtensionProjectTemplate>>,
26) -> bool {
27    project_templates
28        .as_ref()
29        .map(HashMap::is_empty)
30        .unwrap_or(true)
31}
32
33#[derive(Debug, Serialize, Deserialize, JsonSchema)]
34#[serde(deny_unknown_fields)]
35pub struct ExtensionManifest {
36    pub name: String,
37    pub version: VersionWithJsonSchema,
38    pub homepage: String,
39    pub authors: Option<String>,
40    pub summary: String,
41    pub categories: Vec<String>,
42    pub keywords: Option<Vec<String>>,
43    pub description: Option<String>,
44    pub subcommands: Option<ExtensionSubcommandsOpts>,
45    pub dependencies: Option<HashMap<String, ExtensionDependency>>,
46    pub canister_type: Option<ExtensionCanisterType>,
47
48    #[serde(
49        default,
50        skip_serializing_if = "should_skip_serializing_project_templates"
51    )]
52    pub project_templates: Option<HashMap<String, ExtensionProjectTemplate>>,
53
54    /// Components of the download url template are:
55    /// - `{{tag}}`: the tag of the extension release, which will follow the form "<extension name>-v<extension version>"
56    /// - `{{basename}}`: The basename of the release filename, which will follow the form "<extension name>-<arch>-<platform>", for example "nns-x86_64-unknown-linux-gnu"
57    /// - `{{archive-format}}`: the format of the archive, for example "tar.gz"
58    #[serde(
59        default = "default_download_url_template",
60        skip_serializing_if = "Option::is_none"
61    )]
62    pub download_url_template: Option<String>,
63}
64
65fn default_download_url_template() -> Option<String> {
66    Some(DEFAULT_DOWNLOAD_URL_TEMPLATE.to_string())
67}
68
69#[derive(Debug, Serialize, Deserialize, JsonSchema)]
70#[serde(untagged)]
71pub enum ExtensionDependency {
72    /// A SemVer version requirement, for example ">=0.17.0".
73    Version(VersionReqWithJsonSchema),
74}
75
76#[derive(Debug, Serialize, Deserialize, JsonSchema)]
77pub struct ExtensionProjectTemplate {
78    /// The name used for display and sorting
79    pub display: String,
80
81    /// Used to determine which CLI group (`--type`, `--backend`, `--frontend`)
82    /// as well as for interactive selection
83    pub category: ProjectTemplateCategory,
84
85    /// Other project templates to patch in alongside this one
86    pub requirements: Vec<String>,
87
88    /// Run a command after adding the canister to dfx.json
89    pub post_create: SerdeVec<String>,
90
91    /// If set, display a spinner while this command runs
92    pub post_create_spinner_message: Option<String>,
93
94    /// If the post-create command fails, display this warning but don't fail
95    pub post_create_failure_warning: Option<String>,
96}
97
98impl ExtensionManifest {
99    pub fn load(
100        name: &str,
101        extensions_root_dir: &Path,
102    ) -> Result<Self, LoadExtensionManifestError> {
103        let manifest_path = Self::manifest_path(name, extensions_root_dir);
104        let mut m: ExtensionManifest = crate::json::load_json_file(&manifest_path)?;
105        m.name = name.to_string();
106        Ok(m)
107    }
108
109    pub fn exists(name: &str, extensions_root_dir: &Path) -> bool {
110        Self::manifest_path(name, extensions_root_dir).exists()
111    }
112
113    fn manifest_path(name: &str, extensions_root_dir: &Path) -> PathBuf {
114        extensions_root_dir.join(name).join(MANIFEST_FILE_NAME)
115    }
116
117    pub fn download_url_template(&self) -> String {
118        self.download_url_template
119            .clone()
120            .unwrap_or_else(|| DEFAULT_DOWNLOAD_URL_TEMPLATE.to_string())
121    }
122
123    pub fn into_clap_commands(
124        &self,
125    ) -> Result<Vec<clap::Command>, ConvertExtensionSubcommandIntoClapCommandError> {
126        if let Some(sc) = self.subcommands.as_ref() {
127            sc.0.iter()
128                .map(|(subcmd, opts)| opts.as_clap_command(subcmd))
129                .collect::<Result<Vec<_>, _>>()
130        } else {
131            Ok(vec![])
132        }
133    }
134
135    pub fn project_templates(
136        &self,
137        em: &ExtensionManager,
138        builtin_templates: &[ProjectTemplate],
139    ) -> Vec<ProjectTemplate> {
140        let Some(project_templates) = self.project_templates.as_ref() else {
141            return vec![];
142        };
143
144        let extension_dir = em.get_extension_directory(&self.name);
145
146        // the default sort order is after everything built-in
147        let default_sort_order = builtin_templates
148            .iter()
149            .map(|t| t.sort_order)
150            .max()
151            .unwrap_or(0)
152            + 1;
153
154        project_templates
155            .iter()
156            .map(|(name, template)| {
157                let resource_dir = extension_dir.join("project_templates").join(name);
158                let resource_location = ResourceLocation::Directory { path: resource_dir };
159
160                // keep the sort order as a built-in template of the same name,
161                // otherwise put it after everything else
162                let sort_order = builtin_templates
163                    .iter()
164                    .find(|t| t.name == ProjectTemplateName(name.clone()))
165                    .map(|t| t.sort_order)
166                    .unwrap_or(default_sort_order);
167
168                let requirements = template
169                    .requirements
170                    .iter()
171                    .map(|r| ProjectTemplateName(r.clone()))
172                    .collect();
173                ProjectTemplate {
174                    name: ProjectTemplateName(name.clone()),
175                    display: template.display.clone(),
176                    resource_location,
177                    category: template.category.clone(),
178                    requirements,
179                    post_create: template.post_create.clone().into_vec(),
180                    post_create_spinner_message: template.post_create_spinner_message.clone(),
181                    post_create_failure_warning: template.post_create_failure_warning.clone(),
182                    sort_order,
183                }
184            })
185            .collect()
186    }
187}
188
189#[derive(Debug, Serialize, Deserialize, JsonSchema)]
190pub struct ExtensionCanisterType {
191    /// If one field depends on another and both specify a handlebars expression,
192    /// list the fields in the order that they should be evaluated.
193    #[serde(default)]
194    pub evaluation_order: Vec<String>,
195
196    /// Default values for the canister type. These values are used when the user does not provide
197    /// values in dfx.json.
198    /// The "metadata" field, if present, is appended to the metadata field from dfx.json, which
199    /// has the effect of providing defaults.
200    /// The "tech_stack field, if present, it merged with the tech_stack field from dfx.json,
201    /// which also has the effect of providing defaults.
202    #[serde(default)]
203    pub defaults: BTreeMap<String, Value>,
204}
205
206#[derive(Debug, Serialize, Deserialize, Default, JsonSchema)]
207pub struct ExtensionSubcommandsOpts(pub BTreeMap<SubcmdName, ExtensionSubcommandOpts>);
208
209#[derive(Debug, Serialize, Deserialize, JsonSchema)]
210#[serde(deny_unknown_fields)]
211pub struct ExtensionSubcommandOpts {
212    pub about: Option<String>,
213    pub args: Option<BTreeMap<ArgName, ExtensionSubcommandArgOpts>>,
214    pub subcommands: Option<ExtensionSubcommandsOpts>,
215}
216
217#[derive(Debug, Serialize, Deserialize, JsonSchema)]
218#[serde(deny_unknown_fields)]
219pub struct ExtensionSubcommandArgOpts {
220    pub about: Option<String>,
221    pub long: Option<String>,
222    pub short: Option<char>,
223    #[serde(default)]
224    #[deprecated(note = "use `values` instead")]
225    pub multiple: bool,
226    #[serde(default)]
227    pub values: ArgNumberOfValues,
228}
229
230#[derive(Debug, JsonSchema, Eq, PartialEq)]
231pub enum ArgNumberOfValues {
232    /// zero or more values
233    Number(usize),
234    /// non-inclusive range
235    Range(std::ops::Range<usize>),
236    /// unlimited values
237    Unlimited,
238}
239
240impl Default for ArgNumberOfValues {
241    fn default() -> Self {
242        Self::Number(1)
243    }
244}
245
246impl<'de> Deserialize<'de> for ArgNumberOfValues {
247    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
248    where
249        D: Deserializer<'de>,
250    {
251        #[derive(Deserialize)]
252        #[serde(untagged)]
253        enum StrOrUsize<'a> {
254            Str(&'a str),
255            Usize(usize),
256        }
257
258        match StrOrUsize::deserialize(deserializer)? {
259            StrOrUsize::Usize(n) => Ok(Self::Number(n)),
260            StrOrUsize::Str(s) => {
261                if s == "unlimited" {
262                    return Ok(Self::Unlimited);
263                }
264                if s.contains("..=") {
265                    let msg = format!("Inclusive ranges are not supported: {s}");
266                    return Err(serde::de::Error::custom(msg));
267                }
268                if s.contains("..") {
269                    let parts: Vec<&str> = s.split("..").collect();
270                    if let (Ok(start), Ok(end)) =
271                        (parts[0].parse::<usize>(), parts[1].parse::<usize>())
272                    {
273                        return Ok(Self::Range(start..end + 1));
274                    }
275                }
276                Err(serde::de::Error::custom(format!(
277                    "Invalid format for values: '{s}'. Expected 'unlimited' or a positive integer or a range (for example '1..3')"
278                )))
279            }
280        }
281    }
282}
283
284impl Serialize for ArgNumberOfValues {
285    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
286    where
287        S: Serializer,
288    {
289        match self {
290            Self::Number(n) => serializer.serialize_u64(*n as u64),
291            Self::Unlimited => serializer.serialize_str("unlimited"),
292            Self::Range(range) => {
293                let s = format!("{}..{}", range.start, range.end - 1);
294                serializer.serialize_str(&s)
295            }
296        }
297    }
298}
299
300impl ExtensionSubcommandArgOpts {
301    pub fn as_clap_arg(
302        &self,
303        name: &str,
304    ) -> Result<clap::Arg, ConvertExtensionSubcommandIntoClapArgError> {
305        let mut arg = clap::Arg::new(name.to_string());
306        if let Some(about) = &self.about {
307            arg = arg.help(about);
308        } else {
309            return Err(ConvertExtensionSubcommandIntoClapArgError::ExtensionSubcommandArgMissingDescription(
310                name.to_string(),
311            ));
312        }
313        if let Some(l) = &self.long {
314            arg = arg.long(l);
315        }
316        if let Some(s) = &self.short {
317            arg = arg.short(*s);
318        }
319        #[allow(deprecated)]
320        if self.multiple {
321            arg = arg.num_args(0..);
322        } else {
323            arg = match &self.values {
324                ArgNumberOfValues::Number(n) => arg.num_args(*n),
325                ArgNumberOfValues::Range(r) => arg.num_args(r.clone()),
326                ArgNumberOfValues::Unlimited => arg.num_args(0..),
327            };
328        }
329        Ok(arg
330            // let's allow values that start with a hyphen for args (for example, --calculator -2+2)
331            .allow_hyphen_values(true)
332            // don't enforce that args are required
333            .required(false))
334    }
335}
336
337impl ExtensionSubcommandOpts {
338    pub fn as_clap_command(
339        &self,
340        name: &str,
341    ) -> Result<clap::Command, ConvertExtensionSubcommandIntoClapCommandError> {
342        let mut cmd = clap::Command::new(name.to_string());
343
344        if let Some(about) = &self.about {
345            cmd = cmd.about(about);
346        }
347
348        if let Some(args) = &self.args {
349            for (name, opts) in args {
350                cmd = cmd.arg(opts.as_clap_arg(name)?);
351            }
352        }
353
354        if let Some(subcommands) = &self.subcommands {
355            for (name, subcommand) in &subcommands.0 {
356                cmd = cmd.subcommand(subcommand.as_clap_command(name)?);
357            }
358        }
359
360        Ok(cmd)
361    }
362}
363
364#[test]
365fn parse_test_file() {
366    let f = r#"
367{
368  "name": "sns",
369  "version": "0.1.0",
370  "homepage": "https://github.com/dfinity/dfx-extensions",
371  "authors": "DFINITY",
372  "summary": "Toolkit for simulating decentralizing a dapp via SNS.",
373  "categories": [
374    "sns",
375    "nns"
376  ],
377  "dependencies": {
378    "dfx": ">=0.8, <0.9"
379  },
380  "keywords": [
381    "sns",
382    "nns",
383    "deployment"
384  ],
385  "subcommands": {
386    "config": {
387      "about": "About for config command. You're looking at the output of parsing test extension.json.",
388      "subcommands": {
389        "create": {
390          "about": "Command line options for creating an SNS configuration."
391        },
392        "validate": {
393          "about": "Command line options for validating an SNS configuration."
394        }
395      }
396    },
397    "deploy": {
398      "about": "About for deploy command. You're looking at the output of parsing test extension.json."
399    },
400    "import": {
401      "about": "About for import command. You're looking at the output of parsing test extension.json.",
402      "args": {
403        "network_mapping": {
404          "about": "Networks to import canisters ids for.\n  --network-mapping <network name in both places>\n  --network-mapping <network name here>=<network name in project being imported>\nExamples:\n  --network-mapping ic\n  --network-mapping ic=mainnet",
405          "long": "network-mapping"
406        }
407      }
408    },
409    "download": {
410      "about": "About for download command. You're looking at the output of parsing test extension.json.",
411      "args": {
412        "ic_commit": {
413          "about": "IC commit of SNS canister Wasm binaries to download",
414          "long": "ic-commit"
415        },
416        "wasms_dir": {
417          "about": "Path to store downloaded SNS canister Wasm binaries",
418          "long": "wasms-dir"
419        }
420      }
421    },
422    "install": {
423      "about": "About for install command. You're looking at the output of parsing test extension.json.",
424      "args": {
425        "account": {
426          "about": "some arg that accepts multiple values separated by spaces",
427          "long": "account"
428        },
429        "accounts": {
430          "about": "some arg that accepts multiple values separated by spaces",
431          "long": "accounts",
432          "multiple": true
433        },
434        "two-accounts": {
435          "about": "some arg that accepts multiple values separated by spaces",
436          "long": "two-accounts",
437          "values": 2
438        },
439        "two-or-three-accounts": {
440          "about": "some arg that accepts multiple values separated by spaces",
441          "long": "two-or-three-accounts",
442          "values": "2..3"
443        }
444      }
445    },
446    "init-canister": {
447      "about": "About for init-canister command. You're looking at the output of parsing test extension.json.",
448      "args": {
449        "canister_id": {
450          "about": "some arg that accepts multiple values separated by spaces"
451        }
452      }
453    },
454    "init-canisters": {
455      "about": "About for init-canisters command. You're looking at the output of parsing test extension.json.",
456      "args": {
457        "canister_ids": {
458          "about": "some arg that accepts multiple values separated by spaces",
459          "values": "unlimited"
460        }
461      }
462    },
463    "init-two-canisters": {
464      "about": "About for init-two-canisters command. You're looking at the output of parsing test extension.json.",
465      "args": {
466        "canister_ids": {
467          "about": "some arg that accepts multiple values separated by spaces",
468          "values": 2
469        }
470      }
471    },
472    "init-two-or-three-canisters": {
473      "about": "About for init-two-or-three-canisters command. You're looking at the output of parsing test extension.json.",
474      "args": {
475        "canister_ids": {
476          "about": "some arg that accepts multiple values separated by spaces",
477          "values": "2..3"
478        }
479      }
480    }
481  }
482}
483"#;
484    macro_rules! test_cmd {
485        ($cmd:expr, [$($cmds:expr),*], $arg_name:expr => [$($expected:expr),*]) => {{
486            let commands = vec![$($cmds),*];
487            let expected_values: Vec<&str> = vec![$($expected),*];
488            let matches = $cmd.clone().get_matches_from(commands);
489            let output = matches
490                .get_many::<String>(&$arg_name)
491                .unwrap()
492                .map(|s| s.as_str())
493                .collect::<Vec<&str>>();
494            assert_eq!(expected_values, output, "Arg: {}", $arg_name);
495        }};
496        ($cmd:expr, [$($cmds:expr),*], $err_kind:expr) => {{
497            let commands = vec![$($cmds),*];
498            let matches = dbg!($cmd.clone().try_get_matches_from(commands));
499            assert_eq!(matches.as_ref().map_err(|e| e.kind()), Err($err_kind));
500        }};
501    }
502
503    let m: Result<ExtensionManifest, serde_json::Error> = dbg!(serde_json::from_str(f));
504    assert!(m.is_ok());
505    let manifest = m.unwrap();
506
507    let dependencies = manifest.dependencies.as_ref().unwrap();
508    let dfx_dep = dependencies.get("dfx").unwrap();
509    let ExtensionDependency::Version(req) = dfx_dep;
510    assert!(req.matches(&semver::Version::new(0, 8, 5)));
511    assert!(!req.matches(&semver::Version::new(0, 9, 0)));
512
513    let mut subcmds = dbg!(manifest.into_clap_commands().unwrap());
514
515    use clap::error::ErrorKind::*;
516    for c in &mut subcmds {
517        c.print_long_help().unwrap();
518        match c.get_name() {
519            subcmd @ "download" => {
520                test_cmd!(c, [subcmd, "--ic-commit", "C"], "ic_commit" => ["C"]);
521                test_cmd!(c, [subcmd, "--ic-commit", "c1", "c2"], UnknownArgument);
522                test_cmd!(c, [subcmd, "--dosent-extist", "c1", "c2"], UnknownArgument);
523            }
524            #[rustfmt::skip]
525            subcmd @ "install" => {
526                test_cmd!(c, [subcmd, "--account", "A"], "account" => ["A"]);
527                test_cmd!(c, [subcmd, "--account", "A", "B"], UnknownArgument);
528                test_cmd!(c, [subcmd, "--accounts"], "accounts" => []);
529                test_cmd!(c, [subcmd, "--accounts", "A", "B"], "accounts" => ["A", "B"]);
530                test_cmd!(c, [subcmd, "--two-accounts", "A"], WrongNumberOfValues);
531                test_cmd!(c, [subcmd, "--two-accounts", "A", "B"], "two-accounts" => ["A", "B"]);
532                test_cmd!(c, [subcmd, "--two-accounts", "A", "B", "C"], UnknownArgument);
533                test_cmd!(c, [subcmd, "--two-or-three-accounts", "A"], TooFewValues);
534                test_cmd!(c, [subcmd, "--two-or-three-accounts", "A", "B"], "two-or-three-accounts" => ["A", "B"]);
535                test_cmd!(c, [subcmd, "--two-or-three-accounts", "A", "B", "C"], "two-or-three-accounts" => ["A", "B", "C"]);
536                test_cmd!(c, [subcmd, "--two-or-three-accounts", "A", "B", "C", "D"], UnknownArgument);
537            }
538            subcmd @ "init-canister" => {
539                test_cmd!(c, [subcmd, "x1"], "canister_id" => ["x1"]);
540                test_cmd!(c, [subcmd, "x1", "x2"], UnknownArgument);
541            }
542            subcmd @ "init-canisters" => {
543                test_cmd!(c, [subcmd, "y1", "y2", "y3", "y4", "y5"], "canister_ids" => ["y1", "y2", "y3", "y4", "y5"]);
544            }
545            subcmd @ "init-two-canisters" => {
546                test_cmd!(c, [subcmd, "z1"], WrongNumberOfValues);
547                test_cmd!(c, [subcmd, "z1", "z2"], "canister_ids" => ["z1", "z2"]);
548            }
549            subcmd @ "init-two-or-three-canisters" => {
550                test_cmd!(c, [subcmd, "1"], TooFewValues);
551                test_cmd!(c, [subcmd, "1", "2"], "canister_ids" => ["1", "2"]);
552                test_cmd!(c, [subcmd, "1", "2", "3"], "canister_ids" => ["1", "2", "3"]);
553                test_cmd!(c, [subcmd, "1", "2", "3", "4"], TooManyValues);
554            }
555            _ => {}
556        }
557    }
558    clap::Command::new("sns")
559        .subcommands(&subcmds)
560        .print_help()
561        .unwrap();
562    clap::Command::new("sns")
563        .subcommands(&subcmds)
564        .debug_assert();
565}
566
567#[cfg(test)]
568mod tests {
569    use super::*;
570    use serde_json;
571
572    #[test]
573    fn test_arg_number_of_values_number_serialization_deserialization() {
574        let original = ArgNumberOfValues::Number(5);
575        let serialized = serde_json::to_string(&original).unwrap();
576        let deserialized: ArgNumberOfValues = serde_json::from_str(&serialized).unwrap();
577
578        assert_eq!(serialized, "5");
579        assert_eq!(deserialized, ArgNumberOfValues::Number(5));
580        assert_eq!(original, deserialized);
581    }
582
583    #[test]
584    fn test_arg_number_of_values_unlimited_serialization_deserialization() {
585        let original = ArgNumberOfValues::Unlimited;
586        let serialized = serde_json::to_string(&original).unwrap();
587        let deserialized: ArgNumberOfValues = serde_json::from_str(&serialized).unwrap();
588
589        assert_eq!(serialized, "\"unlimited\"");
590        assert_eq!(deserialized, ArgNumberOfValues::Unlimited);
591        assert_eq!(original, deserialized);
592    }
593
594    #[test]
595    fn test_arg_number_of_values_range_serialization_deserialization() {
596        let original = ArgNumberOfValues::Range(1..4);
597        let serialized = serde_json::to_string(&original).unwrap();
598        let deserialized: ArgNumberOfValues = serde_json::from_str(&serialized).unwrap();
599
600        assert_eq!(serialized, "\"1..3\"");
601        assert_eq!(deserialized, ArgNumberOfValues::Range(1_usize..4_usize));
602        assert_eq!(original, deserialized);
603    }
604
605    #[test]
606    fn tolerant_to_no_project_templates() {
607        let f = r#"
608          {
609            "name": "sns",
610            "version": "0.4.7",
611            "homepage": "https://github.com/dfinity/dfx-extensions",
612            "authors": "DFINITY",
613            "summary": "Initialize, deploy and interact with an SNS",
614            "categories": [
615              "sns",
616              "nns"
617            ],
618            "keywords": [
619              "sns",
620              "nns",
621              "deployment"
622            ],
623            "description": null,
624            "subcommands": {
625              "add-sns-wasm-for-tests": {
626                "about": "Add a wasms for one of the SNS canisters, skipping the NNS proposal, for tests",
627                "args": {
628                  "canister_type": {
629                    "about": "The type of the canister that the wasm is for. Must be one of \"archive\", \"root\", \"governance\", \"ledger\", \"swap\", \"index\"",
630                    "long": null,
631                    "short": null,
632                    "multiple": false,
633                    "values": 1
634                  }
635                },
636                "subcommands": null
637              }
638            },
639            "dependencies": {
640              "dfx": ">=0.17.0"
641            },
642            "canister_type": null,
643            "download_url_template": "https://github.com/dfinity/dfx-extensions/releases/download/{{tag}}/{{basename}}.{{archive-format}}"
644          }"#;
645        let manifest: ExtensionManifest = serde_json::from_str(f).unwrap();
646        assert!(manifest.project_templates.is_none());
647
648        // now let's serialize it and check that "project_templates" is not present in the output
649        let serialized = serde_json::to_string(&manifest).unwrap();
650        assert!(!serialized.contains("project_templates"));
651    }
652}