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, Serialize, 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), None).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            history: Default::default(),
656        };
657
658        // Use an in-memory SQLite store for tests.
659        // SAFETY: ":memory:" always opens without error in test context.
660        let store = Store::open(std::path::Path::new(":memory:"), schema.clone())
661            .await
662            .expect("in-memory store must open");
663
664        // Insert one row before passing ownership to the registry.
665        let data = serde_json::json!({"title": "hello", "body": "world"});
666        // SAFETY: validated JSON matches the schema above.
667        let row = store.create(data).await.expect("create must succeed");
668        let row_id = row.id.clone();
669
670        let registry = TableRegistry::from_single(
671            store,
672            schema,
673            PathBuf::from("/fake/schema.yaml"),
674            "test".to_string(),
675        );
676
677        let config = Arc::new(Config {
678            schema_path: None,
679            db_path: None,
680            user_dir: None,
681            project_dir: None,
682            backup_retention: None,
683            snapshot_retention: None,
684        });
685
686        (Arc::new(ArcSwap::from_pointee(registry)), row_id, config)
687    }
688
689    /// Insert a second row via the registry and return its id.
690    async fn add_second_row(tables: &Arc<ArcSwap<TableRegistry>>) -> String {
691        let registry = tables.load_full();
692        // SAFETY: resolve(None) works because a default_table is set in from_single.
693        let entry = registry.resolve(None).expect("resolve must succeed");
694        let data = serde_json::json!({"title": "second", "body": "entry"});
695        // SAFETY: validated JSON matches the test schema.
696        let row = entry.store.create(data).await.expect("create must succeed");
697        row.id
698    }
699
700    // -------------------------------------------------------------------------
701    // 16-cell grid: format (4) × selector (2) × concat (2)
702    // -------------------------------------------------------------------------
703    // Naming: materialize_grid_{format}_{selector}_{concat}
704    //
705    // ById + concat=true (4 cells) → error path (MaterializeInvalidParam)
706    // ById + concat=false (4 cells) → happy path
707    // ByFilter + concat=false (4 cells) → happy path
708    // ByFilter + concat=true (4 cells) → happy path, row_id=None
709    // -------------------------------------------------------------------------
710
711    // -- raw × ById × no_concat --
712
713    #[tokio::test]
714    async fn materialize_grid_raw_by_id_no_concat() {
715        let (tables, row_id, config) = make_test_env().await;
716        let dest = tempfile::tempdir().unwrap();
717        let dest_path = dest.path().to_str().unwrap().to_string();
718
719        let params = MaterializeParams {
720            table: None,
721            selector: RowSelector::ById { id: row_id.clone() },
722            fields: FieldSelector::All,
723            format: MaterializeFormat::Raw,
724            dest: dest_path.clone(),
725            concat: Some(false),
726            write_mode: None,
727            dry_run: None,
728        };
729
730        let result = do_materialize(&config, &tables, params).await.unwrap();
731        assert_eq!(result.count, 1);
732        let f = &result.files[0];
733        assert_eq!(f.row_id, Some(row_id.clone()));
734        assert_eq!(f.sha256.len(), 64);
735        assert!(f.bytes > 0);
736        // File was written.
737        let written = std::fs::read_to_string(&f.path).unwrap();
738        assert!(written.contains("hello"));
739    }
740
741    // -- markdown × ById × no_concat --
742
743    #[tokio::test]
744    async fn materialize_grid_markdown_by_id_no_concat() {
745        let (tables, row_id, config) = make_test_env().await;
746        let dest = tempfile::tempdir().unwrap();
747        let dest_path = dest.path().to_str().unwrap().to_string();
748
749        let params = MaterializeParams {
750            table: None,
751            selector: RowSelector::ById { id: row_id.clone() },
752            fields: FieldSelector::All,
753            format: MaterializeFormat::Markdown,
754            dest: dest_path,
755            concat: Some(false),
756            write_mode: None,
757            dry_run: None,
758        };
759
760        let result = do_materialize(&config, &tables, params).await.unwrap();
761        assert_eq!(result.count, 1);
762        let f = &result.files[0];
763        assert_eq!(f.row_id, Some(row_id.clone()));
764        assert_eq!(f.sha256.len(), 64);
765        assert!(f.path.ends_with(".md"));
766        let written = std::fs::read_to_string(&f.path).unwrap();
767        assert!(written.contains(&format!("# {}", row_id)));
768        assert!(written.contains("## title"));
769    }
770
771    // -- json × ById × no_concat --
772
773    #[tokio::test]
774    async fn materialize_grid_json_by_id_no_concat() {
775        let (tables, row_id, config) = make_test_env().await;
776        let dest = tempfile::tempdir().unwrap();
777        let dest_path = dest.path().to_str().unwrap().to_string();
778
779        let params = MaterializeParams {
780            table: None,
781            selector: RowSelector::ById { id: row_id.clone() },
782            fields: FieldSelector::All,
783            format: MaterializeFormat::Json,
784            dest: dest_path,
785            concat: Some(false),
786            write_mode: None,
787            dry_run: None,
788        };
789
790        let result = do_materialize(&config, &tables, params).await.unwrap();
791        assert_eq!(result.count, 1);
792        let f = &result.files[0];
793        assert_eq!(f.row_id, Some(row_id));
794        assert_eq!(f.sha256.len(), 64);
795        assert!(f.path.ends_with(".json"));
796        let parsed: serde_json::Value =
797            serde_json::from_str(&std::fs::read_to_string(&f.path).unwrap()).unwrap();
798        assert_eq!(parsed["title"], "hello");
799    }
800
801    // -- yaml × ById × no_concat --
802
803    #[tokio::test]
804    async fn materialize_grid_yaml_by_id_no_concat() {
805        let (tables, row_id, config) = make_test_env().await;
806        let dest = tempfile::tempdir().unwrap();
807        let dest_path = dest.path().to_str().unwrap().to_string();
808
809        let params = MaterializeParams {
810            table: None,
811            selector: RowSelector::ById { id: row_id.clone() },
812            fields: FieldSelector::All,
813            format: MaterializeFormat::Yaml,
814            dest: dest_path,
815            concat: Some(false),
816            write_mode: None,
817            dry_run: None,
818        };
819
820        let result = do_materialize(&config, &tables, params).await.unwrap();
821        assert_eq!(result.count, 1);
822        let f = &result.files[0];
823        assert_eq!(f.row_id, Some(row_id));
824        assert_eq!(f.sha256.len(), 64);
825        assert!(f.path.ends_with(".yaml"));
826        let content = std::fs::read_to_string(&f.path).unwrap();
827        assert!(content.contains("title"));
828    }
829
830    // -- raw × ById × concat=true → error path --
831
832    #[tokio::test]
833    async fn materialize_grid_raw_by_id_concat() {
834        let (tables, row_id, config) = make_test_env().await;
835        let dest = tempfile::tempdir().unwrap();
836        let dest_path = format!("{}/out.txt", dest.path().display());
837
838        let params = MaterializeParams {
839            table: None,
840            selector: RowSelector::ById { id: row_id },
841            fields: FieldSelector::All,
842            format: MaterializeFormat::Raw,
843            dest: dest_path,
844            concat: Some(true),
845            write_mode: None,
846            dry_run: None,
847        };
848
849        let err = do_materialize(&config, &tables, params).await.unwrap_err();
850        assert!(matches!(
851            err,
852            MiniAppError::MaterializeInvalidParam { ref field, .. } if field == "concat"
853        ));
854    }
855
856    // -- markdown × ById × concat=true → error path --
857
858    #[tokio::test]
859    async fn materialize_grid_markdown_by_id_concat() {
860        let (tables, row_id, config) = make_test_env().await;
861        let dest = tempfile::tempdir().unwrap();
862        let dest_path = format!("{}/out.md", dest.path().display());
863
864        let params = MaterializeParams {
865            table: None,
866            selector: RowSelector::ById { id: row_id },
867            fields: FieldSelector::All,
868            format: MaterializeFormat::Markdown,
869            dest: dest_path,
870            concat: Some(true),
871            write_mode: None,
872            dry_run: None,
873        };
874
875        let err = do_materialize(&config, &tables, params).await.unwrap_err();
876        assert!(matches!(
877            err,
878            MiniAppError::MaterializeInvalidParam { ref field, .. } if field == "concat"
879        ));
880    }
881
882    // -- json × ById × concat=true → error path --
883
884    #[tokio::test]
885    async fn materialize_grid_json_by_id_concat() {
886        let (tables, row_id, config) = make_test_env().await;
887        let dest = tempfile::tempdir().unwrap();
888        let dest_path = format!("{}/out.json", dest.path().display());
889
890        let params = MaterializeParams {
891            table: None,
892            selector: RowSelector::ById { id: row_id },
893            fields: FieldSelector::All,
894            format: MaterializeFormat::Json,
895            dest: dest_path,
896            concat: Some(true),
897            write_mode: None,
898            dry_run: None,
899        };
900
901        let err = do_materialize(&config, &tables, params).await.unwrap_err();
902        assert!(matches!(
903            err,
904            MiniAppError::MaterializeInvalidParam { ref field, .. } if field == "concat"
905        ));
906    }
907
908    // -- yaml × ById × concat=true → error path --
909
910    #[tokio::test]
911    async fn materialize_grid_yaml_by_id_concat() {
912        let (tables, row_id, config) = make_test_env().await;
913        let dest = tempfile::tempdir().unwrap();
914        let dest_path = format!("{}/out.yaml", dest.path().display());
915
916        let params = MaterializeParams {
917            table: None,
918            selector: RowSelector::ById { id: row_id },
919            fields: FieldSelector::All,
920            format: MaterializeFormat::Yaml,
921            dest: dest_path,
922            concat: Some(true),
923            write_mode: None,
924            dry_run: None,
925        };
926
927        let err = do_materialize(&config, &tables, params).await.unwrap_err();
928        assert!(matches!(
929            err,
930            MiniAppError::MaterializeInvalidParam { ref field, .. } if field == "concat"
931        ));
932    }
933
934    // -- raw × ByFilter × no_concat --
935
936    #[tokio::test]
937    async fn materialize_grid_raw_by_filter_no_concat() {
938        let (tables, row_id, config) = make_test_env().await;
939        let dest = tempfile::tempdir().unwrap();
940        let dest_path = dest.path().to_str().unwrap().to_string();
941
942        let params = MaterializeParams {
943            table: None,
944            selector: RowSelector::ByFilter {
945                filter: crate::filter::ListFilter::Eq {
946                    field: "title".to_string(),
947                    value: serde_json::json!("hello"),
948                },
949                limit: None,
950                offset: None,
951            },
952            fields: FieldSelector::All,
953            format: MaterializeFormat::Raw,
954            dest: dest_path,
955            concat: Some(false),
956            write_mode: None,
957            dry_run: None,
958        };
959
960        let result = do_materialize(&config, &tables, params).await.unwrap();
961        assert_eq!(result.count, 1);
962        let f = &result.files[0];
963        assert_eq!(f.row_id, Some(row_id));
964        assert_eq!(f.sha256.len(), 64);
965        let written = std::fs::read_to_string(&f.path).unwrap();
966        assert!(written.contains("hello"));
967    }
968
969    // -- markdown × ByFilter × no_concat --
970
971    #[tokio::test]
972    async fn materialize_grid_markdown_by_filter_no_concat() {
973        let (tables, row_id, config) = make_test_env().await;
974        let dest = tempfile::tempdir().unwrap();
975        let dest_path = dest.path().to_str().unwrap().to_string();
976
977        let params = MaterializeParams {
978            table: None,
979            selector: RowSelector::ByFilter {
980                filter: crate::filter::ListFilter::Eq {
981                    field: "title".to_string(),
982                    value: serde_json::json!("hello"),
983                },
984                limit: None,
985                offset: None,
986            },
987            fields: FieldSelector::All,
988            format: MaterializeFormat::Markdown,
989            dest: dest_path,
990            concat: Some(false),
991            write_mode: None,
992            dry_run: None,
993        };
994
995        let result = do_materialize(&config, &tables, params).await.unwrap();
996        assert_eq!(result.count, 1);
997        let f = &result.files[0];
998        assert_eq!(f.row_id, Some(row_id.clone()));
999        assert_eq!(f.sha256.len(), 64);
1000        let written = std::fs::read_to_string(&f.path).unwrap();
1001        assert!(written.contains(&format!("# {}", row_id)));
1002    }
1003
1004    // -- json × ByFilter × no_concat --
1005
1006    #[tokio::test]
1007    async fn materialize_grid_json_by_filter_no_concat() {
1008        let (tables, row_id, config) = make_test_env().await;
1009        let dest = tempfile::tempdir().unwrap();
1010        let dest_path = dest.path().to_str().unwrap().to_string();
1011
1012        let params = MaterializeParams {
1013            table: None,
1014            selector: RowSelector::ByFilter {
1015                filter: crate::filter::ListFilter::Eq {
1016                    field: "title".to_string(),
1017                    value: serde_json::json!("hello"),
1018                },
1019                limit: None,
1020                offset: None,
1021            },
1022            fields: FieldSelector::All,
1023            format: MaterializeFormat::Json,
1024            dest: dest_path,
1025            concat: Some(false),
1026            write_mode: None,
1027            dry_run: None,
1028        };
1029
1030        let result = do_materialize(&config, &tables, params).await.unwrap();
1031        assert_eq!(result.count, 1);
1032        let f = &result.files[0];
1033        assert_eq!(f.row_id, Some(row_id));
1034        assert_eq!(f.sha256.len(), 64);
1035        let parsed: serde_json::Value =
1036            serde_json::from_str(&std::fs::read_to_string(&f.path).unwrap()).unwrap();
1037        assert_eq!(parsed["title"], "hello");
1038    }
1039
1040    // -- yaml × ByFilter × no_concat --
1041
1042    #[tokio::test]
1043    async fn materialize_grid_yaml_by_filter_no_concat() {
1044        let (tables, row_id, config) = make_test_env().await;
1045        let dest = tempfile::tempdir().unwrap();
1046        let dest_path = dest.path().to_str().unwrap().to_string();
1047
1048        let params = MaterializeParams {
1049            table: None,
1050            selector: RowSelector::ByFilter {
1051                filter: crate::filter::ListFilter::Eq {
1052                    field: "title".to_string(),
1053                    value: serde_json::json!("hello"),
1054                },
1055                limit: None,
1056                offset: None,
1057            },
1058            fields: FieldSelector::All,
1059            format: MaterializeFormat::Yaml,
1060            dest: dest_path,
1061            concat: Some(false),
1062            write_mode: None,
1063            dry_run: None,
1064        };
1065
1066        let result = do_materialize(&config, &tables, params).await.unwrap();
1067        assert_eq!(result.count, 1);
1068        let f = &result.files[0];
1069        assert_eq!(f.row_id, Some(row_id));
1070        assert_eq!(f.sha256.len(), 64);
1071        let content = std::fs::read_to_string(&f.path).unwrap();
1072        assert!(content.contains("hello"));
1073    }
1074
1075    // -- raw × ByFilter × concat=true --
1076
1077    #[tokio::test]
1078    async fn materialize_grid_raw_by_filter_concat() {
1079        let (tables, _row_id, config) = make_test_env().await;
1080        add_second_row(&tables).await;
1081        let dest = tempfile::tempdir().unwrap();
1082        let out_file = format!("{}/all.txt", dest.path().display());
1083
1084        let params = MaterializeParams {
1085            table: None,
1086            selector: RowSelector::ByFilter {
1087                filter: crate::filter::ListFilter::Eq {
1088                    field: "title".to_string(),
1089                    value: serde_json::json!("hello"),
1090                },
1091                limit: None,
1092                offset: None,
1093            },
1094            fields: FieldSelector::All,
1095            format: MaterializeFormat::Raw,
1096            dest: out_file.clone(),
1097            concat: Some(true),
1098            write_mode: None,
1099            dry_run: None,
1100        };
1101
1102        let result = do_materialize(&config, &tables, params).await.unwrap();
1103        assert_eq!(result.count, 1);
1104        let f = &result.files[0];
1105        // concat=true → row_id must be None (Crux #2 wf-sim restructure_shape #2).
1106        assert_eq!(f.row_id, None);
1107        assert_eq!(f.sha256.len(), 64);
1108        assert_eq!(f.path, out_file);
1109        let content = std::fs::read_to_string(&f.path).unwrap();
1110        assert!(content.contains("hello"));
1111    }
1112
1113    // -- markdown × ByFilter × concat=true --
1114
1115    #[tokio::test]
1116    async fn materialize_grid_markdown_by_filter_concat() {
1117        let (tables, _row_id, config) = make_test_env().await;
1118        let dest = tempfile::tempdir().unwrap();
1119        let out_file = format!("{}/all.md", dest.path().display());
1120
1121        let params = MaterializeParams {
1122            table: None,
1123            selector: RowSelector::ByFilter {
1124                filter: crate::filter::ListFilter::Eq {
1125                    field: "title".to_string(),
1126                    value: serde_json::json!("hello"),
1127                },
1128                limit: None,
1129                offset: None,
1130            },
1131            fields: FieldSelector::All,
1132            format: MaterializeFormat::Markdown,
1133            dest: out_file.clone(),
1134            concat: Some(true),
1135            write_mode: None,
1136            dry_run: None,
1137        };
1138
1139        let result = do_materialize(&config, &tables, params).await.unwrap();
1140        assert_eq!(result.count, 1);
1141        let f = &result.files[0];
1142        assert_eq!(f.row_id, None);
1143        assert_eq!(f.sha256.len(), 64);
1144        let content = std::fs::read_to_string(&f.path).unwrap();
1145        assert!(content.contains("## title"));
1146    }
1147
1148    // -- json × ByFilter × concat=true --
1149
1150    #[tokio::test]
1151    async fn materialize_grid_json_by_filter_concat() {
1152        let (tables, _row_id, config) = make_test_env().await;
1153        let dest = tempfile::tempdir().unwrap();
1154        let out_file = format!("{}/all.json", dest.path().display());
1155
1156        let params = MaterializeParams {
1157            table: None,
1158            selector: RowSelector::ByFilter {
1159                filter: crate::filter::ListFilter::Eq {
1160                    field: "title".to_string(),
1161                    value: serde_json::json!("hello"),
1162                },
1163                limit: None,
1164                offset: None,
1165            },
1166            fields: FieldSelector::All,
1167            format: MaterializeFormat::Json,
1168            dest: out_file.clone(),
1169            concat: Some(true),
1170            write_mode: None,
1171            dry_run: None,
1172        };
1173
1174        let result = do_materialize(&config, &tables, params).await.unwrap();
1175        assert_eq!(result.count, 1);
1176        let f = &result.files[0];
1177        assert_eq!(f.row_id, None);
1178        assert_eq!(f.sha256.len(), 64);
1179        let parsed: serde_json::Value =
1180            serde_json::from_str(&std::fs::read_to_string(&f.path).unwrap()).unwrap();
1181        assert!(parsed.is_array());
1182        assert_eq!(parsed[0]["title"], "hello");
1183    }
1184
1185    // -- yaml × ByFilter × concat=true --
1186
1187    #[tokio::test]
1188    async fn materialize_grid_yaml_by_filter_concat() {
1189        let (tables, _row_id, config) = make_test_env().await;
1190        let dest = tempfile::tempdir().unwrap();
1191        let out_file = format!("{}/all.yaml", dest.path().display());
1192
1193        let params = MaterializeParams {
1194            table: None,
1195            selector: RowSelector::ByFilter {
1196                filter: crate::filter::ListFilter::Eq {
1197                    field: "title".to_string(),
1198                    value: serde_json::json!("hello"),
1199                },
1200                limit: None,
1201                offset: None,
1202            },
1203            fields: FieldSelector::All,
1204            format: MaterializeFormat::Yaml,
1205            dest: out_file.clone(),
1206            concat: Some(true),
1207            write_mode: None,
1208            dry_run: None,
1209        };
1210
1211        let result = do_materialize(&config, &tables, params).await.unwrap();
1212        assert_eq!(result.count, 1);
1213        let f = &result.files[0];
1214        assert_eq!(f.row_id, None);
1215        assert_eq!(f.sha256.len(), 64);
1216        let content = std::fs::read_to_string(&f.path).unwrap();
1217        assert!(content.starts_with("---\n"));
1218        assert!(content.contains("hello"));
1219    }
1220
1221    // -------------------------------------------------------------------------
1222    // Path validation tests (3)
1223    // -------------------------------------------------------------------------
1224
1225    #[tokio::test]
1226    async fn path_validation_relative_dest() {
1227        let (tables, row_id, config) = make_test_env().await;
1228
1229        let params = MaterializeParams {
1230            table: None,
1231            selector: RowSelector::ById { id: row_id },
1232            fields: FieldSelector::All,
1233            format: MaterializeFormat::Raw,
1234            dest: "relative/path".to_string(), // not absolute
1235            concat: None,
1236            write_mode: None,
1237            dry_run: None,
1238        };
1239
1240        let err = do_materialize(&config, &tables, params).await.unwrap_err();
1241        assert!(matches!(
1242            err,
1243            MiniAppError::MaterializeDestRelative { ref path } if path == "relative/path"
1244        ));
1245    }
1246
1247    #[tokio::test]
1248    async fn path_validation_create_dir_all_success() {
1249        let (tables, row_id, config) = make_test_env().await;
1250        let dest = tempfile::tempdir().unwrap();
1251        // Nested subdirectory that does not yet exist.
1252        let nested = format!("{}/subdir/nested", dest.path().display());
1253
1254        let params = MaterializeParams {
1255            table: None,
1256            selector: RowSelector::ById { id: row_id.clone() },
1257            fields: FieldSelector::All,
1258            format: MaterializeFormat::Raw,
1259            dest: nested.clone(),
1260            concat: Some(false),
1261            write_mode: None,
1262            dry_run: None,
1263        };
1264
1265        let result = do_materialize(&config, &tables, params).await.unwrap();
1266        assert_eq!(result.count, 1);
1267        assert!(std::path::Path::new(&nested).is_dir());
1268    }
1269
1270    #[tokio::test]
1271    async fn path_validation_concat_true_file_dest() {
1272        let (tables, _row_id, config) = make_test_env().await;
1273        let dest = tempfile::tempdir().unwrap();
1274        let out_file = format!("{}/out.txt", dest.path().display());
1275
1276        let params = MaterializeParams {
1277            table: None,
1278            selector: RowSelector::ByFilter {
1279                filter: crate::filter::ListFilter::Eq {
1280                    field: "title".to_string(),
1281                    value: serde_json::json!("hello"),
1282                },
1283                limit: None,
1284                offset: None,
1285            },
1286            fields: FieldSelector::All,
1287            format: MaterializeFormat::Raw,
1288            dest: out_file.clone(),
1289            concat: Some(true),
1290            write_mode: None,
1291            dry_run: None,
1292        };
1293
1294        let result = do_materialize(&config, &tables, params).await.unwrap();
1295        assert_eq!(result.count, 1);
1296        assert_eq!(result.files[0].path, out_file);
1297        assert!(std::path::Path::new(&out_file).exists());
1298    }
1299
1300    // -------------------------------------------------------------------------
1301    // Field projection tests (3)
1302    // -------------------------------------------------------------------------
1303
1304    #[tokio::test]
1305    async fn projection_all_fields_in_schema_order() {
1306        let (tables, row_id, config) = make_test_env().await;
1307        let dest = tempfile::tempdir().unwrap();
1308
1309        let params = MaterializeParams {
1310            table: None,
1311            selector: RowSelector::ById { id: row_id },
1312            fields: FieldSelector::All,
1313            format: MaterializeFormat::Json,
1314            dest: dest.path().to_str().unwrap().to_string(),
1315            concat: None,
1316            write_mode: None,
1317            dry_run: None,
1318        };
1319
1320        let result = do_materialize(&config, &tables, params).await.unwrap();
1321        let parsed: serde_json::Value =
1322            serde_json::from_str(&std::fs::read_to_string(&result.files[0].path).unwrap()).unwrap();
1323        // Both schema fields must be present.
1324        assert!(parsed.get("title").is_some());
1325        assert!(parsed.get("body").is_some());
1326    }
1327
1328    #[tokio::test]
1329    async fn projection_list_specified_order() {
1330        let (tables, row_id, config) = make_test_env().await;
1331        let dest = tempfile::tempdir().unwrap();
1332
1333        let params = MaterializeParams {
1334            table: None,
1335            selector: RowSelector::ById { id: row_id },
1336            fields: FieldSelector::List {
1337                fields: vec!["body".to_string()],
1338            },
1339            format: MaterializeFormat::Json,
1340            dest: dest.path().to_str().unwrap().to_string(),
1341            concat: None,
1342            write_mode: None,
1343            dry_run: None,
1344        };
1345
1346        let result = do_materialize(&config, &tables, params).await.unwrap();
1347        let parsed: serde_json::Value =
1348            serde_json::from_str(&std::fs::read_to_string(&result.files[0].path).unwrap()).unwrap();
1349        assert_eq!(parsed["body"], "world");
1350        // "title" was not requested.
1351        assert!(parsed.get("title").is_none());
1352    }
1353
1354    #[tokio::test]
1355    async fn projection_unknown_field_returns_error() {
1356        let (tables, row_id, config) = make_test_env().await;
1357        let dest = tempfile::tempdir().unwrap();
1358
1359        let params = MaterializeParams {
1360            table: None,
1361            selector: RowSelector::ById { id: row_id },
1362            fields: FieldSelector::List {
1363                fields: vec!["nonexistent_field".to_string()],
1364            },
1365            format: MaterializeFormat::Json,
1366            dest: dest.path().to_str().unwrap().to_string(),
1367            concat: None,
1368            write_mode: None,
1369            dry_run: None,
1370        };
1371
1372        let err = do_materialize(&config, &tables, params).await.unwrap_err();
1373        assert!(matches!(
1374            err,
1375            MiniAppError::MaterializeFieldUnknown { ref field } if field == "nonexistent_field"
1376        ));
1377    }
1378
1379    // -------------------------------------------------------------------------
1380    // Error variant tests (6)
1381    // -------------------------------------------------------------------------
1382
1383    #[tokio::test]
1384    async fn error_dest_invalid_write_mode_error_existing_file() {
1385        let (tables, _row_id, config) = make_test_env().await;
1386        let dest = tempfile::tempdir().unwrap();
1387        let out_file = format!("{}/out.txt", dest.path().display());
1388        // Pre-create the file.
1389        std::fs::write(&out_file, b"existing").unwrap();
1390
1391        let params = MaterializeParams {
1392            table: None,
1393            selector: RowSelector::ByFilter {
1394                filter: crate::filter::ListFilter::Eq {
1395                    field: "title".to_string(),
1396                    value: serde_json::json!("hello"),
1397                },
1398                limit: None,
1399                offset: None,
1400            },
1401            fields: FieldSelector::All,
1402            format: MaterializeFormat::Raw,
1403            dest: out_file.clone(),
1404            concat: Some(true),
1405            write_mode: Some(WriteMode::Error),
1406            dry_run: None,
1407        };
1408
1409        let err = do_materialize(&config, &tables, params).await.unwrap_err();
1410        assert!(matches!(
1411            err,
1412            MiniAppError::MaterializeDestInvalid { ref path, .. } if path == &out_file
1413        ));
1414    }
1415
1416    #[tokio::test]
1417    async fn error_row_not_found() {
1418        let (tables, _row_id, config) = make_test_env().await;
1419        let dest = tempfile::tempdir().unwrap();
1420
1421        let params = MaterializeParams {
1422            table: None,
1423            selector: RowSelector::ById {
1424                id: "00000000-0000-0000-0000-000000000000".to_string(),
1425            },
1426            fields: FieldSelector::All,
1427            format: MaterializeFormat::Raw,
1428            dest: dest.path().to_str().unwrap().to_string(),
1429            concat: None,
1430            write_mode: None,
1431            dry_run: None,
1432        };
1433
1434        let err = do_materialize(&config, &tables, params).await.unwrap_err();
1435        assert!(matches!(err, MiniAppError::MaterializeRowNotFound { .. }));
1436    }
1437
1438    #[tokio::test]
1439    async fn error_empty_result() {
1440        let (tables, _row_id, config) = make_test_env().await;
1441        let dest = tempfile::tempdir().unwrap();
1442
1443        let params = MaterializeParams {
1444            table: None,
1445            selector: RowSelector::ByFilter {
1446                filter: crate::filter::ListFilter::Eq {
1447                    field: "title".to_string(),
1448                    value: serde_json::json!("no_such_title"),
1449                },
1450                limit: None,
1451                offset: None,
1452            },
1453            fields: FieldSelector::All,
1454            format: MaterializeFormat::Raw,
1455            dest: dest.path().to_str().unwrap().to_string(),
1456            concat: None,
1457            write_mode: None,
1458            dry_run: None,
1459        };
1460
1461        let err = do_materialize(&config, &tables, params).await.unwrap_err();
1462        assert!(matches!(err, MiniAppError::MaterializeEmptyResult));
1463    }
1464
1465    #[tokio::test]
1466    async fn error_invalid_param_concat_by_id() {
1467        let (tables, row_id, config) = make_test_env().await;
1468        let dest = tempfile::tempdir().unwrap();
1469        let out_file = format!("{}/out.txt", dest.path().display());
1470
1471        let params = MaterializeParams {
1472            table: None,
1473            selector: RowSelector::ById { id: row_id },
1474            fields: FieldSelector::All,
1475            format: MaterializeFormat::Raw,
1476            dest: out_file,
1477            concat: Some(true),
1478            write_mode: None,
1479            dry_run: None,
1480        };
1481
1482        let err = do_materialize(&config, &tables, params).await.unwrap_err();
1483        assert!(matches!(
1484            err,
1485            MiniAppError::MaterializeInvalidParam { ref field, .. } if field == "concat"
1486        ));
1487    }
1488
1489    #[tokio::test]
1490    async fn error_field_unknown() {
1491        let (tables, row_id, config) = make_test_env().await;
1492        let dest = tempfile::tempdir().unwrap();
1493
1494        let params = MaterializeParams {
1495            table: None,
1496            selector: RowSelector::ById { id: row_id },
1497            fields: FieldSelector::List {
1498                fields: vec!["unknown".to_string()],
1499            },
1500            format: MaterializeFormat::Raw,
1501            dest: dest.path().to_str().unwrap().to_string(),
1502            concat: None,
1503            write_mode: None,
1504            dry_run: None,
1505        };
1506
1507        let err = do_materialize(&config, &tables, params).await.unwrap_err();
1508        assert!(matches!(
1509            err,
1510            MiniAppError::MaterializeFieldUnknown { ref field } if field == "unknown"
1511        ));
1512    }
1513
1514    #[tokio::test]
1515    async fn error_dest_relative_is_rejected_at_validation() {
1516        // This is a second test of MaterializeDestRelative to confirm validation
1517        // boundary fires before any store access.
1518        let (tables, row_id, config) = make_test_env().await;
1519
1520        let params = MaterializeParams {
1521            table: None,
1522            selector: RowSelector::ById { id: row_id },
1523            fields: FieldSelector::All,
1524            format: MaterializeFormat::Json,
1525            dest: "not/absolute".to_string(),
1526            concat: None,
1527            write_mode: None,
1528            dry_run: None,
1529        };
1530
1531        let err = do_materialize(&config, &tables, params).await.unwrap_err();
1532        assert!(matches!(
1533            err,
1534            MiniAppError::MaterializeDestRelative { ref path } if path == "not/absolute"
1535        ));
1536    }
1537
1538    // -------------------------------------------------------------------------
1539    // dry_run tests (2)
1540    // -------------------------------------------------------------------------
1541
1542    #[tokio::test]
1543    async fn dry_run_no_write_but_sha256_and_bytes_present() {
1544        let (tables, row_id, config) = make_test_env().await;
1545        let dest = tempfile::tempdir().unwrap();
1546        let dest_path = dest.path().to_str().unwrap().to_string();
1547
1548        let params = MaterializeParams {
1549            table: None,
1550            selector: RowSelector::ById { id: row_id.clone() },
1551            fields: FieldSelector::All,
1552            format: MaterializeFormat::Json,
1553            dest: dest_path.clone(),
1554            concat: Some(false),
1555            write_mode: None,
1556            dry_run: Some(true),
1557        };
1558
1559        let result = do_materialize(&config, &tables, params).await.unwrap();
1560        assert_eq!(result.count, 1);
1561        let f = &result.files[0];
1562        // sha256 must be present (Crux #3 — even in dry_run).
1563        assert_eq!(f.sha256.len(), 64);
1564        assert!(f.bytes > 0);
1565        // File must NOT be written on disk.
1566        let out_path = format!("{}/{}.json", dest_path, row_id);
1567        assert!(!std::path::Path::new(&out_path).exists());
1568    }
1569
1570    #[tokio::test]
1571    async fn dry_run_write_mode_error_existing_file_still_errors() {
1572        let (tables, _row_id, config) = make_test_env().await;
1573        let dest = tempfile::tempdir().unwrap();
1574        let out_file = format!("{}/out.txt", dest.path().display());
1575        // Pre-create the file.
1576        std::fs::write(&out_file, b"existing").unwrap();
1577
1578        let params = MaterializeParams {
1579            table: None,
1580            selector: RowSelector::ByFilter {
1581                filter: crate::filter::ListFilter::Eq {
1582                    field: "title".to_string(),
1583                    value: serde_json::json!("hello"),
1584                },
1585                limit: None,
1586                offset: None,
1587            },
1588            fields: FieldSelector::All,
1589            format: MaterializeFormat::Raw,
1590            dest: out_file.clone(),
1591            concat: Some(true),
1592            write_mode: Some(WriteMode::Error),
1593            dry_run: Some(true), // dry_run=true still validates write_mode=Error.
1594        };
1595
1596        let err = do_materialize(&config, &tables, params).await.unwrap_err();
1597        assert!(matches!(
1598            err,
1599            MiniAppError::MaterializeDestInvalid { ref path, .. } if path == &out_file
1600        ));
1601    }
1602
1603    // -------------------------------------------------------------------------
1604    // row_id tests (2)
1605    // -------------------------------------------------------------------------
1606
1607    #[tokio::test]
1608    async fn row_id_set_for_each_file_when_no_concat() {
1609        let (tables, row_id, config) = make_test_env().await;
1610        let dest = tempfile::tempdir().unwrap();
1611
1612        let params = MaterializeParams {
1613            table: None,
1614            selector: RowSelector::ById { id: row_id.clone() },
1615            fields: FieldSelector::All,
1616            format: MaterializeFormat::Raw,
1617            dest: dest.path().to_str().unwrap().to_string(),
1618            concat: Some(false),
1619            write_mode: None,
1620            dry_run: None,
1621        };
1622
1623        let result = do_materialize(&config, &tables, params).await.unwrap();
1624        assert_eq!(result.files[0].row_id, Some(row_id));
1625    }
1626
1627    #[tokio::test]
1628    async fn row_id_is_none_when_concat() {
1629        let (tables, _row_id, config) = make_test_env().await;
1630        let dest = tempfile::tempdir().unwrap();
1631        let out_file = format!("{}/out.txt", dest.path().display());
1632
1633        let params = MaterializeParams {
1634            table: None,
1635            selector: RowSelector::ByFilter {
1636                filter: crate::filter::ListFilter::Eq {
1637                    field: "title".to_string(),
1638                    value: serde_json::json!("hello"),
1639                },
1640                limit: None,
1641                offset: None,
1642            },
1643            fields: FieldSelector::All,
1644            format: MaterializeFormat::Raw,
1645            dest: out_file,
1646            concat: Some(true),
1647            write_mode: None,
1648            dry_run: None,
1649        };
1650
1651        let result = do_materialize(&config, &tables, params).await.unwrap();
1652        assert_eq!(result.files[0].row_id, None);
1653    }
1654
1655    // -------------------------------------------------------------------------
1656    // FieldSelector::validate — unit tests (Crux #2: schema-based validation)
1657    // -------------------------------------------------------------------------
1658
1659    fn make_schema() -> SchemaConfig {
1660        SchemaConfig {
1661            table: "test".to_string(),
1662            title: None,
1663            description: None,
1664            fields: vec![
1665                FieldDef {
1666                    name: "title".to_string(),
1667                    ty: FieldType::String,
1668                    required: true,
1669                    description: None,
1670                },
1671                FieldDef {
1672                    name: "body".to_string(),
1673                    ty: FieldType::String,
1674                    required: false,
1675                    description: None,
1676                },
1677            ],
1678            dump: None,
1679            history: Default::default(),
1680        }
1681    }
1682
1683    fn make_row(data: serde_json::Value) -> RowRecord {
1684        RowRecord {
1685            id: "test-id".to_string(),
1686            data,
1687            created_at: 0,
1688            updated_at: 0,
1689        }
1690    }
1691
1692    #[test]
1693    fn validate_field_selector_all_is_ok() {
1694        let schema = make_schema();
1695        let fs = FieldSelector::All;
1696        assert!(fs.validate(&schema).is_ok());
1697    }
1698
1699    #[test]
1700    fn validate_field_selector_list_known_fields_ok() {
1701        let schema = make_schema();
1702        let fs = FieldSelector::List {
1703            fields: vec!["title".to_string(), "body".to_string()],
1704        };
1705        assert!(fs.validate(&schema).is_ok());
1706    }
1707
1708    #[test]
1709    fn validate_field_selector_list_single_known_field_ok() {
1710        let schema = make_schema();
1711        let fs = FieldSelector::List {
1712            fields: vec!["title".to_string()],
1713        };
1714        assert!(fs.validate(&schema).is_ok());
1715    }
1716
1717    #[test]
1718    fn validate_field_selector_list_unknown_field_returns_validation_error() {
1719        let schema = make_schema();
1720        let fs = FieldSelector::List {
1721            fields: vec!["title".to_string(), "nonexistent".to_string()],
1722        };
1723        let err = fs.validate(&schema).unwrap_err();
1724        match err {
1725            MiniAppError::Validation { field, reason } => {
1726                assert_eq!(field, "nonexistent");
1727                assert!(reason.contains("nonexistent"));
1728                assert!(reason.contains("schema-registered"));
1729            }
1730            other => panic!("expected Validation error, got {other:?}"),
1731        }
1732    }
1733
1734    #[test]
1735    fn validate_field_selector_list_empty_fields_ok() {
1736        // Empty list is valid; projection will return empty data objects.
1737        let schema = make_schema();
1738        let fs = FieldSelector::List { fields: vec![] };
1739        assert!(fs.validate(&schema).is_ok());
1740    }
1741
1742    // -------------------------------------------------------------------------
1743    // apply_projection — unit tests (Crux #1: single shared boundary)
1744    // -------------------------------------------------------------------------
1745
1746    #[test]
1747    fn apply_projection_none_returns_unchanged() {
1748        let schema = make_schema();
1749        let row = make_row(serde_json::json!({"title": "hello", "body": "world"}));
1750        let records = vec![row];
1751        let result = apply_projection(records.clone(), &None, &schema).unwrap();
1752        assert_eq!(result.len(), 1);
1753        assert_eq!(result[0].id, records[0].id);
1754        assert_eq!(
1755            result[0].data,
1756            serde_json::json!({"title": "hello", "body": "world"})
1757        );
1758    }
1759
1760    #[test]
1761    fn apply_projection_all_returns_unchanged() {
1762        let schema = make_schema();
1763        let row = make_row(serde_json::json!({"title": "hello", "body": "world"}));
1764        let records = vec![row];
1765        let fields = Some(FieldSelector::All);
1766        let result = apply_projection(records.clone(), &fields, &schema).unwrap();
1767        assert_eq!(result.len(), 1);
1768        assert_eq!(
1769            result[0].data,
1770            serde_json::json!({"title": "hello", "body": "world"})
1771        );
1772    }
1773
1774    #[test]
1775    fn apply_projection_list_projects_data() {
1776        let schema = make_schema();
1777        let row = make_row(serde_json::json!({"title": "hello", "body": "world"}));
1778        let original_id = row.id.clone();
1779        let original_created_at = row.created_at;
1780        let records = vec![row];
1781        let fields = Some(FieldSelector::List {
1782            fields: vec!["title".to_string()],
1783        });
1784        let result = apply_projection(records, &fields, &schema).unwrap();
1785        assert_eq!(result.len(), 1);
1786        // Only "title" should be present in projected data.
1787        assert_eq!(result[0].data, serde_json::json!({"title": "hello"}));
1788        // Metadata fields must be preserved.
1789        assert_eq!(result[0].id, original_id);
1790        assert_eq!(result[0].created_at, original_created_at);
1791    }
1792
1793    #[test]
1794    fn apply_projection_list_projects_multiple_rows() {
1795        let schema = make_schema();
1796        let row1 = make_row(serde_json::json!({"title": "first", "body": "one"}));
1797        let row2 = make_row(serde_json::json!({"title": "second", "body": "two"}));
1798        let fields = Some(FieldSelector::List {
1799            fields: vec!["body".to_string()],
1800        });
1801        let result = apply_projection(vec![row1, row2], &fields, &schema).unwrap();
1802        assert_eq!(result.len(), 2);
1803        assert_eq!(result[0].data, serde_json::json!({"body": "one"}));
1804        assert_eq!(result[1].data, serde_json::json!({"body": "two"}));
1805    }
1806
1807    #[test]
1808    fn apply_projection_unknown_field_returns_error() {
1809        let schema = make_schema();
1810        let row = make_row(serde_json::json!({"title": "hello", "body": "world"}));
1811        let fields = Some(FieldSelector::List {
1812            fields: vec!["nonexistent".to_string()],
1813        });
1814        let err = apply_projection(vec![row], &fields, &schema).unwrap_err();
1815        match err {
1816            MiniAppError::Validation { field, .. } => {
1817                assert_eq!(field, "nonexistent");
1818            }
1819            other => panic!("expected Validation error, got {other:?}"),
1820        }
1821    }
1822
1823    #[test]
1824    fn apply_projection_missing_field_in_data_returns_null() {
1825        // project_row returns Null for fields absent from data.
1826        // This is acceptable: validation passes (field is in schema),
1827        // but the stored data doesn't have it.
1828        let schema = make_schema();
1829        let row = make_row(serde_json::json!({"title": "hello"}));
1830        let fields = Some(FieldSelector::List {
1831            fields: vec!["title".to_string(), "body".to_string()],
1832        });
1833        let result = apply_projection(vec![row], &fields, &schema).unwrap();
1834        assert_eq!(result[0].data["title"], "hello");
1835        assert_eq!(result[0].data["body"], serde_json::Value::Null);
1836    }
1837}