Skip to main content

vgi_forge/
resource.rs

1//! [`Resource`]: a normalised, forge-qualified path (§4.5).
2//!
3//! The grammar lives in [`vgi_core::resource`] so the verifier, the VTC's
4//! registry projection and every adapter produce the same bytes for the same
5//! repository; this module wraps it in a type that can only hold a valid
6//! value, and adds the owner/repo shape GitHub and Forgejo share.
7
8use std::fmt;
9use std::str::FromStr;
10
11use serde::{Deserialize, Deserializer, Serialize, Serializer};
12use vgi_core::{normalize_resource, normalize_resource_with_depth, resource_contains};
13
14use crate::error::{ForgeError, Result};
15
16/// Path depth on forges whose paths are exactly `owner[/repo]` — GitHub and
17/// Forgejo (§4.5).
18pub const OWNER_REPO_DEPTH: usize = 2;
19
20/// A normalised forge-qualified resource: `github.com/acme` or
21/// `github.com/acme/widgets`.
22///
23/// Construction always goes through the [`vgi_core`] grammar, so a
24/// `Resource` in hand is lowercased, names its forge, and has no empty or
25/// dot segments. It serialises as its string form and deserialisation
26/// re-validates, so a bridge job cannot smuggle an unnormalised one in.
27#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
28pub struct Resource(String);
29
30impl Resource {
31    /// Parse and normalise any forge-qualified resource (any depth the
32    /// grammar allows). Use [`Resource::parse_owner_repo`] when the forge is
33    /// GitHub or Forgejo.
34    pub fn parse(raw: &str) -> Result<Self> {
35        Ok(Self(normalize_resource(raw)?))
36    }
37
38    /// Parse a resource on an `owner[/repo]` forge (GitHub, Forgejo).
39    ///
40    /// On top of the shared grammar this refuses a `.git` suffix on the repo
41    /// segment: neither forge lets a repository be named that way, so it can
42    /// only be a clone URL pasted in the wrong place.
43    pub fn parse_owner_repo(raw: &str) -> Result<Self> {
44        let canonical = normalize_resource_with_depth(raw, OWNER_REPO_DEPTH)?;
45        if let Some(stem) = canonical.strip_suffix(".git")
46            && canonical.matches('/').count() == 2
47        {
48            return Err(ForgeError::WrongResource {
49                resource: canonical.clone(),
50                expected: format!("a repository name without `.git`: `{stem}`"),
51            });
52        }
53        Ok(Self(canonical))
54    }
55
56    /// Check that this is exactly `host/owner/repo` under the owner/repo
57    /// grammar. A `Resource` that arrived by deserialisation was validated
58    /// against the general grammar only (any depth), so an adapter for an
59    /// owner/repo forge must call this before splitting it into owner and
60    /// name — `github.com/acme/evil/widgets` is not `acme/widgets`.
61    pub fn require_owner_repo(&self) -> Result<()> {
62        let reparsed = Resource::parse_owner_repo(&self.0)?;
63        if reparsed.is_namespace() {
64            return Err(ForgeError::WrongResource {
65                resource: self.0.clone(),
66                expected: "a repository (`<host>/<owner>/<repo>`), not a namespace".into(),
67            });
68        }
69        Ok(())
70    }
71
72    /// Build `host/owner` from parts, validating the result.
73    pub fn namespace_of(host: &str, owner: &str) -> Result<Self> {
74        Self::parse_owner_repo(&format!("{host}/{owner}"))
75    }
76
77    /// The canonical string.
78    pub fn as_str(&self) -> &str {
79        &self.0
80    }
81
82    /// The forge host, e.g. `github.com`.
83    pub fn host(&self) -> &str {
84        self.0.split('/').next().unwrap_or_default()
85    }
86
87    /// Path segments after the host.
88    pub fn path_segments(&self) -> impl Iterator<Item = &str> {
89        self.0.split('/').skip(1)
90    }
91
92    /// The first path segment: the owner (org or user).
93    pub fn owner(&self) -> &str {
94        self.path_segments().next().unwrap_or_default()
95    }
96
97    /// The last path segment when the resource names a repository (more
98    /// than one path segment); `None` for a namespace.
99    pub fn repo_name(&self) -> Option<&str> {
100        if self.is_namespace() {
101            None
102        } else {
103            self.0.rsplit('/').next()
104        }
105    }
106
107    /// Whether this is a namespace (`host/owner`) rather than a repository.
108    pub fn is_namespace(&self) -> bool {
109        self.path_segments().count() == 1
110    }
111
112    /// The namespace this resource sits in (`host/owner`); itself when it is
113    /// one.
114    pub fn namespace(&self) -> Resource {
115        Resource(format!("{}/{}", self.host(), self.owner()))
116    }
117
118    /// A repository under this namespace. Fails if `self` is not a namespace
119    /// or `name` is not a valid repo segment.
120    pub fn join(&self, name: &str) -> Result<Resource> {
121        if !self.is_namespace() {
122            return Err(ForgeError::WrongResource {
123                resource: self.0.clone(),
124                expected: "a namespace (`<forge-host>/<owner>`) to create a repository in".into(),
125            });
126        }
127        if name.contains('/') {
128            return Err(ForgeError::WrongResource {
129                resource: format!("{}/{name}", self.0),
130                expected: "a single repository name, without `/`".into(),
131            });
132        }
133        Resource::parse_owner_repo(&format!("{}/{name}", self.0))
134    }
135
136    /// Segment-prefix containment (§4.2 scope check): a namespace contains
137    /// its repositories, never another owner's, never another forge's.
138    pub fn contains(&self, other: &Resource) -> bool {
139        resource_contains(&self.0, &other.0)
140    }
141}
142
143impl fmt::Display for Resource {
144    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
145        f.write_str(&self.0)
146    }
147}
148
149impl FromStr for Resource {
150    type Err = ForgeError;
151    fn from_str(s: &str) -> Result<Self> {
152        Resource::parse(s)
153    }
154}
155
156impl AsRef<str> for Resource {
157    fn as_ref(&self) -> &str {
158        &self.0
159    }
160}
161
162impl Serialize for Resource {
163    fn serialize<S: Serializer>(&self, s: S) -> std::result::Result<S::Ok, S::Error> {
164        s.serialize_str(&self.0)
165    }
166}
167
168impl<'de> Deserialize<'de> for Resource {
169    fn deserialize<D: Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> {
170        let raw = String::deserialize(d)?;
171        // Re-validate rather than trust the wire: a bridge job is input.
172        let parsed = Resource::parse(&raw).map_err(serde::de::Error::custom)?;
173        if parsed.0 != raw {
174            return Err(serde::de::Error::custom(format!(
175                "resource `{raw}` is not in canonical form (expected `{parsed}`)"
176            )));
177        }
178        Ok(parsed)
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185
186    #[test]
187    fn owner_repo_shape() {
188        let r = Resource::parse_owner_repo("GitHub.com/Acme/Widgets").unwrap();
189        assert_eq!(r.as_str(), "github.com/acme/widgets");
190        assert_eq!(r.host(), "github.com");
191        assert_eq!(r.owner(), "acme");
192        assert_eq!(r.repo_name(), Some("widgets"));
193        assert!(!r.is_namespace());
194        assert_eq!(r.namespace().as_str(), "github.com/acme");
195        assert_eq!(r.namespace().repo_name(), None);
196    }
197
198    #[test]
199    fn owner_repo_refuses_depth_and_dot_git() {
200        assert!(matches!(
201            Resource::parse_owner_repo("github.com/a/b/c"),
202            Err(ForgeError::InvalidResource(_))
203        ));
204        let err = Resource::parse_owner_repo("github.com/acme/widgets.git").unwrap_err();
205        assert!(
206            err.to_string().contains("`github.com/acme/widgets`"),
207            "{err}"
208        );
209    }
210
211    #[test]
212    fn deep_resources_are_not_owner_repo() {
213        let deep: Resource = serde_json::from_str("\"github.com/acme/evil/widgets\"").unwrap();
214        assert!(deep.require_owner_repo().is_err());
215        assert!(
216            Resource::parse("github.com/acme")
217                .unwrap()
218                .require_owner_repo()
219                .is_err()
220        );
221        assert!(
222            Resource::parse("github.com/acme/w")
223                .unwrap()
224                .require_owner_repo()
225                .is_ok()
226        );
227    }
228
229    #[test]
230    fn join_builds_a_repo_under_a_namespace_only() {
231        let ns = Resource::parse_owner_repo("github.com/acme").unwrap();
232        assert_eq!(
233            ns.join("Gadgets").unwrap().as_str(),
234            "github.com/acme/gadgets"
235        );
236        assert!(ns.join("a/b").is_err());
237        assert!(ns.join("..").is_err());
238        assert!(ns.join("gadgets").unwrap().join("x").is_err());
239    }
240
241    #[test]
242    fn containment() {
243        let ns = Resource::parse("github.com/acme").unwrap();
244        assert!(ns.contains(&Resource::parse("github.com/acme/w").unwrap()));
245        assert!(!ns.contains(&Resource::parse("github.com/acme-labs/w").unwrap()));
246        assert!(!ns.contains(&Resource::parse("codeberg.org/acme/w").unwrap()));
247    }
248
249    #[test]
250    fn serde_round_trips_and_refuses_non_canonical_input() {
251        let r = Resource::parse("github.com/acme/widgets").unwrap();
252        let json = serde_json::to_string(&r).unwrap();
253        assert_eq!(json, "\"github.com/acme/widgets\"");
254        assert_eq!(serde_json::from_str::<Resource>(&json).unwrap(), r);
255        assert!(serde_json::from_str::<Resource>("\"GitHub.com/acme\"").is_err());
256        assert!(serde_json::from_str::<Resource>("\"acme/widgets\"").is_err());
257    }
258}