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("Not matching image name pattern"));
53    };
54    Ok(ImageNamePatch {
55        host: Some(captures.name("host").map(|m| m.as_str().into())),
56        port: Some(captures.name("port").map(|m| m.as_str().parse().unwrap())),
57        path: Some(captures["path"].into()),
58        version: Some(
59            match (
60                captures.name("version_char").map(|m| m.as_str()),
61                captures.name("version_value"),
62            ) {
63                (Some(":"), Some(value)) => Some(ImageVersion::Tag(value.as_str().into())),
64                (Some("@"), Some(value)) => Some(ImageVersion::Digest(value.as_str().into())),
65                (None, None) => None,
66                _ => return Err(Error::custom("Invalid version format")),
67            },
68        ),
69        platform: Some(None),
70    })
71});
72
73impl_parsable_patch!(CopyResource, CopyResourcePatch, s, {
74    let parts_regex = format!(
75        r"^(?:(?<git>(?:{git_http}|{git_ssh}))|(?<url>{url})|\S+)(?: (?:{git_http}|{git_ssh}|{url}|\S+))*(?: \S+)?$",
76        git_http = GIT_HTTP_REPO_REGEX,
77        git_ssh = GIT_SSH_REPO_REGEX,
78        url = URL_REGEX
79    );
80    let regex = Regex::new(parts_regex.as_str()).unwrap();
81    let Some(captures) = regex.captures(s) else {
82        return Err(Error::custom("Not matching copy resources pattern"));
83    };
84    if captures.name("git").is_some() {
85        return Ok(CopyResourcePatch::AddGitRepo(s.parse().unwrap()));
86    }
87    if captures.name("url").is_some() {
88        return Ok(CopyResourcePatch::Add(s.parse().unwrap()));
89    }
90    Ok(CopyResourcePatch::Copy(s.parse().unwrap()))
91});
92
93impl_parsable_patch!(Copy, CopyPatch, s, {
94    let mut parts: Vec<String> = s.split(" ").map(|s| s.into()).collect();
95    let target = if parts.len() > 1 { parts.pop() } else { None };
96    Ok(Self {
97        paths: Some(parts.into_patch()),
98        options: Some(CopyOptionsPatch {
99            target: Some(target),
100            chmod: Some(None),
101            chown: Some(None),
102            link: Some(None),
103        }),
104        from: Some(FromContextPatch::default()),
105        exclude: Some(VecPatch::default()),
106        parents: Some(None),
107    })
108});
109
110impl_parsable_patch!(AddGitRepo, AddGitRepoPatch, s, {
111    let (repo, target) = match &s.split(" ").collect::<Vec<&str>>().as_slice() {
112        &[repo, target] => (repo.to_string(), Some(target.to_string())),
113        &[repo] => (repo.to_string(), None),
114        _ => return Err(Error::custom("Invalid add git repo format")),
115    };
116    Ok(Self {
117        repo: Some(repo),
118        options: Some(CopyOptionsPatch {
119            target: Some(target),
120            chmod: Some(None),
121            chown: Some(None),
122            link: Some(None),
123        }),
124        keep_git_dir: Some(None),
125        exclude: Some(VecPatch::default()),
126    })
127});
128
129impl_parsable_patch!(Add, AddPatch, s, {
130    let mut parts: Vec<_> = s.split(" ").collect();
131    let target = if parts.len() > 1 {
132        parts.pop().map(str::to_string)
133    } else {
134        None
135    };
136    let parts: Vec<_> = parts
137        .iter()
138        .map(|s| {
139            Url::parse(s)
140                .map(Resource::Url)
141                .ok()
142                .unwrap_or(Resource::File(s.into()))
143        })
144        .collect();
145    Ok(Self {
146        files: Some(parts.into_patch()),
147        options: Some(CopyOptionsPatch {
148            target: Some(target),
149            chmod: Some(None),
150            chown: Some(None),
151            link: Some(None),
152        }),
153        checksum: Some(None),
154    })
155});
156
157impl_parsable_patch!(User, UserPatch, s, {
158    let regex = Regex::new(r"^(?<user>[a-zA-Z0-9_-]+)(?::(?<group>[a-zA-Z0-9_-]+))?$").unwrap();
159    let Some(captures) = regex.captures(s) else {
160        return Err(Error::custom("Not matching chown pattern"));
161    };
162    Ok(Self {
163        user: Some(captures["user"].into()),
164        group: Some(captures.name("group").map(|m| m.as_str().into())),
165    })
166});
167
168impl_parsable_patch!(Port, PortPatch, s, {
169    let regex = Regex::new(r"^(?<port>\d+)(?:/(?<protocol>(tcp|udp)))?$").unwrap();
170    let Some(captures) = regex.captures(s) else {
171        return Err(Error::custom("Not matching chown pattern"));
172    };
173    Ok(Self {
174        port: Some(captures["port"].parse().map_err(Error::custom)?),
175        protocol: Some(captures.name("protocol").map(|m| match m.as_str() {
176            "tcp" => PortProtocol::Tcp,
177            "udp" => PortProtocol::Udp,
178            _ => unreachable!(),
179        })),
180    })
181});
182
183impl_parsable_patch!(Bind, BindPatch, s, {
184    let regex = Regex::new(r"^(?:(?:(?P<fromType>image|builder|context)\((?P<from>[^:]+)\):)?(?P<source>\S+) )?(?P<target>\S+)$").unwrap();
185    let Some(captures) = regex.captures(s) else {
186        return Err(Error::custom("Not matching bind pattern"));
187    };
188
189    let target = Some(captures["target"].to_string());
190    Ok(Self {
191        source: Some(
192            captures
193                .name("source")
194                .map(|m| m.as_str().into())
195                .or(target.clone()),
196        ),
197        target,
198        from: captures.name("from").map(|m| {
199            let from_type = captures.name("fromType").map(|m| m.as_str()).unwrap();
200            let from = m.as_str();
201            match from_type {
202                "image" => {
203                    FromContextPatch::FromImage(ImageNamePatch::from_str(from).unwrap().into())
204                }
205                "builder" => FromContextPatch::FromBuilder(from.into()),
206                "context" => FromContextPatch::FromContext(Some(from.into())),
207                _ => unreachable!(),
208            }
209        }),
210
211        readwrite: Some(None),
212    })
213});
214
215impl_parsable_patch!(Cache, CachePatch, s, {
216    let regex = Regex::new(r"^(?:(?:(?P<fromType>image|builder|context)\((?P<from>[^:]+)\):)?(?P<source>\S+) )?(?P<target>\S+)$").unwrap();
217    let Some(captures) = regex.captures(s) else {
218        return Err(Error::custom("Not matching bind pattern"));
219    };
220
221    let target = Some(captures["target"].to_string());
222    Ok(Self {
223        source: Some(captures.name("source").map(|m| m.as_str().into())),
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        chmod: Some(None),
238        chown: Some(None),
239        id: Some(None),
240        readonly: Some(None),
241        sharing: Some(None),
242    })
243});
244
245#[cfg(test)]
246mod test_from_str {
247    use super::*;
248    use pretty_assertions_sorted::assert_eq_sorted;
249
250    mod image_name {
251        use pretty_assertions_sorted::assert_eq_sorted;
252
253        use super::*;
254
255        #[test]
256        fn simple() {
257            let input = "example/image";
258            let result = ImageNamePatch::from_str(input).unwrap();
259            assert_eq_sorted!(result.host, Some(None));
260            assert_eq_sorted!(result.path, Some("example/image".into()));
261            assert_eq_sorted!(result.port, Some(None));
262            assert_eq_sorted!(result.version, Some(None));
263        }
264
265        #[test]
266        fn with_host() {
267            let input = "docker.io/example/image";
268            let result = ImageNamePatch::from_str(input).unwrap();
269            assert_eq_sorted!(result.host, Some(Some("docker.io".into())));
270            assert_eq_sorted!(result.path, Some("example/image".into()));
271            assert_eq_sorted!(result.port, Some(None));
272            assert_eq_sorted!(result.version, Some(None));
273        }
274
275        #[test]
276        fn with_tag() {
277            let input = "example/image:tag";
278            let result = ImageNamePatch::from_str(input).unwrap();
279            assert_eq_sorted!(result.host, Some(None));
280            assert_eq_sorted!(result.path, Some("example/image".into()));
281            assert_eq_sorted!(result.port, Some(None));
282            assert_eq_sorted!(result.version, Some(Some(ImageVersion::Tag("tag".into()))));
283        }
284
285        #[test]
286        fn with_digest() {
287            let input = "example/image@sha256:my-sha";
288            let result = ImageNamePatch::from_str(input).unwrap();
289            assert_eq_sorted!(result.host, Some(None));
290            assert_eq_sorted!(result.path, Some("example/image".into()));
291            assert_eq_sorted!(result.port, Some(None));
292            assert_eq_sorted!(
293                result.version,
294                Some(Some(ImageVersion::Digest("sha256:my-sha".into())))
295            );
296        }
297
298        #[test]
299        fn full() {
300            let input = "registry.my-host.io:5001/example/image:stable";
301            let result = ImageNamePatch::from_str(input).unwrap();
302            assert_eq_sorted!(result.host, Some(Some("registry.my-host.io".into())));
303            assert_eq_sorted!(result.path, Some("example/image".into()));
304            assert_eq_sorted!(result.port, Some(Some(5001)));
305            assert_eq_sorted!(
306                result.version,
307                Some(Some(ImageVersion::Tag("stable".into())))
308            );
309        }
310
311        #[test]
312        fn with_arg() {
313            let input = "example/image:${VERSION}";
314            let result = ImageNamePatch::from_str(input);
315            assert!(result.is_err());
316        }
317    }
318
319    mod copy {
320
321        use super::*;
322
323        #[test]
324        fn simple() {
325            let result = CopyPatch::from_str("src").unwrap();
326            assert_eq_sorted!(
327                result,
328                CopyPatch {
329                    paths: Some(vec!["src".to_string()].into_patch()),
330                    options: Some(CopyOptionsPatch {
331                        target: Some(None),
332                        chown: Some(None),
333                        chmod: Some(None),
334                        link: Some(None),
335                    }),
336                    from: Some(FromContextPatch::default()),
337                    exclude: Some(VecPatch::default()),
338                    parents: Some(None),
339                }
340            );
341        }
342
343        #[test]
344        fn with_target_option() {
345            let result = CopyPatch::from_str("src /app").unwrap();
346            assert_eq_sorted!(
347                result,
348                CopyPatch {
349                    paths: Some(vec!["src".to_string()].into_patch()),
350                    options: Some(CopyOptionsPatch {
351                        target: Some(Some("/app".into())),
352                        chown: Some(None),
353                        chmod: Some(None),
354                        link: Some(None),
355                    }),
356                    from: Some(FromContextPatch::default()),
357                    exclude: Some(VecPatch::default()),
358                    parents: Some(None),
359                }
360            );
361        }
362
363        #[test]
364        fn with_multiple_sources_and_target() {
365            let result = CopyPatch::from_str("src1 src2 /app").unwrap();
366            assert_eq_sorted!(
367                result,
368                CopyPatch {
369                    paths: Some(vec!["src1".to_string(), "src2".to_string()].into_patch()),
370                    options: Some(CopyOptionsPatch {
371                        target: Some(Some("/app".into())),
372                        chown: Some(None),
373                        chmod: Some(None),
374                        link: Some(None),
375                    }),
376                    from: Some(FromContextPatch::default()),
377                    exclude: Some(VecPatch::default()),
378                    parents: Some(None),
379                }
380            );
381        }
382    }
383
384    mod add_git_repo {
385
386        use super::*;
387
388        #[test]
389        fn ssh() {
390            let result = AddGitRepoPatch::from_str("git@github.com:lenra-io/dofigen.git").unwrap();
391            assert_eq_sorted!(
392                result,
393                AddGitRepoPatch {
394                    repo: Some("git@github.com:lenra-io/dofigen.git".into()),
395                    options: Some(CopyOptionsPatch {
396                        target: Some(None),
397                        chown: Some(None),
398                        chmod: Some(None),
399                        link: Some(None),
400                    }),
401                    keep_git_dir: Some(None),
402                    exclude: Some(VecPatch::default()),
403                }
404            );
405        }
406
407        #[test]
408        fn ssh_with_target() {
409            let result =
410                AddGitRepoPatch::from_str("git@github.com:lenra-io/dofigen.git /app").unwrap();
411            assert_eq_sorted!(
412                result,
413                AddGitRepoPatch {
414                    repo: Some("git@github.com:lenra-io/dofigen.git".into()),
415                    options: Some(CopyOptionsPatch {
416                        target: Some(Some("/app".into())),
417                        chown: Some(None),
418                        chmod: Some(None),
419                        link: Some(None),
420                    }),
421                    keep_git_dir: Some(None),
422                    exclude: Some(VecPatch::default()),
423                }
424            );
425        }
426
427        #[test]
428        fn http() {
429            let result =
430                AddGitRepoPatch::from_str("https://github.com/lenra-io/dofigen.git").unwrap();
431            assert_eq_sorted!(
432                result,
433                AddGitRepoPatch {
434                    repo: Some("https://github.com/lenra-io/dofigen.git".into()),
435                    options: Some(CopyOptionsPatch {
436                        target: Some(None),
437                        chown: Some(None),
438                        chmod: Some(None),
439                        link: Some(None),
440                    }),
441                    keep_git_dir: Some(None),
442                    exclude: Some(VecPatch::default()),
443                }
444            );
445        }
446
447        #[test]
448        fn http_with_target() {
449            let result =
450                AddGitRepoPatch::from_str("https://github.com/lenra-io/dofigen.git /app").unwrap();
451            assert_eq_sorted!(
452                result,
453                AddGitRepoPatch {
454                    repo: Some("https://github.com/lenra-io/dofigen.git".into()),
455                    options: Some(CopyOptionsPatch {
456                        target: Some(Some("/app".into())),
457                        chown: Some(None),
458                        chmod: Some(None),
459                        link: Some(None),
460                    }),
461                    keep_git_dir: Some(None),
462                    exclude: Some(VecPatch::default()),
463                }
464            );
465        }
466    }
467
468    mod add {
469        use struct_patch::Patch;
470
471        use crate::{CopyOptions, Resource};
472
473        use super::*;
474
475        #[test]
476        fn simple() {
477            let result =
478                AddPatch::from_str("https://github.com/lenra-io/dofigen/raw/main/README.md")
479                    .unwrap();
480            assert_eq_sorted!(
481                result,
482                Add {
483                    files: vec![Resource::Url(
484                        "https://github.com/lenra-io/dofigen/raw/main/README.md"
485                            .parse()
486                            .unwrap()
487                    )],
488                    options: CopyOptions::default(),
489                    ..Default::default()
490                }
491                .into_patch()
492            );
493        }
494
495        #[test]
496        fn with_target_option() {
497            let result =
498                AddPatch::from_str("https://github.com/lenra-io/dofigen/raw/main/README.md /app")
499                    .unwrap();
500            assert_eq_sorted!(
501                result,
502                Add {
503                    files: vec![Resource::Url(
504                        "https://github.com/lenra-io/dofigen/raw/main/README.md"
505                            .parse()
506                            .unwrap()
507                    )],
508                    options: CopyOptions {
509                        target: Some("/app".into()),
510                        ..Default::default()
511                    },
512                    ..Default::default()
513                }
514                .into_patch()
515            );
516        }
517
518        #[test]
519        fn with_multiple_sources_and_target() {
520            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();
521            assert_eq_sorted!(
522                result,
523                Add {
524                    files: vec![
525                        Resource::Url(
526                            "https://github.com/lenra-io/dofigen/raw/main/README.md"
527                                .parse()
528                                .unwrap()
529                        ),
530                        Resource::Url(
531                            "https://github.com/lenra-io/dofigen/raw/main/LICENSE"
532                                .parse()
533                                .unwrap()
534                        )
535                    ],
536                    options: CopyOptions {
537                        target: Some("/app".into()),
538                        ..Default::default()
539                    },
540                    ..Default::default()
541                }
542                .into_patch()
543            );
544        }
545    }
546
547    mod copy_resources {
548        use super::*;
549
550        #[test]
551        fn copy() {
552            let result = CopyResourcePatch::from_str("src").unwrap();
553            assert_eq_sorted!(
554                result,
555                CopyResourcePatch::Copy(CopyPatch::from_str("src").unwrap())
556            );
557        }
558
559        #[test]
560        fn add_git_repo_ssh() {
561            let result =
562                CopyResourcePatch::from_str("git@github.com:lenra-io/dofigen.git").unwrap();
563            assert_eq_sorted!(
564                result,
565                CopyResourcePatch::AddGitRepo(
566                    AddGitRepoPatch::from_str("git@github.com:lenra-io/dofigen.git").unwrap()
567                )
568            );
569        }
570
571        #[test]
572        fn add_git_repo_http() {
573            let result =
574                CopyResourcePatch::from_str("https://github.com/lenra-io/dofigen.git").unwrap();
575            assert_eq_sorted!(
576                result,
577                CopyResourcePatch::AddGitRepo(
578                    AddGitRepoPatch::from_str("https://github.com/lenra-io/dofigen.git").unwrap()
579                )
580            );
581        }
582
583        #[test]
584        fn add() {
585            let result = CopyResourcePatch::from_str(
586                "https://github.com/lenra-io/dofigen/raw/main/README.md",
587            )
588            .unwrap();
589            assert_eq_sorted!(
590                result,
591                CopyResourcePatch::Add(
592                    AddPatch::from_str("https://github.com/lenra-io/dofigen/raw/main/README.md")
593                        .unwrap()
594                )
595            );
596        }
597    }
598
599    mod user {
600        use pretty_assertions_sorted::assert_eq_sorted;
601
602        use super::*;
603
604        #[test]
605        fn user() {
606            let result = UserPatch::from_str("user").unwrap();
607
608            assert_eq_sorted!(result.user, Some("user".into()));
609            assert_eq_sorted!(result.group, Some(None));
610        }
611
612        #[test]
613        fn with_group() {
614            let result = UserPatch::from_str("user:group").unwrap();
615
616            assert_eq_sorted!(result.user, Some("user".into()));
617            assert_eq_sorted!(result.group, Some(Some("group".into())));
618        }
619
620        #[test]
621        fn uid() {
622            let result = UserPatch::from_str("1000").unwrap();
623
624            assert_eq_sorted!(result.user, Some("1000".into()));
625            assert_eq_sorted!(result.group, Some(None));
626        }
627
628        #[test]
629        fn uid_with_gid() {
630            let result = UserPatch::from_str("1000:1000").unwrap();
631
632            assert_eq_sorted!(result.user, Some("1000".into()));
633            assert_eq_sorted!(result.group, Some(Some("1000".into())));
634        }
635
636        #[test]
637        fn invalid_username() {
638            let result = UserPatch::from_str("user*name");
639
640            assert!(result.is_err());
641        }
642
643        #[test]
644        fn invalid_extra() {
645            let result = UserPatch::from_str("user:group:extra");
646
647            assert!(result.is_err());
648        }
649    }
650
651    mod port {
652
653        use super::*;
654
655        #[test]
656        fn simple() {
657            let result = PortPatch::from_str("80").unwrap();
658
659            assert_eq_sorted!(result.port, Some(80));
660            assert_eq_sorted!(result.protocol, Some(None));
661        }
662
663        #[test]
664        fn with_tcp_protocol() {
665            let result = PortPatch::from_str("80/tcp").unwrap();
666
667            assert_eq_sorted!(result.port, Some(80));
668            assert_eq_sorted!(result.protocol, Some(Some(PortProtocol::Tcp)));
669        }
670
671        #[test]
672        fn with_udp_protocol() {
673            let result = PortPatch::from_str("80/udp").unwrap();
674
675            assert_eq_sorted!(result.port, Some(80));
676            assert_eq_sorted!(result.protocol, Some(Some(PortProtocol::Udp)));
677        }
678
679        #[test]
680        fn invalid() {
681            let result = PortPatch::from_str("80/invalid");
682
683            assert!(result.is_err());
684        }
685    }
686}