github_actions_models/
common.rs

1//! Shared models and utilities.
2
3use std::fmt::{self, Display};
4
5use indexmap::IndexMap;
6use self_cell::self_cell;
7use serde::{Deserialize, Deserializer, Serialize, de};
8
9pub mod expr;
10
11/// `permissions` for a workflow, job, or step.
12#[derive(Deserialize, Debug, PartialEq)]
13#[serde(rename_all = "kebab-case", untagged)]
14pub enum Permissions {
15    /// Base, i.e. blanket permissions.
16    Base(BasePermission),
17    /// Fine-grained permissions.
18    ///
19    /// These are modeled with an open-ended mapping rather than a structure
20    /// to make iteration over all defined permissions easier.
21    Explicit(IndexMap<String, Permission>),
22}
23
24impl Default for Permissions {
25    fn default() -> Self {
26        Self::Base(BasePermission::Default)
27    }
28}
29
30/// "Base" permissions, where all individual permissions are configured
31/// with a blanket setting.
32#[derive(Deserialize, Debug, Default, PartialEq)]
33#[serde(rename_all = "kebab-case")]
34pub enum BasePermission {
35    /// Whatever default permissions come from the workflow's `GITHUB_TOKEN`.
36    #[default]
37    Default,
38    /// "Read" access to all resources.
39    ReadAll,
40    /// "Write" access to all resources (implies read).
41    WriteAll,
42}
43
44/// A singular permission setting.
45#[derive(Deserialize, Debug, Default, PartialEq)]
46#[serde(rename_all = "kebab-case")]
47pub enum Permission {
48    /// Read access.
49    Read,
50
51    /// Write access.
52    Write,
53
54    /// No access.
55    #[default]
56    None,
57}
58
59/// An environment mapping.
60pub type Env = IndexMap<String, EnvValue>;
61
62/// Environment variable values are always strings, but GitHub Actions
63/// allows users to configure them as various native YAML types before
64/// internal stringification.
65///
66/// This type also gets used for other places where GitHub Actions
67/// contextually reinterprets a YAML value as a string, e.g. trigger
68/// input values.
69#[derive(Deserialize, Serialize, Debug, PartialEq)]
70#[serde(untagged)]
71pub enum EnvValue {
72    // Missing values are empty strings.
73    #[serde(deserialize_with = "null_to_default")]
74    String(String),
75    Number(f64),
76    Boolean(bool),
77}
78
79impl Display for EnvValue {
80    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
81        match self {
82            Self::String(s) => write!(f, "{s}"),
83            Self::Number(n) => write!(f, "{n}"),
84            Self::Boolean(b) => write!(f, "{b}"),
85        }
86    }
87}
88
89impl EnvValue {
90    /// Returns whether this [`EnvValue`] is a "trueish" value
91    /// per C#'s `Boolean.TryParse`.
92    ///
93    /// This follows the semantics of C#'s `Boolean.TryParse`, where
94    /// the case-insensitive string "true" is considered true, but
95    /// "1", "yes", etc. are not.
96    pub fn csharp_trueish(&self) -> bool {
97        match self {
98            EnvValue::Boolean(true) => true,
99            EnvValue::String(maybe) => maybe.trim().eq_ignore_ascii_case("true"),
100            _ => false,
101        }
102    }
103}
104
105/// A "scalar or vector" type, for places in GitHub Actions where a
106/// key can have either a scalar value or an array of values.
107///
108/// This only appears internally, as an intermediate type for `scalar_or_vector`.
109#[derive(Deserialize, Debug, PartialEq)]
110#[serde(untagged)]
111enum SoV<T> {
112    One(T),
113    Many(Vec<T>),
114}
115
116impl<T> From<SoV<T>> for Vec<T> {
117    fn from(val: SoV<T>) -> Vec<T> {
118        match val {
119            SoV::One(v) => vec![v],
120            SoV::Many(vs) => vs,
121        }
122    }
123}
124
125pub(crate) fn scalar_or_vector<'de, D, T>(de: D) -> Result<Vec<T>, D::Error>
126where
127    D: Deserializer<'de>,
128    T: Deserialize<'de>,
129{
130    SoV::deserialize(de).map(Into::into)
131}
132
133/// A bool or string. This is useful for cases where GitHub Actions contextually
134/// reinterprets a YAML boolean as a string, e.g. `run: true` really means
135/// `run: 'true'`.
136#[derive(Deserialize, Debug, PartialEq)]
137#[serde(untagged)]
138enum BoS {
139    Bool(bool),
140    String(String),
141}
142
143impl From<BoS> for String {
144    fn from(value: BoS) -> Self {
145        match value {
146            BoS::Bool(b) => b.to_string(),
147            BoS::String(s) => s,
148        }
149    }
150}
151
152/// An `if:` condition in a job or action definition.
153///
154/// These are either booleans or bare (i.e. non-curly) expressions.
155#[derive(Deserialize, Serialize, Debug, PartialEq)]
156#[serde(untagged)]
157pub enum If {
158    Bool(bool),
159    // NOTE: condition expressions can be either "bare" or "curly", so we can't
160    // use `BoE` or anything else that assumes curly-only here.
161    Expr(String),
162}
163
164pub(crate) fn bool_is_string<'de, D>(de: D) -> Result<String, D::Error>
165where
166    D: Deserializer<'de>,
167{
168    BoS::deserialize(de).map(Into::into)
169}
170
171fn null_to_default<'de, D, T>(de: D) -> Result<T, D::Error>
172where
173    D: Deserializer<'de>,
174    T: Default + Deserialize<'de>,
175{
176    let key = Option::<T>::deserialize(de)?;
177    Ok(key.unwrap_or_default())
178}
179
180// TODO: Bother with enum variants here?
181#[derive(Debug, PartialEq)]
182pub struct UsesError(String);
183
184impl fmt::Display for UsesError {
185    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
186        write!(f, "malformed `uses` ref: {}", self.0)
187    }
188}
189
190#[derive(Debug, PartialEq)]
191pub enum Uses {
192    /// A local `uses:` clause, e.g. `uses: ./foo/bar`.
193    Local(LocalUses),
194
195    /// A repository `uses:` clause, e.g. `uses: foo/bar`.
196    Repository(RepositoryUses),
197
198    /// A Docker image `uses: clause`, e.g. `uses: docker://ubuntu`.
199    Docker(DockerUses),
200}
201
202impl Uses {
203    /// Parse a `uses:` clause into its appropriate variant.
204    pub fn parse(uses: impl Into<String>) -> Result<Self, UsesError> {
205        let uses = uses.into();
206
207        if uses.starts_with("./") {
208            Ok(Self::Local(LocalUses::new(uses)))
209        } else if let Some(image) = uses.strip_prefix("docker://") {
210            DockerUses::parse(image).map(Self::Docker)
211        } else {
212            RepositoryUses::parse(uses).map(Self::Repository)
213        }
214    }
215}
216
217/// A `uses: ./some/path` clause.
218#[derive(Debug, PartialEq)]
219#[non_exhaustive]
220pub struct LocalUses {
221    pub path: String,
222}
223
224impl LocalUses {
225    fn new(path: String) -> Self {
226        LocalUses { path }
227    }
228}
229
230#[derive(Debug, PartialEq)]
231struct RepositoryUsesInner<'a> {
232    /// The repo user or org.
233    owner: &'a str,
234    /// The repo name.
235    repo: &'a str,
236    /// The owner/repo slug.
237    slug: &'a str,
238    /// The subpath to the action or reusable workflow, if present.
239    subpath: Option<&'a str>,
240    /// The `@<ref>` that the `uses:` is pinned to.
241    git_ref: &'a str,
242}
243
244impl<'a> RepositoryUsesInner<'a> {
245    fn from_str(uses: &'a str) -> Result<Self, UsesError> {
246        // NOTE: Both git refs and paths can contain `@`, but in practice
247        // GHA refuses to run a `uses:` clause with more than one `@` in it.
248        let (path, git_ref) = match uses.rsplit_once('@') {
249            Some((path, git_ref)) => (path, git_ref),
250            None => return Err(UsesError(format!("missing `@<ref>` in {uses}"))),
251        };
252
253        let mut components = path.splitn(3, '/');
254
255        if let Some(owner) = components.next()
256            && let Some(repo) = components.next()
257        {
258            let subpath = components.next();
259
260            let slug = if subpath.is_none() {
261                path
262            } else {
263                &path[..owner.len() + 1 + repo.len()]
264            };
265
266            Ok(RepositoryUsesInner {
267                owner,
268                repo,
269                slug,
270                subpath,
271                git_ref,
272            })
273        } else {
274            Err(UsesError(format!("owner/repo slug is too short: {uses}")))
275        }
276    }
277}
278
279self_cell!(
280    /// A `uses: some/repo` clause.
281    pub struct RepositoryUses {
282        owner: String,
283
284        #[covariant]
285        dependent: RepositoryUsesInner,
286    }
287
288    impl {Debug, PartialEq}
289);
290
291impl RepositoryUses {
292    /// Parse a `uses: some/repo` clause.
293    pub fn parse(uses: impl Into<String>) -> Result<Self, UsesError> {
294        RepositoryUses::try_new(uses.into(), |s| {
295            let inner = RepositoryUsesInner::from_str(s)?;
296            Ok(inner)
297        })
298    }
299
300    /// Get the raw `uses:` string.
301    pub fn raw(&self) -> &str {
302        self.borrow_owner()
303    }
304
305    /// Get the owner (user or org) of this repository `uses:` clause.
306    pub fn owner(&self) -> &str {
307        self.borrow_dependent().owner
308    }
309
310    /// Get the repository name of this repository `uses:` clause.
311    pub fn repo(&self) -> &str {
312        self.borrow_dependent().repo
313    }
314
315    /// Get the owner/repo slug of this repository `uses:` clause.
316    pub fn slug(&self) -> &str {
317        self.borrow_dependent().slug
318    }
319
320    /// Get the optional subpath of this repository `uses:` clause.
321    pub fn subpath(&self) -> Option<&str> {
322        self.borrow_dependent().subpath
323    }
324
325    /// Get the git ref (branch, tag, or SHA) of this repository `uses:` clause.
326    pub fn git_ref(&self) -> &str {
327        self.borrow_dependent().git_ref
328    }
329}
330
331#[derive(Debug, PartialEq)]
332#[non_exhaustive]
333pub struct DockerUsesInner<'a> {
334    /// The registry this image is on, if present.
335    registry: Option<&'a str>,
336    /// The name of the Docker image.
337    image: &'a str,
338    /// An optional tag for the image.
339    tag: Option<&'a str>,
340    /// An optional integrity hash for the image.
341    hash: Option<&'a str>,
342}
343
344impl<'a> DockerUsesInner<'a> {
345    fn is_registry(registry: &str) -> bool {
346        // https://stackoverflow.com/a/42116190
347        registry == "localhost" || registry.contains('.') || registry.contains(':')
348    }
349
350    fn from_str(uses: &'a str) -> Result<Self, UsesError> {
351        let (registry, image) = match uses.split_once('/') {
352            Some((registry, image)) if Self::is_registry(registry) => (Some(registry), image),
353            _ => (None, uses),
354        };
355
356        // NOTE(ww): hashes aren't mentioned anywhere in Docker's own docs,
357        // but appear to be an OCI thing. GitHub doesn't support them
358        // yet either, but we expect them to soon (with "immutable actions").
359        if let Some(at_pos) = image.find('@') {
360            let (image, hash) = image.split_at(at_pos);
361
362            let hash = if hash.is_empty() {
363                None
364            } else {
365                Some(&hash[1..])
366            };
367
368            Ok(DockerUsesInner {
369                registry,
370                image,
371                tag: None,
372                hash,
373            })
374        } else {
375            let (image, tag) = match image.split_once(':') {
376                Some((image, "")) => (image, None),
377                Some((image, tag)) => (image, Some(tag)),
378                _ => (image, None),
379            };
380
381            Ok(DockerUsesInner {
382                registry,
383                image,
384                tag,
385                hash: None,
386            })
387        }
388    }
389}
390
391self_cell!(
392    /// A `uses: docker://some-image` clause.
393    pub struct DockerUses {
394        owner: String,
395
396        #[covariant]
397        dependent: DockerUsesInner,
398    }
399
400    impl {Debug, PartialEq}
401);
402
403impl DockerUses {
404    /// Parse a `uses: docker://some-image` clause.
405    pub fn parse(uses: impl Into<String>) -> Result<Self, UsesError> {
406        DockerUses::try_new(uses.into(), |s| {
407            let inner = DockerUsesInner::from_str(s)?;
408            Ok(inner)
409        })
410    }
411
412    /// Get the raw uses clause. This does not include the `docker://` prefix.
413    pub fn raw(&self) -> &str {
414        self.borrow_owner()
415    }
416
417    /// Get the optional registry of this Docker image.
418    pub fn registry(&self) -> Option<&str> {
419        self.borrow_dependent().registry
420    }
421
422    /// Get the image name of this Docker image.
423    pub fn image(&self) -> &str {
424        self.borrow_dependent().image
425    }
426
427    /// Get the optional tag of this Docker image.
428    pub fn tag(&self) -> Option<&str> {
429        self.borrow_dependent().tag
430    }
431
432    /// Get the optional hash of this Docker image.
433    pub fn hash(&self) -> Option<&str> {
434        self.borrow_dependent().hash
435    }
436}
437
438/// Wraps a `de::Error::custom` call to log the same error as
439/// a `tracing::error!` event.
440///
441/// This is useful when doing custom deserialization within untagged
442/// enum variants, since serde loses track of the original error.
443pub(crate) fn custom_error<'de, D>(msg: impl Display) -> D::Error
444where
445    D: Deserializer<'de>,
446{
447    let msg = msg.to_string();
448    tracing::error!(msg);
449    de::Error::custom(msg)
450}
451
452/// Deserialize a `DockerUses`.
453pub(crate) fn docker_uses<'de, D>(de: D) -> Result<DockerUses, D::Error>
454where
455    D: Deserializer<'de>,
456{
457    let uses = <String>::deserialize(de)?;
458    DockerUses::parse(uses).map_err(custom_error::<D>)
459}
460
461/// Deserialize an ordinary step `uses:`.
462pub(crate) fn step_uses<'de, D>(de: D) -> Result<Uses, D::Error>
463where
464    D: Deserializer<'de>,
465{
466    let uses = <String>::deserialize(de)?;
467    Uses::parse(uses).map_err(custom_error::<D>)
468}
469
470/// Deserialize a reusable workflow step `uses:`
471pub(crate) fn reusable_step_uses<'de, D>(de: D) -> Result<Uses, D::Error>
472where
473    D: Deserializer<'de>,
474{
475    let uses = step_uses(de)?;
476
477    match uses {
478        Uses::Repository(_) => Ok(uses),
479        Uses::Local(ref local) => {
480            // Local reusable workflows cannot be pinned.
481            // We do this with a string scan because `@` *can* occur as
482            // a path component in local actions uses, just not local reusable
483            // workflow uses.
484            if local.path.contains('@') {
485                Err(custom_error::<D>(
486                    "local reusable workflow reference can't specify `@<ref>`",
487                ))
488            } else {
489                Ok(uses)
490            }
491        }
492        // `docker://` is never valid in reusable workflow uses.
493        Uses::Docker(_) => Err(custom_error::<D>(
494            "docker action invalid in reusable workflow `uses`",
495        )),
496    }
497}
498
499#[cfg(test)]
500mod tests {
501    use indexmap::IndexMap;
502    use serde::Deserialize;
503
504    use crate::common::{BasePermission, Env, EnvValue, Permission};
505
506    use super::{Permissions, Uses, reusable_step_uses};
507
508    #[test]
509    fn test_permissions() {
510        assert_eq!(
511            serde_yaml::from_str::<Permissions>("read-all").unwrap(),
512            Permissions::Base(BasePermission::ReadAll)
513        );
514
515        let perm = "security-events: write";
516        assert_eq!(
517            serde_yaml::from_str::<Permissions>(perm).unwrap(),
518            Permissions::Explicit(IndexMap::from([(
519                "security-events".into(),
520                Permission::Write
521            )]))
522        );
523    }
524
525    #[test]
526    fn test_env_empty_value() {
527        let env = "foo:";
528        assert_eq!(
529            serde_yaml::from_str::<Env>(env).unwrap()["foo"],
530            EnvValue::String("".into())
531        );
532    }
533
534    #[test]
535    fn test_env_value_csharp_trueish() {
536        let vectors = [
537            (EnvValue::Boolean(true), true),
538            (EnvValue::Boolean(false), false),
539            (EnvValue::String("true".to_string()), true),
540            (EnvValue::String("TRUE".to_string()), true),
541            (EnvValue::String("TrUe".to_string()), true),
542            (EnvValue::String(" true ".to_string()), true),
543            (EnvValue::String("   \n\r\t True\n\n".to_string()), true),
544            (EnvValue::String("false".to_string()), false),
545            (EnvValue::String("1".to_string()), false),
546            (EnvValue::String("yes".to_string()), false),
547            (EnvValue::String("on".to_string()), false),
548            (EnvValue::String("random".to_string()), false),
549            (EnvValue::Number(1.0), false),
550            (EnvValue::Number(0.0), false),
551            (EnvValue::Number(666.0), false),
552        ];
553
554        for (val, expected) in vectors {
555            assert_eq!(val.csharp_trueish(), expected, "failed for {val:?}");
556        }
557    }
558
559    #[test]
560    fn test_uses_parses() {
561        // Fully pinned.
562        insta::assert_debug_snapshot!(
563            Uses::parse("actions/checkout@8f4b7f84864484a7bf31766abe9204da3cbe65b3").unwrap(),
564            @r#"
565        Repository(
566            RepositoryUses {
567                owner: "actions/checkout@8f4b7f84864484a7bf31766abe9204da3cbe65b3",
568                dependent: RepositoryUsesInner {
569                    owner: "actions",
570                    repo: "checkout",
571                    slug: "actions/checkout",
572                    subpath: None,
573                    git_ref: "8f4b7f84864484a7bf31766abe9204da3cbe65b3",
574                },
575            },
576        )
577        "#,
578        );
579
580        // Fully pinned, subpath.
581        insta::assert_debug_snapshot!(
582            Uses::parse("actions/aws/ec2@8f4b7f84864484a7bf31766abe9204da3cbe65b3").unwrap(),
583            @r#"
584        Repository(
585            RepositoryUses {
586                owner: "actions/aws/ec2@8f4b7f84864484a7bf31766abe9204da3cbe65b3",
587                dependent: RepositoryUsesInner {
588                    owner: "actions",
589                    repo: "aws",
590                    slug: "actions/aws",
591                    subpath: Some(
592                        "ec2",
593                    ),
594                    git_ref: "8f4b7f84864484a7bf31766abe9204da3cbe65b3",
595                },
596            },
597        )
598        "#
599        );
600
601        // Fully pinned, complex subpath.
602        insta::assert_debug_snapshot!(
603            Uses::parse("example/foo/bar/baz/quux@8f4b7f84864484a7bf31766abe9204da3cbe65b3").unwrap(),
604            @r#"
605        Repository(
606            RepositoryUses {
607                owner: "example/foo/bar/baz/quux@8f4b7f84864484a7bf31766abe9204da3cbe65b3",
608                dependent: RepositoryUsesInner {
609                    owner: "example",
610                    repo: "foo",
611                    slug: "example/foo",
612                    subpath: Some(
613                        "bar/baz/quux",
614                    ),
615                    git_ref: "8f4b7f84864484a7bf31766abe9204da3cbe65b3",
616                },
617            },
618        )
619        "#
620        );
621
622        // Pinned with branch/tag.
623        insta::assert_debug_snapshot!(
624            Uses::parse("actions/checkout@v4").unwrap(),
625            @r#"
626        Repository(
627            RepositoryUses {
628                owner: "actions/checkout@v4",
629                dependent: RepositoryUsesInner {
630                    owner: "actions",
631                    repo: "checkout",
632                    slug: "actions/checkout",
633                    subpath: None,
634                    git_ref: "v4",
635                },
636            },
637        )
638        "#
639        );
640
641        insta::assert_debug_snapshot!(
642            Uses::parse("actions/checkout@abcd").unwrap(),
643            @r#"
644        Repository(
645            RepositoryUses {
646                owner: "actions/checkout@abcd",
647                dependent: RepositoryUsesInner {
648                    owner: "actions",
649                    repo: "checkout",
650                    slug: "actions/checkout",
651                    subpath: None,
652                    git_ref: "abcd",
653                },
654            },
655        )
656        "#
657        );
658
659        // Invalid: unpinned.
660        insta::assert_debug_snapshot!(
661            Uses::parse("actions/checkout").unwrap_err(),
662            @r#"
663        UsesError(
664            "missing `@<ref>` in actions/checkout",
665        )
666        "#
667        );
668
669        // Valid: Docker ref, implicit registry.
670        insta::assert_debug_snapshot!(
671            Uses::parse("docker://alpine:3.8").unwrap(),
672            @r#"
673        Docker(
674            DockerUses {
675                owner: "alpine:3.8",
676                dependent: DockerUsesInner {
677                    registry: None,
678                    image: "alpine",
679                    tag: Some(
680                        "3.8",
681                    ),
682                    hash: None,
683                },
684            },
685        )
686        "#
687        );
688
689        // Valid: Docker ref, localhost.
690        insta::assert_debug_snapshot!(
691            Uses::parse("docker://localhost/alpine:3.8").unwrap(),
692            @r#"
693        Docker(
694            DockerUses {
695                owner: "localhost/alpine:3.8",
696                dependent: DockerUsesInner {
697                    registry: Some(
698                        "localhost",
699                    ),
700                    image: "alpine",
701                    tag: Some(
702                        "3.8",
703                    ),
704                    hash: None,
705                },
706            },
707        )
708        "#
709        );
710
711        // Valid: Docker ref, localhost with port.
712        insta::assert_debug_snapshot!(
713            Uses::parse("docker://localhost:1337/alpine:3.8").unwrap(),
714            @r#"
715        Docker(
716            DockerUses {
717                owner: "localhost:1337/alpine:3.8",
718                dependent: DockerUsesInner {
719                    registry: Some(
720                        "localhost:1337",
721                    ),
722                    image: "alpine",
723                    tag: Some(
724                        "3.8",
725                    ),
726                    hash: None,
727                },
728            },
729        )
730        "#
731        );
732
733        // Valid: Docker ref, custom registry.
734        insta::assert_debug_snapshot!(
735            Uses::parse("docker://ghcr.io/foo/alpine:3.8").unwrap(),
736            @r#"
737        Docker(
738            DockerUses {
739                owner: "ghcr.io/foo/alpine:3.8",
740                dependent: DockerUsesInner {
741                    registry: Some(
742                        "ghcr.io",
743                    ),
744                    image: "foo/alpine",
745                    tag: Some(
746                        "3.8",
747                    ),
748                    hash: None,
749                },
750            },
751        )
752        "#
753        );
754
755        // Valid: Docker ref, missing tag.
756        insta::assert_debug_snapshot!(
757            Uses::parse("docker://ghcr.io/foo/alpine").unwrap(),
758            @r#"
759        Docker(
760            DockerUses {
761                owner: "ghcr.io/foo/alpine",
762                dependent: DockerUsesInner {
763                    registry: Some(
764                        "ghcr.io",
765                    ),
766                    image: "foo/alpine",
767                    tag: None,
768                    hash: None,
769                },
770            },
771        )
772        "#
773        );
774
775        // Invalid, but allowed: Docker ref, empty tag
776        insta::assert_debug_snapshot!(
777            Uses::parse("docker://ghcr.io/foo/alpine:").unwrap(),
778            @r#"
779        Docker(
780            DockerUses {
781                owner: "ghcr.io/foo/alpine:",
782                dependent: DockerUsesInner {
783                    registry: Some(
784                        "ghcr.io",
785                    ),
786                    image: "foo/alpine",
787                    tag: None,
788                    hash: None,
789                },
790            },
791        )
792        "#
793        );
794
795        // Valid: Docker ref, bare.
796        insta::assert_debug_snapshot!(
797            Uses::parse("docker://alpine").unwrap(),
798            @r#"
799        Docker(
800            DockerUses {
801                owner: "alpine",
802                dependent: DockerUsesInner {
803                    registry: None,
804                    image: "alpine",
805                    tag: None,
806                    hash: None,
807                },
808            },
809        )
810        "#
811        );
812
813        // Valid: Docker ref, with hash.
814        insta::assert_debug_snapshot!(
815            Uses::parse("docker://alpine@hash").unwrap(),
816            @r#"
817        Docker(
818            DockerUses {
819                owner: "alpine@hash",
820                dependent: DockerUsesInner {
821                    registry: None,
822                    image: "alpine",
823                    tag: None,
824                    hash: Some(
825                        "hash",
826                    ),
827                },
828            },
829        )
830        "#
831        );
832
833        // Valid: Local action "ref", actually part of the path
834        insta::assert_debug_snapshot!(
835            Uses::parse("./.github/actions/hello-world-action@172239021f7ba04fe7327647b213799853a9eb89").unwrap(),
836            @r#"
837        Local(
838            LocalUses {
839                path: "./.github/actions/hello-world-action@172239021f7ba04fe7327647b213799853a9eb89",
840            },
841        )
842        "#
843        );
844
845        // Valid: Local action ref, unpinned.
846        insta::assert_debug_snapshot!(
847            Uses::parse("./.github/actions/hello-world-action").unwrap(),
848            @r#"
849        Local(
850            LocalUses {
851                path: "./.github/actions/hello-world-action",
852            },
853        )
854        "#
855        );
856
857        // Invalid: missing user/repo
858        insta::assert_debug_snapshot!(
859            Uses::parse("checkout@8f4b7f84864484a7bf31766abe9204da3cbe65b3").unwrap_err(),
860            @r#"
861        UsesError(
862            "owner/repo slug is too short: checkout@8f4b7f84864484a7bf31766abe9204da3cbe65b3",
863        )
864        "#
865        );
866    }
867
868    #[test]
869    fn test_uses_deser_reusable() {
870        // Dummy type for testing deser of `Uses`.
871        #[derive(Deserialize)]
872        #[serde(transparent)]
873        struct Dummy(#[serde(deserialize_with = "reusable_step_uses")] Uses);
874
875        insta::assert_debug_snapshot!(
876            serde_yaml::from_str::<Dummy>(
877                "octo-org/this-repo/.github/workflows/workflow-1.yml@172239021f7ba04fe7327647b213799853a9eb89"
878            )
879            .map(|d| d.0)
880            .unwrap(),
881            @r#"
882        Repository(
883            RepositoryUses {
884                owner: "octo-org/this-repo/.github/workflows/workflow-1.yml@172239021f7ba04fe7327647b213799853a9eb89",
885                dependent: RepositoryUsesInner {
886                    owner: "octo-org",
887                    repo: "this-repo",
888                    slug: "octo-org/this-repo",
889                    subpath: Some(
890                        ".github/workflows/workflow-1.yml",
891                    ),
892                    git_ref: "172239021f7ba04fe7327647b213799853a9eb89",
893                },
894            },
895        )
896        "#
897        );
898
899        insta::assert_debug_snapshot!(
900            serde_yaml::from_str::<Dummy>(
901                "octo-org/this-repo/.github/workflows/workflow-1.yml@notahash"
902            ).map(|d| d.0).unwrap(),
903            @r#"
904        Repository(
905            RepositoryUses {
906                owner: "octo-org/this-repo/.github/workflows/workflow-1.yml@notahash",
907                dependent: RepositoryUsesInner {
908                    owner: "octo-org",
909                    repo: "this-repo",
910                    slug: "octo-org/this-repo",
911                    subpath: Some(
912                        ".github/workflows/workflow-1.yml",
913                    ),
914                    git_ref: "notahash",
915                },
916            },
917        )
918        "#
919        );
920
921        insta::assert_debug_snapshot!(
922            serde_yaml::from_str::<Dummy>(
923                "octo-org/this-repo/.github/workflows/workflow-1.yml@abcd"
924            ).map(|d| d.0).unwrap(),
925            @r#"
926        Repository(
927            RepositoryUses {
928                owner: "octo-org/this-repo/.github/workflows/workflow-1.yml@abcd",
929                dependent: RepositoryUsesInner {
930                    owner: "octo-org",
931                    repo: "this-repo",
932                    slug: "octo-org/this-repo",
933                    subpath: Some(
934                        ".github/workflows/workflow-1.yml",
935                    ),
936                    git_ref: "abcd",
937                },
938            },
939        )
940        "#
941        );
942
943        // Invalid: remote reusable workflow without ref
944        insta::assert_debug_snapshot!(
945            serde_yaml::from_str::<Dummy>(
946                "octo-org/this-repo/.github/workflows/workflow-1.yml"
947            ).map(|d| d.0).unwrap_err(),
948            @r#"Error("malformed `uses` ref: missing `@<ref>` in octo-org/this-repo/.github/workflows/workflow-1.yml")"#
949        );
950
951        // Invalid: local reusable workflow with ref
952        insta::assert_debug_snapshot!(
953            serde_yaml::from_str::<Dummy>(
954                "./.github/workflows/workflow-1.yml@172239021f7ba04fe7327647b213799853a9eb89"
955            ).map(|d| d.0).unwrap_err(),
956            @r#"Error("local reusable workflow reference can't specify `@<ref>`")"#
957        );
958
959        // Invalid: no ref at all
960        insta::assert_debug_snapshot!(
961            serde_yaml::from_str::<Dummy>(
962                ".github/workflows/workflow-1.yml"
963            ).map(|d| d.0).unwrap_err(),
964            @r#"Error("malformed `uses` ref: missing `@<ref>` in .github/workflows/workflow-1.yml")"#
965        );
966
967        // Invalid: missing user/repo
968        insta::assert_debug_snapshot!(
969            serde_yaml::from_str::<Dummy>(
970                "workflow-1.yml@172239021f7ba04fe7327647b213799853a9eb89"
971            ).map(|d| d.0).unwrap_err(),
972            @r#"Error("malformed `uses` ref: owner/repo slug is too short: workflow-1.yml@172239021f7ba04fe7327647b213799853a9eb89")"#
973        );
974    }
975}