1use serde::Deserialize;
13use std::collections::BTreeMap;
14use thiserror::Error;
15use wren_core_base::mdl::manifest::Manifest;
16
17pub struct LoadedProject {
19 pub manifest: Manifest,
20}
21
22#[derive(Debug, Error)]
23pub enum LoadError {
24 #[error("{0}")]
25 Message(String),
26}
27
28fn msg(s: impl Into<String>) -> LoadError {
29 LoadError::Message(s.into())
30}
31
32#[derive(Debug, Deserialize)]
35struct ProjectFile {
36 #[serde(default)]
37 catalog: Option<String>,
38 #[serde(default)]
39 schema: Option<String>,
40 #[serde(default)]
41 data_source: Option<String>,
42}
43
44#[derive(Debug, Deserialize)]
45struct ModelFile {
46 name: String,
47 #[serde(default)]
48 table_reference: Option<serde_yaml::Value>,
49 #[serde(default)]
50 base_object: Option<String>,
51 #[serde(default)]
52 ref_sql: Option<String>,
53 #[serde(default)]
54 primary_key: Option<serde_yaml::Value>,
55 #[serde(default)]
56 columns: Vec<ColumnFile>,
57}
58
59#[derive(Debug, Deserialize)]
60struct ColumnFile {
61 name: String,
62 r#type: String,
63 #[serde(default)]
64 not_null: Option<bool>,
65 #[serde(default)]
66 relationship: Option<String>,
67 #[serde(default)]
68 is_calculated: Option<bool>,
69 #[serde(default)]
70 expression: Option<String>,
71 #[serde(default)]
72 is_hidden: Option<bool>,
73}
74
75#[derive(Debug, Deserialize)]
76struct RelationshipFile {
77 name: String,
78 models: Vec<String>,
79 join_type: String,
80 condition: String,
81}
82
83#[derive(Debug, Deserialize)]
88#[serde(untagged)]
89enum RelationshipsDoc {
90 Bare(Vec<RelationshipFile>),
91 Keyed {
92 relationships: Vec<RelationshipFile>,
93 },
94}
95
96impl RelationshipsDoc {
97 fn into_vec(self) -> Vec<RelationshipFile> {
98 match self {
99 RelationshipsDoc::Bare(v) => v,
100 RelationshipsDoc::Keyed { relationships } => relationships,
101 }
102 }
103}
104
105#[derive(Debug, Deserialize)]
106struct CubeFile {
107 name: String,
108 base_object: String,
109 #[serde(default)]
110 measures: Vec<CubeMemberFile>,
111 #[serde(default)]
112 dimensions: Vec<CubeMemberFile>,
113 #[serde(default)]
114 time_dimensions: Vec<CubeMemberFile>,
115}
116
117#[derive(Debug, Deserialize)]
118struct CubeMemberFile {
119 name: String,
120 expression: String,
121 r#type: String,
122}
123
124#[derive(Debug, Deserialize)]
125struct ViewMetaFile {
126 name: String,
127}
128
129#[derive(Debug, Deserialize)]
130struct ViewSqlFile {
131 statement: String,
132}
133
134#[cfg(not(target_arch = "wasm32"))]
142pub fn read_project_dir(dir: &std::path::Path) -> std::io::Result<Option<ProjectSources>> {
143 use std::fs;
144
145 let wren_project_path = dir.join("wren_project.yml");
146 if !wren_project_path.is_file() {
147 return Ok(None);
148 }
149 let wren_project_yml = fs::read_to_string(&wren_project_path)?;
150
151 let mut model_ymls = Vec::new();
152 let models_dir = dir.join("models");
153 if models_dir.is_dir() {
154 let mut entries: Vec<_> = fs::read_dir(&models_dir)?
155 .filter_map(Result::ok)
156 .map(|e| e.path())
157 .filter(|p| p.is_dir())
158 .collect();
159 entries.sort();
160 for model_dir in entries {
161 let meta = model_dir.join("metadata.yml");
162 if meta.is_file() {
163 model_ymls.push(fs::read_to_string(meta)?);
164 }
165 }
166 }
167
168 let relationships_yml = read_if_exists(&dir.join("relationships.yml"))?;
169 let cubes_yml = read_if_exists(&dir.join("cubes.yml"))?;
170
171 let mut cube_ymls = Vec::new();
172 let cubes_dir = dir.join("cubes");
173 if cubes_dir.is_dir() {
174 let mut entries: Vec<_> = fs::read_dir(&cubes_dir)?
175 .filter_map(Result::ok)
176 .map(|e| e.path())
177 .filter(|p| p.is_dir())
178 .collect();
179 entries.sort();
180 for cube_dir in entries {
181 let meta = cube_dir.join("metadata.yml");
182 if meta.is_file() {
183 cube_ymls.push(fs::read_to_string(meta)?);
184 }
185 }
186 }
187
188 let mut views = Vec::new();
189 let views_dir = dir.join("views");
190 if views_dir.is_dir() {
191 let mut entries: Vec<_> = fs::read_dir(&views_dir)?
192 .filter_map(Result::ok)
193 .map(|e| e.path())
194 .filter(|p| p.is_dir())
195 .collect();
196 entries.sort();
197 for view_dir in entries {
198 let meta = view_dir.join("metadata.yml");
199 let sql = view_dir.join("sql.yml");
200 if meta.is_file() && sql.is_file() {
201 views.push((fs::read_to_string(meta)?, fs::read_to_string(sql)?));
202 }
203 }
204 }
205
206 let mut knowledge_sql_mds = Vec::new();
207 let knowledge_sql_dir = dir.join("knowledge").join("sql");
208 if knowledge_sql_dir.is_dir() {
209 let mut entries: Vec<_> = fs::read_dir(&knowledge_sql_dir)?
210 .filter_map(Result::ok)
211 .map(|e| e.path())
212 .filter(|p| p.is_file() && p.extension().is_some_and(|ext| ext == "md"))
213 .collect();
214 entries.sort();
215 for md in entries {
216 let slug = md
217 .file_stem()
218 .map(|s| s.to_string_lossy().into_owned())
219 .unwrap_or_default();
220 knowledge_sql_mds.push((slug, fs::read_to_string(&md)?));
221 }
222 }
223
224 let dashboards_yml = read_if_exists(&dir.join("dashboards.yml"))?;
225
226 Ok(Some(ProjectSources {
227 wren_project_yml,
228 model_ymls,
229 relationships_yml,
230 cubes_yml,
231 cube_ymls,
232 views,
233 knowledge_sql_mds,
234 dashboards_yml,
235 }))
236}
237
238#[cfg(not(target_arch = "wasm32"))]
244pub fn read_knowledge_rules(dir: &std::path::Path) -> std::io::Result<KnowledgeRules> {
245 use std::fs;
246
247 let rules_dir = dir.join("knowledge").join("rules");
248 let mut paths = if rules_dir.is_dir() {
249 fs::read_dir(&rules_dir)?
250 .filter_map(Result::ok)
251 .map(|entry| entry.path())
252 .filter(|path| path.is_file() && path.extension().is_some_and(|ext| ext == "md"))
253 .collect::<Vec<_>>()
254 } else {
255 Vec::new()
256 };
257 paths.sort();
258
259 let mut parts = Vec::new();
260 for path in paths {
261 let contents = fs::read_to_string(path)?;
262 if !contents.trim().is_empty() {
263 parts.push(contents.trim().to_string());
264 }
265 }
266
267 let legacy_path = dir.join("instructions.md");
268 let used_legacy = legacy_path.is_file();
269 if used_legacy {
270 let contents = fs::read_to_string(legacy_path)?;
271 if !contents.trim().is_empty() {
272 parts.push(contents.trim().to_string());
273 }
274 }
275
276 Ok(KnowledgeRules {
277 content: parts.join("\n\n"),
278 used_legacy,
279 })
280}
281
282#[derive(Debug, Clone, PartialEq, Eq)]
284pub struct KnowledgeRules {
285 pub content: String,
286 pub used_legacy: bool,
287}
288
289#[cfg(not(target_arch = "wasm32"))]
290fn read_if_exists(path: &std::path::Path) -> std::io::Result<Option<String>> {
291 if path.is_file() {
292 Ok(Some(std::fs::read_to_string(path)?))
293 } else {
294 Ok(None)
295 }
296}
297
298pub struct ProjectSources {
303 pub wren_project_yml: String,
304 pub model_ymls: Vec<String>,
305 pub relationships_yml: Option<String>,
306 pub cubes_yml: Option<String>,
307 pub cube_ymls: Vec<String>,
310 pub views: Vec<(String, String)>,
312 pub knowledge_sql_mds: Vec<(String, String)>,
316 pub dashboards_yml: Option<String>,
320}
321
322pub fn assemble(sources: &ProjectSources) -> Result<LoadedProject, LoadError> {
325 let project: ProjectFile = serde_yaml::from_str(&sources.wren_project_yml)
326 .map_err(|e| msg(format!("wren_project.yml: {e}")))?;
327
328 let mut models = Vec::with_capacity(sources.model_ymls.len());
329 for yml in &sources.model_ymls {
330 let m: ModelFile = serde_yaml::from_str(yml).map_err(|e| msg(format!("model: {e}")))?;
331 models.push(model_to_json(m)?);
332 }
333
334 let relationships = match &sources.relationships_yml {
335 Some(yml) => {
336 let is_empty = serde_yaml::from_str::<serde_yaml::Value>(yml)
341 .map(|v| v.is_null())
342 .unwrap_or(false);
343 if is_empty {
344 Vec::new()
345 } else {
346 let doc: RelationshipsDoc = serde_yaml::from_str(yml)
347 .map_err(|e| msg(format!("relationships.yml: {e}")))?;
348 doc.into_vec()
349 .into_iter()
350 .map(relationship_to_json)
351 .collect()
352 }
353 }
354 None => Vec::new(),
355 };
356
357 let cubes = match &sources.cubes_yml {
361 Some(yml) => {
362 let cubes: Vec<CubeFile> =
363 serde_yaml::from_str(yml).map_err(|e| msg(format!("cubes.yml: {e}")))?;
364 cubes.into_iter().map(cube_to_json).collect()
365 }
366 None => sources
367 .cube_ymls
368 .iter()
369 .map(|yml| {
370 let cube: CubeFile =
371 serde_yaml::from_str(yml).map_err(|e| msg(format!("cube metadata: {e}")))?;
372 Ok(cube_to_json(cube))
373 })
374 .collect::<Result<Vec<_>, LoadError>>()?,
375 };
376
377 let mut views = Vec::with_capacity(sources.views.len());
378 for (meta_yml, sql_yml) in &sources.views {
379 let meta: ViewMetaFile =
380 serde_yaml::from_str(meta_yml).map_err(|e| msg(format!("view metadata: {e}")))?;
381 let sql: ViewSqlFile =
382 serde_yaml::from_str(sql_yml).map_err(|e| msg(format!("view sql: {e}")))?;
383 views.push(serde_json::json!({ "name": meta.name, "statement": sql.statement }));
384 }
385
386 let mut manifest_json = serde_json::Map::new();
387 if let Some(c) = project.catalog {
388 manifest_json.insert("catalog".into(), serde_json::Value::String(c));
389 }
390 if let Some(s) = project.schema {
391 manifest_json.insert("schema".into(), serde_json::Value::String(s));
392 }
393 if let Some(ds) = project.data_source {
394 manifest_json.insert("dataSource".into(), serde_json::Value::String(ds));
395 }
396 manifest_json.insert("models".into(), serde_json::Value::Array(models));
397 manifest_json.insert(
398 "relationships".into(),
399 serde_json::Value::Array(relationships),
400 );
401 manifest_json.insert("cubes".into(), serde_json::Value::Array(cubes));
402 manifest_json.insert("views".into(), serde_json::Value::Array(views));
403
404 manifest_json
407 .entry("catalog")
408 .or_insert_with(|| serde_json::Value::String("wren".into()));
409 manifest_json
410 .entry("schema")
411 .or_insert_with(|| serde_json::Value::String("public".into()));
412
413 let manifest: Manifest = serde_json::from_value(serde_json::Value::Object(manifest_json))
414 .map_err(|e| msg(format!("assembled manifest is not valid MDL: {e}")))?;
415 manifest
416 .validate_layout_version()
417 .map_err(|e| msg(e.to_string()))?;
418 Ok(LoadedProject { manifest })
419}
420
421fn yaml_to_json(v: serde_yaml::Value) -> Result<serde_json::Value, LoadError> {
422 serde_json::to_value(v).map_err(|e| msg(format!("value conversion: {e}")))
423}
424
425fn normalize_table_reference(tref: serde_yaml::Value) -> Result<serde_json::Value, LoadError> {
442 let mut json = yaml_to_json(tref)?;
443 if let serde_json::Value::Object(obj) = &mut json {
444 for key in ["catalog", "schema", "table"] {
445 obj.entry(key).or_insert(serde_json::Value::Null);
446 }
447 }
448 Ok(json)
449}
450
451fn model_to_json(m: ModelFile) -> Result<serde_json::Value, LoadError> {
452 let mut obj = serde_json::Map::new();
453 obj.insert("name".into(), serde_json::Value::String(m.name));
454 if let Some(tref) = m.table_reference {
455 obj.insert("tableReference".into(), normalize_table_reference(tref)?);
456 }
457 if let Some(base) = m.base_object {
458 obj.insert("baseObject".into(), serde_json::Value::String(base));
459 }
460 if let Some(sql) = m.ref_sql {
461 obj.insert("refSql".into(), serde_json::Value::String(sql));
462 }
463 if let Some(pk) = m.primary_key {
464 obj.insert("primaryKey".into(), yaml_to_json(pk)?);
465 }
466 let columns: Vec<serde_json::Value> = m.columns.into_iter().map(column_to_json).collect();
467 obj.insert("columns".into(), serde_json::Value::Array(columns));
468 Ok(serde_json::Value::Object(obj))
469}
470
471fn column_to_json(c: ColumnFile) -> serde_json::Value {
472 let mut obj = serde_json::Map::new();
473 obj.insert("name".into(), serde_json::Value::String(c.name));
474 obj.insert("type".into(), serde_json::Value::String(c.r#type));
475 if let Some(b) = c.not_null {
476 obj.insert("notNull".into(), serde_json::Value::Bool(b));
477 }
478 if let Some(rel) = c.relationship {
479 obj.insert("relationship".into(), serde_json::Value::String(rel));
480 }
481 if let Some(b) = c.is_calculated {
482 obj.insert("isCalculated".into(), serde_json::Value::Bool(b));
483 }
484 if let Some(expr) = c.expression {
485 obj.insert("expression".into(), serde_json::Value::String(expr));
486 }
487 if let Some(b) = c.is_hidden {
488 obj.insert("isHidden".into(), serde_json::Value::Bool(b));
489 }
490 serde_json::Value::Object(obj)
491}
492
493fn relationship_to_json(r: RelationshipFile) -> serde_json::Value {
494 serde_json::json!({
495 "name": r.name,
496 "models": r.models,
497 "joinType": r.join_type,
498 "condition": r.condition,
499 })
500}
501
502fn cube_member_to_json(m: CubeMemberFile) -> serde_json::Value {
503 serde_json::json!({ "name": m.name, "expression": m.expression, "type": m.r#type })
504}
505
506fn cube_to_json(c: CubeFile) -> serde_json::Value {
507 let empty: BTreeMap<String, Vec<String>> = BTreeMap::new();
509 serde_json::json!({
510 "name": c.name,
511 "baseObject": c.base_object,
512 "measures": c.measures.into_iter().map(cube_member_to_json).collect::<Vec<_>>(),
513 "dimensions": c.dimensions.into_iter().map(cube_member_to_json).collect::<Vec<_>>(),
514 "timeDimensions": c.time_dimensions.into_iter().map(cube_member_to_json).collect::<Vec<_>>(),
515 "hierarchies": empty,
516 })
517}