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