Skip to main content

dofigen_lib/
from_str.rs

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                // Remove the surrounding quotes
168                m = m[1..m.len() - 1].to_string();
169                while let Some(pos) = m.find(format!("\\{}", first).as_str()) {
170                    // Replace escaped quotes with a single quote
171                    m = format!("{}{}", &m[..pos], &m[pos + 1..]);
172                }
173            } else {
174                while let Some(pos) = m.find("\\\"").or(m.find("\\'")) {
175                    // Replace escaped quotes with a single quote
176                    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}