use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::error::MiniAppError;
use crate::schema::SchemaConfig;
use crate::store::RowRecord;
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct DumpConfig {
pub dir: Option<PathBuf>,
pub title_field: Option<String>,
pub body_field: Option<String>,
pub sync: Option<SyncMode>,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum SyncMode {
WriteOnly,
Bidirectional,
}
fn dump_path_with_cwd(cwd: &Path, schema: &SchemaConfig, dump: &DumpConfig, id: &str) -> PathBuf {
match &dump.dir {
None => cwd
.join(".mini-app")
.join(&schema.table)
.join(format!("{id}.md")),
Some(dir) => dir.join(format!("{id}.md")),
}
}
fn dump_path(schema: &SchemaConfig, dump: &DumpConfig, id: &str) -> Result<PathBuf, MiniAppError> {
let cwd = std::env::current_dir()?;
Ok(dump_path_with_cwd(&cwd, schema, dump, id))
}
fn render(schema_is_unused: &SchemaConfig, dump: &DumpConfig, record: &RowRecord) -> String {
let _ = schema_is_unused;
let title_key = dump.title_field.as_deref().unwrap_or("title");
let body_key = dump.body_field.as_deref().unwrap_or("body");
let title = value_as_str(&record.data, title_key);
let body = value_as_str(&record.data, body_key);
let body = body.trim_end_matches('\n');
format!("# {title}\n\n{body}\n")
}
fn value_as_str(data: &serde_json::Value, key: &str) -> String {
match data.get(key) {
None | Some(serde_json::Value::Null) => String::new(),
Some(serde_json::Value::String(s)) => s.clone(),
Some(other) => other.to_string(),
}
}
pub async fn on_change(schema: &SchemaConfig, record: &RowRecord) -> Result<(), MiniAppError> {
let dump = match schema.dump.as_ref() {
None => return Ok(()),
Some(d) => d.clone(),
};
let path = dump_path(schema, &dump, &record.id)?;
let content = render(schema, &dump, record);
tokio::task::spawn_blocking(move || -> Result<(), MiniAppError> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&path, content.as_bytes())?;
Ok(())
})
.await
.map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")))?
}
pub async fn on_delete(_schema: &SchemaConfig, _id: &str) -> Result<(), MiniAppError> {
Ok(())
}
#[cfg(test)]
mod tests {
use std::path::Path;
use super::*;
use crate::schema::{FieldDef, FieldType};
fn make_schema_no_dump(table: &str) -> SchemaConfig {
SchemaConfig {
table: table.to_string(),
title: None,
description: None,
fields: vec![
FieldDef {
name: "title".into(),
ty: FieldType::String,
required: false,
description: None,
},
FieldDef {
name: "body".into(),
ty: FieldType::String,
required: false,
description: None,
},
],
dump: None,
}
}
fn make_schema_with_dump(table: &str, dir: &Path) -> SchemaConfig {
SchemaConfig {
table: table.to_string(),
title: None,
description: None,
fields: vec![
FieldDef {
name: "title".into(),
ty: FieldType::String,
required: false,
description: None,
},
FieldDef {
name: "body".into(),
ty: FieldType::String,
required: false,
description: None,
},
],
dump: Some(DumpConfig {
dir: Some(dir.to_path_buf()),
title_field: None,
body_field: None,
sync: None,
}),
}
}
fn make_record(id: &str, data: serde_json::Value) -> RowRecord {
RowRecord {
id: id.to_string(),
data,
created_at: 0,
updated_at: 0,
}
}
#[test]
fn render_line1_is_title_heading() {
let schema = make_schema_no_dump("issues");
let dump = DumpConfig {
dir: None,
title_field: None,
body_field: None,
sync: None,
};
let record = make_record(
"id1",
serde_json::json!({"title": "Hello", "body": "World"}),
);
let rendered = render(&schema, &dump, &record);
let lines: Vec<&str> = rendered.lines().collect();
assert_eq!(lines[0], "# Hello");
assert_eq!(lines[1], "");
assert_eq!(lines[2], "World");
}
#[test]
fn render_with_custom_title_field() {
let schema = make_schema_no_dump("things");
let dump = DumpConfig {
dir: None,
title_field: Some("name".to_string()),
body_field: None,
sync: None,
};
let record = make_record(
"id2",
serde_json::json!({"name": "Custom Title", "body": "desc"}),
);
let rendered = render(&schema, &dump, &record);
assert!(rendered.starts_with("# Custom Title\n"));
}
#[test]
fn render_missing_title_field_yields_empty_heading() {
let schema = make_schema_no_dump("things");
let dump = DumpConfig {
dir: None,
title_field: None,
body_field: None,
sync: None,
};
let record = make_record("id3", serde_json::json!({"body": "some body"}));
let rendered = render(&schema, &dump, &record);
assert!(rendered.starts_with("# \n"));
}
#[test]
fn render_has_trailing_newline() {
let schema = make_schema_no_dump("t");
let dump = DumpConfig {
dir: None,
title_field: None,
body_field: None,
sync: None,
};
let record = make_record("id4", serde_json::json!({"title": "T", "body": "B"}));
let rendered = render(&schema, &dump, &record);
assert!(rendered.ends_with('\n'));
}
#[test]
fn render_body_with_trailing_newline_collapses_to_single_lf() {
let schema = make_schema_no_dump("t");
let dump = DumpConfig {
dir: None,
title_field: None,
body_field: None,
sync: None,
};
let record = make_record(
"id-trailing",
serde_json::json!({"title": "T", "body": "B\n"}),
);
let rendered = render(&schema, &dump, &record);
assert!(rendered.ends_with('\n'));
assert!(
!rendered.ends_with("\n\n"),
"must not produce double trailing LF; got {rendered:?}"
);
}
#[test]
fn render_non_string_title_uses_to_string() {
let schema = make_schema_no_dump("t");
let dump = DumpConfig {
dir: None,
title_field: None,
body_field: None,
sync: None,
};
let record = make_record("id5", serde_json::json!({"title": 42, "body": ""}));
let rendered = render(&schema, &dump, &record);
assert!(rendered.starts_with("# 42\n"));
}
#[test]
fn dump_path_default_uses_cwd_mini_app_table_id() {
let tmp = tempfile::tempdir().expect("tempdir");
let schema = SchemaConfig {
table: "issues".to_string(),
title: None,
description: None,
fields: vec![],
dump: Some(DumpConfig {
dir: Some(tmp.path().to_path_buf()),
title_field: None,
body_field: None,
sync: None,
}),
};
let dump_cfg = schema.dump.as_ref().unwrap();
let path = dump_path(&schema, dump_cfg, "abc-123").expect("dump_path ok");
assert_eq!(path, tmp.path().join("abc-123.md"));
}
#[test]
fn dump_path_with_cwd_none_branch_joins_mini_app_table_id() {
let schema = SchemaConfig {
table: "issues".to_string(),
title: None,
description: None,
fields: vec![],
dump: None,
};
let dump = DumpConfig {
dir: None,
title_field: None,
body_field: None,
sync: None,
};
let cwd = Path::new("/some/cwd");
let path = dump_path_with_cwd(cwd, &schema, &dump, "abc-123");
assert_eq!(
path,
Path::new("/some/cwd/.mini-app/issues/abc-123.md").to_path_buf()
);
}
#[test]
fn dump_path_custom_dir_override() {
let tmp = tempfile::tempdir().expect("tempdir");
let schema = make_schema_no_dump("issues");
let dump = DumpConfig {
dir: Some(tmp.path().to_path_buf()),
title_field: None,
body_field: None,
sync: None,
};
let path = dump_path(&schema, &dump, "my-id").expect("dump_path ok");
assert_eq!(path, tmp.path().join("my-id.md"));
}
#[tokio::test]
async fn on_change_writes_file() {
let tmp = tempfile::tempdir().expect("tempdir");
let schema = make_schema_with_dump("issues", tmp.path());
let record = make_record(
"test-id-001",
serde_json::json!({"title": "My Issue", "body": "Details here"}),
);
on_change(&schema, &record).await.expect("on_change ok");
let expected_path = tmp.path().join("test-id-001.md");
assert!(expected_path.exists(), "dump file must be created");
let content = std::fs::read_to_string(&expected_path).expect("read dump file");
assert!(content.starts_with("# My Issue\n"));
assert!(content.contains("Details here"));
}
#[tokio::test]
async fn on_change_no_dump_config_is_noop() {
let schema = make_schema_no_dump("issues");
let record = make_record("noop-id", serde_json::json!({"title": "T", "body": "B"}));
let result = on_change(&schema, &record).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn on_change_creates_parent_dirs() {
let tmp = tempfile::tempdir().expect("tempdir");
let subdir = tmp.path().join("nested").join("dir");
let schema = SchemaConfig {
table: "t".to_string(),
title: None,
description: None,
fields: vec![],
dump: Some(DumpConfig {
dir: Some(subdir.clone()),
title_field: None,
body_field: None,
sync: None,
}),
};
let record = make_record("mkdirs-id", serde_json::json!({}));
on_change(&schema, &record).await.expect("on_change ok");
assert!(subdir.join("mkdirs-id.md").exists());
}
#[tokio::test]
async fn on_delete_keeps_file_by_default() {
let tmp = tempfile::tempdir().expect("tempdir");
let schema = make_schema_with_dump("issues", tmp.path());
let record = make_record(
"keep-id",
serde_json::json!({"title": "Keep Me", "body": ""}),
);
on_change(&schema, &record).await.expect("on_change ok");
let path = tmp.path().join("keep-id.md");
assert!(path.exists(), "file must exist after on_change");
on_delete(&schema, "keep-id").await.expect("on_delete ok");
assert!(path.exists(), "file must remain after on_delete");
}
}