1use std::fmt;
2use std::path::{Path, PathBuf};
3
4use lora_wal::{SyncMode, WalConfig};
5
6pub const DEFAULT_DATABASE_MAX_BYTES: u64 = 4 * 1024 * 1024 * 1024;
13
14#[derive(Debug, Clone)]
16pub struct DatabaseOpenOptions {
17 pub database_dir: PathBuf,
18 pub sync_mode: SyncMode,
19 pub segment_target_bytes: u64,
20 pub max_database_bytes: u64,
21}
22
23impl Default for DatabaseOpenOptions {
24 fn default() -> Self {
25 Self {
26 database_dir: PathBuf::from("."),
27 sync_mode: SyncMode::GroupSync { interval_ms: 1_000 },
28 segment_target_bytes: 8 * 1024 * 1024,
29 max_database_bytes: DEFAULT_DATABASE_MAX_BYTES,
30 }
31 }
32}
33
34impl DatabaseOpenOptions {
35 pub fn with_database_dir(mut self, database_dir: impl Into<PathBuf>) -> Self {
36 self.database_dir = database_dir.into();
37 self
38 }
39
40 pub fn wal_config_for(&self, name: &DatabaseName) -> WalConfig {
41 WalConfig::Enabled {
42 dir: self.database_path_for(name),
43 sync_mode: self.sync_mode,
44 segment_target_bytes: self.segment_target_bytes,
45 }
46 }
47
48 pub fn database_path_for(&self, name: &DatabaseName) -> PathBuf {
49 self.database_dir.join(name.relative_path())
50 }
51}
52
53#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
66pub struct DatabaseName {
67 raw: String,
68 relative_path: PathBuf,
69}
70
71impl DatabaseName {
72 pub fn parse(value: impl AsRef<str>) -> Result<Self, DatabaseNameError> {
73 let value = value.as_ref();
74 let parts = normalized_relative_parts(value)?;
75
76 let mut path = PathBuf::new();
77 for (idx, part) in parts.iter().enumerate() {
78 if *part == "." {
79 continue;
80 }
81 if *part == ".." {
82 return Err(DatabaseNameError::Reserved(value.to_string()));
83 }
84 let is_basename = idx == parts.len() - 1;
85 let serialized = serialize_component(part, is_basename)
86 .ok_or_else(|| DatabaseNameError::InvalidCharacters(value.to_string()))?;
87 path.push(serialized);
88 }
89 if path.as_os_str().is_empty() {
90 return Err(DatabaseNameError::Reserved(value.to_string()));
91 }
92 Ok(Self {
93 raw: value.to_string(),
94 relative_path: path,
95 })
96 }
97
98 pub fn as_str(&self) -> &str {
99 &self.raw
100 }
101
102 pub fn relative_path(&self) -> &Path {
103 &self.relative_path
104 }
105}
106
107fn normalized_relative_parts(value: &str) -> Result<Vec<&str>, DatabaseNameError> {
108 if value.is_empty() {
109 return Err(DatabaseNameError::Empty);
110 }
111
112 if value.starts_with('/') || value.starts_with('\\') || looks_like_windows_absolute(value) {
113 return Err(DatabaseNameError::AbsolutePath(value.to_string()));
114 }
115
116 let mut parts: Vec<&str> = value.split(['/', '\\']).collect();
117 if parts.iter().any(|part| part.is_empty()) {
118 return Err(DatabaseNameError::InvalidCharacters(value.to_string()));
119 }
120 let start = parts
121 .iter()
122 .position(|part| *part != ".")
123 .unwrap_or(parts.len());
124 let end = parts
125 .iter()
126 .rposition(|part| *part != ".")
127 .map_or(start, |idx| idx + 1);
128 if start == end {
129 return Err(DatabaseNameError::Reserved(value.to_string()));
130 }
131
132 parts.truncate(end);
133 parts.drain(..start);
134 Ok(parts)
135}
136
137impl fmt::Display for DatabaseName {
138 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
139 self.raw.fmt(f)
140 }
141}
142
143impl TryFrom<&str> for DatabaseName {
144 type Error = DatabaseNameError;
145
146 fn try_from(value: &str) -> Result<Self, Self::Error> {
147 Self::parse(value)
148 }
149}
150
151impl std::str::FromStr for DatabaseName {
152 type Err = DatabaseNameError;
153
154 fn from_str(value: &str) -> Result<Self, Self::Err> {
155 Self::parse(value)
156 }
157}
158
159#[derive(Debug, Clone, PartialEq, Eq)]
160pub enum DatabaseNameError {
161 Empty,
162 Reserved(String),
163 AbsolutePath(String),
164 InvalidCharacters(String),
165}
166
167impl fmt::Display for DatabaseNameError {
168 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
169 match self {
170 Self::Empty => write!(f, "database name must not be empty"),
171 Self::Reserved(name) => write!(f, "database name `{name}` is reserved"),
172 Self::AbsolutePath(name) => write!(
173 f,
174 "invalid database name `{name}`: use a relative path under `database_dir`"
175 ),
176 Self::InvalidCharacters(name) => write!(
177 f,
178 "invalid database name `{name}`: use relative path components containing only letters, digits, `+`, `_`, `-`, with an optional `.loradb` suffix on the basename"
179 ),
180 }
181 }
182}
183
184impl std::error::Error for DatabaseNameError {}
185
186pub fn resolve_database_path(
187 database_name: &str,
188 database_dir: impl AsRef<Path>,
189) -> Result<PathBuf, DatabaseNameError> {
190 let name = DatabaseName::parse(database_name)?;
191 Ok(database_dir.as_ref().join(name.relative_path()))
192}
193
194fn serialize_component(component: &str, is_basename: bool) -> Option<String> {
195 if component.is_empty() {
196 return None;
197 }
198
199 if is_basename {
200 if let Some(stem) = component.strip_suffix(".loradb") {
201 return (!stem.is_empty() && is_portable_component(stem))
202 .then(|| component.to_string());
203 }
204 return is_portable_component(component).then(|| format!("{component}.loradb"));
205 }
206
207 is_portable_component(component).then(|| component.to_string())
208}
209
210fn is_portable_component(value: &str) -> bool {
211 value
212 .bytes()
213 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'+' | b'_' | b'-'))
214}
215
216fn looks_like_windows_absolute(value: &str) -> bool {
217 let bytes = value.as_bytes();
218 bytes.len() >= 3
219 && bytes[0].is_ascii_alphabetic()
220 && bytes[1] == b':'
221 && matches!(bytes[2], b'/' | b'\\')
222}
223
224#[cfg(test)]
225mod tests {
226 use super::*;
227
228 #[test]
229 fn parses_with_standard_from_str_trait() {
230 let name: DatabaseName = "tenant/app".parse().unwrap();
231 assert_eq!(name.as_str(), "tenant/app");
232 assert_eq!(name.relative_path(), Path::new("tenant/app.loradb"));
233 }
234}