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 #[serde(default)]
122 pub history: HistoryMode,
123}
124
125#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
127#[serde(rename_all = "lowercase")]
128pub enum HistoryMode {
129 #[default]
131 Full,
132 Off,
134}
135
136impl HistoryMode {
137 pub fn enabled(self) -> bool {
139 matches!(self, HistoryMode::Full)
140 }
141}
142
143impl SchemaConfig {
144 pub async fn write_to_path(&self, path: &Path) -> Result<(), MiniAppError> {
167 let schema_clone = self.clone();
168 let path_buf = path.to_path_buf();
169
170 tokio::task::spawn_blocking(move || -> Result<(), MiniAppError> {
171 let yaml = serde_yaml_bw::to_string(&schema_clone)
172 .map_err(|e| MiniAppError::Schema(e.to_string()))?;
173
174 let mut tmp_path = path_buf.clone();
175 let mut file_name = tmp_path
177 .file_name()
178 .map(|n| n.to_os_string())
179 .unwrap_or_default();
180 file_name.push(".tmp");
181 tmp_path.set_file_name(file_name);
182
183 std::fs::write(&tmp_path, yaml.as_bytes())?;
184 std::fs::rename(&tmp_path, &path_buf)?;
185
186 Ok(())
187 })
188 .await
189 .map_err(|e| MiniAppError::Backup(format!("blocking task panic: {e}")))?
190 }
191
192 pub fn validate(&self, value: &serde_json::Value) -> Result<(), MiniAppError> {
214 let obj = match value.as_object() {
215 Some(o) => o,
216 None => {
217 return Err(MiniAppError::Validation {
218 field: "(root)".to_string(),
219 reason: "value must be a JSON object".to_string(),
220 });
221 }
222 };
223
224 for field in &self.fields {
225 let field_value = obj.get(&field.name);
226
227 match field_value {
228 None | Some(serde_json::Value::Null) => {
229 if field.required {
230 return Err(MiniAppError::Validation {
231 field: field.name.clone(),
232 reason: "required field missing".to_string(),
233 });
234 }
235 }
237 Some(v) => {
238 if !field.ty.matches(v) {
239 return Err(MiniAppError::Validation {
240 field: field.name.clone(),
241 reason: format!(
242 "expected type '{}', got '{}'",
243 field.ty.as_str(),
244 json_type_name(v)
245 ),
246 });
247 }
248 }
249 }
250 }
251
252 Ok(())
253 }
254}
255
256fn json_type_name(v: &serde_json::Value) -> &'static str {
260 match v {
261 serde_json::Value::Null => "null",
262 serde_json::Value::Bool(_) => "boolean",
263 serde_json::Value::Number(_) => "number",
264 serde_json::Value::String(_) => "string",
265 serde_json::Value::Array(_) => "array",
266 serde_json::Value::Object(_) => "object",
267 }
268}
269
270pub fn load_from_path(path: &Path) -> Result<SchemaConfig, MiniAppError> {
292 let file = File::open(path)?;
293 let config: SchemaConfig =
294 serde_yaml_bw::from_reader(file).map_err(|e| MiniAppError::Schema(e.to_string()))?;
295 Ok(config)
296}
297
298#[cfg(test)]
299mod tests {
300 use super::*;
301 use std::io::Write;
302 use std::path::PathBuf;
303 use tempfile::{NamedTempFile, TempDir};
304
305 fn write_yaml(content: &str) -> NamedTempFile {
307 let mut f = NamedTempFile::new().expect("temp file creation is infallible in tests");
308 f.write_all(content.as_bytes())
309 .expect("writing to temp file is infallible in tests");
310 f
311 }
312
313 #[test]
314 fn history_mode_defaults_to_full_when_absent() {
315 let f = write_yaml("table: t\nfields:\n- name: a\n type: string\n required: true\n");
316 let schema = load_from_path(f.path()).expect("valid YAML must parse");
317 assert_eq!(schema.history, HistoryMode::Full);
318 assert!(schema.history.enabled());
319 }
320
321 #[test]
322 fn history_mode_off_parses_from_yaml() {
323 let f = write_yaml(
324 "table: t\nhistory: off\nfields:\n- name: a\n type: string\n required: true\n",
325 );
326 let schema = load_from_path(f.path()).expect("valid YAML must parse");
327 assert_eq!(schema.history, HistoryMode::Off);
328 assert!(!schema.history.enabled());
329 }
330
331 fn make_test_schema() -> SchemaConfig {
333 SchemaConfig {
334 table: "items".to_string(),
335 title: None,
336 description: None,
337 fields: vec![
338 FieldDef {
339 name: "name".to_string(),
340 ty: FieldType::String,
341 required: true,
342 description: None,
343 },
344 FieldDef {
345 name: "count".to_string(),
346 ty: FieldType::Number,
347 required: false,
348 description: None,
349 },
350 ],
351 dump: None,
352 history: Default::default(),
353 }
354 }
355
356 #[test]
359 fn load_valid_schema_yaml() {
360 let yaml = r#"
361table: issues
362fields:
363 - name: title
364 type: string
365 required: true
366 - name: state
367 type: string
368 required: false
369 - name: tags
370 type: array
371 required: false
372"#;
373 let f = write_yaml(yaml);
374 let schema = load_from_path(f.path()).expect("valid YAML must parse");
375 assert_eq!(schema.table, "issues");
376 assert_eq!(schema.fields.len(), 3);
377 assert_eq!(schema.fields[0].name, "title");
378 assert!(schema.fields[0].required);
379 assert_eq!(schema.fields[0].ty, FieldType::String);
380 assert!(!schema.fields[1].required);
381 assert_eq!(schema.fields[2].ty, FieldType::Array);
382 }
383
384 #[test]
385 fn validate_happy_path_all_fields_present() {
386 let schema = SchemaConfig {
387 table: "issues".to_string(),
388 title: None,
389 description: None,
390 fields: vec![
391 FieldDef {
392 name: "title".to_string(),
393 ty: FieldType::String,
394 required: true,
395 description: None,
396 },
397 FieldDef {
398 name: "count".to_string(),
399 ty: FieldType::Number,
400 required: false,
401 description: None,
402 },
403 ],
404 dump: None,
405 history: Default::default(),
406 };
407 let value = serde_json::json!({ "title": "hello", "count": 42 });
408 assert!(schema.validate(&value).is_ok());
409 }
410
411 #[test]
412 fn validate_optional_field_absent_is_ok() {
413 let schema = SchemaConfig {
414 table: "t".to_string(),
415 title: None,
416 description: None,
417 fields: vec![FieldDef {
418 name: "tags".to_string(),
419 ty: FieldType::Array,
420 required: false,
421 description: None,
422 }],
423 dump: None,
424 history: Default::default(),
425 };
426 let value = serde_json::json!({});
427 assert!(schema.validate(&value).is_ok());
428 }
429
430 #[test]
431 fn validate_unknown_fields_are_accepted() {
432 let schema = SchemaConfig {
434 table: "t".to_string(),
435 title: None,
436 description: None,
437 fields: vec![FieldDef {
438 name: "title".to_string(),
439 ty: FieldType::String,
440 required: true,
441 description: None,
442 }],
443 dump: None,
444 history: Default::default(),
445 };
446 let value = serde_json::json!({ "title": "hi", "extra_key": 99 });
447 assert!(schema.validate(&value).is_ok());
448 }
449
450 #[test]
453 fn validate_null_value_for_required_field_is_error() {
454 let schema = SchemaConfig {
455 table: "t".to_string(),
456 title: None,
457 description: None,
458 fields: vec![FieldDef {
459 name: "title".to_string(),
460 ty: FieldType::String,
461 required: true,
462 description: None,
463 }],
464 dump: None,
465 history: Default::default(),
466 };
467 let value = serde_json::json!({ "title": null });
468 let err = schema
469 .validate(&value)
470 .expect_err("null required field must error");
471 match err {
472 MiniAppError::Validation { field, .. } => assert_eq!(field, "title"),
473 other => panic!("expected Validation, got {:?}", other),
474 }
475 }
476
477 #[test]
478 fn validate_null_value_for_optional_field_is_ok() {
479 let schema = SchemaConfig {
480 table: "t".to_string(),
481 title: None,
482 description: None,
483 fields: vec![FieldDef {
484 name: "state".to_string(),
485 ty: FieldType::String,
486 required: false,
487 description: None,
488 }],
489 dump: None,
490 history: Default::default(),
491 };
492 let value = serde_json::json!({ "state": null });
493 assert!(schema.validate(&value).is_ok());
494 }
495
496 #[test]
497 fn validate_empty_object_with_no_required_fields() {
498 let schema = SchemaConfig {
499 table: "t".to_string(),
500 title: None,
501 description: None,
502 fields: vec![],
503 dump: None,
504 history: Default::default(),
505 };
506 let value = serde_json::json!({});
507 assert!(schema.validate(&value).is_ok());
508 }
509
510 #[test]
511 fn validate_non_object_root_is_error() {
512 let schema = SchemaConfig {
513 table: "t".to_string(),
514 title: None,
515 description: None,
516 fields: vec![],
517 dump: None,
518 history: Default::default(),
519 };
520 let value = serde_json::json!([1, 2, 3]);
521 let err = schema.validate(&value).expect_err("array root must error");
522 assert!(matches!(err, MiniAppError::Validation { .. }));
523 }
524
525 #[test]
528 fn validate_required_field_missing_returns_validation_error() {
529 let schema = SchemaConfig {
530 table: "t".to_string(),
531 title: None,
532 description: None,
533 fields: vec![FieldDef {
534 name: "title".to_string(),
535 ty: FieldType::String,
536 required: true,
537 description: None,
538 }],
539 dump: None,
540 history: Default::default(),
541 };
542 let value = serde_json::json!({});
543 let err = schema
544 .validate(&value)
545 .expect_err("missing required field must error");
546 match err {
547 MiniAppError::Validation { field, reason } => {
548 assert_eq!(field, "title");
549 assert!(reason.contains("required"));
550 }
551 other => panic!("expected Validation, got {:?}", other),
552 }
553 }
554
555 #[test]
556 fn validate_type_mismatch_string_vs_number() {
557 let schema = SchemaConfig {
558 table: "t".to_string(),
559 title: None,
560 description: None,
561 fields: vec![FieldDef {
562 name: "score".to_string(),
563 ty: FieldType::Number,
564 required: true,
565 description: None,
566 }],
567 dump: None,
568 history: Default::default(),
569 };
570 let value = serde_json::json!({ "score": "not-a-number" });
571 let err = schema
572 .validate(&value)
573 .expect_err("type mismatch must error");
574 match err {
575 MiniAppError::Validation { field, reason } => {
576 assert_eq!(field, "score");
577 assert!(
578 reason.contains("number"),
579 "reason should mention expected type"
580 );
581 assert!(
582 reason.contains("string"),
583 "reason should mention actual type"
584 );
585 }
586 other => panic!("expected Validation, got {:?}", other),
587 }
588 }
589
590 #[test]
591 fn validate_type_mismatch_boolean_field() {
592 let schema = SchemaConfig {
593 table: "t".to_string(),
594 title: None,
595 description: None,
596 fields: vec![FieldDef {
597 name: "active".to_string(),
598 ty: FieldType::Boolean,
599 required: true,
600 description: None,
601 }],
602 dump: None,
603 history: Default::default(),
604 };
605 let value = serde_json::json!({ "active": 1 });
606 let err = schema.validate(&value).expect_err("number is not boolean");
607 assert!(matches!(err, MiniAppError::Validation { .. }));
608 }
609
610 #[test]
611 fn validate_type_mismatch_array_field() {
612 let schema = SchemaConfig {
613 table: "t".to_string(),
614 title: None,
615 description: None,
616 fields: vec![FieldDef {
617 name: "tags".to_string(),
618 ty: FieldType::Array,
619 required: true,
620 description: None,
621 }],
622 dump: None,
623 history: Default::default(),
624 };
625 let value = serde_json::json!({ "tags": "not-an-array" });
626 let err = schema.validate(&value).expect_err("string is not array");
627 assert!(matches!(err, MiniAppError::Validation { .. }));
628 }
629
630 #[test]
631 fn load_from_nonexistent_path_returns_io_error() {
632 let result = load_from_path(Path::new("/nonexistent/path/schema.yaml"));
633 let err = result.expect_err("missing file must error");
634 assert!(
635 matches!(err, MiniAppError::Io(_)),
636 "expected Io error, got {:?}",
637 err
638 );
639 }
640
641 #[test]
642 fn load_from_malformed_yaml_returns_schema_error() {
643 let yaml = "table: [\ninvalid yaml {{{\n";
644 let f = write_yaml(yaml);
645 let result = load_from_path(f.path());
646 let err = result.expect_err("malformed YAML must error");
647 assert!(
648 matches!(err, MiniAppError::Schema(_)),
649 "expected Schema error, got {:?}",
650 err
651 );
652 }
653
654 #[test]
655 fn yaml_with_dump_section_deserializes() {
656 let yaml = r#"
657table: issues
658fields:
659 - name: title
660 type: string
661 required: true
662dump:
663 dir: /tmp/test-dump
664 title_field: title
665 body_field: body
666 sync: write-only
667"#;
668 let f = write_yaml(yaml);
669 let schema = load_from_path(f.path()).expect("valid YAML with dump must parse");
670 assert_eq!(schema.table, "issues");
671 let dump = schema.dump.expect("dump must be Some");
672 assert_eq!(dump.title_field.as_deref(), Some("title"));
673 assert_eq!(dump.body_field.as_deref(), Some("body"));
674 assert_eq!(dump.sync, Some(crate::dump::SyncMode::WriteOnly));
675 }
676
677 #[test]
678 fn yaml_without_dump_section_yields_none() {
679 let yaml = r#"
680table: issues
681fields:
682 - name: title
683 type: string
684 required: true
685"#;
686 let f = write_yaml(yaml);
687 let schema = load_from_path(f.path()).expect("valid YAML without dump must parse");
688 assert!(
689 schema.dump.is_none(),
690 "dump must be None when section is absent"
691 );
692 }
693
694 #[test]
695 fn yaml_with_bidirectional_sync_deserializes() {
696 let yaml = r#"
697table: tasks
698fields: []
699dump:
700 sync: bidirectional
701"#;
702 let f = write_yaml(yaml);
703 let schema = load_from_path(f.path()).expect("yaml with bidirectional must parse");
704 let dump = schema.dump.expect("dump must be Some");
705 assert_eq!(dump.sync, Some(crate::dump::SyncMode::Bidirectional));
706 }
707
708 #[test]
709 fn all_field_types_match_correctly() {
710 let cases: Vec<(FieldType, serde_json::Value, bool)> = vec![
711 (FieldType::String, serde_json::json!("hello"), true),
712 (FieldType::String, serde_json::json!(42), false),
713 (FieldType::Number, serde_json::json!(2.5), true),
714 (FieldType::Number, serde_json::json!("3.14"), false),
715 (FieldType::Boolean, serde_json::json!(true), true),
716 (FieldType::Boolean, serde_json::json!(0), false),
717 (FieldType::Array, serde_json::json!([1, 2]), true),
718 (FieldType::Array, serde_json::json!({}), false),
719 (FieldType::Object, serde_json::json!({}), true),
720 (FieldType::Object, serde_json::json!([]), false),
721 ];
722 for (ty, value, expected) in cases {
723 assert_eq!(
724 ty.matches(&value),
725 expected,
726 "FieldType::{} .matches({:?}) should be {}",
727 ty.as_str(),
728 value,
729 expected
730 );
731 }
732 }
733
734 #[tokio::test]
736 async fn write_to_path_round_trips_via_load_from_path() {
737 let dir = TempDir::new().expect("temp dir creation is infallible in tests");
738 let schema_path = dir.path().join("schema.yaml");
739 let original = make_test_schema();
740
741 original
742 .write_to_path(&schema_path)
743 .await
744 .expect("write_to_path must succeed");
745
746 let loaded = load_from_path(&schema_path).expect("load_from_path must succeed");
747
748 assert_eq!(loaded.table, original.table);
749 assert_eq!(loaded.fields.len(), original.fields.len());
750 for (l, r) in loaded.fields.iter().zip(original.fields.iter()) {
751 assert_eq!(l.name, r.name);
752 assert_eq!(l.ty, r.ty);
753 assert_eq!(l.required, r.required);
754 }
755 assert!(loaded.dump.is_none());
756 }
757
758 #[tokio::test]
764 async fn write_to_path_uses_tmp_then_rename() {
765 let dir = TempDir::new().expect("temp dir creation is infallible in tests");
766 let schema_path = dir.path().join("schema.yaml");
767 let schema = make_test_schema();
768
769 schema
770 .write_to_path(&schema_path)
771 .await
772 .expect("write_to_path must succeed");
773
774 assert!(schema_path.exists(), "final schema.yaml must exist");
776 let mut tmp_path = PathBuf::from(&schema_path);
778 let mut file_name = tmp_path
779 .file_name()
780 .map(|n| n.to_os_string())
781 .unwrap_or_default();
782 file_name.push(".tmp");
783 tmp_path.set_file_name(file_name);
784 assert!(
785 !tmp_path.exists(),
786 ".tmp file must not exist after successful write"
787 );
788 }
789
790 #[tokio::test]
792 async fn write_to_path_missing_parent_returns_io_error() {
793 let schema = make_test_schema();
794 let result = schema
795 .write_to_path(Path::new("/nonexistent/deep/path/schema.yaml"))
796 .await;
797 let err = result.expect_err("write to missing dir must error");
798 assert!(
799 matches!(err, MiniAppError::Io(_)),
800 "expected Io error, got {:?}",
801 err
802 );
803 }
804
805 #[test]
806 fn yaml_with_title_and_description_deserializes() {
807 let yaml = r#"
808table: closet_snap
809title: Closet Snap
810description: |
811 Persona の今日その瞬間の自分を保存する情緒 snapshot。
812 outfit という domain-specific な snap で記録する。
813fields:
814 - name: date
815 type: string
816 required: true
817"#;
818 let f = write_yaml(yaml);
819 let schema =
820 load_from_path(f.path()).expect("valid YAML with title/description must parse");
821 assert_eq!(schema.table, "closet_snap");
822 assert_eq!(
823 schema.title.as_deref(),
824 Some("Closet Snap"),
825 "title must be Some(\"Closet Snap\")"
826 );
827 assert!(
828 schema
829 .description
830 .as_deref()
831 .unwrap_or("")
832 .contains("Persona"),
833 "description must be Some and contain 'Persona'"
834 );
835 }
836
837 #[test]
838 fn yaml_without_title_section_yields_none() {
839 let yaml = r#"
840table: issues
841fields:
842 - name: title
843 type: string
844 required: true
845"#;
846 let f = write_yaml(yaml);
847 let schema =
848 load_from_path(f.path()).expect("valid YAML without title/description must parse");
849 assert!(
850 schema.title.is_none(),
851 "title must be None when section is absent"
852 );
853 assert!(
854 schema.description.is_none(),
855 "description must be None when section is absent"
856 );
857 }
858
859 #[test]
860 fn field_def_with_description_round_trips() {
861 let yaml = r#"
862table: snap
863fields:
864 - name: date
865 type: string
866 required: true
867 description: ISO date (YYYY-MM-DD)
868 - name: mood
869 type: string
870 required: false
871"#;
872 let f = write_yaml(yaml);
873 let schema =
874 load_from_path(f.path()).expect("valid YAML with field description must parse");
875 assert_eq!(schema.fields.len(), 2);
876 assert_eq!(
877 schema.fields[0].description.as_deref(),
878 Some("ISO date (YYYY-MM-DD)"),
879 "field description must round-trip"
880 );
881 assert!(
882 schema.fields[1].description.is_none(),
883 "absent field description must be None"
884 );
885 }
886}