Skip to main content

warble_mdl_context/
project.rs

1//! Assemble an on-disk **wren project** (the split-file, `schema_version: 2` layout: a
2//! `wren_project.yml` + per-model `models/<name>/metadata.yml` + `relationships.yml` + optional
3//! `cubes.yml` + `views/<name>/{metadata,sql}.yml`) into a canonical [`wren_core_base::mdl::Manifest`].
4//!
5//! The engine-consumed MDL format is the single camelCase `Manifest`; a wren *project* is the
6//! authored, split, snake_case form the `wren` CLI compiles into that manifest. This loader does
7//! the structural slice of that compilation the compiler needs for introspection (models, columns,
8//! relationships, cubes, views) — it does not expand `ref_sql` or resolve calculated columns
9//! (execution concerns that never enter the sans-IO front-end). Parse is pure over already-read
10//! bytes; the host owns the actual file reads.
11
12use serde::Deserialize;
13use std::collections::BTreeMap;
14use thiserror::Error;
15use wren_core_base::mdl::manifest::Manifest;
16
17/// A wren project assembled into a canonical MDL manifest.
18pub 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// --- on-disk (authored, snake_case) shapes ------------------------------------------------------
33
34#[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/// `relationships.yml` has two authored shapes: the original bare list, and the
84/// `relationships:` keyed mapping that the wren CLI (project schema_version 5) scaffolds
85/// and requires. Accept both; rejecting the keyed form would silently strip every join
86/// from any project authored with the current CLI.
87#[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// --- native host convenience ---------------------------------------------------------------------
135
136/// Read a wren project directory into [`ProjectSources`] (native host only; the pure `assemble`
137/// path is what a WASM host feeds directly). Returns `None` if the directory has no
138/// `wren_project.yml` — i.e. it is not a wren project — so the caller can build an unparseable
139/// context. Model, cube, and view sub-directories are read in sorted order for deterministic
140/// output.
141#[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/// Read the business-rule slice used by `wren context instructions`, without invoking `wren`.
239///
240/// The native host owns these filesystem reads; dispatchers receive only the normalized string.
241/// Current `knowledge/rules/*.md` files are sorted by path and concatenated first, followed by the
242/// legacy root `instructions.md` when present. Empty files contribute nothing.
243#[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/// Normalized business rules loaded by a native host for dispatch-time injection.
283#[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
298// --- assembly -----------------------------------------------------------------------------------
299
300/// The raw file contents of a wren project, read by the host. `assemble` operates purely over
301/// these bytes (WASM-friendly); the native [`read_project_dir`] convenience fills them from disk.
302pub 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    /// Per-cube `cubes/<name>/metadata.yml` contents (wren CLI layout). Used only when
308    /// `cubes_yml` is `None` — a root `cubes.yml` takes precedence.
309    pub cube_ymls: Vec<String>,
310    /// Each view as `(metadata.yml, sql.yml)` contents.
311    pub views: Vec<(String, String)>,
312    /// Confirmed saved queries from the wren CLI's `knowledge/sql/<slug>.md` store, as
313    /// `(slug, file contents)`. Consumer sources: they never enter the MDL manifest — they only
314    /// enrich the lineage graph with `query:<slug>` consumer nodes.
315    pub knowledge_sql_mds: Vec<(String, String)>,
316    /// The root `dashboards.yml` contents (the minimal declarative dashboard-spec convention:
317    /// `dashboards[].name` + `panels[].sql` or `panels[].cube`+`measures`). Consumer source, like
318    /// `knowledge_sql_mds`.
319    pub dashboards_yml: Option<String>,
320}
321
322/// Assemble read project sources into a canonical [`Manifest`]. Returns a [`LoadError`] if any file
323/// fails to parse or the assembled manifest is not a valid MDL manifest.
324pub 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            // A project with no relationships (e.g. a single-table onboarding scaffolded by the
337            // wren CLI) ships a comment-only / empty relationships.yml, which parses to YAML null
338            // and matches neither RelationshipsDoc variant. Treat an empty/null document as "no
339            // relationships" rather than failing the whole project's parse.
340            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    // A root `cubes.yml` (bare list) wins when present; otherwise fall back to the wren CLI's
358    // per-cube layout (`cubes/<name>/metadata.yml`, one cube per file). Never both, so a project
359    // that keeps a root mirror alongside the directory doesn't get every cube twice.
360    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    // `catalog`/`schema` are required by the Manifest schema; default them so a minimal project
405    // (test fixtures) still assembles rather than failing on a missing namespace field.
406    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
425/// Pad a partial `table_reference` mapping so `wren-core-base`'s `TableReference {
426/// catalog: Option<String>, schema: Option<String>, table: Option<String> }` custom deserializer
427/// accepts it.
428///
429/// The `wren generate-mdl` CLI emits file-backed sources (duckdb/csv/local_file) with a bare
430/// `table_reference: { table: /abs/path.parquet }` — no `catalog`/`schema` keys at all. Even
431/// though those fields are `Option<String>`, `wren-core-base`'s custom serde module has no
432/// `#[serde(default)]` on them, so a wholly-absent key is a hard "missing field `catalog`"
433/// deserialize error rather than defaulting to `None`. Filling in the missing keys as explicit
434/// JSON `null` (which `Option<String>` always deserializes as `None`, with no `default` attribute
435/// needed) makes a bare `{table: ...}` object parse the same as a fully-specified one.
436///
437/// Only a YAML *mapping* is padded. The CLI's other authored shapes for this field — a dotted
438/// string (`catalog.schema.table`) or a bare table-name string — go through a different (string)
439/// deserialization path on the `wren-core-base` side and never hit the missing-field case, so
440/// they pass through [`yaml_to_json`] unchanged.
441fn 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    // `hierarchies` is a BTreeMap in the Manifest; an empty map keeps deterministic output.
508    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}