noyalib 0.0.39

A pure Rust YAML library with zero unsafe code and full serde integration
Documentation
// SPDX-License-Identifier: MIT OR Apache-2.0
// Copyright (c) 2026 Noyalib. All rights reserved.

//! `Document::set_path` — parent-creating writes (#327, ADR-0009).
//!
//! A frontmatter writer setting `menu.visible: true` must not care
//! whether `menu:` exists yet. `set_path` creates each missing
//! mapping level through the oracle-guarded mutators, treats an
//! empty document as an empty root mapping, and refuses — leaving
//! the source byte-identical — when an existing segment resolves to
//! something it cannot descend through.

#![cfg(feature = "std")]

use noyalib::Value;
use noyalib::cst::parse_document;

fn set(src: &str, path: &str, value: &Value) -> Result<String, (noyalib::Error, String)> {
    let mut doc = parse_document(src).unwrap();
    match doc.set_path(path, value) {
        Ok(()) => Ok(doc.to_string()),
        Err(e) => Err((e, doc.to_string())),
    }
}

#[test]
fn creates_one_missing_level() {
    let out = set("title: x\n", "menu.visible", &Value::Bool(true)).unwrap();
    assert_eq!(out, "title: x\nmenu:\n  visible: true\n");
}

#[test]
fn creates_three_missing_levels() {
    let out = set("title: x\n", "a.b.c", &Value::Bool(true)).unwrap();
    assert_eq!(out, "title: x\na:\n  b:\n    c: true\n");
}

#[test]
fn empty_document_receives_its_first_key() {
    let out = set("", "menu.visible", &Value::Bool(true)).unwrap();
    assert_eq!(out, "menu:\n  visible: true\n");
}

#[test]
fn comment_only_document_keeps_its_header() {
    let out = set(
        "# generated by tooling\n",
        "menu.visible",
        &Value::Bool(true),
    )
    .unwrap();
    assert_eq!(out, "# generated by tooling\nmenu:\n  visible: true\n");
}

#[test]
fn bare_document_marker_is_preserved() {
    let out = set("---\n", "menu.visible", &Value::Bool(true)).unwrap();
    assert_eq!(out, "---\nmenu:\n  visible: true\n");
}

#[test]
fn missing_trailing_newline_gains_one_before_the_new_entry() {
    let out = set("title: x", "menu.visible", &Value::Bool(true)).unwrap();
    assert_eq!(out, "title: x\nmenu:\n  visible: true\n");
}

#[test]
fn existing_leaf_is_an_ordinary_upsert() {
    let out = set(
        "menu:\n  visible: false\n",
        "menu.visible",
        &Value::Bool(true),
    )
    .unwrap();
    assert_eq!(out, "menu:\n  visible: true\n");
}

#[test]
fn equal_value_leaves_the_bytes_alone() {
    let src = "menu:\n  visible: true\n";
    let out = set(src, "menu.visible", &Value::Bool(true)).unwrap();
    assert_eq!(out, src);
}

#[test]
fn new_levels_follow_the_document_indent_unit() {
    let out = set("top:\n    inner: 1\n", "menu.deep.flag", &Value::Bool(true)).unwrap();
    assert_eq!(
        out,
        "top:\n    inner: 1\nmenu:\n    deep:\n        flag: true\n"
    );
}

#[test]
fn collection_value_for_a_new_key_is_created() {
    let tags: Value = noyalib::from_str("[a, b]").unwrap();
    let out = set("title: x\n", "tags", &tags).unwrap();
    assert_eq!(out, "title: x\ntags:\n  - a\n  - b\n");
    let loaded: Value = noyalib::from_str(&out).unwrap();
    assert_eq!(loaded["tags"], tags);
}

#[test]
fn round_trip_loads_back_as_expected() {
    let out = set("title: x\n", "a.b.c", &Value::from(7_i64)).unwrap();
    let loaded: Value = noyalib::from_str(&out).unwrap();
    assert_eq!(loaded["a"]["b"]["c"], Value::from(7_i64));
    assert_eq!(loaded["title"].as_str(), Some("x"));
}

// ── refusals: source byte-identical ────────────────────────────────

#[test]
fn scalar_prefix_refuses_byte_identical() {
    let src = "title: x\n";
    let (err, out) = set(src, "title.x", &Value::Bool(true)).unwrap_err();
    assert_eq!(out, src);
    assert!(err.to_string().contains("non-mapping"), "got: {err}");
}

#[test]
fn explicit_null_root_refuses_byte_identical() {
    let src = "null\n";
    let (err, out) = set(src, "menu.visible", &Value::Bool(true)).unwrap_err();
    assert_eq!(out, src);
    assert!(
        err.to_string().contains("cannot be created here"),
        "got: {err}"
    );
}

#[test]
fn implicit_null_parent_refuses_with_guidance() {
    let src = "menu:\n";
    let (err, out) = set(src, "menu.visible", &Value::Bool(true)).unwrap_err();
    assert_eq!(out, src);
    assert!(err.to_string().contains("null value"), "got: {err}");
    assert!(err.to_string().contains("`set`"), "got: {err}");
}

#[test]
fn missing_sequence_index_refuses() {
    let src = "items:\n  - a\n";
    let (err, out) = set(src, "items[3].x", &Value::Bool(true)).unwrap_err();
    assert_eq!(out, src);
    assert!(err.to_string().contains("mappings only"), "got: {err}");
}

#[test]
fn flow_parent_creates_flow_members() {
    // A refusal until #338 (ADR-0011) brought single-line flow
    // mappings into the insert surface; the created parents adopt
    // the site's flow style. `cst_flow_inserts.rs` pins the details.
    let src = "a: {x: 1}\n";
    let out = set(src, "a.b.c", &Value::Bool(true)).unwrap();
    assert_eq!(out, "a: {x: 1, b: {c: true}}\n");
}

#[test]
fn wildcard_segment_refuses() {
    let src = "a: 1\n";
    let (err, out) = set(src, "a.*", &Value::Bool(true)).unwrap_err();
    assert_eq!(out, src);
    assert!(err.to_string().contains("wildcard"), "got: {err}");
}

#[test]
fn empty_path_refuses() {
    let src = "a: 1\n";
    let (err, out) = set(src, "", &Value::Bool(true)).unwrap_err();
    assert_eq!(out, src);
    assert!(err.to_string().contains("non-empty path"), "got: {err}");
}

#[test]
fn collection_value_over_an_existing_scalar_refuses() {
    // Growing an existing scalar into a collection is #328's scope;
    // set_path inherits set_value's refusal.
    let src = "tags: plain\n";
    let seq: Value = noyalib::from_str("[a]").unwrap();
    let (err, out) = set(src, "tags", &seq).unwrap_err();
    assert_eq!(out, src);
    assert!(err.to_string().contains("collection"), "got: {err}");
}

#[test]
fn descends_through_existing_sequence_items() {
    // The existing part of the path may cross sequence indices; only
    // the *missing* part must be mapping keys. Inserting into a
    // dash-line mapping is refused today (insert_entry_value's own
    // guard) — pin whichever way it lands: the document either gains
    // exactly the entry, or is untouched.
    let src = "items:\n  - name: a\n";
    match set(src, "items[0].extra", &Value::Bool(true)) {
        Ok(out) => {
            let loaded: Value = noyalib::from_str(&out).unwrap();
            assert_eq!(loaded["items"][0]["extra"], Value::Bool(true));
        }
        Err((_, out)) => assert_eq!(out, src),
    }
}