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 }
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 #[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 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 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 #[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 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 #[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 let result = on_change(&schema, &record).await;
471 assert!(result.is_ok());
472 }
474
475 #[tokio::test]
476 async fn on_change_creates_parent_dirs() {
477 let tmp = tempfile::tempdir().expect("tempdir");
478 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 #[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 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(&schema, "keep-id").await.expect("on_delete ok");
515 assert!(path.exists(), "file must remain after on_delete");
516 }
517}