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 from = if parts
99        .first()
100        .map(|s| {
101            s.starts_with('@')
102                || s.starts_with("image@")
103                || s.starts_with("builder@")
104                || s.starts_with("context@")
105        })
106        .unwrap_or(false)
107    {
108        let from_str = parts.remove(0);
109        let from_parts = from_str.split('@').collect::<Vec<_>>();
110        if from_parts.len() != 2 {
111            return Err(Error::custom("Invalid from context format"));
112        }
113        let from_type = from_parts[0];
114        let from_value = from_parts[1];
115        match from_type {
116            "image" => FromContextPatch::FromImage(from_value.parse()?),
117            "builder" => FromContextPatch::FromBuilder(from_value.into()),
118            "context" | "" => FromContextPatch::FromContext(Some(from_value.into())),
119            _ => return Err(Error::custom("Invalid from context type")),
120        }
121    } else {
122        FromContextPatch::default()
123    };
124    let target = if parts.len() > 1 { parts.pop() } else { None };
125    Ok(Self {
126        paths: Some(parts.into_patch()),
127        options: Some(CopyOptionsPatch {
128            target: Some(target),
129            chmod: Some(None),
130            chown: Some(None),
131            link: Some(None),
132        }),
133        from: Some(from),
134        exclude: Some(VecPatch::default()),
135        parents: Some(None),
136    })
137});
138
139impl_parsable_patch!(AddGitRepo, AddGitRepoPatch, s, {
140    let paths = collect_path_list(s);
141    let (repo, target) = match paths.as_slice() {
142        [repo, target] => (repo, Some(target)),
143        [repo] => (repo, None),
144        _ => return Err(Error::custom("Invalid add git repo format")),
145    };
146    Ok(Self {
147        repo: Some(repo.clone()),
148        options: Some(CopyOptionsPatch {
149            target: Some(target.cloned()),
150            chmod: Some(None),
151            chown: Some(None),
152            link: Some(None),
153        }),
154        keep_git_dir: Some(None),
155        checksum: Some(None),
156        exclude: Some(VecPatch::default()),
157    })
158});
159
160impl_parsable_patch!(Add, AddPatch, s, {
161    let mut parts = collect_path_list(s);
162    let target = if parts.len() > 1 { parts.pop() } else { None };
163    let parts: Vec<_> = parts
164        .iter()
165        .map(|s| {
166            Url::parse(s)
167                .map(Resource::Url)
168                .ok()
169                .unwrap_or(Resource::File(s.into()))
170        })
171        .collect();
172    Ok(Self {
173        files: Some(parts.into_patch()),
174        options: Some(CopyOptionsPatch {
175            target: Some(target),
176            chmod: Some(None),
177            chown: Some(None),
178            link: Some(None),
179        }),
180        checksum: Some(None),
181        unpack: Some(None),
182    })
183});
184
185fn collect_path_list(s: &str) -> Vec<String> {
186    let regex = Regex::new(r#"(?:"(?:\\"|[^"])*"|'(?:\\'|[^'])*'|\S+)"#).unwrap();
187    regex
188        .find_iter(s)
189        .map(|m| {
190            let mut m = m.as_str().to_string();
191            let first = m.chars().next().unwrap();
192            if first == '"' || first == '\'' {
193                // Remove the surrounding quotes
194                m = m[1..m.len() - 1].to_string();
195                while let Some(pos) = m.find(format!("\\{}", first).as_str()) {
196                    // Replace escaped quotes with a single quote
197                    m = format!("{}{}", &m[..pos], &m[pos + 1..]);
198                }
199            } else {
200                while let Some(pos) = m.find("\\\"").or(m.find("\\'")) {
201                    // Replace escaped quotes with a single quote
202                    m = format!("{}{}", &m[..pos], &m[pos + 1..]);
203                }
204            }
205            m
206        })
207        .collect()
208}
209
210impl_parsable_patch!(User, UserPatch, s, {
211    let regex = Regex::new(r"^(?<user>[a-zA-Z0-9_-]+)(?::(?<group>[a-zA-Z0-9_-]+))?$").unwrap();
212    let Some(captures) = regex.captures(s) else {
213        return Err(Error::custom("Not matching chown pattern"));
214    };
215    Ok(Self {
216        user: Some(captures["user"].into()),
217        group: Some(captures.name("group").map(|m| m.as_str().into())),
218    })
219});
220
221impl_parsable_patch!(Port, PortPatch, s, {
222    let regex = Regex::new(r"^(?<port>\d+)(?:/(?<protocol>(tcp|udp)))?$").unwrap();
223    let Some(captures) = regex.captures(s) else {
224        return Err(Error::custom("Not matching chown pattern"));
225    };
226    Ok(Self {
227        port: Some(captures["port"].parse().map_err(Error::custom)?),
228        protocol: Some(captures.name("protocol").map(|m| match m.as_str() {
229            "tcp" => PortProtocol::Tcp,
230            "udp" => PortProtocol::Udp,
231            _ => unreachable!(),
232        })),
233    })
234});
235
236impl_parsable_patch!(Bind, BindPatch, s, {
237    let regex = Regex::new(r"^(?:(?:(?P<fromType>image|builder|context)\((?P<from>[^:]+)\):)?(?P<source>\S+) )?(?P<target>\S+)$").unwrap();
238    let Some(captures) = regex.captures(s) else {
239        return Err(Error::custom("Not matching bind pattern"));
240    };
241
242    let target = Some(captures["target"].to_string());
243    Ok(Self {
244        source: Some(
245            captures
246                .name("source")
247                .map(|m| m.as_str().into())
248                .or(target.clone()),
249        ),
250        target,
251        from: captures.name("from").map(|m| {
252            let from_type = captures.name("fromType").map(|m| m.as_str()).unwrap();
253            let from = m.as_str();
254            match from_type {
255                "image" => {
256                    FromContextPatch::FromImage(ImageNamePatch::from_str(from).unwrap().into())
257                }
258                "builder" => FromContextPatch::FromBuilder(from.into()),
259                "context" => FromContextPatch::FromContext(Some(from.into())),
260                _ => unreachable!(),
261            }
262        }),
263
264        readwrite: Some(None),
265    })
266});
267
268impl_parsable_patch!(Cache, CachePatch, s, {
269    let regex = Regex::new(r"^(?:(?:(?P<fromType>image|builder|context)\((?P<from>[^:]+)\):)?(?P<source>\S+) )?(?P<target>\S+)$").unwrap();
270    let Some(captures) = regex.captures(s) else {
271        return Err(Error::custom("Not matching bind pattern"));
272    };
273
274    let target = Some(captures["target"].to_string());
275    Ok(Self {
276        source: Some(captures.name("source").map(|m| m.as_str().into())),
277        target,
278        from: captures.name("from").map(|m| {
279            let from_type = captures.name("fromType").map(|m| m.as_str()).unwrap();
280            let from = m.as_str();
281            match from_type {
282                "image" => {
283                    FromContextPatch::FromImage(ImageNamePatch::from_str(from).unwrap().into())
284                }
285                "builder" => FromContextPatch::FromBuilder(from.into()),
286                "context" => FromContextPatch::FromContext(Some(from.into())),
287                _ => unreachable!(),
288            }
289        }),
290        chmod: Some(None),
291        chown: Some(None),
292        id: Some(None),
293        readonly: Some(None),
294        sharing: Some(None),
295    })
296});
297
298impl_parsable_patch!(TmpFs, TmpFsPatch, s, {
299    Ok(Self {
300        target: Some(s.into()),
301        size: Some(None),
302    })
303});
304
305#[cfg(test)]
306mod test {
307    use super::*;
308    use pretty_assertions_sorted::assert_eq_sorted;
309    mod from_str {
310        use super::*;
311
312        mod image_name {
313            use pretty_assertions_sorted::assert_eq_sorted;
314
315            use super::*;
316
317            #[test]
318            fn simple() {
319                let input = "example/image";
320                let result = ImageNamePatch::from_str(input).unwrap();
321                assert_eq_sorted!(result.host, Some(None));
322                assert_eq_sorted!(result.path, Some("example/image".into()));
323                assert_eq_sorted!(result.port, Some(None));
324                assert_eq_sorted!(result.version, Some(None));
325            }
326
327            #[test]
328            fn with_host() {
329                let input = "docker.io/example/image";
330                let result = ImageNamePatch::from_str(input).unwrap();
331                assert_eq_sorted!(result.host, Some(Some("docker.io".into())));
332                assert_eq_sorted!(result.path, Some("example/image".into()));
333                assert_eq_sorted!(result.port, Some(None));
334                assert_eq_sorted!(result.version, Some(None));
335            }
336
337            #[test]
338            fn with_tag() {
339                let input = "example/image:tag";
340                let result = ImageNamePatch::from_str(input).unwrap();
341                assert_eq_sorted!(result.host, Some(None));
342                assert_eq_sorted!(result.path, Some("example/image".into()));
343                assert_eq_sorted!(result.port, Some(None));
344                assert_eq_sorted!(result.version, Some(Some(ImageVersion::Tag("tag".into()))));
345            }
346
347            #[test]
348            fn with_digest() {
349                let input = "example/image@sha256:my-sha";
350                let result = ImageNamePatch::from_str(input).unwrap();
351                assert_eq_sorted!(result.host, Some(None));
352                assert_eq_sorted!(result.path, Some("example/image".into()));
353                assert_eq_sorted!(result.port, Some(None));
354                assert_eq_sorted!(
355                    result.version,
356                    Some(Some(ImageVersion::Digest("sha256:my-sha".into())))
357                );
358            }
359
360            #[test]
361            fn full() {
362                let input = "registry.my-host.io:5001/example/image:stable";
363                let result = ImageNamePatch::from_str(input).unwrap();
364                assert_eq_sorted!(result.host, Some(Some("registry.my-host.io".into())));
365                assert_eq_sorted!(result.path, Some("example/image".into()));
366                assert_eq_sorted!(result.port, Some(Some(5001)));
367                assert_eq_sorted!(
368                    result.version,
369                    Some(Some(ImageVersion::Tag("stable".into())))
370                );
371            }
372
373            #[test]
374            fn with_arg() {
375                let input = "example/image:${VERSION}";
376                let result = ImageNamePatch::from_str(input);
377                assert!(result.is_err());
378            }
379        }
380
381        mod copy {
382
383            use super::*;
384
385            #[test]
386            fn simple() {
387                let result = CopyPatch::from_str("src").unwrap();
388                assert_eq_sorted!(
389                    result,
390                    CopyPatch {
391                        paths: Some(vec!["src".to_string()].into_patch()),
392                        options: Some(CopyOptionsPatch {
393                            target: Some(None),
394                            chown: Some(None),
395                            chmod: Some(None),
396                            link: Some(None),
397                        }),
398                        from: Some(FromContextPatch::default()),
399                        exclude: Some(VecPatch::default()),
400                        parents: Some(None),
401                    }
402                );
403            }
404
405            #[test]
406            fn with_target_option() {
407                let result = CopyPatch::from_str("src /app").unwrap();
408                assert_eq_sorted!(
409                    result,
410                    CopyPatch {
411                        paths: Some(vec!["src".to_string()].into_patch()),
412                        options: Some(CopyOptionsPatch {
413                            target: Some(Some("/app".into())),
414                            chown: Some(None),
415                            chmod: Some(None),
416                            link: Some(None),
417                        }),
418                        from: Some(FromContextPatch::default()),
419                        exclude: Some(VecPatch::default()),
420                        parents: Some(None),
421                    }
422                );
423            }
424
425            #[test]
426            fn with_multiple_sources_and_target() {
427                let result = CopyPatch::from_str("src1 src2 /app").unwrap();
428                assert_eq_sorted!(
429                    result,
430                    CopyPatch {
431                        paths: Some(vec!["src1".to_string(), "src2".to_string()].into_patch()),
432                        options: Some(CopyOptionsPatch {
433                            target: Some(Some("/app".into())),
434                            chown: Some(None),
435                            chmod: Some(None),
436                            link: Some(None),
437                        }),
438                        from: Some(FromContextPatch::default()),
439                        exclude: Some(VecPatch::default()),
440                        parents: Some(None),
441                    }
442                );
443            }
444
445            #[test]
446            fn with_target_option_and_from_image() {
447                let result = CopyPatch::from_str("image@alpine src /app").unwrap();
448                assert_eq_sorted!(
449                    result,
450                    CopyPatch {
451                        paths: Some(vec!["src".to_string()].into_patch()),
452                        options: Some(CopyOptionsPatch {
453                            target: Some(Some("/app".into())),
454                            chown: Some(None),
455                            chmod: Some(None),
456                            link: Some(None),
457                        }),
458                        from: Some(FromContextPatch::FromImage("alpine".parse().unwrap())),
459                        exclude: Some(VecPatch::default()),
460                        parents: Some(None),
461                    }
462                );
463            }
464
465            #[test]
466            fn with_target_option_and_from_builder() {
467                let result = CopyPatch::from_str("builder@my-builder src /app").unwrap();
468                assert_eq_sorted!(
469                    result,
470                    CopyPatch {
471                        paths: Some(vec!["src".to_string()].into_patch()),
472                        options: Some(CopyOptionsPatch {
473                            target: Some(Some("/app".into())),
474                            chown: Some(None),
475                            chmod: Some(None),
476                            link: Some(None),
477                        }),
478                        from: Some(FromContextPatch::FromBuilder("my-builder".to_string())),
479                        exclude: Some(VecPatch::default()),
480                        parents: Some(None),
481                    }
482                );
483            }
484
485            #[test]
486            fn with_target_option_and_from_context() {
487                let result = CopyPatch::from_str("@my-context src /app").unwrap();
488                assert_eq_sorted!(
489                    result,
490                    CopyPatch {
491                        paths: Some(vec!["src".to_string()].into_patch()),
492                        options: Some(CopyOptionsPatch {
493                            target: Some(Some("/app".into())),
494                            chown: Some(None),
495                            chmod: Some(None),
496                            link: Some(None),
497                        }),
498                        from: Some(FromContextPatch::FromContext(Some(
499                            "my-context".to_string()
500                        ))),
501                        exclude: Some(VecPatch::default()),
502                        parents: Some(None),
503                    }
504                );
505            }
506        }
507
508        mod add_git_repo {
509
510            use super::*;
511
512            #[test]
513            fn ssh() {
514                let result =
515                    AddGitRepoPatch::from_str("git@github.com:lenra-io/dofigen.git").unwrap();
516                assert_eq_sorted!(
517                    result,
518                    AddGitRepoPatch {
519                        repo: Some("git@github.com:lenra-io/dofigen.git".into()),
520                        options: Some(CopyOptionsPatch {
521                            target: Some(None),
522                            chown: Some(None),
523                            chmod: Some(None),
524                            link: Some(None),
525                        }),
526                        keep_git_dir: Some(None),
527                        exclude: Some(VecPatch::default()),
528                        checksum: Some(None),
529                    }
530                );
531            }
532
533            #[test]
534            fn ssh_with_target() {
535                let result =
536                    AddGitRepoPatch::from_str("git@github.com:lenra-io/dofigen.git /app").unwrap();
537                assert_eq_sorted!(
538                    result,
539                    AddGitRepoPatch {
540                        repo: Some("git@github.com:lenra-io/dofigen.git".into()),
541                        options: Some(CopyOptionsPatch {
542                            target: Some(Some("/app".into())),
543                            chown: Some(None),
544                            chmod: Some(None),
545                            link: Some(None),
546                        }),
547                        keep_git_dir: Some(None),
548                        exclude: Some(VecPatch::default()),
549                        checksum: Some(None),
550                    }
551                );
552            }
553
554            #[test]
555            fn http() {
556                let result =
557                    AddGitRepoPatch::from_str("https://github.com/lenra-io/dofigen.git").unwrap();
558                assert_eq_sorted!(
559                    result,
560                    AddGitRepoPatch {
561                        repo: Some("https://github.com/lenra-io/dofigen.git".into()),
562                        options: Some(CopyOptionsPatch {
563                            target: Some(None),
564                            chown: Some(None),
565                            chmod: Some(None),
566                            link: Some(None),
567                        }),
568                        keep_git_dir: Some(None),
569                        exclude: Some(VecPatch::default()),
570                        checksum: Some(None),
571                    }
572                );
573            }
574
575            #[test]
576            fn http_with_target() {
577                let result =
578                    AddGitRepoPatch::from_str("https://github.com/lenra-io/dofigen.git /app")
579                        .unwrap();
580                assert_eq_sorted!(
581                    result,
582                    AddGitRepoPatch {
583                        repo: Some("https://github.com/lenra-io/dofigen.git".into()),
584                        options: Some(CopyOptionsPatch {
585                            target: Some(Some("/app".into())),
586                            chown: Some(None),
587                            chmod: Some(None),
588                            link: Some(None),
589                        }),
590                        keep_git_dir: Some(None),
591                        exclude: Some(VecPatch::default()),
592                        checksum: Some(None),
593                    }
594                );
595            }
596        }
597
598        mod add {
599            use struct_patch::Patch;
600
601            use crate::{CopyOptions, Resource};
602
603            use super::*;
604
605            #[test]
606            fn simple() {
607                let result =
608                    AddPatch::from_str("https://github.com/lenra-io/dofigen/raw/main/README.md")
609                        .unwrap();
610                assert_eq_sorted!(
611                    result,
612                    Add {
613                        files: vec![Resource::Url(
614                            "https://github.com/lenra-io/dofigen/raw/main/README.md"
615                                .parse()
616                                .unwrap()
617                        )],
618                        options: CopyOptions::default(),
619                        ..Default::default()
620                    }
621                    .into_patch()
622                );
623            }
624
625            #[test]
626            fn with_target_option() {
627                let result = AddPatch::from_str(
628                    "https://github.com/lenra-io/dofigen/raw/main/README.md /app",
629                )
630                .unwrap();
631                assert_eq_sorted!(
632                    result,
633                    Add {
634                        files: vec![Resource::Url(
635                            "https://github.com/lenra-io/dofigen/raw/main/README.md"
636                                .parse()
637                                .unwrap()
638                        )],
639                        options: CopyOptions {
640                            target: Some("/app".into()),
641                            ..Default::default()
642                        },
643                        ..Default::default()
644                    }
645                    .into_patch()
646                );
647            }
648
649            #[test]
650            fn with_multiple_sources_and_target() {
651                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();
652                assert_eq_sorted!(
653                    result,
654                    Add {
655                        files: vec![
656                            Resource::Url(
657                                "https://github.com/lenra-io/dofigen/raw/main/README.md"
658                                    .parse()
659                                    .unwrap()
660                            ),
661                            Resource::Url(
662                                "https://github.com/lenra-io/dofigen/raw/main/LICENSE"
663                                    .parse()
664                                    .unwrap()
665                            )
666                        ],
667                        options: CopyOptions {
668                            target: Some("/app".into()),
669                            ..Default::default()
670                        },
671                        ..Default::default()
672                    }
673                    .into_patch()
674                );
675            }
676        }
677
678        mod copy_resources {
679            use super::*;
680
681            #[test]
682            fn copy() {
683                let result = CopyResourcePatch::from_str("src").unwrap();
684                assert_eq_sorted!(
685                    result,
686                    CopyResourcePatch::Copy(CopyPatch::from_str("src").unwrap())
687                );
688            }
689
690            #[test]
691            fn add_git_repo_ssh() {
692                let result =
693                    CopyResourcePatch::from_str("git@github.com:lenra-io/dofigen.git target")
694                        .unwrap();
695                assert_eq_sorted!(
696                    result,
697                    CopyResourcePatch::AddGitRepo(
698                        AddGitRepoPatch::from_str("git@github.com:lenra-io/dofigen.git target")
699                            .unwrap()
700                    )
701                );
702            }
703
704            #[test]
705            fn add_quoted_git_repo_ssh() {
706                let result =
707                    CopyResourcePatch::from_str(r#""git@github.com:lenra-io/dofigen.git" target"#)
708                        .unwrap();
709                assert_eq_sorted!(
710                    result,
711                    CopyResourcePatch::AddGitRepo(
712                        AddGitRepoPatch::from_str("git@github.com:lenra-io/dofigen.git target")
713                            .unwrap()
714                    )
715                );
716            }
717
718            #[test]
719            fn add_git_repo_http() {
720                let result =
721                    CopyResourcePatch::from_str("https://github.com/lenra-io/dofigen.git target")
722                        .unwrap();
723                assert_eq_sorted!(
724                    result,
725                    CopyResourcePatch::AddGitRepo(
726                        AddGitRepoPatch::from_str("https://github.com/lenra-io/dofigen.git target")
727                            .unwrap()
728                    )
729                );
730            }
731
732            #[test]
733            fn add_quoted_git_repo_http() {
734                let result =
735                    CopyResourcePatch::from_str("'https://github.com/lenra-io/dofigen.git' target")
736                        .unwrap();
737                assert_eq_sorted!(
738                    result,
739                    CopyResourcePatch::AddGitRepo(
740                        AddGitRepoPatch::from_str("https://github.com/lenra-io/dofigen.git target")
741                            .unwrap()
742                    )
743                );
744            }
745
746            #[test]
747            fn add() {
748                let result = CopyResourcePatch::from_str(
749                    "https://github.com/lenra-io/dofigen/raw/main/README.md",
750                )
751                .unwrap();
752                assert_eq_sorted!(
753                    result,
754                    CopyResourcePatch::Add(
755                        AddPatch::from_str(
756                            "https://github.com/lenra-io/dofigen/raw/main/README.md"
757                        )
758                        .unwrap()
759                    )
760                );
761            }
762        }
763
764        mod user {
765            use pretty_assertions_sorted::assert_eq_sorted;
766
767            use super::*;
768
769            #[test]
770            fn user() {
771                let result = UserPatch::from_str("user").unwrap();
772
773                assert_eq_sorted!(result.user, Some("user".into()));
774                assert_eq_sorted!(result.group, Some(None));
775            }
776
777            #[test]
778            fn with_group() {
779                let result = UserPatch::from_str("user:group").unwrap();
780
781                assert_eq_sorted!(result.user, Some("user".into()));
782                assert_eq_sorted!(result.group, Some(Some("group".into())));
783            }
784
785            #[test]
786            fn uid() {
787                let result = UserPatch::from_str("1000").unwrap();
788
789                assert_eq_sorted!(result.user, Some("1000".into()));
790                assert_eq_sorted!(result.group, Some(None));
791            }
792
793            #[test]
794            fn uid_with_gid() {
795                let result = UserPatch::from_str("1000:1000").unwrap();
796
797                assert_eq_sorted!(result.user, Some("1000".into()));
798                assert_eq_sorted!(result.group, Some(Some("1000".into())));
799            }
800
801            #[test]
802            fn invalid_username() {
803                let result = UserPatch::from_str("user*name");
804
805                assert!(result.is_err());
806            }
807
808            #[test]
809            fn invalid_extra() {
810                let result = UserPatch::from_str("user:group:extra");
811
812                assert!(result.is_err());
813            }
814        }
815
816        mod port {
817
818            use super::*;
819
820            #[test]
821            fn simple() {
822                let result = PortPatch::from_str("80").unwrap();
823
824                assert_eq_sorted!(result.port, Some(80));
825                assert_eq_sorted!(result.protocol, Some(None));
826            }
827
828            #[test]
829            fn with_tcp_protocol() {
830                let result = PortPatch::from_str("80/tcp").unwrap();
831
832                assert_eq_sorted!(result.port, Some(80));
833                assert_eq_sorted!(result.protocol, Some(Some(PortProtocol::Tcp)));
834            }
835
836            #[test]
837            fn with_udp_protocol() {
838                let result = PortPatch::from_str("80/udp").unwrap();
839
840                assert_eq_sorted!(result.port, Some(80));
841                assert_eq_sorted!(result.protocol, Some(Some(PortProtocol::Udp)));
842            }
843
844            #[test]
845            fn invalid() {
846                let result = PortPatch::from_str("80/invalid");
847
848                assert!(result.is_err());
849            }
850        }
851    }
852
853    mod collect_path_list {
854        use super::*;
855
856        #[test]
857        fn simple() {
858            let input = "path1 path2 path3";
859            let result = collect_path_list(input);
860            assert_eq!(result, vec!["path1", "path2", "path3"]);
861        }
862
863        #[test]
864        fn with_quotes() {
865            let input = r#""path 1" 'path 2' path3"#;
866            let result = collect_path_list(input);
867            assert_eq!(result, vec!["path 1", "path 2", "path3"]);
868        }
869
870        #[test]
871        fn with_escaped_quotes() {
872            let input = r#""path\" 1" 'path\' 2' path\"3"#;
873            let result = collect_path_list(input);
874            assert_eq!(result, vec![r#"path" 1"#, "path' 2", r#"path"3"#]);
875        }
876    }
877}