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