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