use std::fs::File;
use std::path::Path;
use serde::{Deserialize, Serialize};
use crate::error::MiniAppError;
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum FieldType {
String,
Number,
Boolean,
Array,
Object,
}
impl FieldType {
pub fn as_str(&self) -> &'static str {
match self {
FieldType::String => "string",
FieldType::Number => "number",
FieldType::Boolean => "boolean",
FieldType::Array => "array",
FieldType::Object => "object",
}
}
pub fn matches(&self, value: &serde_json::Value) -> bool {
match self {
FieldType::String => value.is_string(),
FieldType::Number => value.is_number(),
FieldType::Boolean => value.is_boolean(),
FieldType::Array => value.is_array(),
FieldType::Object => value.is_object(),
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct FieldDef {
pub name: String,
#[serde(rename = "type")]
pub ty: FieldType,
#[serde(default)]
pub required: bool,
#[serde(default)]
pub description: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct SchemaConfig {
pub table: String,
#[serde(default)]
pub title: Option<String>,
#[serde(default)]
pub description: Option<String>,
pub fields: Vec<FieldDef>,
#[serde(default)]
pub dump: Option<crate::dump::DumpConfig>,
}
impl SchemaConfig {
pub async fn write_to_path(&self, path: &Path) -> Result<(), MiniAppError> {
let schema_clone = self.clone();
let path_buf = path.to_path_buf();
tokio::task::spawn_blocking(move || -> Result<(), MiniAppError> {
let yaml = serde_yaml_bw::to_string(&schema_clone)
.map_err(|e| MiniAppError::Schema(e.to_string()))?;
let mut tmp_path = path_buf.clone();
let mut file_name = tmp_path
.file_name()
.map(|n| n.to_os_string())
.unwrap_or_default();
file_name.push(".tmp");
tmp_path.set_file_name(file_name);
std::fs::write(&tmp_path, yaml.as_bytes())?;
std::fs::rename(&tmp_path, &path_buf)?;
Ok(())
})
.await
.map_err(|e| MiniAppError::Backup(format!("blocking task panic: {e}")))?
}
pub fn validate(&self, value: &serde_json::Value) -> Result<(), MiniAppError> {
let obj = match value.as_object() {
Some(o) => o,
None => {
return Err(MiniAppError::Validation {
field: "(root)".to_string(),
reason: "value must be a JSON object".to_string(),
});
}
};
for field in &self.fields {
let field_value = obj.get(&field.name);
match field_value {
None | Some(serde_json::Value::Null) => {
if field.required {
return Err(MiniAppError::Validation {
field: field.name.clone(),
reason: "required field missing".to_string(),
});
}
}
Some(v) => {
if !field.ty.matches(v) {
return Err(MiniAppError::Validation {
field: field.name.clone(),
reason: format!(
"expected type '{}', got '{}'",
field.ty.as_str(),
json_type_name(v)
),
});
}
}
}
}
Ok(())
}
}
fn json_type_name(v: &serde_json::Value) -> &'static str {
match v {
serde_json::Value::Null => "null",
serde_json::Value::Bool(_) => "boolean",
serde_json::Value::Number(_) => "number",
serde_json::Value::String(_) => "string",
serde_json::Value::Array(_) => "array",
serde_json::Value::Object(_) => "object",
}
}
pub fn load_from_path(path: &Path) -> Result<SchemaConfig, MiniAppError> {
let file = File::open(path)?;
let config: SchemaConfig =
serde_yaml_bw::from_reader(file).map_err(|e| MiniAppError::Schema(e.to_string()))?;
Ok(config)
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use std::path::PathBuf;
use tempfile::{NamedTempFile, TempDir};
fn write_yaml(content: &str) -> NamedTempFile {
let mut f = NamedTempFile::new().expect("temp file creation is infallible in tests");
f.write_all(content.as_bytes())
.expect("writing to temp file is infallible in tests");
f
}
fn make_test_schema() -> SchemaConfig {
SchemaConfig {
table: "items".to_string(),
title: None,
description: None,
fields: vec![
FieldDef {
name: "name".to_string(),
ty: FieldType::String,
required: true,
description: None,
},
FieldDef {
name: "count".to_string(),
ty: FieldType::Number,
required: false,
description: None,
},
],
dump: None,
}
}
#[test]
fn load_valid_schema_yaml() {
let yaml = r#"
table: issues
fields:
- name: title
type: string
required: true
- name: state
type: string
required: false
- name: tags
type: array
required: false
"#;
let f = write_yaml(yaml);
let schema = load_from_path(f.path()).expect("valid YAML must parse");
assert_eq!(schema.table, "issues");
assert_eq!(schema.fields.len(), 3);
assert_eq!(schema.fields[0].name, "title");
assert!(schema.fields[0].required);
assert_eq!(schema.fields[0].ty, FieldType::String);
assert!(!schema.fields[1].required);
assert_eq!(schema.fields[2].ty, FieldType::Array);
}
#[test]
fn validate_happy_path_all_fields_present() {
let schema = SchemaConfig {
table: "issues".to_string(),
title: None,
description: None,
fields: vec![
FieldDef {
name: "title".to_string(),
ty: FieldType::String,
required: true,
description: None,
},
FieldDef {
name: "count".to_string(),
ty: FieldType::Number,
required: false,
description: None,
},
],
dump: None,
};
let value = serde_json::json!({ "title": "hello", "count": 42 });
assert!(schema.validate(&value).is_ok());
}
#[test]
fn validate_optional_field_absent_is_ok() {
let schema = SchemaConfig {
table: "t".to_string(),
title: None,
description: None,
fields: vec![FieldDef {
name: "tags".to_string(),
ty: FieldType::Array,
required: false,
description: None,
}],
dump: None,
};
let value = serde_json::json!({});
assert!(schema.validate(&value).is_ok());
}
#[test]
fn validate_unknown_fields_are_accepted() {
let schema = SchemaConfig {
table: "t".to_string(),
title: None,
description: None,
fields: vec![FieldDef {
name: "title".to_string(),
ty: FieldType::String,
required: true,
description: None,
}],
dump: None,
};
let value = serde_json::json!({ "title": "hi", "extra_key": 99 });
assert!(schema.validate(&value).is_ok());
}
#[test]
fn validate_null_value_for_required_field_is_error() {
let schema = SchemaConfig {
table: "t".to_string(),
title: None,
description: None,
fields: vec![FieldDef {
name: "title".to_string(),
ty: FieldType::String,
required: true,
description: None,
}],
dump: None,
};
let value = serde_json::json!({ "title": null });
let err = schema
.validate(&value)
.expect_err("null required field must error");
match err {
MiniAppError::Validation { field, .. } => assert_eq!(field, "title"),
other => panic!("expected Validation, got {:?}", other),
}
}
#[test]
fn validate_null_value_for_optional_field_is_ok() {
let schema = SchemaConfig {
table: "t".to_string(),
title: None,
description: None,
fields: vec![FieldDef {
name: "state".to_string(),
ty: FieldType::String,
required: false,
description: None,
}],
dump: None,
};
let value = serde_json::json!({ "state": null });
assert!(schema.validate(&value).is_ok());
}
#[test]
fn validate_empty_object_with_no_required_fields() {
let schema = SchemaConfig {
table: "t".to_string(),
title: None,
description: None,
fields: vec![],
dump: None,
};
let value = serde_json::json!({});
assert!(schema.validate(&value).is_ok());
}
#[test]
fn validate_non_object_root_is_error() {
let schema = SchemaConfig {
table: "t".to_string(),
title: None,
description: None,
fields: vec![],
dump: None,
};
let value = serde_json::json!([1, 2, 3]);
let err = schema.validate(&value).expect_err("array root must error");
assert!(matches!(err, MiniAppError::Validation { .. }));
}
#[test]
fn validate_required_field_missing_returns_validation_error() {
let schema = SchemaConfig {
table: "t".to_string(),
title: None,
description: None,
fields: vec![FieldDef {
name: "title".to_string(),
ty: FieldType::String,
required: true,
description: None,
}],
dump: None,
};
let value = serde_json::json!({});
let err = schema
.validate(&value)
.expect_err("missing required field must error");
match err {
MiniAppError::Validation { field, reason } => {
assert_eq!(field, "title");
assert!(reason.contains("required"));
}
other => panic!("expected Validation, got {:?}", other),
}
}
#[test]
fn validate_type_mismatch_string_vs_number() {
let schema = SchemaConfig {
table: "t".to_string(),
title: None,
description: None,
fields: vec![FieldDef {
name: "score".to_string(),
ty: FieldType::Number,
required: true,
description: None,
}],
dump: None,
};
let value = serde_json::json!({ "score": "not-a-number" });
let err = schema
.validate(&value)
.expect_err("type mismatch must error");
match err {
MiniAppError::Validation { field, reason } => {
assert_eq!(field, "score");
assert!(
reason.contains("number"),
"reason should mention expected type"
);
assert!(
reason.contains("string"),
"reason should mention actual type"
);
}
other => panic!("expected Validation, got {:?}", other),
}
}
#[test]
fn validate_type_mismatch_boolean_field() {
let schema = SchemaConfig {
table: "t".to_string(),
title: None,
description: None,
fields: vec![FieldDef {
name: "active".to_string(),
ty: FieldType::Boolean,
required: true,
description: None,
}],
dump: None,
};
let value = serde_json::json!({ "active": 1 });
let err = schema.validate(&value).expect_err("number is not boolean");
assert!(matches!(err, MiniAppError::Validation { .. }));
}
#[test]
fn validate_type_mismatch_array_field() {
let schema = SchemaConfig {
table: "t".to_string(),
title: None,
description: None,
fields: vec![FieldDef {
name: "tags".to_string(),
ty: FieldType::Array,
required: true,
description: None,
}],
dump: None,
};
let value = serde_json::json!({ "tags": "not-an-array" });
let err = schema.validate(&value).expect_err("string is not array");
assert!(matches!(err, MiniAppError::Validation { .. }));
}
#[test]
fn load_from_nonexistent_path_returns_io_error() {
let result = load_from_path(Path::new("/nonexistent/path/schema.yaml"));
let err = result.expect_err("missing file must error");
assert!(
matches!(err, MiniAppError::Io(_)),
"expected Io error, got {:?}",
err
);
}
#[test]
fn load_from_malformed_yaml_returns_schema_error() {
let yaml = "table: [\ninvalid yaml {{{\n";
let f = write_yaml(yaml);
let result = load_from_path(f.path());
let err = result.expect_err("malformed YAML must error");
assert!(
matches!(err, MiniAppError::Schema(_)),
"expected Schema error, got {:?}",
err
);
}
#[test]
fn yaml_with_dump_section_deserializes() {
let yaml = r#"
table: issues
fields:
- name: title
type: string
required: true
dump:
dir: /tmp/test-dump
title_field: title
body_field: body
sync: write-only
"#;
let f = write_yaml(yaml);
let schema = load_from_path(f.path()).expect("valid YAML with dump must parse");
assert_eq!(schema.table, "issues");
let dump = schema.dump.expect("dump must be Some");
assert_eq!(dump.title_field.as_deref(), Some("title"));
assert_eq!(dump.body_field.as_deref(), Some("body"));
assert_eq!(dump.sync, Some(crate::dump::SyncMode::WriteOnly));
}
#[test]
fn yaml_without_dump_section_yields_none() {
let yaml = r#"
table: issues
fields:
- name: title
type: string
required: true
"#;
let f = write_yaml(yaml);
let schema = load_from_path(f.path()).expect("valid YAML without dump must parse");
assert!(
schema.dump.is_none(),
"dump must be None when section is absent"
);
}
#[test]
fn yaml_with_bidirectional_sync_deserializes() {
let yaml = r#"
table: tasks
fields: []
dump:
sync: bidirectional
"#;
let f = write_yaml(yaml);
let schema = load_from_path(f.path()).expect("yaml with bidirectional must parse");
let dump = schema.dump.expect("dump must be Some");
assert_eq!(dump.sync, Some(crate::dump::SyncMode::Bidirectional));
}
#[test]
fn all_field_types_match_correctly() {
let cases: Vec<(FieldType, serde_json::Value, bool)> = vec![
(FieldType::String, serde_json::json!("hello"), true),
(FieldType::String, serde_json::json!(42), false),
(FieldType::Number, serde_json::json!(2.5), true),
(FieldType::Number, serde_json::json!("3.14"), false),
(FieldType::Boolean, serde_json::json!(true), true),
(FieldType::Boolean, serde_json::json!(0), false),
(FieldType::Array, serde_json::json!([1, 2]), true),
(FieldType::Array, serde_json::json!({}), false),
(FieldType::Object, serde_json::json!({}), true),
(FieldType::Object, serde_json::json!([]), false),
];
for (ty, value, expected) in cases {
assert_eq!(
ty.matches(&value),
expected,
"FieldType::{} .matches({:?}) should be {}",
ty.as_str(),
value,
expected
);
}
}
#[tokio::test]
async fn write_to_path_round_trips_via_load_from_path() {
let dir = TempDir::new().expect("temp dir creation is infallible in tests");
let schema_path = dir.path().join("schema.yaml");
let original = make_test_schema();
original
.write_to_path(&schema_path)
.await
.expect("write_to_path must succeed");
let loaded = load_from_path(&schema_path).expect("load_from_path must succeed");
assert_eq!(loaded.table, original.table);
assert_eq!(loaded.fields.len(), original.fields.len());
for (l, r) in loaded.fields.iter().zip(original.fields.iter()) {
assert_eq!(l.name, r.name);
assert_eq!(l.ty, r.ty);
assert_eq!(l.required, r.required);
}
assert!(loaded.dump.is_none());
}
#[tokio::test]
async fn write_to_path_uses_tmp_then_rename() {
let dir = TempDir::new().expect("temp dir creation is infallible in tests");
let schema_path = dir.path().join("schema.yaml");
let schema = make_test_schema();
schema
.write_to_path(&schema_path)
.await
.expect("write_to_path must succeed");
assert!(schema_path.exists(), "final schema.yaml must exist");
let mut tmp_path = PathBuf::from(&schema_path);
let mut file_name = tmp_path
.file_name()
.map(|n| n.to_os_string())
.unwrap_or_default();
file_name.push(".tmp");
tmp_path.set_file_name(file_name);
assert!(
!tmp_path.exists(),
".tmp file must not exist after successful write"
);
}
#[tokio::test]
async fn write_to_path_missing_parent_returns_io_error() {
let schema = make_test_schema();
let result = schema
.write_to_path(Path::new("/nonexistent/deep/path/schema.yaml"))
.await;
let err = result.expect_err("write to missing dir must error");
assert!(
matches!(err, MiniAppError::Io(_)),
"expected Io error, got {:?}",
err
);
}
#[test]
fn yaml_with_title_and_description_deserializes() {
let yaml = r#"
table: closet_snap
title: Closet Snap
description: |
Persona の今日その瞬間の自分を保存する情緒 snapshot。
outfit という domain-specific な snap で記録する。
fields:
- name: date
type: string
required: true
"#;
let f = write_yaml(yaml);
let schema =
load_from_path(f.path()).expect("valid YAML with title/description must parse");
assert_eq!(schema.table, "closet_snap");
assert_eq!(
schema.title.as_deref(),
Some("Closet Snap"),
"title must be Some(\"Closet Snap\")"
);
assert!(
schema
.description
.as_deref()
.unwrap_or("")
.contains("Persona"),
"description must be Some and contain 'Persona'"
);
}
#[test]
fn yaml_without_title_section_yields_none() {
let yaml = r#"
table: issues
fields:
- name: title
type: string
required: true
"#;
let f = write_yaml(yaml);
let schema =
load_from_path(f.path()).expect("valid YAML without title/description must parse");
assert!(
schema.title.is_none(),
"title must be None when section is absent"
);
assert!(
schema.description.is_none(),
"description must be None when section is absent"
);
}
#[test]
fn field_def_with_description_round_trips() {
let yaml = r#"
table: snap
fields:
- name: date
type: string
required: true
description: ISO date (YYYY-MM-DD)
- name: mood
type: string
required: false
"#;
let f = write_yaml(yaml);
let schema =
load_from_path(f.path()).expect("valid YAML with field description must parse");
assert_eq!(schema.fields.len(), 2);
assert_eq!(
schema.fields[0].description.as_deref(),
Some("ISO date (YYYY-MM-DD)"),
"field description must round-trip"
);
assert!(
schema.fields[1].description.is_none(),
"absent field description must be None"
);
}
}