Skip to main content

github_actions_models/
common.rs

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