dofigen_lib/
from_str.rs

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