1use std::fs::File;
8use std::path::Path;
9
10use serde::{Deserialize, Serialize};
11
12use crate::error::MiniAppError;
13
14#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
19#[serde(rename_all = "lowercase")]
20pub enum FieldType {
21 String,
23 Number,
25 Boolean,
27 Array,
29 Object,
31}
32
33impl FieldType {
34 pub fn as_str(&self) -> &'static str {
36 match self {
37 FieldType::String => "string",
38 FieldType::Number => "number",
39 FieldType::Boolean => "boolean",
40 FieldType::Array => "array",
41 FieldType::Object => "object",
42 }
43 }
44
45 pub fn matches(&self, value: &serde_json::Value) -> bool {
51 match self {
52 FieldType::String => value.is_string(),
53 FieldType::Number => value.is_number(),
54 FieldType::Boolean => value.is_boolean(),
55 FieldType::Array => value.is_array(),
56 FieldType::Object => value.is_object(),
57 }
58 }
59}
60
61#[derive(Debug, Clone, Deserialize, Serialize)]
70pub struct FieldDef {
71 pub name: String,
73 #[serde(rename = "type")]
75 pub ty: FieldType,
76 #[serde(default)]
78 pub required: bool,
79 #[serde(default)]
81 pub description: Option<String>,
82}
83
84#[derive(Debug, Clone, Deserialize, Serialize)]
97pub struct SchemaConfig {
98 pub table: String,
100 #[serde(default)]
102 pub title: Option<String>,
103 #[serde(default)]
105 pub description: Option<String>,
106 pub fields: Vec<FieldDef>,
108 #[serde(default)]
113 pub dump: Option<crate::dump::DumpConfig>,
114}
115
116impl SchemaConfig {
117 pub async fn write_to_path(&self, path: &Path) -> Result<(), MiniAppError> {
140 let schema_clone = self.clone();
141 let path_buf = path.to_path_buf();
142
143 tokio::task::spawn_blocking(move || -> Result<(), MiniAppError> {
144 let yaml = serde_yaml_bw::to_string(&schema_clone)
145 .map_err(|e| MiniAppError::Schema(e.to_string()))?;
146
147 let mut tmp_path = path_buf.clone();
148 let mut file_name = tmp_path
150 .file_name()
151 .map(|n| n.to_os_string())
152 .unwrap_or_default();
153 file_name.push(".tmp");
154 tmp_path.set_file_name(file_name);
155
156 std::fs::write(&tmp_path, yaml.as_bytes())?;
157 std::fs::rename(&tmp_path, &path_buf)?;
158
159 Ok(())
160 })
161 .await
162 .map_err(|e| MiniAppError::Backup(format!("blocking task panic: {e}")))?
163 }
164
165 pub fn validate(&self, value: &serde_json::Value) -> Result<(), MiniAppError> {
187 let obj = match value.as_object() {
188 Some(o) => o,
189 None => {
190 return Err(MiniAppError::Validation {
191 field: "(root)".to_string(),
192 reason: "value must be a JSON object".to_string(),
193 });
194 }
195 };
196
197 for field in &self.fields {
198 let field_value = obj.get(&field.name);
199
200 match field_value {
201 None | Some(serde_json::Value::Null) => {
202 if field.required {
203 return Err(MiniAppError::Validation {
204 field: field.name.clone(),
205 reason: "required field missing".to_string(),
206 });
207 }
208 }
210 Some(v) => {
211 if !field.ty.matches(v) {
212 return Err(MiniAppError::Validation {
213 field: field.name.clone(),
214 reason: format!(
215 "expected type '{}', got '{}'",
216 field.ty.as_str(),
217 json_type_name(v)
218 ),
219 });
220 }
221 }
222 }
223 }
224
225 Ok(())
226 }
227}
228
229fn json_type_name(v: &serde_json::Value) -> &'static str {
233 match v {
234 serde_json::Value::Null => "null",
235 serde_json::Value::Bool(_) => "boolean",
236 serde_json::Value::Number(_) => "number",
237 serde_json::Value::String(_) => "string",
238 serde_json::Value::Array(_) => "array",
239 serde_json::Value::Object(_) => "object",
240 }
241}
242
243pub fn load_from_path(path: &Path) -> Result<SchemaConfig, MiniAppError> {
265 let file = File::open(path)?;
266 let config: SchemaConfig =
267 serde_yaml_bw::from_reader(file).map_err(|e| MiniAppError::Schema(e.to_string()))?;
268 Ok(config)
269}
270
271#[cfg(test)]
272mod tests {
273 use super::*;
274 use std::io::Write;
275 use std::path::PathBuf;
276 use tempfile::{NamedTempFile, TempDir};
277
278 fn write_yaml(content: &str) -> NamedTempFile {
280 let mut f = NamedTempFile::new().expect("temp file creation is infallible in tests");
281 f.write_all(content.as_bytes())
282 .expect("writing to temp file is infallible in tests");
283 f
284 }
285
286 fn make_test_schema() -> SchemaConfig {
288 SchemaConfig {
289 table: "items".to_string(),
290 title: None,
291 description: None,
292 fields: vec![
293 FieldDef {
294 name: "name".to_string(),
295 ty: FieldType::String,
296 required: true,
297 description: None,
298 },
299 FieldDef {
300 name: "count".to_string(),
301 ty: FieldType::Number,
302 required: false,
303 description: None,
304 },
305 ],
306 dump: None,
307 }
308 }
309
310 #[test]
313 fn load_valid_schema_yaml() {
314 let yaml = r#"
315table: issues
316fields:
317 - name: title
318 type: string
319 required: true
320 - name: state
321 type: string
322 required: false
323 - name: tags
324 type: array
325 required: false
326"#;
327 let f = write_yaml(yaml);
328 let schema = load_from_path(f.path()).expect("valid YAML must parse");
329 assert_eq!(schema.table, "issues");
330 assert_eq!(schema.fields.len(), 3);
331 assert_eq!(schema.fields[0].name, "title");
332 assert!(schema.fields[0].required);
333 assert_eq!(schema.fields[0].ty, FieldType::String);
334 assert!(!schema.fields[1].required);
335 assert_eq!(schema.fields[2].ty, FieldType::Array);
336 }
337
338 #[test]
339 fn validate_happy_path_all_fields_present() {
340 let schema = SchemaConfig {
341 table: "issues".to_string(),
342 title: None,
343 description: None,
344 fields: vec![
345 FieldDef {
346 name: "title".to_string(),
347 ty: FieldType::String,
348 required: true,
349 description: None,
350 },
351 FieldDef {
352 name: "count".to_string(),
353 ty: FieldType::Number,
354 required: false,
355 description: None,
356 },
357 ],
358 dump: None,
359 };
360 let value = serde_json::json!({ "title": "hello", "count": 42 });
361 assert!(schema.validate(&value).is_ok());
362 }
363
364 #[test]
365 fn validate_optional_field_absent_is_ok() {
366 let schema = SchemaConfig {
367 table: "t".to_string(),
368 title: None,
369 description: None,
370 fields: vec![FieldDef {
371 name: "tags".to_string(),
372 ty: FieldType::Array,
373 required: false,
374 description: None,
375 }],
376 dump: None,
377 };
378 let value = serde_json::json!({});
379 assert!(schema.validate(&value).is_ok());
380 }
381
382 #[test]
383 fn validate_unknown_fields_are_accepted() {
384 let schema = SchemaConfig {
386 table: "t".to_string(),
387 title: None,
388 description: None,
389 fields: vec![FieldDef {
390 name: "title".to_string(),
391 ty: FieldType::String,
392 required: true,
393 description: None,
394 }],
395 dump: None,
396 };
397 let value = serde_json::json!({ "title": "hi", "extra_key": 99 });
398 assert!(schema.validate(&value).is_ok());
399 }
400
401 #[test]
404 fn validate_null_value_for_required_field_is_error() {
405 let schema = SchemaConfig {
406 table: "t".to_string(),
407 title: None,
408 description: None,
409 fields: vec![FieldDef {
410 name: "title".to_string(),
411 ty: FieldType::String,
412 required: true,
413 description: None,
414 }],
415 dump: None,
416 };
417 let value = serde_json::json!({ "title": null });
418 let err = schema
419 .validate(&value)
420 .expect_err("null required field must error");
421 match err {
422 MiniAppError::Validation { field, .. } => assert_eq!(field, "title"),
423 other => panic!("expected Validation, got {:?}", other),
424 }
425 }
426
427 #[test]
428 fn validate_null_value_for_optional_field_is_ok() {
429 let schema = SchemaConfig {
430 table: "t".to_string(),
431 title: None,
432 description: None,
433 fields: vec![FieldDef {
434 name: "state".to_string(),
435 ty: FieldType::String,
436 required: false,
437 description: None,
438 }],
439 dump: None,
440 };
441 let value = serde_json::json!({ "state": null });
442 assert!(schema.validate(&value).is_ok());
443 }
444
445 #[test]
446 fn validate_empty_object_with_no_required_fields() {
447 let schema = SchemaConfig {
448 table: "t".to_string(),
449 title: None,
450 description: None,
451 fields: vec![],
452 dump: None,
453 };
454 let value = serde_json::json!({});
455 assert!(schema.validate(&value).is_ok());
456 }
457
458 #[test]
459 fn validate_non_object_root_is_error() {
460 let schema = SchemaConfig {
461 table: "t".to_string(),
462 title: None,
463 description: None,
464 fields: vec![],
465 dump: None,
466 };
467 let value = serde_json::json!([1, 2, 3]);
468 let err = schema.validate(&value).expect_err("array root must error");
469 assert!(matches!(err, MiniAppError::Validation { .. }));
470 }
471
472 #[test]
475 fn validate_required_field_missing_returns_validation_error() {
476 let schema = SchemaConfig {
477 table: "t".to_string(),
478 title: None,
479 description: None,
480 fields: vec![FieldDef {
481 name: "title".to_string(),
482 ty: FieldType::String,
483 required: true,
484 description: None,
485 }],
486 dump: None,
487 };
488 let value = serde_json::json!({});
489 let err = schema
490 .validate(&value)
491 .expect_err("missing required field must error");
492 match err {
493 MiniAppError::Validation { field, reason } => {
494 assert_eq!(field, "title");
495 assert!(reason.contains("required"));
496 }
497 other => panic!("expected Validation, got {:?}", other),
498 }
499 }
500
501 #[test]
502 fn validate_type_mismatch_string_vs_number() {
503 let schema = SchemaConfig {
504 table: "t".to_string(),
505 title: None,
506 description: None,
507 fields: vec![FieldDef {
508 name: "score".to_string(),
509 ty: FieldType::Number,
510 required: true,
511 description: None,
512 }],
513 dump: None,
514 };
515 let value = serde_json::json!({ "score": "not-a-number" });
516 let err = schema
517 .validate(&value)
518 .expect_err("type mismatch must error");
519 match err {
520 MiniAppError::Validation { field, reason } => {
521 assert_eq!(field, "score");
522 assert!(
523 reason.contains("number"),
524 "reason should mention expected type"
525 );
526 assert!(
527 reason.contains("string"),
528 "reason should mention actual type"
529 );
530 }
531 other => panic!("expected Validation, got {:?}", other),
532 }
533 }
534
535 #[test]
536 fn validate_type_mismatch_boolean_field() {
537 let schema = SchemaConfig {
538 table: "t".to_string(),
539 title: None,
540 description: None,
541 fields: vec![FieldDef {
542 name: "active".to_string(),
543 ty: FieldType::Boolean,
544 required: true,
545 description: None,
546 }],
547 dump: None,
548 };
549 let value = serde_json::json!({ "active": 1 });
550 let err = schema.validate(&value).expect_err("number is not boolean");
551 assert!(matches!(err, MiniAppError::Validation { .. }));
552 }
553
554 #[test]
555 fn validate_type_mismatch_array_field() {
556 let schema = SchemaConfig {
557 table: "t".to_string(),
558 title: None,
559 description: None,
560 fields: vec![FieldDef {
561 name: "tags".to_string(),
562 ty: FieldType::Array,
563 required: true,
564 description: None,
565 }],
566 dump: None,
567 };
568 let value = serde_json::json!({ "tags": "not-an-array" });
569 let err = schema.validate(&value).expect_err("string is not array");
570 assert!(matches!(err, MiniAppError::Validation { .. }));
571 }
572
573 #[test]
574 fn load_from_nonexistent_path_returns_io_error() {
575 let result = load_from_path(Path::new("/nonexistent/path/schema.yaml"));
576 let err = result.expect_err("missing file must error");
577 assert!(
578 matches!(err, MiniAppError::Io(_)),
579 "expected Io error, got {:?}",
580 err
581 );
582 }
583
584 #[test]
585 fn load_from_malformed_yaml_returns_schema_error() {
586 let yaml = "table: [\ninvalid yaml {{{\n";
587 let f = write_yaml(yaml);
588 let result = load_from_path(f.path());
589 let err = result.expect_err("malformed YAML must error");
590 assert!(
591 matches!(err, MiniAppError::Schema(_)),
592 "expected Schema error, got {:?}",
593 err
594 );
595 }
596
597 #[test]
598 fn yaml_with_dump_section_deserializes() {
599 let yaml = r#"
600table: issues
601fields:
602 - name: title
603 type: string
604 required: true
605dump:
606 dir: /tmp/test-dump
607 title_field: title
608 body_field: body
609 sync: write-only
610"#;
611 let f = write_yaml(yaml);
612 let schema = load_from_path(f.path()).expect("valid YAML with dump must parse");
613 assert_eq!(schema.table, "issues");
614 let dump = schema.dump.expect("dump must be Some");
615 assert_eq!(dump.title_field.as_deref(), Some("title"));
616 assert_eq!(dump.body_field.as_deref(), Some("body"));
617 assert_eq!(dump.sync, Some(crate::dump::SyncMode::WriteOnly));
618 }
619
620 #[test]
621 fn yaml_without_dump_section_yields_none() {
622 let yaml = r#"
623table: issues
624fields:
625 - name: title
626 type: string
627 required: true
628"#;
629 let f = write_yaml(yaml);
630 let schema = load_from_path(f.path()).expect("valid YAML without dump must parse");
631 assert!(
632 schema.dump.is_none(),
633 "dump must be None when section is absent"
634 );
635 }
636
637 #[test]
638 fn yaml_with_bidirectional_sync_deserializes() {
639 let yaml = r#"
640table: tasks
641fields: []
642dump:
643 sync: bidirectional
644"#;
645 let f = write_yaml(yaml);
646 let schema = load_from_path(f.path()).expect("yaml with bidirectional must parse");
647 let dump = schema.dump.expect("dump must be Some");
648 assert_eq!(dump.sync, Some(crate::dump::SyncMode::Bidirectional));
649 }
650
651 #[test]
652 fn all_field_types_match_correctly() {
653 let cases: Vec<(FieldType, serde_json::Value, bool)> = vec![
654 (FieldType::String, serde_json::json!("hello"), true),
655 (FieldType::String, serde_json::json!(42), false),
656 (FieldType::Number, serde_json::json!(2.5), true),
657 (FieldType::Number, serde_json::json!("3.14"), false),
658 (FieldType::Boolean, serde_json::json!(true), true),
659 (FieldType::Boolean, serde_json::json!(0), false),
660 (FieldType::Array, serde_json::json!([1, 2]), true),
661 (FieldType::Array, serde_json::json!({}), false),
662 (FieldType::Object, serde_json::json!({}), true),
663 (FieldType::Object, serde_json::json!([]), false),
664 ];
665 for (ty, value, expected) in cases {
666 assert_eq!(
667 ty.matches(&value),
668 expected,
669 "FieldType::{} .matches({:?}) should be {}",
670 ty.as_str(),
671 value,
672 expected
673 );
674 }
675 }
676
677 #[tokio::test]
679 async fn write_to_path_round_trips_via_load_from_path() {
680 let dir = TempDir::new().expect("temp dir creation is infallible in tests");
681 let schema_path = dir.path().join("schema.yaml");
682 let original = make_test_schema();
683
684 original
685 .write_to_path(&schema_path)
686 .await
687 .expect("write_to_path must succeed");
688
689 let loaded = load_from_path(&schema_path).expect("load_from_path must succeed");
690
691 assert_eq!(loaded.table, original.table);
692 assert_eq!(loaded.fields.len(), original.fields.len());
693 for (l, r) in loaded.fields.iter().zip(original.fields.iter()) {
694 assert_eq!(l.name, r.name);
695 assert_eq!(l.ty, r.ty);
696 assert_eq!(l.required, r.required);
697 }
698 assert!(loaded.dump.is_none());
699 }
700
701 #[tokio::test]
707 async fn write_to_path_uses_tmp_then_rename() {
708 let dir = TempDir::new().expect("temp dir creation is infallible in tests");
709 let schema_path = dir.path().join("schema.yaml");
710 let schema = make_test_schema();
711
712 schema
713 .write_to_path(&schema_path)
714 .await
715 .expect("write_to_path must succeed");
716
717 assert!(schema_path.exists(), "final schema.yaml must exist");
719 let mut tmp_path = PathBuf::from(&schema_path);
721 let mut file_name = tmp_path
722 .file_name()
723 .map(|n| n.to_os_string())
724 .unwrap_or_default();
725 file_name.push(".tmp");
726 tmp_path.set_file_name(file_name);
727 assert!(
728 !tmp_path.exists(),
729 ".tmp file must not exist after successful write"
730 );
731 }
732
733 #[tokio::test]
735 async fn write_to_path_missing_parent_returns_io_error() {
736 let schema = make_test_schema();
737 let result = schema
738 .write_to_path(Path::new("/nonexistent/deep/path/schema.yaml"))
739 .await;
740 let err = result.expect_err("write to missing dir must error");
741 assert!(
742 matches!(err, MiniAppError::Io(_)),
743 "expected Io error, got {:?}",
744 err
745 );
746 }
747
748 #[test]
749 fn yaml_with_title_and_description_deserializes() {
750 let yaml = r#"
751table: closet_snap
752title: Closet Snap
753description: |
754 Persona の今日その瞬間の自分を保存する情緒 snapshot。
755 outfit という domain-specific な snap で記録する。
756fields:
757 - name: date
758 type: string
759 required: true
760"#;
761 let f = write_yaml(yaml);
762 let schema =
763 load_from_path(f.path()).expect("valid YAML with title/description must parse");
764 assert_eq!(schema.table, "closet_snap");
765 assert_eq!(
766 schema.title.as_deref(),
767 Some("Closet Snap"),
768 "title must be Some(\"Closet Snap\")"
769 );
770 assert!(
771 schema
772 .description
773 .as_deref()
774 .unwrap_or("")
775 .contains("Persona"),
776 "description must be Some and contain 'Persona'"
777 );
778 }
779
780 #[test]
781 fn yaml_without_title_section_yields_none() {
782 let yaml = r#"
783table: issues
784fields:
785 - name: title
786 type: string
787 required: true
788"#;
789 let f = write_yaml(yaml);
790 let schema =
791 load_from_path(f.path()).expect("valid YAML without title/description must parse");
792 assert!(
793 schema.title.is_none(),
794 "title must be None when section is absent"
795 );
796 assert!(
797 schema.description.is_none(),
798 "description must be None when section is absent"
799 );
800 }
801
802 #[test]
803 fn field_def_with_description_round_trips() {
804 let yaml = r#"
805table: snap
806fields:
807 - name: date
808 type: string
809 required: true
810 description: ISO date (YYYY-MM-DD)
811 - name: mood
812 type: string
813 required: false
814"#;
815 let f = write_yaml(yaml);
816 let schema =
817 load_from_path(f.path()).expect("valid YAML with field description must parse");
818 assert_eq!(schema.fields.len(), 2);
819 assert_eq!(
820 schema.fields[0].description.as_deref(),
821 Some("ISO date (YYYY-MM-DD)"),
822 "field description must round-trip"
823 );
824 assert!(
825 schema.fields[1].description.is_none(),
826 "absent field description must be None"
827 );
828 }
829}