Skip to main content

mini_app_core/
materialize.rs

1/// `row_materialize` MCP tool implementation.
2///
3/// This module exposes [`do_materialize`] which selects rows from a table,
4/// projects their fields, serialises them into one of four formats, and writes
5/// the result to an absolute filesystem path.
6///
7/// # Crux constraints
8///
9/// - **Absolute path Agent-First trust** (Crux #1): `dest` is validated with
10///   [`std::path::Path::is_absolute`] only.  Relative paths are rejected at
11///   the parameter-validation boundary; no project-root sandbox is applied to
12///   absolute paths.
13/// - **format × selector × concat grid test** (Crux #2): the test suite covers
14///   every cell of the 4 × 2 × 2 grid.
15/// - **SHA256 digest** (Crux #3): every `MaterializeFile` entry carries a
16///   64-character hex SHA-256 digest computed from the written bytes.  The
17///   field is never empty, `None`, or truncated — even when `dry_run=true`.
18use std::path::Path;
19use std::sync::Arc;
20
21use arc_swap::ArcSwap;
22use hex;
23use schemars::JsonSchema;
24use serde::{Deserialize, Serialize};
25use sha2::{Digest, Sha256};
26
27use crate::config::Config;
28use crate::error::MiniAppError;
29use crate::filter::ListFilter;
30use crate::registry::TableRegistry;
31use crate::schema::SchemaConfig;
32use crate::store::RowRecord;
33
34// =============================================================================
35// Public parameter / result types
36// =============================================================================
37
38/// Selector that identifies which rows to materialise.
39///
40/// # Variants
41/// - `ById` — fetch a single row by its UUID primary key.
42/// - `ByFilter` — fetch rows matching a [`ListFilter`] predicate.
43#[derive(Debug, Deserialize, JsonSchema)]
44#[serde(tag = "type", rename_all = "snake_case")]
45pub enum RowSelector {
46    /// Select a single row by its UUID.
47    ById {
48        /// The UUID of the row to select.
49        id: String,
50    },
51    /// Select rows matching a filter predicate.
52    ByFilter {
53        /// The filter to apply.
54        filter: ListFilter,
55        /// Maximum number of rows to return.  Defaults to 100.
56        limit: Option<u32>,
57        /// Number of rows to skip.  Defaults to 0.
58        offset: Option<u32>,
59    },
60}
61
62/// Field projection selector.
63///
64/// # Variants
65/// - `All` — include all schema fields in declaration order.
66/// - `List` — include only the listed fields, in the specified order.
67#[derive(Debug, Deserialize, JsonSchema)]
68#[serde(tag = "mode", rename_all = "snake_case")]
69pub enum FieldSelector {
70    /// Include every schema field in declaration order.
71    All,
72    /// Include only the listed fields, in the specified order.
73    List {
74        /// The field names to include.
75        fields: Vec<String>,
76    },
77}
78
79impl FieldSelector {
80    /// Validate field names against the schema's canonical field definitions.
81    ///
82    /// # Errors
83    /// Returns `MiniAppError::Validation` (code: `VALIDATION_ERROR`) if any
84    /// field name in `FieldSelector::List` is not present in the schema.
85    ///
86    /// # Crux compliance
87    /// Validates against `schema.fields` (canonical definitions), never
88    /// against actual keys present in materialized data (Crux #2).
89    pub fn validate(&self, schema: &SchemaConfig) -> Result<(), MiniAppError> {
90        if let FieldSelector::List { fields } = self {
91            let schema_names: std::collections::HashSet<&str> =
92                schema.fields.iter().map(|f| f.name.as_str()).collect();
93            for f in fields {
94                if !schema_names.contains(f.as_str()) {
95                    return Err(MiniAppError::Validation {
96                        field: f.clone(),
97                        reason: format!(
98                            "unknown field '{}' — only schema-registered fields are allowed in field projection",
99                            f
100                        ),
101                    });
102                }
103            }
104        }
105        Ok(())
106    }
107}
108
109/// Output serialisation format.
110///
111/// Determines both the content written to each file and the file extension
112/// used in the `{dest}/{id}.{ext}` naming scheme when `concat=false`.
113#[derive(Debug, Deserialize, JsonSchema)]
114#[serde(rename_all = "lowercase")]
115pub enum MaterializeFormat {
116    /// Plain text: field values joined by newlines.  Extension: `.txt`.
117    Raw,
118    /// Markdown: each field rendered as a heading + body block.  Extension: `.md`.
119    Markdown,
120    /// JSON: single object per row, or array when `concat=true`.  Extension: `.json`.
121    Json,
122    /// YAML: single document per row, or YAML document stream when `concat=true`.  Extension: `.yaml`.
123    Yaml,
124}
125
126/// Behaviour when the target file already exists.
127#[derive(Debug, Deserialize, JsonSchema)]
128#[serde(rename_all = "lowercase")]
129pub enum WriteMode {
130    /// Overwrite the existing file (default).
131    Overwrite,
132    /// Return [`MiniAppError::MaterializeDestInvalid`] if the file already exists.
133    Error,
134}
135
136/// Parameters for the `row_materialize` MCP tool.
137///
138/// # Required fields
139/// - `selector` — identifies which rows to materialise.
140/// - `fields` — field projection.
141/// - `format` — output serialisation format.
142/// - `dest` — **absolute** filesystem path.  Relative paths are rejected at
143///   validation time (Crux #1: Agent-First trust model).
144#[derive(Debug, Deserialize, JsonSchema)]
145pub struct MaterializeParams {
146    /// Target table name.  Optional in legacy single-table mode.
147    pub table: Option<String>,
148    /// Row selector (by id or by filter).
149    pub selector: RowSelector,
150    /// Field projection (all fields or a named subset).
151    pub fields: FieldSelector,
152    /// Output format.
153    pub format: MaterializeFormat,
154    /// **Absolute** destination path.  When `concat=false` (default) this is
155    /// treated as a directory; when `concat=true` it is the output file path.
156    pub dest: String,
157    /// When `false` (default) each row is written to `{dest}/{row.id}.{ext}`.
158    /// When `true` all rows are concatenated into a single file at `{dest}`.
159    pub concat: Option<bool>,
160    /// Behaviour when the target file already exists.  Defaults to `Overwrite`.
161    pub write_mode: Option<WriteMode>,
162    /// When `true`, validation, projection, serialisation, and SHA-256
163    /// computation are performed but **no file is written**.  The returned
164    /// [`MaterializeFile`] entries carry would-be `path`, `bytes`, and
165    /// `sha256` values (Crux #3 — digest is always present).
166    pub dry_run: Option<bool>,
167}
168
169/// A single output file produced by `row_materialize`.
170///
171/// # Fields
172/// - `path` — absolute filesystem path of the written (or would-be) file.
173/// - `bytes` — number of bytes written.
174/// - `sha256` — 64-character lower-hex SHA-256 digest of the written bytes
175///   (Crux #3: always present, never empty).
176/// - `row_id` — UUID of the source row when `concat=false`; `null` when
177///   `concat=true` (one file covers many rows).  `null` is serialised as JSON
178///   `null` (no `skip_serializing_if`).
179#[derive(Debug, Serialize)]
180pub struct MaterializeFile {
181    /// Absolute path of the output file.
182    pub path: String,
183    /// Byte length of the written content.
184    pub bytes: u64,
185    /// 64-character lower-hex SHA-256 digest (Crux #3).
186    pub sha256: String,
187    /// Source row UUID, or `null` when `concat=true`.
188    pub row_id: Option<String>,
189}
190
191/// Return value of `row_materialize`.
192#[derive(Debug, Serialize)]
193pub struct MaterializeResult {
194    /// Number of output files written (or would-be written when `dry_run=true`).
195    pub count: usize,
196    /// Per-file metadata.
197    pub files: Vec<MaterializeFile>,
198}
199
200// =============================================================================
201// Internal helpers
202// =============================================================================
203
204/// Returns the file extension for a given format.
205fn ext_for(format: &MaterializeFormat) -> &'static str {
206    match format {
207        MaterializeFormat::Raw => "txt",
208        MaterializeFormat::Markdown => "md",
209        MaterializeFormat::Json => "json",
210        MaterializeFormat::Yaml => "yaml",
211    }
212}
213
214/// Project a single row's `data` JSON object into a `serde_json::Map` using
215/// the selected field names.
216///
217/// Fields that are not present in `data` are mapped to `serde_json::Value::Null`.
218fn project_row(
219    data: &serde_json::Value,
220    field_names: &[String],
221) -> serde_json::Map<String, serde_json::Value> {
222    let mut map = serde_json::Map::new();
223    for name in field_names {
224        let v = data.get(name).cloned().unwrap_or(serde_json::Value::Null);
225        map.insert(name.clone(), v);
226    }
227    map
228}
229
230/// Apply field projection to a list of [`RowRecord`]s.
231///
232/// This is the **single shared post-materialization, pre-serialization boundary**
233/// for field projection across the `list`, `get`, and `alias_run` operations
234/// (Crux #1: one function, called by all three handlers).
235///
236/// - `None` or `Some(FieldSelector::All)` → returns `records` unchanged (backward-compatible).
237/// - `Some(FieldSelector::List { fields })` → validates field names against
238///   `schema` (Crux #2), then projects each row's `data` object to the listed
239///   fields.  The row's `id`, `created_at`, and `updated_at` are always preserved.
240///
241/// # Errors
242/// Returns `MiniAppError::Validation` (`VALIDATION_ERROR`) if any field name in
243/// `FieldSelector::List` is not present in the schema's canonical field definitions.
244pub fn apply_projection(
245    records: Vec<RowRecord>,
246    fields: &Option<FieldSelector>,
247    schema: &SchemaConfig,
248) -> Result<Vec<RowRecord>, MiniAppError> {
249    let field_selector = match fields {
250        None => return Ok(records),
251        Some(fs) => fs,
252    };
253    match field_selector {
254        FieldSelector::All => Ok(records),
255        FieldSelector::List {
256            fields: field_names,
257        } => {
258            field_selector.validate(schema)?;
259            let projected = records
260                .into_iter()
261                .map(|row| {
262                    let projected_map = project_row(&row.data, field_names);
263                    RowRecord {
264                        data: serde_json::Value::Object(projected_map),
265                        ..row
266                    }
267                })
268                .collect();
269            Ok(projected)
270        }
271    }
272}
273
274/// Serialise a single projected row into bytes for the given format.
275///
276/// # Errors
277/// - [`MiniAppError::MaterializeFormatError`] if serialisation fails.
278fn serialize_row(
279    format: &MaterializeFormat,
280    projected: &serde_json::Map<String, serde_json::Value>,
281    row_id: &str,
282) -> Result<Vec<u8>, MiniAppError> {
283    match format {
284        MaterializeFormat::Raw => {
285            // Field values joined by newlines, one value per line.
286            let lines: Vec<String> = projected
287                .values()
288                .map(|v| match v {
289                    serde_json::Value::String(s) => s.clone(),
290                    other => other.to_string(),
291                })
292                .collect();
293            Ok(lines.join("\n").into_bytes())
294        }
295        MaterializeFormat::Markdown => {
296            // `# {id}\n\n## {field}\n\n{value}\n\n...`
297            let mut md = format!("# {}\n", row_id);
298            for (field, value) in projected {
299                let text = match value {
300                    serde_json::Value::String(s) => s.clone(),
301                    other => other.to_string(),
302                };
303                md.push_str(&format!("\n## {}\n\n{}\n", field, text));
304            }
305            Ok(md.into_bytes())
306        }
307        MaterializeFormat::Json => {
308            let val = serde_json::Value::Object(projected.clone());
309            serde_json::to_vec_pretty(&val)
310                .map_err(|e| MiniAppError::MaterializeFormatError(format!("json: {e}")))
311        }
312        MaterializeFormat::Yaml => serde_yaml_bw::to_string(projected)
313            .map(|s| s.into_bytes())
314            .map_err(|e| MiniAppError::MaterializeFormatError(format!("yaml: {e}"))),
315    }
316}
317
318/// Concatenate multiple per-row byte sequences according to format rules.
319///
320/// # Rules
321/// - `Raw`: join with `\n\n`
322/// - `Markdown`: join with `\n---\n\n`
323/// - `Json`: serialise as a JSON array of projected objects
324/// - `Yaml`: join with `---\n` (YAML document stream)
325///
326/// # Errors
327/// - [`MiniAppError::MaterializeFormatError`] if JSON serialisation fails.
328fn concat_rows(
329    format: &MaterializeFormat,
330    rows: &[serde_json::Map<String, serde_json::Value>],
331    ids: &[String],
332) -> Result<Vec<u8>, MiniAppError> {
333    match format {
334        MaterializeFormat::Raw => {
335            // Each row serialised as newline-joined values, separated by \n\n.
336            let parts: Result<Vec<String>, _> = rows
337                .iter()
338                .zip(ids.iter())
339                .map(|(projected, id)| {
340                    serialize_row(&MaterializeFormat::Raw, projected, id)
341                        .map(|b| String::from_utf8_lossy(&b).into_owned())
342                })
343                .collect();
344            let parts = parts?;
345            Ok(parts.join("\n\n").into_bytes())
346        }
347        MaterializeFormat::Markdown => {
348            let parts: Result<Vec<String>, _> = rows
349                .iter()
350                .zip(ids.iter())
351                .map(|(projected, id)| {
352                    serialize_row(&MaterializeFormat::Markdown, projected, id)
353                        .map(|b| String::from_utf8_lossy(&b).into_owned())
354                })
355                .collect();
356            let parts = parts?;
357            Ok(parts.join("\n---\n\n").into_bytes())
358        }
359        MaterializeFormat::Json => {
360            let arr: Vec<serde_json::Value> = rows
361                .iter()
362                .map(|m| serde_json::Value::Object(m.clone()))
363                .collect();
364            serde_json::to_vec_pretty(&arr)
365                .map_err(|e| MiniAppError::MaterializeFormatError(format!("json array: {e}")))
366        }
367        MaterializeFormat::Yaml => {
368            // YAML document stream: each doc preceded by `---\n`.
369            let mut out = String::new();
370            for projected in rows {
371                let doc = serde_yaml_bw::to_string(projected)
372                    .map_err(|e| MiniAppError::MaterializeFormatError(format!("yaml: {e}")))?;
373                out.push_str("---\n");
374                out.push_str(&doc);
375            }
376            Ok(out.into_bytes())
377        }
378    }
379}
380
381/// Compute the SHA-256 hex digest of a byte slice (Crux #3).
382fn sha256_hex(bytes: &[u8]) -> String {
383    hex::encode(Sha256::digest(bytes))
384}
385
386// =============================================================================
387// Core function
388// =============================================================================
389
390/// Execute the `row_materialize` tool.
391///
392/// Selects rows from the target table, projects fields, serialises to the
393/// requested format, and writes the result to absolute filesystem path(s).
394///
395/// # Crux constraints enforced here
396/// 1. **Absolute path**: `params.dest` is checked with `Path::is_absolute()`
397///    at step (a).  Relative paths return `MaterializeDestRelative` immediately.
398///    No sandbox constraint is applied to absolute paths.
399/// 2. **SHA-256**: every `MaterializeFile` entry carries a 64-char hex SHA-256
400///    digest, including when `dry_run=true`.
401///
402/// # Arguments
403/// - `config` — server mount configuration.
404/// - `tables` — live `ArcSwap`-wrapped table registry.
405/// - `params` — tool parameters.
406///
407/// # Returns
408/// [`MaterializeResult`] on success (JSON-serialisable).
409///
410/// # Errors
411/// - [`MiniAppError::MaterializeDestRelative`] — `dest` is not absolute.
412/// - [`MiniAppError::MaterializeFieldUnknown`] — projected field not in schema.
413/// - [`MiniAppError::MaterializeInvalidParam`] — incompatible parameter combination.
414/// - [`MiniAppError::MaterializeRowNotFound`] — `ById` selector found no row.
415/// - [`MiniAppError::MaterializeEmptyResult`] — `ByFilter` selector matched zero rows.
416/// - [`MiniAppError::MaterializeFormatError`] — serialisation failure.
417/// - [`MiniAppError::MaterializeDestInvalid`] — dest path problem or write_mode conflict.
418/// - [`MiniAppError::MaterializeIo`] — filesystem write failure.
419/// - [`MiniAppError::MaterializeSha256`] — `spawn_blocking` task panic during SHA-256/write.
420pub async fn do_materialize(
421    _config: &Config,
422    tables: &Arc<ArcSwap<TableRegistry>>,
423    params: MaterializeParams,
424) -> Result<MaterializeResult, MiniAppError> {
425    // (a) Absolute path validation (Crux #1).
426    if !Path::new(&params.dest).is_absolute() {
427        tracing::warn!(dest = %params.dest, "row_materialize: dest is not absolute");
428        return Err(MiniAppError::MaterializeDestRelative {
429            path: params.dest.clone(),
430        });
431    }
432
433    let dest = params.dest.clone();
434    let concat = params.concat.unwrap_or(false);
435    let dry_run = params.dry_run.unwrap_or(false);
436    let write_mode_is_error = matches!(params.write_mode, Some(WriteMode::Error));
437
438    // (b) Resolve table — ArcSwap Guard dropped before any .await (K-103).
439    let (store, schema) = {
440        let registry = tables.load_full();
441        let entry = registry.resolve(params.table.as_deref())?;
442        (Arc::clone(&entry.store), Arc::clone(&entry.schema))
443    };
444
445    // (c) Validate projected field names against schema.
446    let field_names: Vec<String> = match &params.fields {
447        FieldSelector::All => schema.fields.iter().map(|f| f.name.clone()).collect(),
448        FieldSelector::List { fields } => {
449            let schema_names: std::collections::HashSet<&str> =
450                schema.fields.iter().map(|f| f.name.as_str()).collect();
451            for f in fields {
452                if !schema_names.contains(f.as_str()) {
453                    tracing::warn!(field = %f, "row_materialize: unknown projection field");
454                    return Err(MiniAppError::MaterializeFieldUnknown { field: f.clone() });
455                }
456            }
457            fields.clone()
458        }
459    };
460
461    // (d) Parameter consistency check.
462    if let RowSelector::ById { .. } = &params.selector {
463        if concat {
464            tracing::warn!("row_materialize: concat=true with selector=by_id is invalid");
465            return Err(MiniAppError::MaterializeInvalidParam {
466                field: "concat".to_string(),
467                reason: "concat=true requires selector=by_filter (ById always yields a single row)"
468                    .to_string(),
469            });
470        }
471    }
472
473    // (e) Fetch rows.
474    let rows = match params.selector {
475        RowSelector::ById { ref id } => {
476            let row = store.get(id).await.map_err(|e| match e {
477                MiniAppError::NotFound { .. } => {
478                    tracing::warn!(id = %id, "row_materialize: row not found");
479                    MiniAppError::MaterializeRowNotFound { id: id.clone() }
480                }
481                other => other,
482            })?;
483            vec![row]
484        }
485        RowSelector::ByFilter {
486            filter,
487            limit,
488            offset,
489        } => {
490            let rows = store.list(limit, offset, Some(filter)).await?;
491            if rows.is_empty() {
492                tracing::warn!("row_materialize: by_filter selector matched zero rows");
493                return Err(MiniAppError::MaterializeEmptyResult);
494            }
495            rows
496        }
497    };
498
499    // (f) Project each row's data into ordered maps.
500    let projected_rows: Vec<serde_json::Map<String, serde_json::Value>> = rows
501        .iter()
502        .map(|row| project_row(&row.data, &field_names))
503        .collect();
504
505    let row_ids: Vec<String> = rows.iter().map(|r| r.id.clone()).collect();
506
507    let format = &params.format;
508    let ext = ext_for(format);
509
510    let mut files: Vec<MaterializeFile> = Vec::new();
511
512    if concat {
513        // (g)+(h) Concat path: all rows → one file.
514        let bytes = concat_rows(format, &projected_rows, &row_ids)?;
515        let sha256 = sha256_hex(&bytes);
516        let byte_len = bytes.len() as u64;
517        let dest_path = dest.clone();
518
519        // write_mode=Error check (dry_run=true still validates — AC #6).
520        if write_mode_is_error && Path::new(&dest_path).exists() {
521            tracing::warn!(path = %dest_path, "row_materialize: dest already exists with write_mode=error");
522            return Err(MiniAppError::MaterializeDestInvalid {
523                path: dest_path.clone(),
524                reason: "file already exists with write_mode=error".to_string(),
525            });
526        }
527
528        if !dry_run {
529            // Ensure parent directory exists, then write — both inside spawn_blocking (K-110).
530            let dest_clone = dest_path.clone();
531            let bytes_clone = bytes.clone();
532            tokio::task::spawn_blocking(move || -> Result<(), MiniAppError> {
533                if let Some(parent) = Path::new(&dest_clone).parent() {
534                    if !parent.as_os_str().is_empty() {
535                        std::fs::create_dir_all(parent).map_err(|e| {
536                            MiniAppError::MaterializeIo(format!(
537                                "create_dir_all '{}': {e}",
538                                parent.display()
539                            ))
540                        })?;
541                    }
542                }
543                std::fs::write(&dest_clone, &bytes_clone).map_err(|e| {
544                    MiniAppError::MaterializeIo(format!("write '{}': {e}", dest_clone))
545                })
546            })
547            .await
548            .map_err(|e| MiniAppError::MaterializeIo(format!("blocking task panic: {e}")))??;
549        }
550
551        // (i) row_id is None for concat (Crux #2 wf-sim restructure_shape #2).
552        files.push(MaterializeFile {
553            path: dest_path,
554            bytes: byte_len,
555            sha256,
556            row_id: None,
557        });
558    } else {
559        // Non-concat path: one file per row.
560
561        // Ensure destination directory exists (idempotent), inside spawn_blocking (K-110).
562        if !dry_run {
563            let dest_dir = dest.clone();
564            tokio::task::spawn_blocking(move || -> Result<(), MiniAppError> {
565                std::fs::create_dir_all(&dest_dir).map_err(|e| {
566                    MiniAppError::MaterializeIo(format!("create_dir_all '{}': {e}", dest_dir))
567                })
568            })
569            .await
570            .map_err(|e| MiniAppError::MaterializeIo(format!("blocking task panic: {e}")))??;
571        }
572
573        for (row, projected) in rows.iter().zip(projected_rows.iter()) {
574            let out_path = format!("{}/{}.{}", dest, row.id, ext);
575
576            // write_mode=Error check (dry_run=true still validates — AC #6).
577            if write_mode_is_error && Path::new(&out_path).exists() {
578                tracing::warn!(path = %out_path, "row_materialize: output file already exists with write_mode=error");
579                return Err(MiniAppError::MaterializeDestInvalid {
580                    path: out_path.clone(),
581                    reason: "file already exists with write_mode=error".to_string(),
582                });
583            }
584
585            let bytes = serialize_row(format, projected, &row.id)?;
586            let sha256 = sha256_hex(&bytes);
587            let byte_len = bytes.len() as u64;
588
589            if !dry_run {
590                let out_path_clone = out_path.clone();
591                let bytes_clone = bytes.clone();
592                tokio::task::spawn_blocking(move || -> Result<(), MiniAppError> {
593                    std::fs::write(&out_path_clone, &bytes_clone).map_err(|e| {
594                        MiniAppError::MaterializeIo(format!("write '{}': {e}", out_path_clone))
595                    })
596                })
597                .await
598                .map_err(|e| MiniAppError::MaterializeIo(format!("blocking task panic: {e}")))??;
599            }
600
601            // (i) row_id = Some(row.id) for non-concat.
602            files.push(MaterializeFile {
603                path: out_path,
604                bytes: byte_len,
605                sha256,
606                row_id: Some(row.id.clone()),
607            });
608        }
609    }
610
611    // (j) Return result.
612    let count = files.len();
613    Ok(MaterializeResult { count, files })
614}
615
616// =============================================================================
617// Tests
618// =============================================================================
619
620#[cfg(test)]
621mod tests {
622    use super::*;
623    use crate::config::Config;
624    use crate::registry::TableRegistry;
625    use crate::schema::{FieldDef, FieldType, SchemaConfig};
626    use crate::store::Store;
627    use std::path::PathBuf;
628    use std::sync::Arc;
629
630    // -------------------------------------------------------------------------
631    // Test helpers
632    // -------------------------------------------------------------------------
633
634    /// Build a minimal test server backed by an in-memory store with one row.
635    async fn make_test_env() -> (Arc<ArcSwap<TableRegistry>>, String, Arc<Config>) {
636        let schema = SchemaConfig {
637            table: "test".to_string(),
638            title: None,
639            description: None,
640            fields: vec![
641                FieldDef {
642                    name: "title".to_string(),
643                    ty: FieldType::String,
644                    required: true,
645                    description: None,
646                },
647                FieldDef {
648                    name: "body".to_string(),
649                    ty: FieldType::String,
650                    required: false,
651                    description: None,
652                },
653            ],
654            dump: None,
655        };
656
657        // Use an in-memory SQLite store for tests.
658        // SAFETY: ":memory:" always opens without error in test context.
659        let store = Store::open(std::path::Path::new(":memory:"), schema.clone())
660            .await
661            .expect("in-memory store must open");
662
663        // Insert one row before passing ownership to the registry.
664        let data = serde_json::json!({"title": "hello", "body": "world"});
665        // SAFETY: validated JSON matches the schema above.
666        let row = store.create(data).await.expect("create must succeed");
667        let row_id = row.id.clone();
668
669        let registry = TableRegistry::from_single(
670            store,
671            schema,
672            PathBuf::from("/fake/schema.yaml"),
673            "test".to_string(),
674        );
675
676        let config = Arc::new(Config {
677            schema_path: None,
678            db_path: None,
679            user_dir: None,
680            project_dir: None,
681            backup_retention: None,
682            snapshot_retention: None,
683        });
684
685        (Arc::new(ArcSwap::from_pointee(registry)), row_id, config)
686    }
687
688    /// Insert a second row via the registry and return its id.
689    async fn add_second_row(tables: &Arc<ArcSwap<TableRegistry>>) -> String {
690        let registry = tables.load_full();
691        // SAFETY: resolve(None) works because a default_table is set in from_single.
692        let entry = registry.resolve(None).expect("resolve must succeed");
693        let data = serde_json::json!({"title": "second", "body": "entry"});
694        // SAFETY: validated JSON matches the test schema.
695        let row = entry.store.create(data).await.expect("create must succeed");
696        row.id
697    }
698
699    // -------------------------------------------------------------------------
700    // 16-cell grid: format (4) × selector (2) × concat (2)
701    // -------------------------------------------------------------------------
702    // Naming: materialize_grid_{format}_{selector}_{concat}
703    //
704    // ById + concat=true (4 cells) → error path (MaterializeInvalidParam)
705    // ById + concat=false (4 cells) → happy path
706    // ByFilter + concat=false (4 cells) → happy path
707    // ByFilter + concat=true (4 cells) → happy path, row_id=None
708    // -------------------------------------------------------------------------
709
710    // -- raw × ById × no_concat --
711
712    #[tokio::test]
713    async fn materialize_grid_raw_by_id_no_concat() {
714        let (tables, row_id, config) = make_test_env().await;
715        let dest = tempfile::tempdir().unwrap();
716        let dest_path = dest.path().to_str().unwrap().to_string();
717
718        let params = MaterializeParams {
719            table: None,
720            selector: RowSelector::ById { id: row_id.clone() },
721            fields: FieldSelector::All,
722            format: MaterializeFormat::Raw,
723            dest: dest_path.clone(),
724            concat: Some(false),
725            write_mode: None,
726            dry_run: None,
727        };
728
729        let result = do_materialize(&config, &tables, params).await.unwrap();
730        assert_eq!(result.count, 1);
731        let f = &result.files[0];
732        assert_eq!(f.row_id, Some(row_id.clone()));
733        assert_eq!(f.sha256.len(), 64);
734        assert!(f.bytes > 0);
735        // File was written.
736        let written = std::fs::read_to_string(&f.path).unwrap();
737        assert!(written.contains("hello"));
738    }
739
740    // -- markdown × ById × no_concat --
741
742    #[tokio::test]
743    async fn materialize_grid_markdown_by_id_no_concat() {
744        let (tables, row_id, config) = make_test_env().await;
745        let dest = tempfile::tempdir().unwrap();
746        let dest_path = dest.path().to_str().unwrap().to_string();
747
748        let params = MaterializeParams {
749            table: None,
750            selector: RowSelector::ById { id: row_id.clone() },
751            fields: FieldSelector::All,
752            format: MaterializeFormat::Markdown,
753            dest: dest_path,
754            concat: Some(false),
755            write_mode: None,
756            dry_run: None,
757        };
758
759        let result = do_materialize(&config, &tables, params).await.unwrap();
760        assert_eq!(result.count, 1);
761        let f = &result.files[0];
762        assert_eq!(f.row_id, Some(row_id.clone()));
763        assert_eq!(f.sha256.len(), 64);
764        assert!(f.path.ends_with(".md"));
765        let written = std::fs::read_to_string(&f.path).unwrap();
766        assert!(written.contains(&format!("# {}", row_id)));
767        assert!(written.contains("## title"));
768    }
769
770    // -- json × ById × no_concat --
771
772    #[tokio::test]
773    async fn materialize_grid_json_by_id_no_concat() {
774        let (tables, row_id, config) = make_test_env().await;
775        let dest = tempfile::tempdir().unwrap();
776        let dest_path = dest.path().to_str().unwrap().to_string();
777
778        let params = MaterializeParams {
779            table: None,
780            selector: RowSelector::ById { id: row_id.clone() },
781            fields: FieldSelector::All,
782            format: MaterializeFormat::Json,
783            dest: dest_path,
784            concat: Some(false),
785            write_mode: None,
786            dry_run: None,
787        };
788
789        let result = do_materialize(&config, &tables, params).await.unwrap();
790        assert_eq!(result.count, 1);
791        let f = &result.files[0];
792        assert_eq!(f.row_id, Some(row_id));
793        assert_eq!(f.sha256.len(), 64);
794        assert!(f.path.ends_with(".json"));
795        let parsed: serde_json::Value =
796            serde_json::from_str(&std::fs::read_to_string(&f.path).unwrap()).unwrap();
797        assert_eq!(parsed["title"], "hello");
798    }
799
800    // -- yaml × ById × no_concat --
801
802    #[tokio::test]
803    async fn materialize_grid_yaml_by_id_no_concat() {
804        let (tables, row_id, config) = make_test_env().await;
805        let dest = tempfile::tempdir().unwrap();
806        let dest_path = dest.path().to_str().unwrap().to_string();
807
808        let params = MaterializeParams {
809            table: None,
810            selector: RowSelector::ById { id: row_id.clone() },
811            fields: FieldSelector::All,
812            format: MaterializeFormat::Yaml,
813            dest: dest_path,
814            concat: Some(false),
815            write_mode: None,
816            dry_run: None,
817        };
818
819        let result = do_materialize(&config, &tables, params).await.unwrap();
820        assert_eq!(result.count, 1);
821        let f = &result.files[0];
822        assert_eq!(f.row_id, Some(row_id));
823        assert_eq!(f.sha256.len(), 64);
824        assert!(f.path.ends_with(".yaml"));
825        let content = std::fs::read_to_string(&f.path).unwrap();
826        assert!(content.contains("title"));
827    }
828
829    // -- raw × ById × concat=true → error path --
830
831    #[tokio::test]
832    async fn materialize_grid_raw_by_id_concat() {
833        let (tables, row_id, config) = make_test_env().await;
834        let dest = tempfile::tempdir().unwrap();
835        let dest_path = format!("{}/out.txt", dest.path().display());
836
837        let params = MaterializeParams {
838            table: None,
839            selector: RowSelector::ById { id: row_id },
840            fields: FieldSelector::All,
841            format: MaterializeFormat::Raw,
842            dest: dest_path,
843            concat: Some(true),
844            write_mode: None,
845            dry_run: None,
846        };
847
848        let err = do_materialize(&config, &tables, params).await.unwrap_err();
849        assert!(matches!(
850            err,
851            MiniAppError::MaterializeInvalidParam { ref field, .. } if field == "concat"
852        ));
853    }
854
855    // -- markdown × ById × concat=true → error path --
856
857    #[tokio::test]
858    async fn materialize_grid_markdown_by_id_concat() {
859        let (tables, row_id, config) = make_test_env().await;
860        let dest = tempfile::tempdir().unwrap();
861        let dest_path = format!("{}/out.md", dest.path().display());
862
863        let params = MaterializeParams {
864            table: None,
865            selector: RowSelector::ById { id: row_id },
866            fields: FieldSelector::All,
867            format: MaterializeFormat::Markdown,
868            dest: dest_path,
869            concat: Some(true),
870            write_mode: None,
871            dry_run: None,
872        };
873
874        let err = do_materialize(&config, &tables, params).await.unwrap_err();
875        assert!(matches!(
876            err,
877            MiniAppError::MaterializeInvalidParam { ref field, .. } if field == "concat"
878        ));
879    }
880
881    // -- json × ById × concat=true → error path --
882
883    #[tokio::test]
884    async fn materialize_grid_json_by_id_concat() {
885        let (tables, row_id, config) = make_test_env().await;
886        let dest = tempfile::tempdir().unwrap();
887        let dest_path = format!("{}/out.json", dest.path().display());
888
889        let params = MaterializeParams {
890            table: None,
891            selector: RowSelector::ById { id: row_id },
892            fields: FieldSelector::All,
893            format: MaterializeFormat::Json,
894            dest: dest_path,
895            concat: Some(true),
896            write_mode: None,
897            dry_run: None,
898        };
899
900        let err = do_materialize(&config, &tables, params).await.unwrap_err();
901        assert!(matches!(
902            err,
903            MiniAppError::MaterializeInvalidParam { ref field, .. } if field == "concat"
904        ));
905    }
906
907    // -- yaml × ById × concat=true → error path --
908
909    #[tokio::test]
910    async fn materialize_grid_yaml_by_id_concat() {
911        let (tables, row_id, config) = make_test_env().await;
912        let dest = tempfile::tempdir().unwrap();
913        let dest_path = format!("{}/out.yaml", dest.path().display());
914
915        let params = MaterializeParams {
916            table: None,
917            selector: RowSelector::ById { id: row_id },
918            fields: FieldSelector::All,
919            format: MaterializeFormat::Yaml,
920            dest: dest_path,
921            concat: Some(true),
922            write_mode: None,
923            dry_run: None,
924        };
925
926        let err = do_materialize(&config, &tables, params).await.unwrap_err();
927        assert!(matches!(
928            err,
929            MiniAppError::MaterializeInvalidParam { ref field, .. } if field == "concat"
930        ));
931    }
932
933    // -- raw × ByFilter × no_concat --
934
935    #[tokio::test]
936    async fn materialize_grid_raw_by_filter_no_concat() {
937        let (tables, row_id, config) = make_test_env().await;
938        let dest = tempfile::tempdir().unwrap();
939        let dest_path = dest.path().to_str().unwrap().to_string();
940
941        let params = MaterializeParams {
942            table: None,
943            selector: RowSelector::ByFilter {
944                filter: crate::filter::ListFilter::Eq {
945                    field: "title".to_string(),
946                    value: serde_json::json!("hello"),
947                },
948                limit: None,
949                offset: None,
950            },
951            fields: FieldSelector::All,
952            format: MaterializeFormat::Raw,
953            dest: dest_path,
954            concat: Some(false),
955            write_mode: None,
956            dry_run: None,
957        };
958
959        let result = do_materialize(&config, &tables, params).await.unwrap();
960        assert_eq!(result.count, 1);
961        let f = &result.files[0];
962        assert_eq!(f.row_id, Some(row_id));
963        assert_eq!(f.sha256.len(), 64);
964        let written = std::fs::read_to_string(&f.path).unwrap();
965        assert!(written.contains("hello"));
966    }
967
968    // -- markdown × ByFilter × no_concat --
969
970    #[tokio::test]
971    async fn materialize_grid_markdown_by_filter_no_concat() {
972        let (tables, row_id, config) = make_test_env().await;
973        let dest = tempfile::tempdir().unwrap();
974        let dest_path = dest.path().to_str().unwrap().to_string();
975
976        let params = MaterializeParams {
977            table: None,
978            selector: RowSelector::ByFilter {
979                filter: crate::filter::ListFilter::Eq {
980                    field: "title".to_string(),
981                    value: serde_json::json!("hello"),
982                },
983                limit: None,
984                offset: None,
985            },
986            fields: FieldSelector::All,
987            format: MaterializeFormat::Markdown,
988            dest: dest_path,
989            concat: Some(false),
990            write_mode: None,
991            dry_run: None,
992        };
993
994        let result = do_materialize(&config, &tables, params).await.unwrap();
995        assert_eq!(result.count, 1);
996        let f = &result.files[0];
997        assert_eq!(f.row_id, Some(row_id.clone()));
998        assert_eq!(f.sha256.len(), 64);
999        let written = std::fs::read_to_string(&f.path).unwrap();
1000        assert!(written.contains(&format!("# {}", row_id)));
1001    }
1002
1003    // -- json × ByFilter × no_concat --
1004
1005    #[tokio::test]
1006    async fn materialize_grid_json_by_filter_no_concat() {
1007        let (tables, row_id, config) = make_test_env().await;
1008        let dest = tempfile::tempdir().unwrap();
1009        let dest_path = dest.path().to_str().unwrap().to_string();
1010
1011        let params = MaterializeParams {
1012            table: None,
1013            selector: RowSelector::ByFilter {
1014                filter: crate::filter::ListFilter::Eq {
1015                    field: "title".to_string(),
1016                    value: serde_json::json!("hello"),
1017                },
1018                limit: None,
1019                offset: None,
1020            },
1021            fields: FieldSelector::All,
1022            format: MaterializeFormat::Json,
1023            dest: dest_path,
1024            concat: Some(false),
1025            write_mode: None,
1026            dry_run: None,
1027        };
1028
1029        let result = do_materialize(&config, &tables, params).await.unwrap();
1030        assert_eq!(result.count, 1);
1031        let f = &result.files[0];
1032        assert_eq!(f.row_id, Some(row_id));
1033        assert_eq!(f.sha256.len(), 64);
1034        let parsed: serde_json::Value =
1035            serde_json::from_str(&std::fs::read_to_string(&f.path).unwrap()).unwrap();
1036        assert_eq!(parsed["title"], "hello");
1037    }
1038
1039    // -- yaml × ByFilter × no_concat --
1040
1041    #[tokio::test]
1042    async fn materialize_grid_yaml_by_filter_no_concat() {
1043        let (tables, row_id, config) = make_test_env().await;
1044        let dest = tempfile::tempdir().unwrap();
1045        let dest_path = dest.path().to_str().unwrap().to_string();
1046
1047        let params = MaterializeParams {
1048            table: None,
1049            selector: RowSelector::ByFilter {
1050                filter: crate::filter::ListFilter::Eq {
1051                    field: "title".to_string(),
1052                    value: serde_json::json!("hello"),
1053                },
1054                limit: None,
1055                offset: None,
1056            },
1057            fields: FieldSelector::All,
1058            format: MaterializeFormat::Yaml,
1059            dest: dest_path,
1060            concat: Some(false),
1061            write_mode: None,
1062            dry_run: None,
1063        };
1064
1065        let result = do_materialize(&config, &tables, params).await.unwrap();
1066        assert_eq!(result.count, 1);
1067        let f = &result.files[0];
1068        assert_eq!(f.row_id, Some(row_id));
1069        assert_eq!(f.sha256.len(), 64);
1070        let content = std::fs::read_to_string(&f.path).unwrap();
1071        assert!(content.contains("hello"));
1072    }
1073
1074    // -- raw × ByFilter × concat=true --
1075
1076    #[tokio::test]
1077    async fn materialize_grid_raw_by_filter_concat() {
1078        let (tables, _row_id, config) = make_test_env().await;
1079        add_second_row(&tables).await;
1080        let dest = tempfile::tempdir().unwrap();
1081        let out_file = format!("{}/all.txt", dest.path().display());
1082
1083        let params = MaterializeParams {
1084            table: None,
1085            selector: RowSelector::ByFilter {
1086                filter: crate::filter::ListFilter::Eq {
1087                    field: "title".to_string(),
1088                    value: serde_json::json!("hello"),
1089                },
1090                limit: None,
1091                offset: None,
1092            },
1093            fields: FieldSelector::All,
1094            format: MaterializeFormat::Raw,
1095            dest: out_file.clone(),
1096            concat: Some(true),
1097            write_mode: None,
1098            dry_run: None,
1099        };
1100
1101        let result = do_materialize(&config, &tables, params).await.unwrap();
1102        assert_eq!(result.count, 1);
1103        let f = &result.files[0];
1104        // concat=true → row_id must be None (Crux #2 wf-sim restructure_shape #2).
1105        assert_eq!(f.row_id, None);
1106        assert_eq!(f.sha256.len(), 64);
1107        assert_eq!(f.path, out_file);
1108        let content = std::fs::read_to_string(&f.path).unwrap();
1109        assert!(content.contains("hello"));
1110    }
1111
1112    // -- markdown × ByFilter × concat=true --
1113
1114    #[tokio::test]
1115    async fn materialize_grid_markdown_by_filter_concat() {
1116        let (tables, _row_id, config) = make_test_env().await;
1117        let dest = tempfile::tempdir().unwrap();
1118        let out_file = format!("{}/all.md", dest.path().display());
1119
1120        let params = MaterializeParams {
1121            table: None,
1122            selector: RowSelector::ByFilter {
1123                filter: crate::filter::ListFilter::Eq {
1124                    field: "title".to_string(),
1125                    value: serde_json::json!("hello"),
1126                },
1127                limit: None,
1128                offset: None,
1129            },
1130            fields: FieldSelector::All,
1131            format: MaterializeFormat::Markdown,
1132            dest: out_file.clone(),
1133            concat: Some(true),
1134            write_mode: None,
1135            dry_run: None,
1136        };
1137
1138        let result = do_materialize(&config, &tables, params).await.unwrap();
1139        assert_eq!(result.count, 1);
1140        let f = &result.files[0];
1141        assert_eq!(f.row_id, None);
1142        assert_eq!(f.sha256.len(), 64);
1143        let content = std::fs::read_to_string(&f.path).unwrap();
1144        assert!(content.contains("## title"));
1145    }
1146
1147    // -- json × ByFilter × concat=true --
1148
1149    #[tokio::test]
1150    async fn materialize_grid_json_by_filter_concat() {
1151        let (tables, _row_id, config) = make_test_env().await;
1152        let dest = tempfile::tempdir().unwrap();
1153        let out_file = format!("{}/all.json", dest.path().display());
1154
1155        let params = MaterializeParams {
1156            table: None,
1157            selector: RowSelector::ByFilter {
1158                filter: crate::filter::ListFilter::Eq {
1159                    field: "title".to_string(),
1160                    value: serde_json::json!("hello"),
1161                },
1162                limit: None,
1163                offset: None,
1164            },
1165            fields: FieldSelector::All,
1166            format: MaterializeFormat::Json,
1167            dest: out_file.clone(),
1168            concat: Some(true),
1169            write_mode: None,
1170            dry_run: None,
1171        };
1172
1173        let result = do_materialize(&config, &tables, params).await.unwrap();
1174        assert_eq!(result.count, 1);
1175        let f = &result.files[0];
1176        assert_eq!(f.row_id, None);
1177        assert_eq!(f.sha256.len(), 64);
1178        let parsed: serde_json::Value =
1179            serde_json::from_str(&std::fs::read_to_string(&f.path).unwrap()).unwrap();
1180        assert!(parsed.is_array());
1181        assert_eq!(parsed[0]["title"], "hello");
1182    }
1183
1184    // -- yaml × ByFilter × concat=true --
1185
1186    #[tokio::test]
1187    async fn materialize_grid_yaml_by_filter_concat() {
1188        let (tables, _row_id, config) = make_test_env().await;
1189        let dest = tempfile::tempdir().unwrap();
1190        let out_file = format!("{}/all.yaml", dest.path().display());
1191
1192        let params = MaterializeParams {
1193            table: None,
1194            selector: RowSelector::ByFilter {
1195                filter: crate::filter::ListFilter::Eq {
1196                    field: "title".to_string(),
1197                    value: serde_json::json!("hello"),
1198                },
1199                limit: None,
1200                offset: None,
1201            },
1202            fields: FieldSelector::All,
1203            format: MaterializeFormat::Yaml,
1204            dest: out_file.clone(),
1205            concat: Some(true),
1206            write_mode: None,
1207            dry_run: None,
1208        };
1209
1210        let result = do_materialize(&config, &tables, params).await.unwrap();
1211        assert_eq!(result.count, 1);
1212        let f = &result.files[0];
1213        assert_eq!(f.row_id, None);
1214        assert_eq!(f.sha256.len(), 64);
1215        let content = std::fs::read_to_string(&f.path).unwrap();
1216        assert!(content.starts_with("---\n"));
1217        assert!(content.contains("hello"));
1218    }
1219
1220    // -------------------------------------------------------------------------
1221    // Path validation tests (3)
1222    // -------------------------------------------------------------------------
1223
1224    #[tokio::test]
1225    async fn path_validation_relative_dest() {
1226        let (tables, row_id, config) = make_test_env().await;
1227
1228        let params = MaterializeParams {
1229            table: None,
1230            selector: RowSelector::ById { id: row_id },
1231            fields: FieldSelector::All,
1232            format: MaterializeFormat::Raw,
1233            dest: "relative/path".to_string(), // not absolute
1234            concat: None,
1235            write_mode: None,
1236            dry_run: None,
1237        };
1238
1239        let err = do_materialize(&config, &tables, params).await.unwrap_err();
1240        assert!(matches!(
1241            err,
1242            MiniAppError::MaterializeDestRelative { ref path } if path == "relative/path"
1243        ));
1244    }
1245
1246    #[tokio::test]
1247    async fn path_validation_create_dir_all_success() {
1248        let (tables, row_id, config) = make_test_env().await;
1249        let dest = tempfile::tempdir().unwrap();
1250        // Nested subdirectory that does not yet exist.
1251        let nested = format!("{}/subdir/nested", dest.path().display());
1252
1253        let params = MaterializeParams {
1254            table: None,
1255            selector: RowSelector::ById { id: row_id.clone() },
1256            fields: FieldSelector::All,
1257            format: MaterializeFormat::Raw,
1258            dest: nested.clone(),
1259            concat: Some(false),
1260            write_mode: None,
1261            dry_run: None,
1262        };
1263
1264        let result = do_materialize(&config, &tables, params).await.unwrap();
1265        assert_eq!(result.count, 1);
1266        assert!(std::path::Path::new(&nested).is_dir());
1267    }
1268
1269    #[tokio::test]
1270    async fn path_validation_concat_true_file_dest() {
1271        let (tables, _row_id, config) = make_test_env().await;
1272        let dest = tempfile::tempdir().unwrap();
1273        let out_file = format!("{}/out.txt", dest.path().display());
1274
1275        let params = MaterializeParams {
1276            table: None,
1277            selector: RowSelector::ByFilter {
1278                filter: crate::filter::ListFilter::Eq {
1279                    field: "title".to_string(),
1280                    value: serde_json::json!("hello"),
1281                },
1282                limit: None,
1283                offset: None,
1284            },
1285            fields: FieldSelector::All,
1286            format: MaterializeFormat::Raw,
1287            dest: out_file.clone(),
1288            concat: Some(true),
1289            write_mode: None,
1290            dry_run: None,
1291        };
1292
1293        let result = do_materialize(&config, &tables, params).await.unwrap();
1294        assert_eq!(result.count, 1);
1295        assert_eq!(result.files[0].path, out_file);
1296        assert!(std::path::Path::new(&out_file).exists());
1297    }
1298
1299    // -------------------------------------------------------------------------
1300    // Field projection tests (3)
1301    // -------------------------------------------------------------------------
1302
1303    #[tokio::test]
1304    async fn projection_all_fields_in_schema_order() {
1305        let (tables, row_id, config) = make_test_env().await;
1306        let dest = tempfile::tempdir().unwrap();
1307
1308        let params = MaterializeParams {
1309            table: None,
1310            selector: RowSelector::ById { id: row_id },
1311            fields: FieldSelector::All,
1312            format: MaterializeFormat::Json,
1313            dest: dest.path().to_str().unwrap().to_string(),
1314            concat: None,
1315            write_mode: None,
1316            dry_run: None,
1317        };
1318
1319        let result = do_materialize(&config, &tables, params).await.unwrap();
1320        let parsed: serde_json::Value =
1321            serde_json::from_str(&std::fs::read_to_string(&result.files[0].path).unwrap()).unwrap();
1322        // Both schema fields must be present.
1323        assert!(parsed.get("title").is_some());
1324        assert!(parsed.get("body").is_some());
1325    }
1326
1327    #[tokio::test]
1328    async fn projection_list_specified_order() {
1329        let (tables, row_id, config) = make_test_env().await;
1330        let dest = tempfile::tempdir().unwrap();
1331
1332        let params = MaterializeParams {
1333            table: None,
1334            selector: RowSelector::ById { id: row_id },
1335            fields: FieldSelector::List {
1336                fields: vec!["body".to_string()],
1337            },
1338            format: MaterializeFormat::Json,
1339            dest: dest.path().to_str().unwrap().to_string(),
1340            concat: None,
1341            write_mode: None,
1342            dry_run: None,
1343        };
1344
1345        let result = do_materialize(&config, &tables, params).await.unwrap();
1346        let parsed: serde_json::Value =
1347            serde_json::from_str(&std::fs::read_to_string(&result.files[0].path).unwrap()).unwrap();
1348        assert_eq!(parsed["body"], "world");
1349        // "title" was not requested.
1350        assert!(parsed.get("title").is_none());
1351    }
1352
1353    #[tokio::test]
1354    async fn projection_unknown_field_returns_error() {
1355        let (tables, row_id, config) = make_test_env().await;
1356        let dest = tempfile::tempdir().unwrap();
1357
1358        let params = MaterializeParams {
1359            table: None,
1360            selector: RowSelector::ById { id: row_id },
1361            fields: FieldSelector::List {
1362                fields: vec!["nonexistent_field".to_string()],
1363            },
1364            format: MaterializeFormat::Json,
1365            dest: dest.path().to_str().unwrap().to_string(),
1366            concat: None,
1367            write_mode: None,
1368            dry_run: None,
1369        };
1370
1371        let err = do_materialize(&config, &tables, params).await.unwrap_err();
1372        assert!(matches!(
1373            err,
1374            MiniAppError::MaterializeFieldUnknown { ref field } if field == "nonexistent_field"
1375        ));
1376    }
1377
1378    // -------------------------------------------------------------------------
1379    // Error variant tests (6)
1380    // -------------------------------------------------------------------------
1381
1382    #[tokio::test]
1383    async fn error_dest_invalid_write_mode_error_existing_file() {
1384        let (tables, _row_id, config) = make_test_env().await;
1385        let dest = tempfile::tempdir().unwrap();
1386        let out_file = format!("{}/out.txt", dest.path().display());
1387        // Pre-create the file.
1388        std::fs::write(&out_file, b"existing").unwrap();
1389
1390        let params = MaterializeParams {
1391            table: None,
1392            selector: RowSelector::ByFilter {
1393                filter: crate::filter::ListFilter::Eq {
1394                    field: "title".to_string(),
1395                    value: serde_json::json!("hello"),
1396                },
1397                limit: None,
1398                offset: None,
1399            },
1400            fields: FieldSelector::All,
1401            format: MaterializeFormat::Raw,
1402            dest: out_file.clone(),
1403            concat: Some(true),
1404            write_mode: Some(WriteMode::Error),
1405            dry_run: None,
1406        };
1407
1408        let err = do_materialize(&config, &tables, params).await.unwrap_err();
1409        assert!(matches!(
1410            err,
1411            MiniAppError::MaterializeDestInvalid { ref path, .. } if path == &out_file
1412        ));
1413    }
1414
1415    #[tokio::test]
1416    async fn error_row_not_found() {
1417        let (tables, _row_id, config) = make_test_env().await;
1418        let dest = tempfile::tempdir().unwrap();
1419
1420        let params = MaterializeParams {
1421            table: None,
1422            selector: RowSelector::ById {
1423                id: "00000000-0000-0000-0000-000000000000".to_string(),
1424            },
1425            fields: FieldSelector::All,
1426            format: MaterializeFormat::Raw,
1427            dest: dest.path().to_str().unwrap().to_string(),
1428            concat: None,
1429            write_mode: None,
1430            dry_run: None,
1431        };
1432
1433        let err = do_materialize(&config, &tables, params).await.unwrap_err();
1434        assert!(matches!(err, MiniAppError::MaterializeRowNotFound { .. }));
1435    }
1436
1437    #[tokio::test]
1438    async fn error_empty_result() {
1439        let (tables, _row_id, config) = make_test_env().await;
1440        let dest = tempfile::tempdir().unwrap();
1441
1442        let params = MaterializeParams {
1443            table: None,
1444            selector: RowSelector::ByFilter {
1445                filter: crate::filter::ListFilter::Eq {
1446                    field: "title".to_string(),
1447                    value: serde_json::json!("no_such_title"),
1448                },
1449                limit: None,
1450                offset: None,
1451            },
1452            fields: FieldSelector::All,
1453            format: MaterializeFormat::Raw,
1454            dest: dest.path().to_str().unwrap().to_string(),
1455            concat: None,
1456            write_mode: None,
1457            dry_run: None,
1458        };
1459
1460        let err = do_materialize(&config, &tables, params).await.unwrap_err();
1461        assert!(matches!(err, MiniAppError::MaterializeEmptyResult));
1462    }
1463
1464    #[tokio::test]
1465    async fn error_invalid_param_concat_by_id() {
1466        let (tables, row_id, config) = make_test_env().await;
1467        let dest = tempfile::tempdir().unwrap();
1468        let out_file = format!("{}/out.txt", dest.path().display());
1469
1470        let params = MaterializeParams {
1471            table: None,
1472            selector: RowSelector::ById { id: row_id },
1473            fields: FieldSelector::All,
1474            format: MaterializeFormat::Raw,
1475            dest: out_file,
1476            concat: Some(true),
1477            write_mode: None,
1478            dry_run: None,
1479        };
1480
1481        let err = do_materialize(&config, &tables, params).await.unwrap_err();
1482        assert!(matches!(
1483            err,
1484            MiniAppError::MaterializeInvalidParam { ref field, .. } if field == "concat"
1485        ));
1486    }
1487
1488    #[tokio::test]
1489    async fn error_field_unknown() {
1490        let (tables, row_id, config) = make_test_env().await;
1491        let dest = tempfile::tempdir().unwrap();
1492
1493        let params = MaterializeParams {
1494            table: None,
1495            selector: RowSelector::ById { id: row_id },
1496            fields: FieldSelector::List {
1497                fields: vec!["unknown".to_string()],
1498            },
1499            format: MaterializeFormat::Raw,
1500            dest: dest.path().to_str().unwrap().to_string(),
1501            concat: None,
1502            write_mode: None,
1503            dry_run: None,
1504        };
1505
1506        let err = do_materialize(&config, &tables, params).await.unwrap_err();
1507        assert!(matches!(
1508            err,
1509            MiniAppError::MaterializeFieldUnknown { ref field } if field == "unknown"
1510        ));
1511    }
1512
1513    #[tokio::test]
1514    async fn error_dest_relative_is_rejected_at_validation() {
1515        // This is a second test of MaterializeDestRelative to confirm validation
1516        // boundary fires before any store access.
1517        let (tables, row_id, config) = make_test_env().await;
1518
1519        let params = MaterializeParams {
1520            table: None,
1521            selector: RowSelector::ById { id: row_id },
1522            fields: FieldSelector::All,
1523            format: MaterializeFormat::Json,
1524            dest: "not/absolute".to_string(),
1525            concat: None,
1526            write_mode: None,
1527            dry_run: None,
1528        };
1529
1530        let err = do_materialize(&config, &tables, params).await.unwrap_err();
1531        assert!(matches!(
1532            err,
1533            MiniAppError::MaterializeDestRelative { ref path } if path == "not/absolute"
1534        ));
1535    }
1536
1537    // -------------------------------------------------------------------------
1538    // dry_run tests (2)
1539    // -------------------------------------------------------------------------
1540
1541    #[tokio::test]
1542    async fn dry_run_no_write_but_sha256_and_bytes_present() {
1543        let (tables, row_id, config) = make_test_env().await;
1544        let dest = tempfile::tempdir().unwrap();
1545        let dest_path = dest.path().to_str().unwrap().to_string();
1546
1547        let params = MaterializeParams {
1548            table: None,
1549            selector: RowSelector::ById { id: row_id.clone() },
1550            fields: FieldSelector::All,
1551            format: MaterializeFormat::Json,
1552            dest: dest_path.clone(),
1553            concat: Some(false),
1554            write_mode: None,
1555            dry_run: Some(true),
1556        };
1557
1558        let result = do_materialize(&config, &tables, params).await.unwrap();
1559        assert_eq!(result.count, 1);
1560        let f = &result.files[0];
1561        // sha256 must be present (Crux #3 — even in dry_run).
1562        assert_eq!(f.sha256.len(), 64);
1563        assert!(f.bytes > 0);
1564        // File must NOT be written on disk.
1565        let out_path = format!("{}/{}.json", dest_path, row_id);
1566        assert!(!std::path::Path::new(&out_path).exists());
1567    }
1568
1569    #[tokio::test]
1570    async fn dry_run_write_mode_error_existing_file_still_errors() {
1571        let (tables, _row_id, config) = make_test_env().await;
1572        let dest = tempfile::tempdir().unwrap();
1573        let out_file = format!("{}/out.txt", dest.path().display());
1574        // Pre-create the file.
1575        std::fs::write(&out_file, b"existing").unwrap();
1576
1577        let params = MaterializeParams {
1578            table: None,
1579            selector: RowSelector::ByFilter {
1580                filter: crate::filter::ListFilter::Eq {
1581                    field: "title".to_string(),
1582                    value: serde_json::json!("hello"),
1583                },
1584                limit: None,
1585                offset: None,
1586            },
1587            fields: FieldSelector::All,
1588            format: MaterializeFormat::Raw,
1589            dest: out_file.clone(),
1590            concat: Some(true),
1591            write_mode: Some(WriteMode::Error),
1592            dry_run: Some(true), // dry_run=true still validates write_mode=Error.
1593        };
1594
1595        let err = do_materialize(&config, &tables, params).await.unwrap_err();
1596        assert!(matches!(
1597            err,
1598            MiniAppError::MaterializeDestInvalid { ref path, .. } if path == &out_file
1599        ));
1600    }
1601
1602    // -------------------------------------------------------------------------
1603    // row_id tests (2)
1604    // -------------------------------------------------------------------------
1605
1606    #[tokio::test]
1607    async fn row_id_set_for_each_file_when_no_concat() {
1608        let (tables, row_id, config) = make_test_env().await;
1609        let dest = tempfile::tempdir().unwrap();
1610
1611        let params = MaterializeParams {
1612            table: None,
1613            selector: RowSelector::ById { id: row_id.clone() },
1614            fields: FieldSelector::All,
1615            format: MaterializeFormat::Raw,
1616            dest: dest.path().to_str().unwrap().to_string(),
1617            concat: Some(false),
1618            write_mode: None,
1619            dry_run: None,
1620        };
1621
1622        let result = do_materialize(&config, &tables, params).await.unwrap();
1623        assert_eq!(result.files[0].row_id, Some(row_id));
1624    }
1625
1626    #[tokio::test]
1627    async fn row_id_is_none_when_concat() {
1628        let (tables, _row_id, config) = make_test_env().await;
1629        let dest = tempfile::tempdir().unwrap();
1630        let out_file = format!("{}/out.txt", dest.path().display());
1631
1632        let params = MaterializeParams {
1633            table: None,
1634            selector: RowSelector::ByFilter {
1635                filter: crate::filter::ListFilter::Eq {
1636                    field: "title".to_string(),
1637                    value: serde_json::json!("hello"),
1638                },
1639                limit: None,
1640                offset: None,
1641            },
1642            fields: FieldSelector::All,
1643            format: MaterializeFormat::Raw,
1644            dest: out_file,
1645            concat: Some(true),
1646            write_mode: None,
1647            dry_run: None,
1648        };
1649
1650        let result = do_materialize(&config, &tables, params).await.unwrap();
1651        assert_eq!(result.files[0].row_id, None);
1652    }
1653
1654    // -------------------------------------------------------------------------
1655    // FieldSelector::validate — unit tests (Crux #2: schema-based validation)
1656    // -------------------------------------------------------------------------
1657
1658    fn make_schema() -> SchemaConfig {
1659        SchemaConfig {
1660            table: "test".to_string(),
1661            title: None,
1662            description: None,
1663            fields: vec![
1664                FieldDef {
1665                    name: "title".to_string(),
1666                    ty: FieldType::String,
1667                    required: true,
1668                    description: None,
1669                },
1670                FieldDef {
1671                    name: "body".to_string(),
1672                    ty: FieldType::String,
1673                    required: false,
1674                    description: None,
1675                },
1676            ],
1677            dump: None,
1678        }
1679    }
1680
1681    fn make_row(data: serde_json::Value) -> RowRecord {
1682        RowRecord {
1683            id: "test-id".to_string(),
1684            data,
1685            created_at: 0,
1686            updated_at: 0,
1687        }
1688    }
1689
1690    #[test]
1691    fn validate_field_selector_all_is_ok() {
1692        let schema = make_schema();
1693        let fs = FieldSelector::All;
1694        assert!(fs.validate(&schema).is_ok());
1695    }
1696
1697    #[test]
1698    fn validate_field_selector_list_known_fields_ok() {
1699        let schema = make_schema();
1700        let fs = FieldSelector::List {
1701            fields: vec!["title".to_string(), "body".to_string()],
1702        };
1703        assert!(fs.validate(&schema).is_ok());
1704    }
1705
1706    #[test]
1707    fn validate_field_selector_list_single_known_field_ok() {
1708        let schema = make_schema();
1709        let fs = FieldSelector::List {
1710            fields: vec!["title".to_string()],
1711        };
1712        assert!(fs.validate(&schema).is_ok());
1713    }
1714
1715    #[test]
1716    fn validate_field_selector_list_unknown_field_returns_validation_error() {
1717        let schema = make_schema();
1718        let fs = FieldSelector::List {
1719            fields: vec!["title".to_string(), "nonexistent".to_string()],
1720        };
1721        let err = fs.validate(&schema).unwrap_err();
1722        match err {
1723            MiniAppError::Validation { field, reason } => {
1724                assert_eq!(field, "nonexistent");
1725                assert!(reason.contains("nonexistent"));
1726                assert!(reason.contains("schema-registered"));
1727            }
1728            other => panic!("expected Validation error, got {other:?}"),
1729        }
1730    }
1731
1732    #[test]
1733    fn validate_field_selector_list_empty_fields_ok() {
1734        // Empty list is valid; projection will return empty data objects.
1735        let schema = make_schema();
1736        let fs = FieldSelector::List { fields: vec![] };
1737        assert!(fs.validate(&schema).is_ok());
1738    }
1739
1740    // -------------------------------------------------------------------------
1741    // apply_projection — unit tests (Crux #1: single shared boundary)
1742    // -------------------------------------------------------------------------
1743
1744    #[test]
1745    fn apply_projection_none_returns_unchanged() {
1746        let schema = make_schema();
1747        let row = make_row(serde_json::json!({"title": "hello", "body": "world"}));
1748        let records = vec![row];
1749        let result = apply_projection(records.clone(), &None, &schema).unwrap();
1750        assert_eq!(result.len(), 1);
1751        assert_eq!(result[0].id, records[0].id);
1752        assert_eq!(
1753            result[0].data,
1754            serde_json::json!({"title": "hello", "body": "world"})
1755        );
1756    }
1757
1758    #[test]
1759    fn apply_projection_all_returns_unchanged() {
1760        let schema = make_schema();
1761        let row = make_row(serde_json::json!({"title": "hello", "body": "world"}));
1762        let records = vec![row];
1763        let fields = Some(FieldSelector::All);
1764        let result = apply_projection(records.clone(), &fields, &schema).unwrap();
1765        assert_eq!(result.len(), 1);
1766        assert_eq!(
1767            result[0].data,
1768            serde_json::json!({"title": "hello", "body": "world"})
1769        );
1770    }
1771
1772    #[test]
1773    fn apply_projection_list_projects_data() {
1774        let schema = make_schema();
1775        let row = make_row(serde_json::json!({"title": "hello", "body": "world"}));
1776        let original_id = row.id.clone();
1777        let original_created_at = row.created_at;
1778        let records = vec![row];
1779        let fields = Some(FieldSelector::List {
1780            fields: vec!["title".to_string()],
1781        });
1782        let result = apply_projection(records, &fields, &schema).unwrap();
1783        assert_eq!(result.len(), 1);
1784        // Only "title" should be present in projected data.
1785        assert_eq!(result[0].data, serde_json::json!({"title": "hello"}));
1786        // Metadata fields must be preserved.
1787        assert_eq!(result[0].id, original_id);
1788        assert_eq!(result[0].created_at, original_created_at);
1789    }
1790
1791    #[test]
1792    fn apply_projection_list_projects_multiple_rows() {
1793        let schema = make_schema();
1794        let row1 = make_row(serde_json::json!({"title": "first", "body": "one"}));
1795        let row2 = make_row(serde_json::json!({"title": "second", "body": "two"}));
1796        let fields = Some(FieldSelector::List {
1797            fields: vec!["body".to_string()],
1798        });
1799        let result = apply_projection(vec![row1, row2], &fields, &schema).unwrap();
1800        assert_eq!(result.len(), 2);
1801        assert_eq!(result[0].data, serde_json::json!({"body": "one"}));
1802        assert_eq!(result[1].data, serde_json::json!({"body": "two"}));
1803    }
1804
1805    #[test]
1806    fn apply_projection_unknown_field_returns_error() {
1807        let schema = make_schema();
1808        let row = make_row(serde_json::json!({"title": "hello", "body": "world"}));
1809        let fields = Some(FieldSelector::List {
1810            fields: vec!["nonexistent".to_string()],
1811        });
1812        let err = apply_projection(vec![row], &fields, &schema).unwrap_err();
1813        match err {
1814            MiniAppError::Validation { field, .. } => {
1815                assert_eq!(field, "nonexistent");
1816            }
1817            other => panic!("expected Validation error, got {other:?}"),
1818        }
1819    }
1820
1821    #[test]
1822    fn apply_projection_missing_field_in_data_returns_null() {
1823        // project_row returns Null for fields absent from data.
1824        // This is acceptable: validation passes (field is in schema),
1825        // but the stored data doesn't have it.
1826        let schema = make_schema();
1827        let row = make_row(serde_json::json!({"title": "hello"}));
1828        let fields = Some(FieldSelector::List {
1829            fields: vec!["title".to_string(), "body".to_string()],
1830        });
1831        let result = apply_projection(vec![row], &fields, &schema).unwrap();
1832        assert_eq!(result[0].data["title"], "hello");
1833        assert_eq!(result[0].data["body"], serde_json::Value::Null);
1834    }
1835}