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("./") || 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    /// Whether this [`LocalUses`] is a "self-referencing" action,
324    /// i.e. references the repository it's being used from.
325    ///
326    /// See: <https://github.blog/changelog/2026-07-30-reference-same-repository-actions-with-self-repository-syntax/>
327    pub fn is_self_repository(&self) -> bool {
328        self.path.starts_with('$')
329    }
330}
331
332#[derive(Debug, PartialEq)]
333struct RepositoryUsesInner<'a> {
334    /// The repo user or org.
335    owner: &'a str,
336    /// The repo name.
337    repo: &'a str,
338    /// The owner/repo slug.
339    slug: &'a str,
340    /// The subpath to the action or reusable workflow, if present.
341    subpath: Option<&'a str>,
342    /// The `@<ref>` that the `uses:` is pinned to.
343    git_ref: &'a str,
344}
345
346impl<'a> RepositoryUsesInner<'a> {
347    fn from_str(uses: &'a str) -> Result<Self, UsesError> {
348        // NOTE: Empirically, GitHub Actions strips whitespace from the start and end of `uses:` clauses.
349        let uses = uses.trim();
350
351        // NOTE: Both git refs and paths can contain `@`, but in practice
352        // GHA refuses to run a `uses:` clause with more than one `@` in it.
353        let (path, git_ref) = match uses.rsplit_once('@') {
354            Some((path, git_ref)) => (path, git_ref),
355            None => return Err(UsesError(format!("missing `@<ref>` in {uses}"))),
356        };
357
358        let mut components = path.splitn(3, '/');
359
360        if let Some(owner) = components.next()
361            && let Some(repo) = components.next()
362        {
363            let subpath = components.next();
364
365            let slug = if subpath.is_none() {
366                path
367            } else {
368                &path[..owner.len() + 1 + repo.len()]
369            };
370
371            Ok(RepositoryUsesInner {
372                owner,
373                repo,
374                slug,
375                subpath,
376                git_ref,
377            })
378        } else {
379            Err(UsesError(format!("owner/repo slug is too short: {uses}")))
380        }
381    }
382}
383
384self_cell!(
385    /// A `uses: some/repo` clause.
386    pub struct RepositoryUses {
387        owner: String,
388
389        #[covariant]
390        dependent: RepositoryUsesInner,
391    }
392
393    impl {Debug, PartialEq}
394);
395
396impl Display for RepositoryUses {
397    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
398        write!(f, "{}", self.raw())
399    }
400}
401
402impl RepositoryUses {
403    /// Parse a `uses: some/repo` clause.
404    pub fn parse(uses: impl Into<String>) -> Result<Self, UsesError> {
405        RepositoryUses::try_new(uses.into(), |s| {
406            let inner = RepositoryUsesInner::from_str(s)?;
407            Ok(inner)
408        })
409    }
410
411    /// Get the raw `uses:` string.
412    pub fn raw(&self) -> &str {
413        self.borrow_owner()
414    }
415
416    /// Get the owner (user or org) of this repository `uses:` clause.
417    pub fn owner(&self) -> &str {
418        self.borrow_dependent().owner
419    }
420
421    /// Get the repository name of this repository `uses:` clause.
422    pub fn repo(&self) -> &str {
423        self.borrow_dependent().repo
424    }
425
426    /// Get the owner/repo slug of this repository `uses:` clause.
427    pub fn slug(&self) -> &str {
428        self.borrow_dependent().slug
429    }
430
431    /// Get the optional subpath of this repository `uses:` clause.
432    pub fn subpath(&self) -> Option<&str> {
433        self.borrow_dependent().subpath
434    }
435
436    /// Get the git ref (branch, tag, or SHA) of this repository `uses:` clause.
437    pub fn git_ref(&self) -> &str {
438        self.borrow_dependent().git_ref
439    }
440}
441
442#[derive(Debug, PartialEq)]
443#[non_exhaustive]
444pub struct DockerUsesInner<'a> {
445    /// The registry this image is on, if present.
446    registry: Option<&'a str>,
447    /// The name of the Docker image.
448    image: &'a str,
449    /// An optional tag for the image.
450    tag: Option<&'a str>,
451    /// An optional integrity hash for the image.
452    hash: Option<&'a str>,
453}
454
455impl<'a> DockerUsesInner<'a> {
456    fn is_registry(registry: &str) -> bool {
457        // https://stackoverflow.com/a/42116190
458        registry == "localhost" || registry.contains('.') || registry.contains(':')
459    }
460
461    fn from_str(uses: &'a str) -> Self {
462        // NOTE: Empirically, GitHub Actions strips whitespace from the start and end of `uses:` clauses.
463        let uses = uses.trim();
464
465        let (registry, image) = match uses.split_once('/') {
466            Some((registry, image)) if Self::is_registry(registry) => (Some(registry), image),
467            _ => (None, uses),
468        };
469
470        // NOTE(ww): hashes aren't mentioned anywhere in Docker's own docs,
471        // but appear to be an OCI thing. GitHub doesn't support them
472        // yet either, but we expect them to soon (with "immutable actions").
473        if let Some(at_pos) = image.find('@') {
474            let (image, hash) = image.split_at(at_pos);
475
476            let hash = if hash.is_empty() {
477                None
478            } else {
479                Some(&hash[1..])
480            };
481
482            DockerUsesInner {
483                registry,
484                image,
485                tag: None,
486                hash,
487            }
488        } else {
489            let (image, tag) = match image.split_once(':') {
490                Some((image, "")) => (image, None),
491                Some((image, tag)) => (image, Some(tag)),
492                _ => (image, None),
493            };
494
495            DockerUsesInner {
496                registry,
497                image,
498                tag,
499                hash: None,
500            }
501        }
502    }
503}
504
505self_cell!(
506    /// A `uses: docker://some-image` clause.
507    pub struct DockerUses {
508        owner: String,
509
510        #[covariant]
511        dependent: DockerUsesInner,
512    }
513
514    impl {Debug, PartialEq}
515);
516
517impl DockerUses {
518    /// Parse a `uses: docker://some-image` clause.
519    pub fn parse(uses: impl Into<String>) -> Self {
520        DockerUses::new(uses.into(), |s| DockerUsesInner::from_str(s))
521    }
522
523    /// Get the raw uses clause. This does not include the `docker://` prefix.
524    pub fn raw(&self) -> &str {
525        self.borrow_owner()
526    }
527
528    /// Get the optional registry of this Docker image.
529    pub fn registry(&self) -> Option<&str> {
530        self.borrow_dependent().registry
531    }
532
533    /// Get the image name of this Docker image.
534    pub fn image(&self) -> &str {
535        self.borrow_dependent().image
536    }
537
538    /// Get the optional tag of this Docker image.
539    pub fn tag(&self) -> Option<&str> {
540        self.borrow_dependent().tag
541    }
542
543    /// Get the optional hash of this Docker image.
544    pub fn hash(&self) -> Option<&str> {
545        self.borrow_dependent().hash
546    }
547}
548
549impl<'de> Deserialize<'de> for DockerUses {
550    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
551    where
552        D: Deserializer<'de>,
553    {
554        let uses = <Cow<'de, str>>::deserialize(deserializer)?;
555        Ok(DockerUses::parse(uses))
556    }
557}
558
559/// Wraps a `de::Error::custom` call to log the same error as
560/// a `tracing::error!` event.
561///
562/// This is useful when doing custom deserialization within untagged
563/// enum variants, since serde loses track of the original error.
564pub(crate) fn custom_error<'de, D>(msg: impl Display) -> D::Error
565where
566    D: Deserializer<'de>,
567{
568    let msg = msg.to_string();
569    tracing::error!(msg);
570    de::Error::custom(msg)
571}
572
573/// Deserialize an ordinary step `uses:`.
574pub(crate) fn step_uses<'de, D>(de: D) -> Result<Uses, D::Error>
575where
576    D: Deserializer<'de>,
577{
578    let uses = <Cow<'de, str>>::deserialize(de)?;
579    Uses::parse(uses).map_err(custom_error::<D>)
580}
581
582/// Deserialize a reusable workflow step `uses:`
583pub(crate) fn reusable_step_uses<'de, D>(de: D) -> Result<Uses, D::Error>
584where
585    D: Deserializer<'de>,
586{
587    let uses = step_uses(de)?;
588
589    match uses {
590        Uses::Repository(_) => Ok(uses),
591        Uses::Local(ref local) => {
592            // Local reusable workflows cannot be pinned.
593            // We do this with a string scan because `@` *can* occur as
594            // a path component in local actions uses, just not local reusable
595            // workflow uses.
596            if local.path.contains('@') {
597                Err(custom_error::<D>(
598                    "local reusable workflow reference can't specify `@<ref>`",
599                ))
600            } else {
601                Ok(uses)
602            }
603        }
604        // `docker://` is never valid in reusable workflow uses.
605        Uses::Docker(_) => Err(custom_error::<D>(
606            "docker action invalid in reusable workflow `uses`",
607        )),
608    }
609}
610
611#[cfg(test)]
612mod tests {
613    use indexmap::IndexMap;
614    use serde::Deserialize;
615
616    use crate::common::{BasePermission, Env, EnvValue, Permission};
617
618    use super::{Permissions, Uses, reusable_step_uses};
619
620    #[test]
621    fn test_permissions() {
622        assert_eq!(
623            yaml_serde::from_str::<Permissions>("read-all").unwrap(),
624            Permissions::Base(BasePermission::ReadAll)
625        );
626
627        let perm = "security-events: write";
628        assert_eq!(
629            yaml_serde::from_str::<Permissions>(perm).unwrap(),
630            Permissions::Explicit(IndexMap::from([(
631                "security-events".into(),
632                Permission::Write
633            )]))
634        );
635    }
636
637    #[test]
638    fn test_env_empty_value() {
639        let env = "foo:";
640        assert_eq!(
641            yaml_serde::from_str::<Env>(env).unwrap()["foo"],
642            EnvValue::String("".into())
643        );
644    }
645
646    #[test]
647    fn test_env_value_csharp_trueish() {
648        let vectors = [
649            (EnvValue::Boolean(true), true),
650            (EnvValue::Boolean(false), false),
651            (EnvValue::String("true".to_string()), true),
652            (EnvValue::String("TRUE".to_string()), true),
653            (EnvValue::String("TrUe".to_string()), true),
654            (EnvValue::String(" true ".to_string()), true),
655            (EnvValue::String("   \n\r\t True\n\n".to_string()), true),
656            (EnvValue::String("false".to_string()), false),
657            (EnvValue::String("1".to_string()), false),
658            (EnvValue::String("yes".to_string()), false),
659            (EnvValue::String("on".to_string()), false),
660            (EnvValue::String("random".to_string()), false),
661            (EnvValue::Number(1.0), false),
662            (EnvValue::Number(0.0), false),
663            (EnvValue::Number(666.0), false),
664        ];
665
666        for (val, expected) in vectors {
667            assert_eq!(val.csharp_bool(), expected, "failed for {val:?}");
668        }
669    }
670
671    #[test]
672    fn test_uses_parses() {
673        // Fully pinned.
674        insta::assert_debug_snapshot!(
675            Uses::parse("actions/checkout@8f4b7f84864484a7bf31766abe9204da3cbe65b3").unwrap(),
676            @r#"
677        Repository(
678            RepositoryUses {
679                owner: "actions/checkout@8f4b7f84864484a7bf31766abe9204da3cbe65b3",
680                dependent: RepositoryUsesInner {
681                    owner: "actions",
682                    repo: "checkout",
683                    slug: "actions/checkout",
684                    subpath: None,
685                    git_ref: "8f4b7f84864484a7bf31766abe9204da3cbe65b3",
686                },
687            },
688        )
689        "#,
690        );
691
692        // Fully pinned, subpath.
693        insta::assert_debug_snapshot!(
694            Uses::parse("actions/aws/ec2@8f4b7f84864484a7bf31766abe9204da3cbe65b3").unwrap(),
695            @r#"
696        Repository(
697            RepositoryUses {
698                owner: "actions/aws/ec2@8f4b7f84864484a7bf31766abe9204da3cbe65b3",
699                dependent: RepositoryUsesInner {
700                    owner: "actions",
701                    repo: "aws",
702                    slug: "actions/aws",
703                    subpath: Some(
704                        "ec2",
705                    ),
706                    git_ref: "8f4b7f84864484a7bf31766abe9204da3cbe65b3",
707                },
708            },
709        )
710        "#
711        );
712
713        // Fully pinned, complex subpath.
714        insta::assert_debug_snapshot!(
715            Uses::parse("example/foo/bar/baz/quux@8f4b7f84864484a7bf31766abe9204da3cbe65b3").unwrap(),
716            @r#"
717        Repository(
718            RepositoryUses {
719                owner: "example/foo/bar/baz/quux@8f4b7f84864484a7bf31766abe9204da3cbe65b3",
720                dependent: RepositoryUsesInner {
721                    owner: "example",
722                    repo: "foo",
723                    slug: "example/foo",
724                    subpath: Some(
725                        "bar/baz/quux",
726                    ),
727                    git_ref: "8f4b7f84864484a7bf31766abe9204da3cbe65b3",
728                },
729            },
730        )
731        "#
732        );
733
734        // Pinned with branch/tag.
735        insta::assert_debug_snapshot!(
736            Uses::parse("actions/checkout@v4").unwrap(),
737            @r#"
738        Repository(
739            RepositoryUses {
740                owner: "actions/checkout@v4",
741                dependent: RepositoryUsesInner {
742                    owner: "actions",
743                    repo: "checkout",
744                    slug: "actions/checkout",
745                    subpath: None,
746                    git_ref: "v4",
747                },
748            },
749        )
750        "#
751        );
752
753        insta::assert_debug_snapshot!(
754            Uses::parse("actions/checkout@abcd").unwrap(),
755            @r#"
756        Repository(
757            RepositoryUses {
758                owner: "actions/checkout@abcd",
759                dependent: RepositoryUsesInner {
760                    owner: "actions",
761                    repo: "checkout",
762                    slug: "actions/checkout",
763                    subpath: None,
764                    git_ref: "abcd",
765                },
766            },
767        )
768        "#
769        );
770
771        // Invalid: unpinned.
772        insta::assert_debug_snapshot!(
773            Uses::parse("actions/checkout").unwrap_err(),
774            @r#"
775        UsesError(
776            "missing `@<ref>` in actions/checkout",
777        )
778        "#
779        );
780
781        // Valid: Docker ref, implicit registry.
782        insta::assert_debug_snapshot!(
783            Uses::parse("docker://alpine:3.8").unwrap(),
784            @r#"
785        Docker(
786            DockerUses {
787                owner: "alpine:3.8",
788                dependent: DockerUsesInner {
789                    registry: None,
790                    image: "alpine",
791                    tag: Some(
792                        "3.8",
793                    ),
794                    hash: None,
795                },
796            },
797        )
798        "#
799        );
800
801        // Valid: Docker ref, localhost.
802        insta::assert_debug_snapshot!(
803            Uses::parse("docker://localhost/alpine:3.8").unwrap(),
804            @r#"
805        Docker(
806            DockerUses {
807                owner: "localhost/alpine:3.8",
808                dependent: DockerUsesInner {
809                    registry: Some(
810                        "localhost",
811                    ),
812                    image: "alpine",
813                    tag: Some(
814                        "3.8",
815                    ),
816                    hash: None,
817                },
818            },
819        )
820        "#
821        );
822
823        // Valid: Docker ref, localhost with port.
824        insta::assert_debug_snapshot!(
825            Uses::parse("docker://localhost:1337/alpine:3.8").unwrap(),
826            @r#"
827        Docker(
828            DockerUses {
829                owner: "localhost:1337/alpine:3.8",
830                dependent: DockerUsesInner {
831                    registry: Some(
832                        "localhost:1337",
833                    ),
834                    image: "alpine",
835                    tag: Some(
836                        "3.8",
837                    ),
838                    hash: None,
839                },
840            },
841        )
842        "#
843        );
844
845        // Valid: Docker ref, custom registry.
846        insta::assert_debug_snapshot!(
847            Uses::parse("docker://ghcr.io/foo/alpine:3.8").unwrap(),
848            @r#"
849        Docker(
850            DockerUses {
851                owner: "ghcr.io/foo/alpine:3.8",
852                dependent: DockerUsesInner {
853                    registry: Some(
854                        "ghcr.io",
855                    ),
856                    image: "foo/alpine",
857                    tag: Some(
858                        "3.8",
859                    ),
860                    hash: None,
861                },
862            },
863        )
864        "#
865        );
866
867        // Valid: Docker ref, missing tag.
868        insta::assert_debug_snapshot!(
869            Uses::parse("docker://ghcr.io/foo/alpine").unwrap(),
870            @r#"
871        Docker(
872            DockerUses {
873                owner: "ghcr.io/foo/alpine",
874                dependent: DockerUsesInner {
875                    registry: Some(
876                        "ghcr.io",
877                    ),
878                    image: "foo/alpine",
879                    tag: None,
880                    hash: None,
881                },
882            },
883        )
884        "#
885        );
886
887        // Invalid, but allowed: Docker ref, empty tag
888        insta::assert_debug_snapshot!(
889            Uses::parse("docker://ghcr.io/foo/alpine:").unwrap(),
890            @r#"
891        Docker(
892            DockerUses {
893                owner: "ghcr.io/foo/alpine:",
894                dependent: DockerUsesInner {
895                    registry: Some(
896                        "ghcr.io",
897                    ),
898                    image: "foo/alpine",
899                    tag: None,
900                    hash: None,
901                },
902            },
903        )
904        "#
905        );
906
907        // Valid: Docker ref, bare.
908        insta::assert_debug_snapshot!(
909            Uses::parse("docker://alpine").unwrap(),
910            @r#"
911        Docker(
912            DockerUses {
913                owner: "alpine",
914                dependent: DockerUsesInner {
915                    registry: None,
916                    image: "alpine",
917                    tag: None,
918                    hash: None,
919                },
920            },
921        )
922        "#
923        );
924
925        // Valid: Docker ref, with hash.
926        insta::assert_debug_snapshot!(
927            Uses::parse("docker://alpine@hash").unwrap(),
928            @r#"
929        Docker(
930            DockerUses {
931                owner: "alpine@hash",
932                dependent: DockerUsesInner {
933                    registry: None,
934                    image: "alpine",
935                    tag: None,
936                    hash: Some(
937                        "hash",
938                    ),
939                },
940            },
941        )
942        "#
943        );
944
945        // Valid: Local action "ref", actually part of the path
946        insta::assert_debug_snapshot!(
947            Uses::parse("./.github/actions/hello-world-action@172239021f7ba04fe7327647b213799853a9eb89").unwrap(),
948            @r#"
949        Local(
950            LocalUses {
951                path: "./.github/actions/hello-world-action@172239021f7ba04fe7327647b213799853a9eb89",
952            },
953        )
954        "#
955        );
956
957        // Valid: Local action ref, unpinned.
958        insta::assert_debug_snapshot!(
959            Uses::parse("./.github/actions/hello-world-action").unwrap(),
960            @r#"
961        Local(
962            LocalUses {
963                path: "./.github/actions/hello-world-action",
964            },
965        )
966        "#
967        );
968
969        // Valid: new $-style local uses.
970        insta::assert_debug_snapshot!(
971            Uses::parse("$/.github/actions/hello-world-action").unwrap(),
972            @r#"
973        Local(
974            LocalUses {
975                path: "$/.github/actions/hello-world-action",
976            },
977        )
978        "#
979        );
980
981        // Invalid: missing user/repo
982        insta::assert_debug_snapshot!(
983            Uses::parse("checkout@8f4b7f84864484a7bf31766abe9204da3cbe65b3").unwrap_err(),
984            @r#"
985        UsesError(
986            "owner/repo slug is too short: checkout@8f4b7f84864484a7bf31766abe9204da3cbe65b3",
987        )
988        "#
989        );
990
991        // Valid: leading/trailing whitespace.
992        insta::assert_debug_snapshot!(
993            Uses::parse("\nactions/checkout@v4  \n").unwrap(),
994            @r#"
995        Repository(
996            RepositoryUses {
997                owner: "actions/checkout@v4",
998                dependent: RepositoryUsesInner {
999                    owner: "actions",
1000                    repo: "checkout",
1001                    slug: "actions/checkout",
1002                    subpath: None,
1003                    git_ref: "v4",
1004                },
1005            },
1006        )
1007        "#,
1008        );
1009
1010        insta::assert_debug_snapshot!(
1011            Uses::parse("\ndocker://alpine:3.8  \n").unwrap(),
1012            @r#"
1013        Docker(
1014            DockerUses {
1015                owner: "alpine:3.8",
1016                dependent: DockerUsesInner {
1017                    registry: None,
1018                    image: "alpine",
1019                    tag: Some(
1020                        "3.8",
1021                    ),
1022                    hash: None,
1023                },
1024            },
1025        )
1026        "#
1027        );
1028
1029        insta::assert_debug_snapshot!(
1030            Uses::parse("\n./.github/workflows/example.yml  \n").unwrap(),
1031            @r#"
1032        Local(
1033            LocalUses {
1034                path: "./.github/workflows/example.yml",
1035            },
1036        )
1037        "#
1038        );
1039    }
1040
1041    #[test]
1042    fn test_uses_deser_reusable() {
1043        // Dummy type for testing deser of `Uses`.
1044        #[derive(Deserialize)]
1045        #[serde(transparent)]
1046        struct Dummy(#[serde(deserialize_with = "reusable_step_uses")] Uses);
1047
1048        insta::assert_debug_snapshot!(
1049            yaml_serde::from_str::<Dummy>(
1050                "octo-org/this-repo/.github/workflows/workflow-1.yml@172239021f7ba04fe7327647b213799853a9eb89"
1051            )
1052            .map(|d| d.0)
1053            .unwrap(),
1054            @r#"
1055        Repository(
1056            RepositoryUses {
1057                owner: "octo-org/this-repo/.github/workflows/workflow-1.yml@172239021f7ba04fe7327647b213799853a9eb89",
1058                dependent: RepositoryUsesInner {
1059                    owner: "octo-org",
1060                    repo: "this-repo",
1061                    slug: "octo-org/this-repo",
1062                    subpath: Some(
1063                        ".github/workflows/workflow-1.yml",
1064                    ),
1065                    git_ref: "172239021f7ba04fe7327647b213799853a9eb89",
1066                },
1067            },
1068        )
1069        "#
1070        );
1071
1072        insta::assert_debug_snapshot!(
1073            yaml_serde::from_str::<Dummy>(
1074                "octo-org/this-repo/.github/workflows/workflow-1.yml@notahash"
1075            ).map(|d| d.0).unwrap(),
1076            @r#"
1077        Repository(
1078            RepositoryUses {
1079                owner: "octo-org/this-repo/.github/workflows/workflow-1.yml@notahash",
1080                dependent: RepositoryUsesInner {
1081                    owner: "octo-org",
1082                    repo: "this-repo",
1083                    slug: "octo-org/this-repo",
1084                    subpath: Some(
1085                        ".github/workflows/workflow-1.yml",
1086                    ),
1087                    git_ref: "notahash",
1088                },
1089            },
1090        )
1091        "#
1092        );
1093
1094        insta::assert_debug_snapshot!(
1095            yaml_serde::from_str::<Dummy>(
1096                "octo-org/this-repo/.github/workflows/workflow-1.yml@abcd"
1097            ).map(|d| d.0).unwrap(),
1098            @r#"
1099        Repository(
1100            RepositoryUses {
1101                owner: "octo-org/this-repo/.github/workflows/workflow-1.yml@abcd",
1102                dependent: RepositoryUsesInner {
1103                    owner: "octo-org",
1104                    repo: "this-repo",
1105                    slug: "octo-org/this-repo",
1106                    subpath: Some(
1107                        ".github/workflows/workflow-1.yml",
1108                    ),
1109                    git_ref: "abcd",
1110                },
1111            },
1112        )
1113        "#
1114        );
1115
1116        // Invalid: remote reusable workflow without ref
1117        insta::assert_debug_snapshot!(
1118            yaml_serde::from_str::<Dummy>(
1119                "octo-org/this-repo/.github/workflows/workflow-1.yml"
1120            ).map(|d| d.0).unwrap_err(),
1121            @r#"Error("malformed `uses` ref: missing `@<ref>` in octo-org/this-repo/.github/workflows/workflow-1.yml")"#
1122        );
1123
1124        // Invalid: local reusable workflow with ref
1125        insta::assert_debug_snapshot!(
1126            yaml_serde::from_str::<Dummy>(
1127                "./.github/workflows/workflow-1.yml@172239021f7ba04fe7327647b213799853a9eb89"
1128            ).map(|d| d.0).unwrap_err(),
1129            @r#"Error("local reusable workflow reference can't specify `@<ref>`")"#
1130        );
1131
1132        // Invalid: no ref at all
1133        insta::assert_debug_snapshot!(
1134            yaml_serde::from_str::<Dummy>(
1135                ".github/workflows/workflow-1.yml"
1136            ).map(|d| d.0).unwrap_err(),
1137            @r#"Error("malformed `uses` ref: missing `@<ref>` in .github/workflows/workflow-1.yml")"#
1138        );
1139
1140        // Invalid: missing user/repo
1141        insta::assert_debug_snapshot!(
1142            yaml_serde::from_str::<Dummy>(
1143                "workflow-1.yml@172239021f7ba04fe7327647b213799853a9eb89"
1144            ).map(|d| d.0).unwrap_err(),
1145            @r#"Error("malformed `uses` ref: owner/repo slug is too short: workflow-1.yml@172239021f7ba04fe7327647b213799853a9eb89")"#
1146        );
1147    }
1148
1149    #[test]
1150    fn test_bool_or_unit() {
1151        #[derive(Deserialize)]
1152        struct Dummy {
1153            #[serde(deserialize_with = "crate::common::bool_or_unit")]
1154            x: bool,
1155        }
1156
1157        assert_eq!(yaml_serde::from_str::<Dummy>("x:").unwrap().x, true);
1158        // TODO: Not sure if this is an overcorrection.
1159        assert_eq!(yaml_serde::from_str::<Dummy>("x: null").unwrap().x, true);
1160        assert_eq!(yaml_serde::from_str::<Dummy>("x: true").unwrap().x, true);
1161        assert_eq!(yaml_serde::from_str::<Dummy>("x: false").unwrap().x, false)
1162    }
1163}