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::Group { 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 if value.is_empty() {
75 return Err(DatabaseNameError::Empty);
76 }
77
78 if value.starts_with('/') || value.starts_with('\\') || looks_like_windows_absolute(value) {
79 return Err(DatabaseNameError::AbsolutePath(value.to_string()));
80 }
81
82 let mut parts: Vec<&str> = value.split(['/', '\\']).collect();
83 if parts.iter().any(|part| part.is_empty()) {
84 return Err(DatabaseNameError::InvalidCharacters(value.to_string()));
85 }
86 while parts.first() == Some(&".") {
87 parts.remove(0);
88 }
89 while parts.last() == Some(&".") {
90 parts.pop();
91 }
92 if parts.is_empty() {
93 return Err(DatabaseNameError::Reserved(value.to_string()));
94 }
95
96 let mut path = PathBuf::new();
97 for (idx, part) in parts.iter().enumerate() {
98 if *part == "." {
99 continue;
100 }
101 if *part == ".." {
102 return Err(DatabaseNameError::Reserved(value.to_string()));
103 }
104 let is_basename = idx == parts.len() - 1;
105 let serialized = serialize_component(part, is_basename)
106 .ok_or_else(|| DatabaseNameError::InvalidCharacters(value.to_string()))?;
107 path.push(serialized);
108 }
109 if path.as_os_str().is_empty() {
110 return Err(DatabaseNameError::Reserved(value.to_string()));
111 }
112 Ok(Self {
113 raw: value.to_string(),
114 relative_path: path,
115 })
116 }
117
118 pub fn as_str(&self) -> &str {
119 &self.raw
120 }
121
122 pub fn relative_path(&self) -> &Path {
123 &self.relative_path
124 }
125}
126
127impl fmt::Display for DatabaseName {
128 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
129 self.raw.fmt(f)
130 }
131}
132
133impl TryFrom<&str> for DatabaseName {
134 type Error = DatabaseNameError;
135
136 fn try_from(value: &str) -> Result<Self, Self::Error> {
137 Self::parse(value)
138 }
139}
140
141#[derive(Debug, Clone, PartialEq, Eq)]
142pub enum DatabaseNameError {
143 Empty,
144 Reserved(String),
145 AbsolutePath(String),
146 InvalidCharacters(String),
147}
148
149impl fmt::Display for DatabaseNameError {
150 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
151 match self {
152 Self::Empty => write!(f, "database name must not be empty"),
153 Self::Reserved(name) => write!(f, "database name '{name}' is reserved"),
154 Self::AbsolutePath(name) => write!(
155 f,
156 "invalid database name '{name}': use a relative path under database_dir"
157 ),
158 Self::InvalidCharacters(name) => write!(
159 f,
160 "invalid database name '{name}': use relative path components containing only letters, digits, '+', '_', '-', with an optional .loradb suffix on the basename"
161 ),
162 }
163 }
164}
165
166impl std::error::Error for DatabaseNameError {}
167
168pub fn resolve_database_path(
169 database_name: &str,
170 database_dir: impl AsRef<Path>,
171) -> Result<PathBuf, DatabaseNameError> {
172 let name = DatabaseName::parse(database_name)?;
173 Ok(database_dir.as_ref().join(name.relative_path()))
174}
175
176fn serialize_component(component: &str, is_basename: bool) -> Option<String> {
177 if component.is_empty() {
178 return None;
179 }
180
181 if is_basename {
182 if let Some(stem) = component.strip_suffix(".loradb") {
183 return (!stem.is_empty() && is_portable_component(stem))
184 .then(|| component.to_string());
185 }
186 return is_portable_component(component).then(|| format!("{component}.loradb"));
187 }
188
189 is_portable_component(component).then(|| component.to_string())
190}
191
192fn is_portable_component(value: &str) -> bool {
193 value
194 .bytes()
195 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'+' | b'_' | b'-'))
196}
197
198fn looks_like_windows_absolute(value: &str) -> bool {
199 let bytes = value.as_bytes();
200 bytes.len() >= 3
201 && bytes[0].is_ascii_alphabetic()
202 && bytes[1] == b':'
203 && matches!(bytes[2], b'/' | b'\\')
204}