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    /// Row-history recording mode for this table.
115    ///
116    /// `full` (default) records every create/update/delete into `_row_history`
117    /// (with automatic compressed archive roll — see `row_history` module).
118    /// `off` disables history recording entirely; intended for automated
119    /// high-frequency writers (log-cache style tables) where per-write history
120    /// has no recovery value and only accumulates disk.
121    #[serde(default)]
122    pub history: HistoryMode,
123}
124
125/// Row-history recording mode declared in `schema.yaml` (`history:` key).
126#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
127#[serde(rename_all = "lowercase")]
128pub enum HistoryMode {
129    /// Record every mutation (default).
130    #[default]
131    Full,
132    /// Do not record history for this table.
133    Off,
134}
135
136impl HistoryMode {
137    /// Whether mutations on this table should be recorded into `_row_history`.
138    pub fn enabled(self) -> bool {
139        matches!(self, HistoryMode::Full)
140    }
141}
142
143impl SchemaConfig {
144    /// Writes this schema to a YAML file using an atomic tmp+rename strategy.
145    ///
146    /// The write is performed inside `tokio::task::spawn_blocking` to avoid
147    /// blocking the async executor (K-110).  The rename is performed with
148    /// `std::fs::rename`, which is atomic on the same filesystem on Linux/macOS
149    /// (POSIX `rename(2)` guarantee).
150    ///
151    /// # Algorithm
152    /// 1. Serialise `self` to a YAML string via `serde_yaml_bw::to_string`.
153    /// 2. Write to `<path>.tmp` (same directory, so same filesystem).
154    /// 3. Atomically rename `<path>.tmp` to `<path>`.
155    ///
156    /// # Arguments
157    /// - `path`: destination path for `schema.yaml` (the final file, not `.tmp`).
158    ///
159    /// # Returns
160    /// `Ok(())` on success.
161    ///
162    /// # Errors
163    /// - [`MiniAppError::Schema`] if serialisation fails.
164    /// - [`MiniAppError::Io`] if the write or rename fails.
165    /// - [`MiniAppError::Backup`] if the `spawn_blocking` task panics.
166    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            // Append ".tmp" to the file name to stay on the same filesystem.
176            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    /// Validates a JSON object against this schema.
193    ///
194    /// Rules (applied in order, iterating over [`self.fields`]):
195    ///
196    /// 1. If `field.required` is `true` and the key is absent from `value`
197    ///    (or its value is `null`), return
198    ///    [`MiniAppError::Validation`] with `reason = "required field missing"`.
199    /// 2. If the key is present and non-null but its JSON type does not match
200    ///    `field.ty`, return [`MiniAppError::Validation`] with a descriptive
201    ///    `reason`.
202    /// 3. Unknown keys (present in `value` but not in `self.fields`) are
203    ///    silently accepted — Agent-First extensibility.
204    ///
205    /// # Arguments
206    /// - `value`: the JSON object to validate. Must be a
207    ///   [`serde_json::Value::Object`]; if it is not, a `Validation` error is
208    ///   returned immediately.
209    ///
210    /// # Errors
211    /// Returns [`MiniAppError::Validation`] on the first validation failure
212    /// encountered.
213    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                    // optional and absent/null — OK
236                }
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
256/// Returns a human-readable JSON type name for a [`serde_json::Value`].
257///
258/// Used in validation error messages to describe the actual type received.
259fn 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
270/// Loads and parses a `schema.yaml` file from the given path.
271///
272/// The YAML file must conform to the following structure:
273/// ```yaml
274/// table: <table_name>
275/// fields:
276///   - name: <field_name>
277///     type: string|number|boolean|array|object
278///     required: true|false   # optional, defaults to false
279/// ```
280///
281/// # Arguments
282/// - `path`: filesystem path to the `schema.yaml` file.
283///
284/// # Returns
285/// A fully-parsed [`SchemaConfig`] on success.
286///
287/// # Errors
288/// - [`MiniAppError::Io`] if the file cannot be opened.
289/// - [`MiniAppError::Schema`] if the YAML is malformed or structurally
290///   invalid (e.g. missing `table` or `fields` keys).
291pub 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    /// Helper: write YAML text to a temp file and return its path.
306    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    /// Helper: build a simple SchemaConfig for write_to_path tests.
332    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    // ── T1: happy-path tests ──────────────────────────────────────────────
357
358    #[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        // Crux #1: Agent-First extensibility — extra keys must not cause errors.
433        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    // ── T2: boundary / edge cases ────────────────────────────────────────
451
452    #[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    // ── T3: error-path tests ─────────────────────────────────────────────
526
527    #[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    // T1: write_to_path round-trips via load_from_path
735    #[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    // T2: write_to_path uses tmp+rename — no partial file visible on simulated error
759    //
760    // This test verifies that the .tmp file does NOT persist after a successful
761    // write.  A failed-write scenario (writing to a read-only path) verifies
762    // the output path is clean.
763    #[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        // After successful write, the final file exists...
775        assert!(schema_path.exists(), "final schema.yaml must exist");
776        // ...but the tmp file must have been cleaned up by rename.
777        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    // T3: write_to_path to a non-existent directory returns Io error
791    #[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}