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