1use std::path::Path;
4
5use crate::errors::MdqlError;
6use crate::parser::parse_file;
7use crate::schema::MDQL_FILENAME;
8
9#[derive(Debug, Clone)]
10pub struct ForeignKey {
11 pub from_table: String,
12 pub from_column: String,
13 pub to_table: String,
14 pub to_column: String,
15}
16
17#[derive(Debug, Clone)]
18pub struct ViewDef {
19 pub name: String,
20 pub query: String,
21}
22
23#[derive(Debug, Clone)]
24pub struct SyncConfig {
25 pub remote_host: String,
26 pub remote_path: String,
27 pub ssh_key: Option<String>,
28}
29
30#[derive(Debug, Clone)]
31pub struct DatabaseConfig {
32 pub name: String,
33 pub foreign_keys: Vec<ForeignKey>,
34 pub views: Vec<ViewDef>,
35 pub sync: Option<SyncConfig>,
36}
37
38pub fn is_database_dir(folder: &Path) -> bool {
39 let mdql_file = folder.join(MDQL_FILENAME);
40 if !mdql_file.exists() {
41 return false;
42 }
43 if let Ok(text) = std::fs::read_to_string(&mdql_file) {
44 if let Some(fm_text) = text
45 .strip_prefix("---\n")
46 .and_then(|rest| rest.split_once("\n---").map(|(fm, _)| fm))
47 {
48 if let Ok(val) = serde_yaml::from_str::<serde_yaml::Value>(fm_text) {
49 if let Some(m) = val.as_mapping() {
50 return m
51 .get(&serde_yaml::Value::String("type".into()))
52 .and_then(|v| v.as_str())
53 == Some("database");
54 }
55 }
56 }
57 }
58 false
59}
60
61pub fn load_database_config(db_dir: &Path) -> crate::errors::Result<DatabaseConfig> {
62 let db_path = db_dir.join(MDQL_FILENAME);
63 if !db_path.exists() {
64 return Err(MdqlError::DatabaseConfig(format!(
65 "No {} in {}",
66 MDQL_FILENAME,
67 db_dir.display()
68 )));
69 }
70
71 let parsed = parse_file(&db_path, Some(db_dir), false)?;
72
73 if !parsed.parse_errors.is_empty() {
74 return Err(MdqlError::DatabaseConfig(format!(
75 "Cannot parse {}: {}",
76 MDQL_FILENAME,
77 parsed.parse_errors.join("; ")
78 )));
79 }
80
81 let fm = &parsed.raw_frontmatter;
82 let fm_map = fm.as_mapping().ok_or_else(|| {
83 MdqlError::DatabaseConfig(format!(
84 "{}: frontmatter must be a mapping",
85 MDQL_FILENAME
86 ))
87 })?;
88
89 let type_val = fm_map.get(&serde_yaml::Value::String("type".into()));
90 if type_val.and_then(|v| v.as_str()) != Some("database") {
91 return Err(MdqlError::DatabaseConfig(format!(
92 "{}: frontmatter must have 'type: database'",
93 MDQL_FILENAME
94 )));
95 }
96
97 let name = fm_map
98 .get(&serde_yaml::Value::String("name".into()))
99 .and_then(|v| v.as_str())
100 .ok_or_else(|| {
101 MdqlError::DatabaseConfig(format!(
102 "{}: frontmatter must have 'name' as a string",
103 MDQL_FILENAME
104 ))
105 })?
106 .to_string();
107
108 let mut fks = Vec::new();
109 if let Some(fk_list) = fm_map.get(&serde_yaml::Value::String("foreign_keys".into())) {
110 if let Some(seq) = fk_list.as_sequence() {
111 for fk_def in seq {
112 let fk_map = fk_def.as_mapping().ok_or_else(|| {
113 MdqlError::DatabaseConfig(format!(
114 "{}: each foreign_key must be a mapping",
115 MDQL_FILENAME
116 ))
117 })?;
118
119 let from_spec = fk_map
120 .get(&serde_yaml::Value::String("from".into()))
121 .and_then(|v| v.as_str())
122 .unwrap_or("");
123 let to_spec = fk_map
124 .get(&serde_yaml::Value::String("to".into()))
125 .and_then(|v| v.as_str())
126 .unwrap_or("");
127
128 if !from_spec.contains('.') || !to_spec.contains('.') {
129 return Err(MdqlError::DatabaseConfig(format!(
130 "{}: foreign_key 'from' and 'to' must be 'table.column' format",
131 MDQL_FILENAME
132 )));
133 }
134
135 let (from_table, from_col) = from_spec.split_once('.').unwrap();
136 let (to_table, to_col) = to_spec.split_once('.').unwrap();
137
138 fks.push(ForeignKey {
139 from_table: from_table.to_string(),
140 from_column: from_col.to_string(),
141 to_table: to_table.to_string(),
142 to_column: to_col.to_string(),
143 });
144 }
145 }
146 }
147
148 let mut views = Vec::new();
149 if let Some(view_list) = fm_map.get(&serde_yaml::Value::String("views".into())) {
150 if let Some(seq) = view_list.as_sequence() {
151 for view_def in seq {
152 let view_map = view_def.as_mapping().ok_or_else(|| {
153 MdqlError::DatabaseConfig(format!(
154 "{}: each view must be a mapping with 'name' and 'query'",
155 MDQL_FILENAME
156 ))
157 })?;
158
159 let view_name = view_map
160 .get(&serde_yaml::Value::String("name".into()))
161 .and_then(|v| v.as_str())
162 .ok_or_else(|| {
163 MdqlError::DatabaseConfig(format!(
164 "{}: each view must have a 'name' string",
165 MDQL_FILENAME
166 ))
167 })?
168 .to_string();
169
170 let view_query = view_map
171 .get(&serde_yaml::Value::String("query".into()))
172 .and_then(|v| v.as_str())
173 .ok_or_else(|| {
174 MdqlError::DatabaseConfig(format!(
175 "{}: view '{}' must have a 'query' string",
176 MDQL_FILENAME, view_name
177 ))
178 })?
179 .to_string();
180
181 views.push(ViewDef {
182 name: view_name,
183 query: view_query,
184 });
185 }
186 }
187 }
188
189 let sync = if let Some(sync_val) = fm_map.get(&serde_yaml::Value::String("sync".into())) {
190 let sync_map = sync_val.as_mapping().ok_or_else(|| {
191 MdqlError::DatabaseConfig(format!(
192 "{}: 'sync' must be a mapping with 'remote_host' and 'remote_path'",
193 MDQL_FILENAME
194 ))
195 })?;
196 let remote_host = sync_map
197 .get(&serde_yaml::Value::String("remote_host".into()))
198 .and_then(|v| v.as_str())
199 .ok_or_else(|| {
200 MdqlError::DatabaseConfig(format!(
201 "{}: sync must have 'remote_host' as a string",
202 MDQL_FILENAME
203 ))
204 })?
205 .to_string();
206 let remote_path = sync_map
207 .get(&serde_yaml::Value::String("remote_path".into()))
208 .and_then(|v| v.as_str())
209 .ok_or_else(|| {
210 MdqlError::DatabaseConfig(format!(
211 "{}: sync must have 'remote_path' as a string",
212 MDQL_FILENAME
213 ))
214 })?
215 .to_string();
216 let ssh_key = sync_map
217 .get(&serde_yaml::Value::String("ssh_key".into()))
218 .and_then(|v| v.as_str())
219 .map(|s| s.to_string());
220 Some(SyncConfig {
221 remote_host,
222 remote_path,
223 ssh_key,
224 })
225 } else {
226 None
227 };
228
229 Ok(DatabaseConfig {
230 name,
231 foreign_keys: fks,
232 views,
233 sync,
234 })
235}
236
237pub(crate) fn save_database_config(db_dir: &Path, config: &DatabaseConfig) -> crate::errors::Result<()> {
238 let db_path = db_dir.join(MDQL_FILENAME);
239 let text = std::fs::read_to_string(&db_path)?;
240
241 let (_before_fm, fm_text, after_fm) = if let Some(rest) = text.strip_prefix("---\n") {
242 if let Some((fm, after)) = rest.split_once("\n---") {
243 ("---\n".to_string(), fm.to_string(), format!("\n---{}", after))
244 } else {
245 return Err(MdqlError::DatabaseConfig("Malformed frontmatter".into()));
246 }
247 } else {
248 return Err(MdqlError::DatabaseConfig("No frontmatter found".into()));
249 };
250
251 let mut fm: serde_yaml::Value = serde_yaml::from_str(&fm_text)
252 .map_err(|e| MdqlError::DatabaseConfig(format!("YAML parse error: {}", e)))?;
253
254 let fm_map = fm.as_mapping_mut().ok_or_else(|| {
255 MdqlError::DatabaseConfig("Frontmatter is not a mapping".into())
256 })?;
257
258 let views_key = serde_yaml::Value::String("views".into());
259 if config.views.is_empty() {
260 fm_map.remove(&views_key);
261 } else {
262 let views_seq: Vec<serde_yaml::Value> = config
263 .views
264 .iter()
265 .map(|v| {
266 let mut m = serde_yaml::Mapping::new();
267 m.insert(
268 serde_yaml::Value::String("name".into()),
269 serde_yaml::Value::String(v.name.clone()),
270 );
271 m.insert(
272 serde_yaml::Value::String("query".into()),
273 serde_yaml::Value::String(v.query.clone()),
274 );
275 serde_yaml::Value::Mapping(m)
276 })
277 .collect();
278 fm_map.insert(views_key, serde_yaml::Value::Sequence(views_seq));
279 }
280
281 let new_fm_text = serde_yaml::to_string(&fm)
282 .map_err(|e| MdqlError::DatabaseConfig(format!("YAML serialize error: {}", e)))?;
283
284 let new_content = format!("---\n{}---{}", new_fm_text, &after_fm[4..]);
285 crate::txn::atomic_write(&db_path, &new_content)?;
286 Ok(())
287}