1use crate::{AwsRegion, Region, WorkspaceId};
2use miette::Diagnostic;
3use nom::{
4 branch::alt,
5 bytes::complete::{tag, take_while1, take_while_m_n},
6 combinator::{all_consuming, opt},
7 sequence::{preceded, separated_pair},
8 IResult, Parser,
9};
10use serde::{Deserialize, Serialize};
11use std::{fmt::Display, str::FromStr};
12use thiserror::Error;
13
14#[derive(Error, Debug, Diagnostic)]
15pub enum InvalidCrn {
16 #[error("Invalid CRN: {0}")]
17 #[diagnostic(help = "CRN format: `crn:<region>:<workspace_id>[:<service_name>]`")]
18 InvalidFormat(String),
19
20 #[error(transparent)]
21 #[diagnostic(transparent)]
22 InvalidRegion(#[from] crate::region::RegionError),
23
24 #[error(transparent)]
25 #[diagnostic(transparent)]
26 InvalidWorkspaceId(#[from] crate::workspace::InvalidWorkspaceId),
27}
28
29impl InvalidCrn {
30 pub fn invalid_format(input: &str) -> Self {
31 Self::InvalidFormat(input.to_string())
32 }
33}
34
35pub trait AsCrn {
36 fn as_crn(&self) -> Crn;
38}
39
40#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct Crn {
45 pub workspace_id: WorkspaceId,
47
48 pub region: Region,
50
51 pub service_name: Option<String>,
53}
54
55impl Crn {
56 pub fn new(region: Region, workspace_id: WorkspaceId) -> Self {
58 Self {
59 workspace_id,
60 region,
61 service_name: None,
62 }
63 }
64
65 pub fn with_service_name(mut self, service_name: &str) -> Self {
66 self.service_name = Some(service_name.into());
67 self
68 }
69}
70
71impl Serialize for Crn {
72 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
73 where
74 S: serde::Serializer,
75 {
76 let s = self.to_string();
77 serializer.serialize_str(&s)
78 }
79}
80
81impl<'de> Deserialize<'de> for Crn {
82 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
83 where
84 D: serde::Deserializer<'de>,
85 {
86 let s = String::deserialize(deserializer)?;
87 Crn::try_from(s.as_str()).map_err(serde::de::Error::custom)
88 }
89}
90
91impl Display for Crn {
92 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
93 write!(f, "crn:{}:{}", self.region, self.workspace_id)?;
94 if let Some(service_name) = &self.service_name {
95 write!(f, ":{}", service_name)?;
96 }
97 Ok(())
98 }
99}
100
101impl TryFrom<&str> for Crn {
102 type Error = InvalidCrn;
103
104 fn try_from(value: &str) -> Result<Self, Self::Error> {
105 parse_crn(value)
106 }
107}
108
109impl FromStr for Crn {
110 type Err = InvalidCrn;
111
112 fn from_str(value: &str) -> Result<Self, Self::Err> {
113 Self::try_from(value)
114 }
115}
116
117fn region_geo(input: &str) -> IResult<&str, &str> {
121 alt((
122 tag("ap-southeast-2"),
123 tag("eu-central-1"),
124 tag("eu-west-1"),
125 tag("us-east-1"),
126 tag("us-east-2"),
127 tag("us-west-1"),
128 tag("us-west-2"),
129 ))
130 .parse(input)
131}
132
133#[inline]
136fn region_vendor(input: &str) -> IResult<&str, &str> {
137 tag("aws")(input)
138}
139
140#[inline]
142fn region(input: &str) -> IResult<&str, Region, nom::error::Error<&str>> {
143 separated_pair(region_geo, tag("."), region_vendor)
145 .parse(input)
146 .map(|(rest, (geo, _))| {
147 (
150 rest,
151 Region::Aws(AwsRegion::try_from(geo).expect("Invalid geo")),
152 )
153 })
154}
155
156#[inline]
159fn workspace_id(input: &str) -> IResult<&str, WorkspaceId, nom::error::Error<&str>> {
160 take_while_m_n(16, 16, |c: char| c.is_alphanumeric())(input).map(|(rest, id)| {
162 let id = WorkspaceId::try_from(id).expect("Invalid workspace ID");
166 (rest, id)
167 })
168}
169
170fn service_name_chars(input: &str) -> IResult<&str, &str> {
171 let (rest, service_name) =
173 take_while1(|c: char| c.is_alphanumeric() || c == '-' || c == '_').parse(input)?;
174 Ok((rest, service_name))
175}
176
177fn parse_crn(input: &str) -> Result<Crn, InvalidCrn> {
178 let (_, (region, workspace_id, service_name)) = all_consuming((
179 preceded(tag("crn:"), region),
180 preceded(tag(":"), workspace_id),
181 opt(preceded(tag(":"), service_name_chars)),
182 ))
183 .parse(input)
184 .map_err(|_| InvalidCrn::invalid_format(input))?;
185
186 Ok(Crn {
187 region,
188 workspace_id,
189 service_name: service_name.map(String::from),
190 })
191}
192
193#[cfg(test)]
194mod tests {
195 use super::*;
196 use crate::AwsRegion;
197
198 mod try_from_str {
199 use super::*;
200
201 #[test]
202 fn success_valid_with_service() {
203 let region = Region::new("us-east-1.aws").unwrap();
204 let workspace_id = WorkspaceId::try_from("ZVATKW3VHMFG27DY").unwrap();
205
206 assert_eq!(
207 Crn::try_from("crn:us-east-1.aws:ZVATKW3VHMFG27DY:service_name").unwrap(),
208 Crn::new(region, workspace_id).with_service_name("service_name")
209 );
210
211 assert_eq!(
212 Crn::try_from("crn:us-east-1.aws:ZVATKW3VHMFG27DY:service-name").unwrap(),
213 Crn::new(region, workspace_id).with_service_name("service-name")
214 );
215 }
216
217 #[test]
218 fn success_valid_without_service() {
219 let crn_str = "crn:us-east-1.aws:ZVATKW3VHMFG27DY";
220 let crn = Crn::try_from(crn_str).unwrap();
221 assert_eq!(crn.region, Region::Aws(AwsRegion::UsEast1));
222 assert_eq!(crn.workspace_id.to_string(), "ZVATKW3VHMFG27DY");
223 assert!(crn.service_name.is_none());
224 }
225
226 #[test]
227 fn test_invalid_crn() {
228 assert!(Crn::try_from("invalid_crn").is_err());
229 assert!(Crn::try_from("crn:invalid_crn").is_err());
230 assert!(Crn::try_from("crn:us-east-1.aws:ZVATKW3VHMFG27DY:").is_err());
232 assert!(
234 Crn::try_from("crn:us-east-1.aws:ZVATKW3VHMFG27DY:service_name:extra").is_err()
235 );
236 assert!(
238 Crn::try_from("crn:us-east-1.aws:ZVATKW3VHMFG27DY:service_name:extra:extra")
239 .is_err()
240 );
241 assert!(Crn::try_from("crn:us-east-1.aws:ZVATKW3VH").is_err());
243 assert!(Crn::try_from("crn:us-east-1:ZVATKW3VHMFG27DY").is_err());
245 assert!(Crn::try_from("us-east-1.aws:ZVATKW3VHMFG27DY:service_name").is_err());
247 }
248 }
249
250 mod display {
251 use super::*;
252
253 #[test]
254 fn test_with_workspace_id() {
255 let workspace_id = WorkspaceId::generate().unwrap();
256 let crn = Crn::new(Region::new("us-east-1.aws").unwrap(), workspace_id);
257 assert_eq!(crn.to_string(), format!("crn:us-east-1.aws:{workspace_id}"));
258 }
259
260 #[test]
261 fn test_with_workspace_id_and_service() {
262 let workspace_id = WorkspaceId::generate().unwrap();
263 let crn = Crn::new(Region::new("us-east-1.aws").unwrap(), workspace_id)
264 .with_service_name("zerokms");
265 assert_eq!(
266 crn.to_string(),
267 format!("crn:us-east-1.aws:{workspace_id}:zerokms")
268 );
269 }
270 }
271}