1use crate::deserialize::*;
2use crate::dofigen_struct::*;
3use regex::Regex;
4use serde::de::{Error as DeError, value::Error};
5use std::str::FromStr;
6use struct_patch::Patch;
7use url::Url;
8
9const GIT_HTTP_REPO_REGEX: &str = "https?://(?:.+@)?[a-zA-Z0-9_-]+(?:\\.[a-zA-Z0-9_-]+)+/[a-zA-Z0-9_-]+/[a-zA-Z0-9_-]+\\.git(?:#[a-zA-Z0-9_/.-]*(?::[a-zA-Z0-9_/-]+)?)?";
10const GIT_SSH_REPO_REGEX: &str = "[a-zA-Z0-9_-]+@[a-zA-Z0-9_-]+(?:\\.[a-zA-Z0-9_-]+)+:[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+(?:#[a-zA-Z0-9_/.-]+)?(?::[a-zA-Z0-9_/-]+)?";
11const URL_REGEX: &str = "https?://(?:.+@)?[a-zA-Z0-9_-]+(?:\\.[a-zA-Z0-9_-]+)+(/[a-zA-Z0-9_.-]+)*";
12
13macro_rules! impl_parsable_patch {
14 ($struct:ty, $patch:ty, $param:ident, $expression:expr_2021) => {
15 impl Patch<ParsableStruct<$patch>> for $struct {
16 fn apply(&mut self, patch: ParsableStruct<$patch>) {
17 self.apply(patch.0);
18 }
19
20 fn into_patch(self) -> ParsableStruct<$patch> {
21 ParsableStruct(self.into_patch())
22 }
23
24 fn into_patch_by_diff(self, previous_struct: Self) -> ParsableStruct<$patch> {
25 ParsableStruct(self.into_patch_by_diff(previous_struct))
26 }
27
28 fn new_empty_patch() -> ParsableStruct<$patch> {
29 ParsableStruct(Self::new_empty_patch())
30 }
31 }
32
33 impl From<ParsableStruct<$patch>> for $struct {
34 fn from(value: ParsableStruct<$patch>) -> Self {
35 value.0.into()
36 }
37 }
38
39 impl FromStr for $patch {
40 type Err = Error;
41
42 fn from_str($param: &str) -> std::result::Result<Self, Self::Err> {
43 $expression
44 }
45 }
46 };
47}
48
49impl_parsable_patch!(ImageName, ImageNamePatch, s, {
50 let regex = Regex::new(r"^(?:(?<host>[^:\/.]+(?:\.[^:\/.]+)+)(?::(?<port>\d{1,5}))?\/)?(?<path>[a-zA-Z0-9-]{1,63}(?:\/[a-zA-Z0-9-]{1,63})*)(?:(?<version_char>[:@])(?<version_value>[a-zA-Z0-9_.:-]{1,128}))?$").unwrap();
51 let Some(captures) = regex.captures(s) else {
52 return Err(Error::custom(format!(
53 "String '{}' is not matching image name pattern",
54 s
55 )));
56 };
57 Ok(ImageNamePatch {
58 host: Some(captures.name("host").map(|m| m.as_str().into())),
59 port: Some(captures.name("port").map(|m| m.as_str().parse().unwrap())),
60 path: Some(captures["path"].into()),
61 version: Some(
62 match (
63 captures.name("version_char").map(|m| m.as_str()),
64 captures.name("version_value"),
65 ) {
66 (Some(":"), Some(value)) => Some(ImageVersion::Tag(value.as_str().into())),
67 (Some("@"), Some(value)) => Some(ImageVersion::Digest(value.as_str().into())),
68 (None, None) => None,
69 _ => return Err(Error::custom("Invalid version format")),
70 },
71 ),
72 platform: Some(None),
73 })
74});
75
76impl_parsable_patch!(CopyResource, CopyResourcePatch, s, {
77 let parts_regex = format!(
78 r#"^(?:(?<git>(?:{git_http}|"{git_http}"|'{git_http}'|{git_ssh}|"{git_ssh}"|'{git_ssh}'))|(?<url>{url}|"{url}"|'{url}')|\S+)(?: (?:"\S+"|'\S+'|\S+))*(?: (?:"\S+"|'\S+'|\S+))?$"#,
79 git_http = GIT_HTTP_REPO_REGEX,
80 git_ssh = GIT_SSH_REPO_REGEX,
81 url = URL_REGEX
82 );
83 let regex = Regex::new(parts_regex.as_str()).unwrap();
84 let Some(captures) = regex.captures(s) else {
85 return Err(Error::custom("Not matching copy resources pattern"));
86 };
87 if captures.name("git").is_some() {
88 return Ok(CopyResourcePatch::AddGitRepo(s.parse().unwrap()));
89 }
90 if captures.name("url").is_some() {
91 return Ok(CopyResourcePatch::Add(s.parse().unwrap()));
92 }
93 Ok(CopyResourcePatch::Copy(s.parse().unwrap()))
94});
95
96impl_parsable_patch!(Copy, CopyPatch, s, {
97 let mut parts: Vec<String> = collect_path_list(s);
98 let target = if parts.len() > 1 { parts.pop() } else { None };
99 Ok(Self {
100 paths: Some(parts.into_patch()),
101 options: Some(CopyOptionsPatch {
102 target: Some(target),
103 chmod: Some(None),
104 chown: Some(None),
105 link: Some(None),
106 }),
107 from: Some(FromContextPatch::default()),
108 exclude: Some(VecPatch::default()),
109 parents: Some(None),
110 })
111});
112
113impl_parsable_patch!(AddGitRepo, AddGitRepoPatch, s, {
114 let paths = collect_path_list(s);
115 let (repo, target) = match paths.as_slice() {
116 [repo, target] => (repo, Some(target)),
117 [repo] => (repo, None),
118 _ => return Err(Error::custom("Invalid add git repo format")),
119 };
120 Ok(Self {
121 repo: Some(repo.clone()),
122 options: Some(CopyOptionsPatch {
123 target: Some(target.cloned()),
124 chmod: Some(None),
125 chown: Some(None),
126 link: Some(None),
127 }),
128 keep_git_dir: Some(None),
129 checksum: Some(None),
130 exclude: Some(VecPatch::default()),
131 })
132});
133
134impl_parsable_patch!(Add, AddPatch, s, {
135 let mut parts = collect_path_list(s);
136 let target = if parts.len() > 1 { parts.pop() } else { None };
137 let parts: Vec<_> = parts
138 .iter()
139 .map(|s| {
140 Url::parse(s)
141 .map(Resource::Url)
142 .ok()
143 .unwrap_or(Resource::File(s.into()))
144 })
145 .collect();
146 Ok(Self {
147 files: Some(parts.into_patch()),
148 options: Some(CopyOptionsPatch {
149 target: Some(target),
150 chmod: Some(None),
151 chown: Some(None),
152 link: Some(None),
153 }),
154 checksum: Some(None),
155 unpack: Some(None),
156 })
157});
158
159fn collect_path_list(s: &str) -> Vec<String> {
160 let regex = Regex::new(r#"(?:"(?:\\"|[^"])*"|'(?:\\'|[^'])*'|\S+)"#).unwrap();
161 regex
162 .find_iter(s)
163 .map(|m| {
164 let mut m = m.as_str().to_string();
165 let first = m.chars().next().unwrap();
166 if first == '"' || first == '\'' {
167 m = m[1..m.len() - 1].to_string();
169 while let Some(pos) = m.find(format!("\\{}", first).as_str()) {
170 m = format!("{}{}", &m[..pos], &m[pos + 1..]);
172 }
173 } else {
174 while let Some(pos) = m.find("\\\"").or(m.find("\\'")) {
175 m = format!("{}{}", &m[..pos], &m[pos + 1..]);
177 }
178 }
179 m
180 })
181 .collect()
182}
183
184impl_parsable_patch!(User, UserPatch, s, {
185 let regex = Regex::new(r"^(?<user>[a-zA-Z0-9_-]+)(?::(?<group>[a-zA-Z0-9_-]+))?$").unwrap();
186 let Some(captures) = regex.captures(s) else {
187 return Err(Error::custom("Not matching chown pattern"));
188 };
189 Ok(Self {
190 user: Some(captures["user"].into()),
191 group: Some(captures.name("group").map(|m| m.as_str().into())),
192 })
193});
194
195impl_parsable_patch!(Port, PortPatch, s, {
196 let regex = Regex::new(r"^(?<port>\d+)(?:/(?<protocol>(tcp|udp)))?$").unwrap();
197 let Some(captures) = regex.captures(s) else {
198 return Err(Error::custom("Not matching chown pattern"));
199 };
200 Ok(Self {
201 port: Some(captures["port"].parse().map_err(Error::custom)?),
202 protocol: Some(captures.name("protocol").map(|m| match m.as_str() {
203 "tcp" => PortProtocol::Tcp,
204 "udp" => PortProtocol::Udp,
205 _ => unreachable!(),
206 })),
207 })
208});
209
210impl_parsable_patch!(Bind, BindPatch, s, {
211 let regex = Regex::new(r"^(?:(?:(?P<fromType>image|builder|context)\((?P<from>[^:]+)\):)?(?P<source>\S+) )?(?P<target>\S+)$").unwrap();
212 let Some(captures) = regex.captures(s) else {
213 return Err(Error::custom("Not matching bind pattern"));
214 };
215
216 let target = Some(captures["target"].to_string());
217 Ok(Self {
218 source: Some(
219 captures
220 .name("source")
221 .map(|m| m.as_str().into())
222 .or(target.clone()),
223 ),
224 target,
225 from: captures.name("from").map(|m| {
226 let from_type = captures.name("fromType").map(|m| m.as_str()).unwrap();
227 let from = m.as_str();
228 match from_type {
229 "image" => {
230 FromContextPatch::FromImage(ImageNamePatch::from_str(from).unwrap().into())
231 }
232 "builder" => FromContextPatch::FromBuilder(from.into()),
233 "context" => FromContextPatch::FromContext(Some(from.into())),
234 _ => unreachable!(),
235 }
236 }),
237
238 readwrite: Some(None),
239 })
240});
241
242impl_parsable_patch!(Cache, CachePatch, s, {
243 let regex = Regex::new(r"^(?:(?:(?P<fromType>image|builder|context)\((?P<from>[^:]+)\):)?(?P<source>\S+) )?(?P<target>\S+)$").unwrap();
244 let Some(captures) = regex.captures(s) else {
245 return Err(Error::custom("Not matching bind pattern"));
246 };
247
248 let target = Some(captures["target"].to_string());
249 Ok(Self {
250 source: Some(captures.name("source").map(|m| m.as_str().into())),
251 target,
252 from: captures.name("from").map(|m| {
253 let from_type = captures.name("fromType").map(|m| m.as_str()).unwrap();
254 let from = m.as_str();
255 match from_type {
256 "image" => {
257 FromContextPatch::FromImage(ImageNamePatch::from_str(from).unwrap().into())
258 }
259 "builder" => FromContextPatch::FromBuilder(from.into()),
260 "context" => FromContextPatch::FromContext(Some(from.into())),
261 _ => unreachable!(),
262 }
263 }),
264 chmod: Some(None),
265 chown: Some(None),
266 id: Some(None),
267 readonly: Some(None),
268 sharing: Some(None),
269 })
270});
271
272impl_parsable_patch!(TmpFs, TmpFsPatch, s, {
273 Ok(Self {
274 target: Some(s.into()),
275 size: Some(None),
276 })
277});
278
279#[cfg(test)]
280mod test {
281 use super::*;
282 use pretty_assertions_sorted::assert_eq_sorted;
283 mod from_str {
284 use super::*;
285
286 mod image_name {
287 use pretty_assertions_sorted::assert_eq_sorted;
288
289 use super::*;
290
291 #[test]
292 fn simple() {
293 let input = "example/image";
294 let result = ImageNamePatch::from_str(input).unwrap();
295 assert_eq_sorted!(result.host, Some(None));
296 assert_eq_sorted!(result.path, Some("example/image".into()));
297 assert_eq_sorted!(result.port, Some(None));
298 assert_eq_sorted!(result.version, Some(None));
299 }
300
301 #[test]
302 fn with_host() {
303 let input = "docker.io/example/image";
304 let result = ImageNamePatch::from_str(input).unwrap();
305 assert_eq_sorted!(result.host, Some(Some("docker.io".into())));
306 assert_eq_sorted!(result.path, Some("example/image".into()));
307 assert_eq_sorted!(result.port, Some(None));
308 assert_eq_sorted!(result.version, Some(None));
309 }
310
311 #[test]
312 fn with_tag() {
313 let input = "example/image:tag";
314 let result = ImageNamePatch::from_str(input).unwrap();
315 assert_eq_sorted!(result.host, Some(None));
316 assert_eq_sorted!(result.path, Some("example/image".into()));
317 assert_eq_sorted!(result.port, Some(None));
318 assert_eq_sorted!(result.version, Some(Some(ImageVersion::Tag("tag".into()))));
319 }
320
321 #[test]
322 fn with_digest() {
323 let input = "example/image@sha256:my-sha";
324 let result = ImageNamePatch::from_str(input).unwrap();
325 assert_eq_sorted!(result.host, Some(None));
326 assert_eq_sorted!(result.path, Some("example/image".into()));
327 assert_eq_sorted!(result.port, Some(None));
328 assert_eq_sorted!(
329 result.version,
330 Some(Some(ImageVersion::Digest("sha256:my-sha".into())))
331 );
332 }
333
334 #[test]
335 fn full() {
336 let input = "registry.my-host.io:5001/example/image:stable";
337 let result = ImageNamePatch::from_str(input).unwrap();
338 assert_eq_sorted!(result.host, Some(Some("registry.my-host.io".into())));
339 assert_eq_sorted!(result.path, Some("example/image".into()));
340 assert_eq_sorted!(result.port, Some(Some(5001)));
341 assert_eq_sorted!(
342 result.version,
343 Some(Some(ImageVersion::Tag("stable".into())))
344 );
345 }
346
347 #[test]
348 fn with_arg() {
349 let input = "example/image:${VERSION}";
350 let result = ImageNamePatch::from_str(input);
351 assert!(result.is_err());
352 }
353 }
354
355 mod copy {
356
357 use super::*;
358
359 #[test]
360 fn simple() {
361 let result = CopyPatch::from_str("src").unwrap();
362 assert_eq_sorted!(
363 result,
364 CopyPatch {
365 paths: Some(vec!["src".to_string()].into_patch()),
366 options: Some(CopyOptionsPatch {
367 target: Some(None),
368 chown: Some(None),
369 chmod: Some(None),
370 link: Some(None),
371 }),
372 from: Some(FromContextPatch::default()),
373 exclude: Some(VecPatch::default()),
374 parents: Some(None),
375 }
376 );
377 }
378
379 #[test]
380 fn with_target_option() {
381 let result = CopyPatch::from_str("src /app").unwrap();
382 assert_eq_sorted!(
383 result,
384 CopyPatch {
385 paths: Some(vec!["src".to_string()].into_patch()),
386 options: Some(CopyOptionsPatch {
387 target: Some(Some("/app".into())),
388 chown: Some(None),
389 chmod: Some(None),
390 link: Some(None),
391 }),
392 from: Some(FromContextPatch::default()),
393 exclude: Some(VecPatch::default()),
394 parents: Some(None),
395 }
396 );
397 }
398
399 #[test]
400 fn with_multiple_sources_and_target() {
401 let result = CopyPatch::from_str("src1 src2 /app").unwrap();
402 assert_eq_sorted!(
403 result,
404 CopyPatch {
405 paths: Some(vec!["src1".to_string(), "src2".to_string()].into_patch()),
406 options: Some(CopyOptionsPatch {
407 target: Some(Some("/app".into())),
408 chown: Some(None),
409 chmod: Some(None),
410 link: Some(None),
411 }),
412 from: Some(FromContextPatch::default()),
413 exclude: Some(VecPatch::default()),
414 parents: Some(None),
415 }
416 );
417 }
418 }
419
420 mod add_git_repo {
421
422 use super::*;
423
424 #[test]
425 fn ssh() {
426 let result =
427 AddGitRepoPatch::from_str("git@github.com:lenra-io/dofigen.git").unwrap();
428 assert_eq_sorted!(
429 result,
430 AddGitRepoPatch {
431 repo: Some("git@github.com:lenra-io/dofigen.git".into()),
432 options: Some(CopyOptionsPatch {
433 target: Some(None),
434 chown: Some(None),
435 chmod: Some(None),
436 link: Some(None),
437 }),
438 keep_git_dir: Some(None),
439 exclude: Some(VecPatch::default()),
440 checksum: Some(None),
441 }
442 );
443 }
444
445 #[test]
446 fn ssh_with_target() {
447 let result =
448 AddGitRepoPatch::from_str("git@github.com:lenra-io/dofigen.git /app").unwrap();
449 assert_eq_sorted!(
450 result,
451 AddGitRepoPatch {
452 repo: Some("git@github.com:lenra-io/dofigen.git".into()),
453 options: Some(CopyOptionsPatch {
454 target: Some(Some("/app".into())),
455 chown: Some(None),
456 chmod: Some(None),
457 link: Some(None),
458 }),
459 keep_git_dir: Some(None),
460 exclude: Some(VecPatch::default()),
461 checksum: Some(None),
462 }
463 );
464 }
465
466 #[test]
467 fn http() {
468 let result =
469 AddGitRepoPatch::from_str("https://github.com/lenra-io/dofigen.git").unwrap();
470 assert_eq_sorted!(
471 result,
472 AddGitRepoPatch {
473 repo: Some("https://github.com/lenra-io/dofigen.git".into()),
474 options: Some(CopyOptionsPatch {
475 target: Some(None),
476 chown: Some(None),
477 chmod: Some(None),
478 link: Some(None),
479 }),
480 keep_git_dir: Some(None),
481 exclude: Some(VecPatch::default()),
482 checksum: Some(None),
483 }
484 );
485 }
486
487 #[test]
488 fn http_with_target() {
489 let result =
490 AddGitRepoPatch::from_str("https://github.com/lenra-io/dofigen.git /app")
491 .unwrap();
492 assert_eq_sorted!(
493 result,
494 AddGitRepoPatch {
495 repo: Some("https://github.com/lenra-io/dofigen.git".into()),
496 options: Some(CopyOptionsPatch {
497 target: Some(Some("/app".into())),
498 chown: Some(None),
499 chmod: Some(None),
500 link: Some(None),
501 }),
502 keep_git_dir: Some(None),
503 exclude: Some(VecPatch::default()),
504 checksum: Some(None),
505 }
506 );
507 }
508 }
509
510 mod add {
511 use struct_patch::Patch;
512
513 use crate::{CopyOptions, Resource};
514
515 use super::*;
516
517 #[test]
518 fn simple() {
519 let result =
520 AddPatch::from_str("https://github.com/lenra-io/dofigen/raw/main/README.md")
521 .unwrap();
522 assert_eq_sorted!(
523 result,
524 Add {
525 files: vec![Resource::Url(
526 "https://github.com/lenra-io/dofigen/raw/main/README.md"
527 .parse()
528 .unwrap()
529 )],
530 options: CopyOptions::default(),
531 ..Default::default()
532 }
533 .into_patch()
534 );
535 }
536
537 #[test]
538 fn with_target_option() {
539 let result = AddPatch::from_str(
540 "https://github.com/lenra-io/dofigen/raw/main/README.md /app",
541 )
542 .unwrap();
543 assert_eq_sorted!(
544 result,
545 Add {
546 files: vec![Resource::Url(
547 "https://github.com/lenra-io/dofigen/raw/main/README.md"
548 .parse()
549 .unwrap()
550 )],
551 options: CopyOptions {
552 target: Some("/app".into()),
553 ..Default::default()
554 },
555 ..Default::default()
556 }
557 .into_patch()
558 );
559 }
560
561 #[test]
562 fn with_multiple_sources_and_target() {
563 let result = AddPatch::from_str("https://github.com/lenra-io/dofigen/raw/main/README.md https://github.com/lenra-io/dofigen/raw/main/LICENSE /app").unwrap();
564 assert_eq_sorted!(
565 result,
566 Add {
567 files: vec![
568 Resource::Url(
569 "https://github.com/lenra-io/dofigen/raw/main/README.md"
570 .parse()
571 .unwrap()
572 ),
573 Resource::Url(
574 "https://github.com/lenra-io/dofigen/raw/main/LICENSE"
575 .parse()
576 .unwrap()
577 )
578 ],
579 options: CopyOptions {
580 target: Some("/app".into()),
581 ..Default::default()
582 },
583 ..Default::default()
584 }
585 .into_patch()
586 );
587 }
588 }
589
590 mod copy_resources {
591 use super::*;
592
593 #[test]
594 fn copy() {
595 let result = CopyResourcePatch::from_str("src").unwrap();
596 assert_eq_sorted!(
597 result,
598 CopyResourcePatch::Copy(CopyPatch::from_str("src").unwrap())
599 );
600 }
601
602 #[test]
603 fn add_git_repo_ssh() {
604 let result =
605 CopyResourcePatch::from_str("git@github.com:lenra-io/dofigen.git target")
606 .unwrap();
607 assert_eq_sorted!(
608 result,
609 CopyResourcePatch::AddGitRepo(
610 AddGitRepoPatch::from_str("git@github.com:lenra-io/dofigen.git target")
611 .unwrap()
612 )
613 );
614 }
615
616 #[test]
617 fn add_quoted_git_repo_ssh() {
618 let result =
619 CopyResourcePatch::from_str(r#""git@github.com:lenra-io/dofigen.git" target"#)
620 .unwrap();
621 assert_eq_sorted!(
622 result,
623 CopyResourcePatch::AddGitRepo(
624 AddGitRepoPatch::from_str("git@github.com:lenra-io/dofigen.git target")
625 .unwrap()
626 )
627 );
628 }
629
630 #[test]
631 fn add_git_repo_http() {
632 let result =
633 CopyResourcePatch::from_str("https://github.com/lenra-io/dofigen.git target")
634 .unwrap();
635 assert_eq_sorted!(
636 result,
637 CopyResourcePatch::AddGitRepo(
638 AddGitRepoPatch::from_str("https://github.com/lenra-io/dofigen.git target")
639 .unwrap()
640 )
641 );
642 }
643
644 #[test]
645 fn add_quoted_git_repo_http() {
646 let result =
647 CopyResourcePatch::from_str("'https://github.com/lenra-io/dofigen.git' target")
648 .unwrap();
649 assert_eq_sorted!(
650 result,
651 CopyResourcePatch::AddGitRepo(
652 AddGitRepoPatch::from_str("https://github.com/lenra-io/dofigen.git target")
653 .unwrap()
654 )
655 );
656 }
657
658 #[test]
659 fn add() {
660 let result = CopyResourcePatch::from_str(
661 "https://github.com/lenra-io/dofigen/raw/main/README.md",
662 )
663 .unwrap();
664 assert_eq_sorted!(
665 result,
666 CopyResourcePatch::Add(
667 AddPatch::from_str(
668 "https://github.com/lenra-io/dofigen/raw/main/README.md"
669 )
670 .unwrap()
671 )
672 );
673 }
674 }
675
676 mod user {
677 use pretty_assertions_sorted::assert_eq_sorted;
678
679 use super::*;
680
681 #[test]
682 fn user() {
683 let result = UserPatch::from_str("user").unwrap();
684
685 assert_eq_sorted!(result.user, Some("user".into()));
686 assert_eq_sorted!(result.group, Some(None));
687 }
688
689 #[test]
690 fn with_group() {
691 let result = UserPatch::from_str("user:group").unwrap();
692
693 assert_eq_sorted!(result.user, Some("user".into()));
694 assert_eq_sorted!(result.group, Some(Some("group".into())));
695 }
696
697 #[test]
698 fn uid() {
699 let result = UserPatch::from_str("1000").unwrap();
700
701 assert_eq_sorted!(result.user, Some("1000".into()));
702 assert_eq_sorted!(result.group, Some(None));
703 }
704
705 #[test]
706 fn uid_with_gid() {
707 let result = UserPatch::from_str("1000:1000").unwrap();
708
709 assert_eq_sorted!(result.user, Some("1000".into()));
710 assert_eq_sorted!(result.group, Some(Some("1000".into())));
711 }
712
713 #[test]
714 fn invalid_username() {
715 let result = UserPatch::from_str("user*name");
716
717 assert!(result.is_err());
718 }
719
720 #[test]
721 fn invalid_extra() {
722 let result = UserPatch::from_str("user:group:extra");
723
724 assert!(result.is_err());
725 }
726 }
727
728 mod port {
729
730 use super::*;
731
732 #[test]
733 fn simple() {
734 let result = PortPatch::from_str("80").unwrap();
735
736 assert_eq_sorted!(result.port, Some(80));
737 assert_eq_sorted!(result.protocol, Some(None));
738 }
739
740 #[test]
741 fn with_tcp_protocol() {
742 let result = PortPatch::from_str("80/tcp").unwrap();
743
744 assert_eq_sorted!(result.port, Some(80));
745 assert_eq_sorted!(result.protocol, Some(Some(PortProtocol::Tcp)));
746 }
747
748 #[test]
749 fn with_udp_protocol() {
750 let result = PortPatch::from_str("80/udp").unwrap();
751
752 assert_eq_sorted!(result.port, Some(80));
753 assert_eq_sorted!(result.protocol, Some(Some(PortProtocol::Udp)));
754 }
755
756 #[test]
757 fn invalid() {
758 let result = PortPatch::from_str("80/invalid");
759
760 assert!(result.is_err());
761 }
762 }
763 }
764
765 mod collect_path_list {
766 use super::*;
767
768 #[test]
769 fn simple() {
770 let input = "path1 path2 path3";
771 let result = collect_path_list(input);
772 assert_eq!(result, vec!["path1", "path2", "path3"]);
773 }
774
775 #[test]
776 fn with_quotes() {
777 let input = r#""path 1" 'path 2' path3"#;
778 let result = collect_path_list(input);
779 assert_eq!(result, vec!["path 1", "path 2", "path3"]);
780 }
781
782 #[test]
783 fn with_escaped_quotes() {
784 let input = r#""path\" 1" 'path\' 2' path\"3"#;
785 let result = collect_path_list(input);
786 assert_eq!(result, vec![r#"path" 1"#, "path' 2", r#"path"3"#]);
787 }
788 }
789}