Skip to main content

pg_ephemeral/
lib.rs

1pub mod certificate;
2pub mod cli;
3pub mod config;
4pub mod container;
5pub mod definition;
6pub mod image;
7pub mod seed;
8
9pub use config::{Config, Instance};
10pub use container::Container;
11pub use definition::Definition;
12pub use image::Image;
13pub use seed::Command;
14pub use seed::CommandCacheConfig;
15pub use seed::DuplicateSeedName;
16pub use seed::LoadError;
17pub use seed::Seed;
18pub use seed::SeedName;
19pub use seed::SeedNameError;
20
21pub(crate) const VERSION_STR: &str = env!("CARGO_PKG_VERSION");
22pub(crate) const LOCALHOST_IP: std::net::IpAddr =
23    std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST);
24pub(crate) const UNSPECIFIED_IP: std::net::IpAddr =
25    std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED);
26pub(crate) const LOCALHOST_HOST_ADDR: pg_client::config::HostAddr =
27    pg_client::config::HostAddr::new(LOCALHOST_IP);
28pub(crate) const ENV_DATABASE_URL: cmd_proc::EnvVariableName =
29    cmd_proc::EnvVariableName::from_static_or_panic("DATABASE_URL");
30
31#[must_use]
32pub fn version() -> &'static semver::Version {
33    static VERSION: std::sync::LazyLock<semver::Version> =
34        std::sync::LazyLock::new(|| semver::Version::parse(VERSION_STR).unwrap());
35    &VERSION
36}
37
38pub(crate) fn convert_schema(value: &[u8]) -> String {
39    std::str::from_utf8(value)
40        .expect("schema contains invalid utf8")
41        .to_string()
42}
43
44/// Error parsing an instance name.
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum InstanceNameError {
47    /// Instance name cannot be empty.
48    Empty,
49    /// Instance name contains an invalid character.
50    InvalidCharacter,
51    /// Instance name starts with a dash.
52    StartsWithDash,
53    /// Instance name ends with a dash.
54    EndsWithDash,
55}
56
57impl InstanceNameError {
58    #[must_use]
59    const fn message(&self) -> &'static str {
60        match self {
61            Self::Empty => "instance name cannot be empty",
62            Self::InvalidCharacter => {
63                "instance name must contain only lowercase ASCII alphanumeric characters or dashes"
64            }
65            Self::StartsWithDash => "instance name cannot start with a dash",
66            Self::EndsWithDash => "instance name cannot end with a dash",
67        }
68    }
69}
70
71impl std::fmt::Display for InstanceNameError {
72    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
73        write!(formatter, "{}", self.message())
74    }
75}
76
77impl std::error::Error for InstanceNameError {}
78
79const fn validate_instance_name(input: &str) -> Option<InstanceNameError> {
80    let bytes = input.as_bytes();
81
82    if bytes.is_empty() {
83        return Some(InstanceNameError::Empty);
84    }
85
86    if bytes[0] == b'-' {
87        return Some(InstanceNameError::StartsWithDash);
88    }
89
90    if bytes[bytes.len() - 1] == b'-' {
91        return Some(InstanceNameError::EndsWithDash);
92    }
93
94    let mut index = 0;
95
96    while index < bytes.len() {
97        let byte = bytes[index];
98        if !(byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') {
99            return Some(InstanceNameError::InvalidCharacter);
100        }
101        index += 1;
102    }
103
104    None
105}
106
107#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, serde::Serialize, serde::Deserialize)]
108#[serde(try_from = "String")]
109pub struct InstanceName(std::borrow::Cow<'static, str>);
110
111impl TryFrom<String> for InstanceName {
112    type Error = InstanceNameError;
113
114    fn try_from(value: String) -> Result<Self, Self::Error> {
115        match validate_instance_name(&value) {
116            Some(error) => Err(error),
117            None => Ok(Self(std::borrow::Cow::Owned(value))),
118        }
119    }
120}
121
122impl InstanceName {
123    pub const MAIN: Self = Self::from_static_or_panic("main");
124
125    /// Creates a new instance name from a static string.
126    ///
127    /// # Panics
128    ///
129    /// Panics if the input is empty, contains non-alphanumeric/dash characters,
130    /// or starts/ends with a dash.
131    #[must_use]
132    pub const fn from_static_or_panic(input: &'static str) -> Self {
133        match validate_instance_name(input) {
134            Some(error) => panic!("{}", error.message()),
135            None => Self(std::borrow::Cow::Borrowed(input)),
136        }
137    }
138
139    /// Returns the instance name as a string slice.
140    #[must_use]
141    pub fn as_str(&self) -> &str {
142        &self.0
143    }
144}
145
146impl std::default::Default for InstanceName {
147    fn default() -> Self {
148        Self::MAIN
149    }
150}
151
152impl std::str::FromStr for InstanceName {
153    type Err = InstanceNameError;
154
155    fn from_str(value: &str) -> Result<Self, Self::Err> {
156        match validate_instance_name(value) {
157            Some(error) => Err(error),
158            None => Ok(Self(std::borrow::Cow::Owned(value.to_owned()))),
159        }
160    }
161}
162
163impl std::fmt::Display for InstanceName {
164    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
165        write!(formatter, "{}", self.0)
166    }
167}
168
169impl AsRef<str> for InstanceName {
170    fn as_ref(&self) -> &str {
171        &self.0
172    }
173}
174
175pub type InstanceMap = std::collections::BTreeMap<InstanceName, config::Instance>;
176
177#[cfg(test)]
178mod test {
179    use super::*;
180
181    #[test]
182    fn parse_valid_simple() {
183        let name: InstanceName = "main".parse().unwrap();
184        assert_eq!(name.to_string(), "main");
185    }
186
187    #[test]
188    fn parse_valid_with_dash() {
189        let name: InstanceName = "my-instance".parse().unwrap();
190        assert_eq!(name.to_string(), "my-instance");
191    }
192
193    #[test]
194    fn parse_valid_single_char() {
195        let name: InstanceName = "a".parse().unwrap();
196        assert_eq!(name.to_string(), "a");
197    }
198
199    #[test]
200    fn parse_valid_numeric() {
201        let name: InstanceName = "123".parse().unwrap();
202        assert_eq!(name.to_string(), "123");
203    }
204
205    #[test]
206    fn parse_empty_fails() {
207        assert_eq!("".parse::<InstanceName>(), Err(InstanceNameError::Empty));
208    }
209
210    #[test]
211    fn parse_starts_with_dash_fails() {
212        assert_eq!(
213            "-foo".parse::<InstanceName>(),
214            Err(InstanceNameError::StartsWithDash)
215        );
216    }
217
218    #[test]
219    fn parse_ends_with_dash_fails() {
220        assert_eq!(
221            "foo-".parse::<InstanceName>(),
222            Err(InstanceNameError::EndsWithDash)
223        );
224    }
225
226    #[test]
227    fn parse_invalid_character_fails() {
228        assert_eq!(
229            "foo bar".parse::<InstanceName>(),
230            Err(InstanceNameError::InvalidCharacter)
231        );
232    }
233
234    #[test]
235    fn parse_underscore_fails() {
236        assert_eq!(
237            "foo_bar".parse::<InstanceName>(),
238            Err(InstanceNameError::InvalidCharacter)
239        );
240    }
241
242    #[test]
243    fn parse_uppercase_fails() {
244        assert_eq!(
245            "Main".parse::<InstanceName>(),
246            Err(InstanceNameError::InvalidCharacter)
247        );
248    }
249
250    #[test]
251    fn default_is_main() {
252        assert_eq!(InstanceName::default(), InstanceName::MAIN);
253        assert_eq!(InstanceName::default().to_string(), "main");
254    }
255}