1use 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
16pub const OWNER_REPO_DEPTH: usize = 2;
19
20#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
28pub struct Resource(String);
29
30impl Resource {
31 pub fn parse(raw: &str) -> Result<Self> {
35 Ok(Self(normalize_resource(raw)?))
36 }
37
38 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 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 pub fn namespace_of(host: &str, owner: &str) -> Result<Self> {
74 Self::parse_owner_repo(&format!("{host}/{owner}"))
75 }
76
77 pub fn as_str(&self) -> &str {
79 &self.0
80 }
81
82 pub fn host(&self) -> &str {
84 self.0.split('/').next().unwrap_or_default()
85 }
86
87 pub fn path_segments(&self) -> impl Iterator<Item = &str> {
89 self.0.split('/').skip(1)
90 }
91
92 pub fn owner(&self) -> &str {
94 self.path_segments().next().unwrap_or_default()
95 }
96
97 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 pub fn is_namespace(&self) -> bool {
109 self.path_segments().count() == 1
110 }
111
112 pub fn namespace(&self) -> Resource {
115 Resource(format!("{}/{}", self.host(), self.owner()))
116 }
117
118 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 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 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}