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 #[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 Version(VersionReqWithJsonSchema),
74}
75
76#[derive(Debug, Serialize, Deserialize, JsonSchema)]
77pub struct ExtensionProjectTemplate {
78 pub display: String,
80
81 pub category: ProjectTemplateCategory,
84
85 pub requirements: Vec<String>,
87
88 pub post_create: SerdeVec<String>,
90
91 pub post_create_spinner_message: Option<String>,
93
94 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 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 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 #[serde(default)]
194 pub evaluation_order: Vec<String>,
195
196 #[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 Number(usize),
234 Range(std::ops::Range<usize>),
236 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 .allow_hyphen_values(true)
332 .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 let serialized = serde_json::to_string(&manifest).unwrap();
650 assert!(!serialized.contains("project_templates"));
651 }
652}