Skip to main content

mini_app_core/
dump.rs

1//! Framework-level dump hook utilities (write-only file materialization).
2//!
3//! This module provides the [`on_change`] and [`on_delete`] hooks that
4//! `Store::create`, `Store::update`, and `Store::delete` call after each
5//! successful database operation.  The hooks are defined here — not inlined
6//! into `store.rs` — so any future mini-app can call them directly (Crux #1
7//! compliance: framework-level hook placement).
8
9use std::path::{Path, PathBuf};
10
11use serde::{Deserialize, Serialize};
12
13use crate::error::MiniAppError;
14use crate::schema::SchemaConfig;
15use crate::store::RowRecord;
16
17// ---------------------------------------------------------------------------
18// Public types
19// ---------------------------------------------------------------------------
20
21/// Configuration for the write-only file-materialization feature.
22///
23/// Placed under the `dump:` key in `schema.yaml`.  All fields are optional;
24/// the entire `dump:` section may be absent (defaults to no materialization).
25#[derive(Debug, Clone, Deserialize, Serialize)]
26pub struct DumpConfig {
27    /// Override directory for dump files.
28    ///
29    /// - `None` → files are written to `<cwd>/.mini-app/<table>/<id>.md`.
30    /// - `Some(P)` → files are written to `P/<id>.md`.  A relative path is
31    ///   resolved relative to the current working directory at runtime.
32    pub dir: Option<PathBuf>,
33
34    /// Name of the JSON field in `record.data` to use as the markdown heading.
35    ///
36    /// Defaults to `"title"` when `None`.
37    pub title_field: Option<String>,
38
39    /// Name of the JSON field in `record.data` to use as the markdown body.
40    ///
41    /// Defaults to `"body"` when `None`.
42    pub body_field: Option<String>,
43
44    /// Sync mode.  `None` / `Some(WriteOnly)` → write-only (default).
45    /// `Some(Bidirectional)` is accepted in the schema but bidirectional sync
46    /// is not yet implemented; a `tracing::warn!` is emitted in `Store::open`.
47    pub sync: Option<SyncMode>,
48}
49
50/// Sync direction for the dump feature.
51///
52/// Deserialized from YAML using kebab-case: `write-only` / `bidirectional`.
53#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
54#[serde(rename_all = "kebab-case")]
55pub enum SyncMode {
56    /// App → file only (default behaviour).
57    WriteOnly,
58    /// Bidirectional (not yet implemented; triggers a `tracing::warn!` at
59    /// `Store::open` time and falls back to write-only).
60    Bidirectional,
61}
62
63// ---------------------------------------------------------------------------
64// Private helpers
65// ---------------------------------------------------------------------------
66
67/// Pure path-construction helper.  Separated from [`dump_path`] so the
68/// `dir = None` branch can be unit-tested without mutating process-global
69/// `current_dir()` (which would race with other parallel tests).
70fn dump_path_with_cwd(cwd: &Path, schema: &SchemaConfig, dump: &DumpConfig, id: &str) -> PathBuf {
71    match &dump.dir {
72        None => cwd
73            .join(".mini-app")
74            .join(&schema.table)
75            .join(format!("{id}.md")),
76        Some(dir) => dir.join(format!("{id}.md")),
77    }
78}
79
80/// Compute the destination path for a dump file.
81///
82/// - `DumpConfig.dir = None`  → `<cwd>/.mini-app/<table>/<id>.md`
83/// - `DumpConfig.dir = Some(P)` → `P/<id>.md`
84fn dump_path(schema: &SchemaConfig, dump: &DumpConfig, id: &str) -> Result<PathBuf, MiniAppError> {
85    let cwd = std::env::current_dir()?;
86    Ok(dump_path_with_cwd(&cwd, schema, dump, id))
87}
88
89/// Render a [`RowRecord`] into the markdown format required by the dump spec.
90///
91/// Format:
92/// ```text
93/// # <title>
94///
95/// <body>
96/// ```
97/// - `title` is the value of `dump.title_field` (default `"title"`) in
98///   `record.data`, converted to a string.  Missing or non-string values fall
99///   back to an empty string (`# ` heading).
100/// - `body` is the value of `dump.body_field` (default `"body"`) in
101///   `record.data`, converted to a string.  Missing or non-string values fall
102///   back to an empty string.
103/// - A single trailing newline is appended (POSIX convention).
104fn render(schema_is_unused: &SchemaConfig, dump: &DumpConfig, record: &RowRecord) -> String {
105    let _ = schema_is_unused; // schema reserved for future field-type lookups
106
107    let title_key = dump.title_field.as_deref().unwrap_or("title");
108    let body_key = dump.body_field.as_deref().unwrap_or("body");
109
110    let title = value_as_str(&record.data, title_key);
111    let body = value_as_str(&record.data, body_key);
112    // Strip body trailing newlines so the format always ends with exactly one
113    // LF (POSIX convention) regardless of whether the source body already
114    // included a terminating newline.
115    let body = body.trim_end_matches('\n');
116
117    format!("# {title}\n\n{body}\n")
118}
119
120/// Extract a field from a JSON value as a `String`.
121///
122/// - If the field is absent, returns `""`.
123/// - If the field is a JSON string, returns the string directly.
124/// - Otherwise, calls `to_string()` on the value (e.g. numbers become `"42"`).
125fn value_as_str(data: &serde_json::Value, key: &str) -> String {
126    match data.get(key) {
127        None | Some(serde_json::Value::Null) => String::new(),
128        Some(serde_json::Value::String(s)) => s.clone(),
129        Some(other) => other.to_string(),
130    }
131}
132
133// ---------------------------------------------------------------------------
134// Public hooks
135// ---------------------------------------------------------------------------
136
137/// Materialize `record` as a markdown file under the configured dump directory.
138///
139/// # Concurrency
140/// This function is `Send`. It does not hold any lock. File I/O executes
141/// inside `tokio::task::spawn_blocking` (using `std::fs::create_dir_all` and
142/// `std::fs::write`), consistent with the existing `Store` I/O pattern.
143/// Concurrent calls with distinct `record.id` values write to distinct paths
144/// (UUID v4) and do not interfere.
145///
146/// Concurrent calls with the **same** `record.id` are *not* order-preserving:
147/// the disk write order is determined by `spawn_blocking` scheduling, so the
148/// final file content reflects whichever write finishes last — which may not
149/// match the DB's last write. Callers that require strict file-DB ordering
150/// must serialise same-id writes upstream.
151///
152/// # Cancel Safety
153/// Not cancel-safe. Once the `spawn_blocking` closure has started, the file
154/// write completes regardless of `Future` cancellation. Dropping this
155/// `Future` after the closure has started may leave a partially-written file
156/// on disk (in practice `std::fs::write` is atomic at the OS level for small
157/// files on most filesystems, but this is not guaranteed).
158///
159/// # Errors
160/// - Returns `Ok(())` immediately if `schema.dump` is `None` (no-op path).
161/// - [`MiniAppError::Io`] — `create_dir_all` or `write` failure (e.g. permission denied, disk full).
162/// - [`MiniAppError::Schema`] — blocking thread panicked (JoinError).
163///
164/// # Panic
165/// Does not panic. No `Mutex` or lock is held. `spawn_blocking` JoinError is
166/// converted via `map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")))`.
167pub async fn on_change(schema: &SchemaConfig, record: &RowRecord) -> Result<(), MiniAppError> {
168    let dump = match schema.dump.as_ref() {
169        None => return Ok(()),
170        Some(d) => d.clone(),
171    };
172
173    let path = dump_path(schema, &dump, &record.id)?;
174    let content = render(schema, &dump, record);
175
176    tokio::task::spawn_blocking(move || -> Result<(), MiniAppError> {
177        if let Some(parent) = path.parent() {
178            std::fs::create_dir_all(parent)?;
179        }
180        std::fs::write(&path, content.as_bytes())?;
181        Ok(())
182    })
183    .await
184    .map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")))?
185}
186
187/// No-op stub for delete-time hook (default: keep file on disk).
188///
189/// # Concurrency
190/// This function returns `Ok(())` immediately. No I/O, no lock, no
191/// `spawn_blocking`. It is `Send + Sync` with zero blocking cost.
192///
193/// # Cancel Safety
194/// Cancel-safe. The function completes synchronously without any `.await`.
195///
196/// # Errors
197/// Always returns `Ok(())` in the current implementation.
198/// A future `dump.on_delete: keep | remove` schema flag may cause this
199/// function to perform file removal, at which point the cancel-safety and
200/// error contract will be updated.
201///
202/// # Panic
203/// Does not panic.
204pub async fn on_delete(_schema: &SchemaConfig, _id: &str) -> Result<(), MiniAppError> {
205    Ok(())
206}
207
208// ---------------------------------------------------------------------------
209// Tests
210// ---------------------------------------------------------------------------
211
212#[cfg(test)]
213mod tests {
214    use std::path::Path;
215
216    use super::*;
217    use crate::schema::{FieldDef, FieldType};
218
219    fn make_schema_no_dump(table: &str) -> SchemaConfig {
220        SchemaConfig {
221            table: table.to_string(),
222            title: None,
223            description: None,
224            fields: vec![
225                FieldDef {
226                    name: "title".into(),
227                    ty: FieldType::String,
228                    required: false,
229                    description: None,
230                },
231                FieldDef {
232                    name: "body".into(),
233                    ty: FieldType::String,
234                    required: false,
235                    description: None,
236                },
237            ],
238            dump: None,
239            history: Default::default(),
240        }
241    }
242
243    fn make_schema_with_dump(table: &str, dir: &Path) -> SchemaConfig {
244        SchemaConfig {
245            table: table.to_string(),
246            title: None,
247            description: None,
248            fields: vec![
249                FieldDef {
250                    name: "title".into(),
251                    ty: FieldType::String,
252                    required: false,
253                    description: None,
254                },
255                FieldDef {
256                    name: "body".into(),
257                    ty: FieldType::String,
258                    required: false,
259                    description: None,
260                },
261            ],
262            history: Default::default(),
263            dump: Some(DumpConfig {
264                dir: Some(dir.to_path_buf()),
265                title_field: None,
266                body_field: None,
267                sync: None,
268            }),
269        }
270    }
271
272    fn make_record(id: &str, data: serde_json::Value) -> RowRecord {
273        RowRecord {
274            id: id.to_string(),
275            data,
276            created_at: 0,
277            updated_at: 0,
278        }
279    }
280
281    // ── render tests ────────────────────────────────────────────────────────
282
283    #[test]
284    fn render_line1_is_title_heading() {
285        let schema = make_schema_no_dump("issues");
286        let dump = DumpConfig {
287            dir: None,
288            title_field: None,
289            body_field: None,
290            sync: None,
291        };
292        let record = make_record(
293            "id1",
294            serde_json::json!({"title": "Hello", "body": "World"}),
295        );
296        let rendered = render(&schema, &dump, &record);
297        let lines: Vec<&str> = rendered.lines().collect();
298        assert_eq!(lines[0], "# Hello");
299        assert_eq!(lines[1], "");
300        assert_eq!(lines[2], "World");
301    }
302
303    #[test]
304    fn render_with_custom_title_field() {
305        let schema = make_schema_no_dump("things");
306        let dump = DumpConfig {
307            dir: None,
308            title_field: Some("name".to_string()),
309            body_field: None,
310            sync: None,
311        };
312        let record = make_record(
313            "id2",
314            serde_json::json!({"name": "Custom Title", "body": "desc"}),
315        );
316        let rendered = render(&schema, &dump, &record);
317        assert!(rendered.starts_with("# Custom Title\n"));
318    }
319
320    #[test]
321    fn render_missing_title_field_yields_empty_heading() {
322        let schema = make_schema_no_dump("things");
323        let dump = DumpConfig {
324            dir: None,
325            title_field: None,
326            body_field: None,
327            sync: None,
328        };
329        // No "title" key in data
330        let record = make_record("id3", serde_json::json!({"body": "some body"}));
331        let rendered = render(&schema, &dump, &record);
332        assert!(rendered.starts_with("# \n"));
333    }
334
335    #[test]
336    fn render_has_trailing_newline() {
337        let schema = make_schema_no_dump("t");
338        let dump = DumpConfig {
339            dir: None,
340            title_field: None,
341            body_field: None,
342            sync: None,
343        };
344        let record = make_record("id4", serde_json::json!({"title": "T", "body": "B"}));
345        let rendered = render(&schema, &dump, &record);
346        assert!(rendered.ends_with('\n'));
347    }
348
349    #[test]
350    fn render_body_with_trailing_newline_collapses_to_single_lf() {
351        let schema = make_schema_no_dump("t");
352        let dump = DumpConfig {
353            dir: None,
354            title_field: None,
355            body_field: None,
356            sync: None,
357        };
358        // body already ends with "\n" — final output must still end with exactly
359        // one trailing LF, never two.
360        let record = make_record(
361            "id-trailing",
362            serde_json::json!({"title": "T", "body": "B\n"}),
363        );
364        let rendered = render(&schema, &dump, &record);
365        assert!(rendered.ends_with('\n'));
366        assert!(
367            !rendered.ends_with("\n\n"),
368            "must not produce double trailing LF; got {rendered:?}"
369        );
370    }
371
372    #[test]
373    fn render_non_string_title_uses_to_string() {
374        let schema = make_schema_no_dump("t");
375        let dump = DumpConfig {
376            dir: None,
377            title_field: None,
378            body_field: None,
379            sync: None,
380        };
381        let record = make_record("id5", serde_json::json!({"title": 42, "body": ""}));
382        let rendered = render(&schema, &dump, &record);
383        assert!(rendered.starts_with("# 42\n"));
384    }
385
386    // ── dump_path tests ──────────────────────────────────────────────────────
387
388    #[test]
389    fn dump_path_default_uses_cwd_mini_app_table_id() {
390        let tmp = tempfile::tempdir().expect("tempdir");
391        let schema = SchemaConfig {
392            table: "issues".to_string(),
393            title: None,
394            description: None,
395            fields: vec![],
396            history: Default::default(),
397            dump: Some(DumpConfig {
398                dir: Some(tmp.path().to_path_buf()),
399                title_field: None,
400                body_field: None,
401                sync: None,
402            }),
403        };
404        let dump_cfg = schema.dump.as_ref().unwrap();
405        let path = dump_path(&schema, dump_cfg, "abc-123").expect("dump_path ok");
406        assert_eq!(path, tmp.path().join("abc-123.md"));
407    }
408
409    #[test]
410    fn dump_path_with_cwd_none_branch_joins_mini_app_table_id() {
411        // Pure-helper test for the `dump.dir = None` branch — no process-global
412        // cwd mutation needed, so this is safe under parallel test execution.
413        let schema = SchemaConfig {
414            table: "issues".to_string(),
415            title: None,
416            description: None,
417            fields: vec![],
418            dump: None,
419            history: Default::default(),
420        };
421        let dump = DumpConfig {
422            dir: None,
423            title_field: None,
424            body_field: None,
425            sync: None,
426        };
427        let cwd = Path::new("/some/cwd");
428        let path = dump_path_with_cwd(cwd, &schema, &dump, "abc-123");
429        assert_eq!(
430            path,
431            Path::new("/some/cwd/.mini-app/issues/abc-123.md").to_path_buf()
432        );
433    }
434
435    #[test]
436    fn dump_path_custom_dir_override() {
437        let tmp = tempfile::tempdir().expect("tempdir");
438        let schema = make_schema_no_dump("issues");
439        let dump = DumpConfig {
440            dir: Some(tmp.path().to_path_buf()),
441            title_field: None,
442            body_field: None,
443            sync: None,
444        };
445        let path = dump_path(&schema, &dump, "my-id").expect("dump_path ok");
446        assert_eq!(path, tmp.path().join("my-id.md"));
447    }
448
449    // ── on_change tests ──────────────────────────────────────────────────────
450
451    #[tokio::test]
452    async fn on_change_writes_file() {
453        let tmp = tempfile::tempdir().expect("tempdir");
454        let schema = make_schema_with_dump("issues", tmp.path());
455        let record = make_record(
456            "test-id-001",
457            serde_json::json!({"title": "My Issue", "body": "Details here"}),
458        );
459        on_change(&schema, &record).await.expect("on_change ok");
460
461        let expected_path = tmp.path().join("test-id-001.md");
462        assert!(expected_path.exists(), "dump file must be created");
463
464        let content = std::fs::read_to_string(&expected_path).expect("read dump file");
465        assert!(content.starts_with("# My Issue\n"));
466        assert!(content.contains("Details here"));
467    }
468
469    #[tokio::test]
470    async fn on_change_no_dump_config_is_noop() {
471        let schema = make_schema_no_dump("issues");
472        let record = make_record("noop-id", serde_json::json!({"title": "T", "body": "B"}));
473        // Must return Ok(()) without creating any file
474        let result = on_change(&schema, &record).await;
475        assert!(result.is_ok());
476        // No file was created in any predictable location — we simply verify Ok
477    }
478
479    #[tokio::test]
480    async fn on_change_creates_parent_dirs() {
481        let tmp = tempfile::tempdir().expect("tempdir");
482        // Put dump files in a subdirectory that doesn't yet exist
483        let subdir = tmp.path().join("nested").join("dir");
484        let schema = SchemaConfig {
485            table: "t".to_string(),
486            title: None,
487            description: None,
488            fields: vec![],
489            history: Default::default(),
490            dump: Some(DumpConfig {
491                dir: Some(subdir.clone()),
492                title_field: None,
493                body_field: None,
494                sync: None,
495            }),
496        };
497        let record = make_record("mkdirs-id", serde_json::json!({}));
498        on_change(&schema, &record).await.expect("on_change ok");
499        assert!(subdir.join("mkdirs-id.md").exists());
500    }
501
502    // ── on_delete tests ──────────────────────────────────────────────────────
503
504    #[tokio::test]
505    async fn on_delete_keeps_file_by_default() {
506        let tmp = tempfile::tempdir().expect("tempdir");
507        let schema = make_schema_with_dump("issues", tmp.path());
508        let record = make_record(
509            "keep-id",
510            serde_json::json!({"title": "Keep Me", "body": ""}),
511        );
512
513        // Create the file first
514        on_change(&schema, &record).await.expect("on_change ok");
515        let path = tmp.path().join("keep-id.md");
516        assert!(path.exists(), "file must exist after on_change");
517
518        // on_delete must not remove it
519        on_delete(&schema, "keep-id").await.expect("on_delete ok");
520        assert!(path.exists(), "file must remain after on_delete");
521    }
522}