patchloom 0.34.0

Structured file editing library and CLI for AI agents: parser-backed JSON/YAML/TOML edits, AST-aware code operations, multi-file batching, markdown operations, and MCP server
Documentation
//! Document operations (JSON, YAML, TOML) for the public library API.
//!
//! Write functions delegate to the tx engine via `execute_as_edit_result`,
//! sharing the same code path as CLI and MCP. Read functions (doc_get,
//! doc_has, doc_keys, doc_len) load and query directly.
//!
//! Re-exported from api:: via `mod doc; pub use self::doc::*;`.

use std::path::Path;

use crate::containment::PathGuard;
use crate::ops;
use crate::ops::doc::query::{QueryResult, query_get, query_has};
use crate::plan::Operation;

use super::{ApplyMode, EditResult};

/// Load and parse a JSON/YAML/TOML file for read-only queries.
///
/// Load first (same order as CLI `load_file`) so a missing path peels as
/// `not_found` even when the extension is absent or unsupported.
fn load_doc_value(path: &Path) -> anyhow::Result<serde_json::Value> {
    ops::doc::load_for_query(path)
}

/// Unified write path: delegates to the tx engine when available (cli/files),
/// falls back to direct mutation when the tx module is not compiled in.
#[cfg(any(feature = "cli", feature = "files"))]
fn doc_write(
    op: Operation,
    path: &Path,
    mode: ApplyMode,
    guard: Option<&PathGuard>,
    action: &'static str,
) -> anyhow::Result<EditResult> {
    let abs = super::library_abs_path(path, guard)?;
    let mut op = op;
    rewrite_op_path(&mut op, &super::library_op_path(path, &abs, guard));
    let display = path.to_string_lossy();
    super::execute_as_edit_result_with_path(
        op,
        mode,
        super::library_project_root(&abs, guard),
        guard,
        action,
        None,
        Some(display.as_ref()),
    )
}

/// Put the engine dest on doc ops: caller spelling under a guard, abs otherwise.
#[cfg(any(feature = "cli", feature = "files"))]
fn rewrite_op_path(op: &mut Operation, dest: &str) {
    match op {
        Operation::DocSet { path, .. }
        | Operation::DocDelete { path, .. }
        | Operation::DocMerge { path, .. }
        | Operation::DocAppend { path, .. }
        | Operation::DocPrepend { path, .. }
        | Operation::DocUpdate { path, .. }
        | Operation::DocMove { path, .. }
        | Operation::DocEnsure { path, .. }
        | Operation::DocDeleteWhere { path, .. } => {
            *path = dest.into();
        }
        _ => {}
    }
}

#[cfg(not(any(feature = "cli", feature = "files")))]
fn doc_write(
    op: Operation,
    path: &Path,
    mode: ApplyMode,
    guard: Option<&PathGuard>,
    action: &'static str,
) -> anyhow::Result<EditResult> {
    use crate::ops::doc::MutationResult;
    use crate::write::WritePolicy;

    // Extract the mutation from the operation.
    let (_, mutation) = crate::plan::op_to_doc_mutation(&op)
        .ok_or_else(|| anyhow::anyhow!("doc_write called with non-doc operation"))?;

    let display = path.to_string_lossy().into_owned();
    let path_owned = super::library_abs_path(path, guard)?;
    let path = path_owned.as_path();
    let path_str = display;
    let format = ops::doc::detect_format(&path_str)?;
    let if_exists_set = matches!(
        op,
        Operation::DocSet {
            if_exists: true,
            ..
        }
    );
    let original = match crate::files::load_text_strict(path, &path_str) {
        Ok(s) => s,
        Err(e) if if_exists_set && crate::exit::is_io_not_found(&e) => {
            return Ok(super::build_edit_result(
                &path_str,
                String::new(),
                String::new(),
                false,
                action,
                None,
            ));
        }
        Err(e) => return Err(e),
    };
    let value = ops::doc::parse_doc(&original, &format)?;
    if if_exists_set
        && let Operation::DocSet { selector, .. } = &op
        && !ops::doc::query::query_has(&value, selector)?
    {
        return Ok(super::build_edit_result(
            &path_str,
            original.clone(),
            original,
            false,
            action,
            None,
        ));
    }
    let mut new_value = value.clone();

    let result = ops::doc::apply_doc_mutation(&mut new_value, mutation)?;
    if let MutationResult::TypeError(msg) = result {
        return Err(anyhow::Error::new(crate::exit::TypeErrorError { msg }));
    }
    let removed = match &result {
        MutationResult::Removed(n) => *n,
        MutationResult::NoMatch if matches!(action, "doc.delete" | "doc.delete_where") => 0,
        _ => 0,
    };

    let new_content = ops::doc::serialize_value_preserving(&original, &value, &new_value, &format)?;
    let policy = WritePolicy::default();
    // Do not write (or report applied) when the mutation is a no-op.
    let content_changed = original != new_content;
    let (applied, backup_session) = if content_changed {
        super::write_if_apply(path, &new_content, mode, &policy, guard)?
    } else {
        (false, None)
    };
    let mut edit =
        super::build_edit_result(&path_str, original, new_content, applied, action, None);
    edit.removed = removed;
    edit.backup_session = backup_session;
    Ok(edit)
}

/// Set a value at a selector path in a JSON, YAML, or TOML file.
///
/// The file format is detected from the extension. The selector uses
/// patchloom's selector syntax (e.g., `"database.host"`, `"items[0].name"`).
pub fn doc_set(
    path: &Path,
    selector: &str,
    value: serde_json::Value,
    mode: ApplyMode,
    guard: Option<&PathGuard>,
) -> anyhow::Result<EditResult> {
    let op = Operation::DocSet {
        path: path.to_string_lossy().into(),
        selector: selector.into(),
        value,
        if_exists: false,
    };
    doc_write(op, path, mode, guard, "doc.set")
}

/// Delete a value at a selector path in a JSON, YAML, or TOML file.
pub fn doc_delete(
    path: &Path,
    selector: &str,
    mode: ApplyMode,
    guard: Option<&PathGuard>,
) -> anyhow::Result<EditResult> {
    let op = Operation::DocDelete {
        path: path.to_string_lossy().into(),
        selector: selector.into(),
    };
    doc_write(op, path, mode, guard, "doc.delete")
}

/// Deep-merge a value into a JSON, YAML, or TOML file.
///
/// When `selector` is [`None`], merges into the document root (single-document
/// files). For multi-document YAML (top-level array of documents), pass
/// `Some("0")` or `Some("[0]")` to merge into the first document without
/// replacing the whole stream. Merging a non-array overlay into a multi-doc
/// **root** returns [`crate::exit::TypeErrorError`] (peels to
/// [`crate::fallback::EditErrorKind::TypeError`]).
///
/// # Example
///
/// ```rust,no_run
/// use patchloom::api::{self, ApplyMode};
/// use std::path::Path;
///
/// // Root merge (single-doc JSON/YAML/TOML)
/// let _ = api::doc_merge(
///     Path::new("config.json"),
///     serde_json::json!({"debug": true}),
///     ApplyMode::Apply,
///     None,
///     None,
/// )?;
///
/// // Multi-doc YAML: merge into document 0 only
/// let _ = api::doc_merge(
///     Path::new("stream.yaml"),
///     serde_json::json!({"c": 3}),
///     ApplyMode::Apply,
///     None,
///     Some("0"),
/// )?;
/// # Ok::<(), anyhow::Error>(())
/// ```
pub fn doc_merge(
    path: &Path,
    value: serde_json::Value,
    mode: ApplyMode,
    guard: Option<&PathGuard>,
    selector: Option<&str>,
) -> anyhow::Result<EditResult> {
    let op = Operation::DocMerge {
        path: path.to_string_lossy().into(),
        selector: selector.map(|s| s.into()),
        value,
    };
    doc_write(op, path, mode, guard, "doc.merge")
}

/// Get a value at a selector path from a JSON, YAML, or TOML file.
///
/// Load-first: a missing file peels as `not_found`.
pub fn doc_get(path: &Path, selector: &str) -> anyhow::Result<serde_json::Value> {
    let value = load_doc_value(path)?;

    match query_get(&value, selector)? {
        QueryResult::NoMatch => Err(crate::exit::NoMatchError {
            msg: crate::ops::doc::query::with_similar_object_key_hint(
                format!("selector '{selector}' matched nothing"),
                &value,
                selector,
            ),
        }
        .into()),
        QueryResult::Values(vals) if vals.len() == 1 => Ok(vals
            .into_iter()
            .next()
            .expect("len()==1 guarantees element")),
        QueryResult::Values(vals) => Ok(serde_json::Value::Array(vals)),
    }
}

/// Check whether a selector path exists in a JSON, YAML, or TOML file.
pub fn doc_has(path: &Path, selector: &str) -> anyhow::Result<bool> {
    let value = load_doc_value(path)?;
    query_has(&value, selector)
}

/// List object keys at a selector path in a JSON, YAML, or TOML file.
///
/// Pass one object (`database`). Empty / `"."` lists keys of the document
/// root. An array target (`items`) is [`crate::exit::TypeErrorError`] (use
/// `items[0]` for one object, or [`doc_len`] on `items`). A missing selector
/// is [`crate::exit::NoMatchError`]. A wildcard or predicate (`items[*]`) is
/// [`crate::exit::AmbiguousError`] even on 0 or 1 match, and names
/// `items[0]` / `items[1]`. A missing file peels as `not_found`.
pub fn doc_keys(path: &Path, selector: &str) -> anyhow::Result<Vec<String>> {
    let value = load_doc_value(path)?;
    crate::ops::doc::query::keys_at(&value, selector)
}

/// Count items in an array or object at a selector path (`items`, `database`).
///
/// Empty / `"."` means the document root. A scalar (or other non-container)
/// is [`crate::exit::TypeErrorError`]. A missing selector is
/// [`crate::exit::NoMatchError`]. A wildcard or predicate (`items[*]`) is
/// [`crate::exit::AmbiguousError`] even on 0 or 1 match, and names
/// `items[0]` / `items[1]`. A missing file peels as `not_found`.
pub fn doc_len(path: &Path, selector: &str) -> anyhow::Result<usize> {
    let value = load_doc_value(path)?;
    crate::ops::doc::query::len_at(&value, selector)
}

/// Append a value to an array at a selector path.
pub fn doc_append(
    path: &Path,
    selector: &str,
    value: serde_json::Value,
    mode: ApplyMode,
    guard: Option<&PathGuard>,
) -> anyhow::Result<EditResult> {
    let op = Operation::DocAppend {
        path: path.to_string_lossy().into(),
        selector: selector.into(),
        value,
    };
    doc_write(op, path, mode, guard, "doc.append")
}

/// Prepend a value to an array at a selector path.
pub fn doc_prepend(
    path: &Path,
    selector: &str,
    value: serde_json::Value,
    mode: ApplyMode,
    guard: Option<&PathGuard>,
) -> anyhow::Result<EditResult> {
    let op = Operation::DocPrepend {
        path: path.to_string_lossy().into(),
        selector: selector.into(),
        value,
    };
    doc_write(op, path, mode, guard, "doc.prepend")
}

/// Update all values matching a selector with a new value.
///
/// Returns an `EditResult`. The number of matches updated is reflected in
/// whether the content changed.
pub fn doc_update(
    path: &Path,
    selector: &str,
    value: serde_json::Value,
    mode: ApplyMode,
    guard: Option<&PathGuard>,
) -> anyhow::Result<EditResult> {
    let op = Operation::DocUpdate {
        path: path.to_string_lossy().into(),
        selector: selector.into(),
        value,
    };
    doc_write(op, path, mode, guard, "doc.update")
}

/// Ensure a value exists at a selector path; set it only if missing.
pub fn doc_ensure(
    path: &Path,
    selector: &str,
    value: serde_json::Value,
    mode: ApplyMode,
    guard: Option<&PathGuard>,
) -> anyhow::Result<EditResult> {
    let op = Operation::DocEnsure {
        path: path.to_string_lossy().into(),
        selector: selector.into(),
        value,
    };
    doc_write(op, path, mode, guard, "doc.ensure")
}

/// Delete array elements matching a predicate (e.g., `"name=old"`).
pub fn doc_delete_where(
    path: &Path,
    selector: &str,
    predicate: &str,
    mode: ApplyMode,
    guard: Option<&PathGuard>,
) -> anyhow::Result<EditResult> {
    let op = Operation::DocDeleteWhere {
        path: path.to_string_lossy().into(),
        selector: selector.into(),
        predicate: predicate.into(),
    };
    doc_write(op, path, mode, guard, "doc.delete_where")
}

/// Move a value from one selector path to another within the same file.
pub fn doc_move(
    path: &Path,
    from_selector: &str,
    to_selector: &str,
    mode: ApplyMode,
    guard: Option<&PathGuard>,
) -> anyhow::Result<EditResult> {
    let op = Operation::DocMove {
        path: path.to_string_lossy().into(),
        from: from_selector.into(),
        to: to_selector.into(),
    };
    doc_write(op, path, mode, guard, "doc.move")
}