1use std::path::{Path, PathBuf};
10
11use serde::{Deserialize, Serialize};
12
13use crate::error::MiniAppError;
14use crate::schema::SchemaConfig;
15use crate::store::RowRecord;
16
17#[derive(Debug, Clone, Deserialize, Serialize)]
26pub struct DumpConfig {
27 pub dir: Option<PathBuf>,
33
34 pub title_field: Option<String>,
38
39 pub body_field: Option<String>,
43
44 pub sync: Option<SyncMode>,
48}
49
50#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
54#[serde(rename_all = "kebab-case")]
55pub enum SyncMode {
56 WriteOnly,
58 Bidirectional,
61}
62
63fn 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
80fn 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
89fn render(schema_is_unused: &SchemaConfig, dump: &DumpConfig, record: &RowRecord) -> String {
105 let _ = schema_is_unused; 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 let body = body.trim_end_matches('\n');
116
117 format!("# {title}\n\n{body}\n")
118}
119
120fn 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
133pub 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
187pub async fn on_delete(_schema: &SchemaConfig, _id: &str) -> Result<(), MiniAppError> {
205 Ok(())
206}
207
208#[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 #[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 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 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 #[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 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 #[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 let result = on_change(&schema, &record).await;
475 assert!(result.is_ok());
476 }
478
479 #[tokio::test]
480 async fn on_change_creates_parent_dirs() {
481 let tmp = tempfile::tempdir().expect("tempdir");
482 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 #[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 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(&schema, "keep-id").await.expect("on_delete ok");
520 assert!(path.exists(), "file must remain after on_delete");
521 }
522}