Skip to main content

mini_app_core/
schema.rs

1/// Schema definition types and runtime loading / validation for mini-app-mcp.
2///
3/// The YAML schema file (`schema.yaml`) is the **sole authority** for field
4/// definitions, type coercions, and required-field validation.  No field name
5/// is ever hard-coded in this module; all checks iterate over the parsed
6/// [`Vec<FieldDef>`] at runtime.
7use std::fs::File;
8use std::path::Path;
9
10use serde::{Deserialize, Serialize};
11
12use crate::error::MiniAppError;
13
14/// The type of a field as declared in `schema.yaml`.
15///
16/// Supported values in YAML: `string`, `number`, `boolean`, `array`, `object`.
17/// The type determines how an incoming JSON value is validated.
18#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
19#[serde(rename_all = "lowercase")]
20pub enum FieldType {
21    /// A UTF-8 string value.
22    String,
23    /// A JSON number (integer or floating-point).
24    Number,
25    /// A JSON boolean (`true` / `false`).
26    Boolean,
27    /// A JSON array.
28    Array,
29    /// A JSON object.
30    Object,
31}
32
33impl FieldType {
34    /// Returns a human-readable name for use in validation error messages.
35    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    /// Returns `true` if the given JSON [`serde_json::Value`] matches this
46    /// field type.
47    ///
48    /// `Value::Null` is always considered a type mismatch (callers handle the
49    /// `required=false` + null case before calling this).
50    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/// A single field definition parsed from `schema.yaml`.
62///
63/// # Fields
64/// - `name`: the field name as it appears in stored JSON rows.
65/// - `ty`: the expected JSON type.
66/// - `required`: if `true`, the field must be present and non-null in every
67///   row.
68/// - `description`: optional human-readable description of this field.
69#[derive(Debug, Clone, Deserialize, Serialize)]
70pub struct FieldDef {
71    /// Field name (arbitrary string — never hard-coded in application logic).
72    pub name: String,
73    /// Expected JSON type for this field.
74    #[serde(rename = "type")]
75    pub ty: FieldType,
76    /// Whether the field must be present in every row.
77    #[serde(default)]
78    pub required: bool,
79    /// Optional human-readable description of this field.
80    #[serde(default)]
81    pub description: Option<String>,
82}
83
84/// The parsed contents of a `schema.yaml` file.
85///
86/// This struct is the runtime representation of the schema and acts as the
87/// single source of truth for all validation decisions.  It is created once at
88/// daemon startup via [`load_from_path`] and passed to every CRUD operation.
89///
90/// # Fields
91/// - `table`: the SQLite table name (also used as a human-readable label).
92/// - `title`: optional human-readable title for the table (short summary).
93/// - `description`: optional long-form description for the table.
94/// - `fields`: ordered list of field definitions.
95/// - `dump`: optional write-only file-materialization configuration.
96#[derive(Debug, Clone, Deserialize, Serialize)]
97pub struct SchemaConfig {
98    /// The logical table name declared in `schema.yaml`.
99    pub table: String,
100    /// Optional human-readable title for the table (short summary, plain string).
101    #[serde(default)]
102    pub title: Option<String>,
103    /// Optional long-form description for the table. Plain string; CommonMark MAY be used by render tools (server stores it verbatim).
104    #[serde(default)]
105    pub description: Option<String>,
106    /// All field definitions, in declaration order.
107    pub fields: Vec<FieldDef>,
108    /// Optional dump / file-materialization configuration.
109    ///
110    /// When absent from `schema.yaml`, the field deserializes to `None` and
111    /// the dump feature is disabled entirely (backward-compatible default).
112    #[serde(default)]
113    pub dump: Option<crate::dump::DumpConfig>,
114}
115
116impl SchemaConfig {
117    /// Writes this schema to a YAML file using an atomic tmp+rename strategy.
118    ///
119    /// The write is performed inside `tokio::task::spawn_blocking` to avoid
120    /// blocking the async executor (K-110).  The rename is performed with
121    /// `std::fs::rename`, which is atomic on the same filesystem on Linux/macOS
122    /// (POSIX `rename(2)` guarantee).
123    ///
124    /// # Algorithm
125    /// 1. Serialise `self` to a YAML string via `serde_yaml_bw::to_string`.
126    /// 2. Write to `<path>.tmp` (same directory, so same filesystem).
127    /// 3. Atomically rename `<path>.tmp` to `<path>`.
128    ///
129    /// # Arguments
130    /// - `path`: destination path for `schema.yaml` (the final file, not `.tmp`).
131    ///
132    /// # Returns
133    /// `Ok(())` on success.
134    ///
135    /// # Errors
136    /// - [`MiniAppError::Schema`] if serialisation fails.
137    /// - [`MiniAppError::Io`] if the write or rename fails.
138    /// - [`MiniAppError::Backup`] if the `spawn_blocking` task panics.
139    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            // Append ".tmp" to the file name to stay on the same filesystem.
149            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    /// Validates a JSON object against this schema.
166    ///
167    /// Rules (applied in order, iterating over [`self.fields`]):
168    ///
169    /// 1. If `field.required` is `true` and the key is absent from `value`
170    ///    (or its value is `null`), return
171    ///    [`MiniAppError::Validation`] with `reason = "required field missing"`.
172    /// 2. If the key is present and non-null but its JSON type does not match
173    ///    `field.ty`, return [`MiniAppError::Validation`] with a descriptive
174    ///    `reason`.
175    /// 3. Unknown keys (present in `value` but not in `self.fields`) are
176    ///    silently accepted — Agent-First extensibility.
177    ///
178    /// # Arguments
179    /// - `value`: the JSON object to validate. Must be a
180    ///   [`serde_json::Value::Object`]; if it is not, a `Validation` error is
181    ///   returned immediately.
182    ///
183    /// # Errors
184    /// Returns [`MiniAppError::Validation`] on the first validation failure
185    /// encountered.
186    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                    // optional and absent/null — OK
209                }
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
229/// Returns a human-readable JSON type name for a [`serde_json::Value`].
230///
231/// Used in validation error messages to describe the actual type received.
232fn 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
243/// Loads and parses a `schema.yaml` file from the given path.
244///
245/// The YAML file must conform to the following structure:
246/// ```yaml
247/// table: <table_name>
248/// fields:
249///   - name: <field_name>
250///     type: string|number|boolean|array|object
251///     required: true|false   # optional, defaults to false
252/// ```
253///
254/// # Arguments
255/// - `path`: filesystem path to the `schema.yaml` file.
256///
257/// # Returns
258/// A fully-parsed [`SchemaConfig`] on success.
259///
260/// # Errors
261/// - [`MiniAppError::Io`] if the file cannot be opened.
262/// - [`MiniAppError::Schema`] if the YAML is malformed or structurally
263///   invalid (e.g. missing `table` or `fields` keys).
264pub 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    /// Helper: write YAML text to a temp file and return its path.
279    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    /// Helper: build a simple SchemaConfig for write_to_path tests.
287    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    // ── T1: happy-path tests ──────────────────────────────────────────────
311
312    #[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        // Crux #1: Agent-First extensibility — extra keys must not cause errors.
385        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    // ── T2: boundary / edge cases ────────────────────────────────────────
402
403    #[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    // ── T3: error-path tests ─────────────────────────────────────────────
473
474    #[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    // T1: write_to_path round-trips via load_from_path
678    #[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    // T2: write_to_path uses tmp+rename — no partial file visible on simulated error
702    //
703    // This test verifies that the .tmp file does NOT persist after a successful
704    // write.  A failed-write scenario (writing to a read-only path) verifies
705    // the output path is clean.
706    #[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        // After successful write, the final file exists...
718        assert!(schema_path.exists(), "final schema.yaml must exist");
719        // ...but the tmp file must have been cleaned up by rename.
720        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    // T3: write_to_path to a non-existent directory returns Io error
734    #[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}