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        }
240    }
241
242    fn make_schema_with_dump(table: &str, dir: &Path) -> SchemaConfig {
243        SchemaConfig {
244            table: table.to_string(),
245            title: None,
246            description: None,
247            fields: vec![
248                FieldDef {
249                    name: "title".into(),
250                    ty: FieldType::String,
251                    required: false,
252                    description: None,
253                },
254                FieldDef {
255                    name: "body".into(),
256                    ty: FieldType::String,
257                    required: false,
258                    description: None,
259                },
260            ],
261            dump: Some(DumpConfig {
262                dir: Some(dir.to_path_buf()),
263                title_field: None,
264                body_field: None,
265                sync: None,
266            }),
267        }
268    }
269
270    fn make_record(id: &str, data: serde_json::Value) -> RowRecord {
271        RowRecord {
272            id: id.to_string(),
273            data,
274            created_at: 0,
275            updated_at: 0,
276        }
277    }
278
279    // ── render tests ────────────────────────────────────────────────────────
280
281    #[test]
282    fn render_line1_is_title_heading() {
283        let schema = make_schema_no_dump("issues");
284        let dump = DumpConfig {
285            dir: None,
286            title_field: None,
287            body_field: None,
288            sync: None,
289        };
290        let record = make_record(
291            "id1",
292            serde_json::json!({"title": "Hello", "body": "World"}),
293        );
294        let rendered = render(&schema, &dump, &record);
295        let lines: Vec<&str> = rendered.lines().collect();
296        assert_eq!(lines[0], "# Hello");
297        assert_eq!(lines[1], "");
298        assert_eq!(lines[2], "World");
299    }
300
301    #[test]
302    fn render_with_custom_title_field() {
303        let schema = make_schema_no_dump("things");
304        let dump = DumpConfig {
305            dir: None,
306            title_field: Some("name".to_string()),
307            body_field: None,
308            sync: None,
309        };
310        let record = make_record(
311            "id2",
312            serde_json::json!({"name": "Custom Title", "body": "desc"}),
313        );
314        let rendered = render(&schema, &dump, &record);
315        assert!(rendered.starts_with("# Custom Title\n"));
316    }
317
318    #[test]
319    fn render_missing_title_field_yields_empty_heading() {
320        let schema = make_schema_no_dump("things");
321        let dump = DumpConfig {
322            dir: None,
323            title_field: None,
324            body_field: None,
325            sync: None,
326        };
327        // No "title" key in data
328        let record = make_record("id3", serde_json::json!({"body": "some body"}));
329        let rendered = render(&schema, &dump, &record);
330        assert!(rendered.starts_with("# \n"));
331    }
332
333    #[test]
334    fn render_has_trailing_newline() {
335        let schema = make_schema_no_dump("t");
336        let dump = DumpConfig {
337            dir: None,
338            title_field: None,
339            body_field: None,
340            sync: None,
341        };
342        let record = make_record("id4", serde_json::json!({"title": "T", "body": "B"}));
343        let rendered = render(&schema, &dump, &record);
344        assert!(rendered.ends_with('\n'));
345    }
346
347    #[test]
348    fn render_body_with_trailing_newline_collapses_to_single_lf() {
349        let schema = make_schema_no_dump("t");
350        let dump = DumpConfig {
351            dir: None,
352            title_field: None,
353            body_field: None,
354            sync: None,
355        };
356        // body already ends with "\n" — final output must still end with exactly
357        // one trailing LF, never two.
358        let record = make_record(
359            "id-trailing",
360            serde_json::json!({"title": "T", "body": "B\n"}),
361        );
362        let rendered = render(&schema, &dump, &record);
363        assert!(rendered.ends_with('\n'));
364        assert!(
365            !rendered.ends_with("\n\n"),
366            "must not produce double trailing LF; got {rendered:?}"
367        );
368    }
369
370    #[test]
371    fn render_non_string_title_uses_to_string() {
372        let schema = make_schema_no_dump("t");
373        let dump = DumpConfig {
374            dir: None,
375            title_field: None,
376            body_field: None,
377            sync: None,
378        };
379        let record = make_record("id5", serde_json::json!({"title": 42, "body": ""}));
380        let rendered = render(&schema, &dump, &record);
381        assert!(rendered.starts_with("# 42\n"));
382    }
383
384    // ── dump_path tests ──────────────────────────────────────────────────────
385
386    #[test]
387    fn dump_path_default_uses_cwd_mini_app_table_id() {
388        let tmp = tempfile::tempdir().expect("tempdir");
389        let schema = SchemaConfig {
390            table: "issues".to_string(),
391            title: None,
392            description: None,
393            fields: vec![],
394            dump: Some(DumpConfig {
395                dir: Some(tmp.path().to_path_buf()),
396                title_field: None,
397                body_field: None,
398                sync: None,
399            }),
400        };
401        let dump_cfg = schema.dump.as_ref().unwrap();
402        let path = dump_path(&schema, dump_cfg, "abc-123").expect("dump_path ok");
403        assert_eq!(path, tmp.path().join("abc-123.md"));
404    }
405
406    #[test]
407    fn dump_path_with_cwd_none_branch_joins_mini_app_table_id() {
408        // Pure-helper test for the `dump.dir = None` branch — no process-global
409        // cwd mutation needed, so this is safe under parallel test execution.
410        let schema = SchemaConfig {
411            table: "issues".to_string(),
412            title: None,
413            description: None,
414            fields: vec![],
415            dump: None,
416        };
417        let dump = DumpConfig {
418            dir: None,
419            title_field: None,
420            body_field: None,
421            sync: None,
422        };
423        let cwd = Path::new("/some/cwd");
424        let path = dump_path_with_cwd(cwd, &schema, &dump, "abc-123");
425        assert_eq!(
426            path,
427            Path::new("/some/cwd/.mini-app/issues/abc-123.md").to_path_buf()
428        );
429    }
430
431    #[test]
432    fn dump_path_custom_dir_override() {
433        let tmp = tempfile::tempdir().expect("tempdir");
434        let schema = make_schema_no_dump("issues");
435        let dump = DumpConfig {
436            dir: Some(tmp.path().to_path_buf()),
437            title_field: None,
438            body_field: None,
439            sync: None,
440        };
441        let path = dump_path(&schema, &dump, "my-id").expect("dump_path ok");
442        assert_eq!(path, tmp.path().join("my-id.md"));
443    }
444
445    // ── on_change tests ──────────────────────────────────────────────────────
446
447    #[tokio::test]
448    async fn on_change_writes_file() {
449        let tmp = tempfile::tempdir().expect("tempdir");
450        let schema = make_schema_with_dump("issues", tmp.path());
451        let record = make_record(
452            "test-id-001",
453            serde_json::json!({"title": "My Issue", "body": "Details here"}),
454        );
455        on_change(&schema, &record).await.expect("on_change ok");
456
457        let expected_path = tmp.path().join("test-id-001.md");
458        assert!(expected_path.exists(), "dump file must be created");
459
460        let content = std::fs::read_to_string(&expected_path).expect("read dump file");
461        assert!(content.starts_with("# My Issue\n"));
462        assert!(content.contains("Details here"));
463    }
464
465    #[tokio::test]
466    async fn on_change_no_dump_config_is_noop() {
467        let schema = make_schema_no_dump("issues");
468        let record = make_record("noop-id", serde_json::json!({"title": "T", "body": "B"}));
469        // Must return Ok(()) without creating any file
470        let result = on_change(&schema, &record).await;
471        assert!(result.is_ok());
472        // No file was created in any predictable location — we simply verify Ok
473    }
474
475    #[tokio::test]
476    async fn on_change_creates_parent_dirs() {
477        let tmp = tempfile::tempdir().expect("tempdir");
478        // Put dump files in a subdirectory that doesn't yet exist
479        let subdir = tmp.path().join("nested").join("dir");
480        let schema = SchemaConfig {
481            table: "t".to_string(),
482            title: None,
483            description: None,
484            fields: vec![],
485            dump: Some(DumpConfig {
486                dir: Some(subdir.clone()),
487                title_field: None,
488                body_field: None,
489                sync: None,
490            }),
491        };
492        let record = make_record("mkdirs-id", serde_json::json!({}));
493        on_change(&schema, &record).await.expect("on_change ok");
494        assert!(subdir.join("mkdirs-id.md").exists());
495    }
496
497    // ── on_delete tests ──────────────────────────────────────────────────────
498
499    #[tokio::test]
500    async fn on_delete_keeps_file_by_default() {
501        let tmp = tempfile::tempdir().expect("tempdir");
502        let schema = make_schema_with_dump("issues", tmp.path());
503        let record = make_record(
504            "keep-id",
505            serde_json::json!({"title": "Keep Me", "body": ""}),
506        );
507
508        // Create the file first
509        on_change(&schema, &record).await.expect("on_change ok");
510        let path = tmp.path().join("keep-id.md");
511        assert!(path.exists(), "file must exist after on_change");
512
513        // on_delete must not remove it
514        on_delete(&schema, "keep-id").await.expect("on_delete ok");
515        assert!(path.exists(), "file must remain after on_delete");
516    }
517}