Skip to main content

patchloom/api/
doc.rs

1//! Document operations (JSON, YAML, TOML) for the public library API.
2//!
3//! Write functions delegate to the tx engine via `execute_as_edit_result`,
4//! sharing the same code path as CLI and MCP. Read functions (doc_get,
5//! doc_has, doc_keys, doc_len) load and query directly.
6//!
7//! Re-exported from api:: via `mod doc; pub use self::doc::*;`.
8
9use std::path::Path;
10
11use crate::containment::PathGuard;
12use crate::ops;
13use crate::ops::doc::query::{QueryResult, query_get, query_has};
14use crate::plan::Operation;
15
16use super::{ApplyMode, EditResult};
17
18/// Load and parse a JSON/YAML/TOML file for read-only queries.
19///
20/// Load first (same order as CLI `load_file`) so a missing path peels as
21/// `not_found` even when the extension is absent or unsupported.
22fn load_doc_value(path: &Path) -> anyhow::Result<serde_json::Value> {
23    ops::doc::load_for_query(path)
24}
25
26/// Unified write path: delegates to the tx engine when available (cli/files),
27/// falls back to direct mutation when the tx module is not compiled in.
28#[cfg(any(feature = "cli", feature = "files"))]
29fn doc_write(
30    op: Operation,
31    path: &Path,
32    mode: ApplyMode,
33    guard: Option<&PathGuard>,
34    action: &'static str,
35) -> anyhow::Result<EditResult> {
36    let abs = super::library_abs_path(path, guard)?;
37    let mut op = op;
38    rewrite_op_path(&mut op, &super::library_op_path(path, &abs, guard));
39    let display = path.to_string_lossy();
40    super::execute_as_edit_result_with_path(
41        op,
42        mode,
43        super::library_project_root(&abs, guard),
44        guard,
45        action,
46        None,
47        Some(display.as_ref()),
48    )
49}
50
51/// Put the engine dest on doc ops: caller spelling under a guard, abs otherwise.
52#[cfg(any(feature = "cli", feature = "files"))]
53fn rewrite_op_path(op: &mut Operation, dest: &str) {
54    match op {
55        Operation::DocSet { path, .. }
56        | Operation::DocDelete { path, .. }
57        | Operation::DocMerge { path, .. }
58        | Operation::DocAppend { path, .. }
59        | Operation::DocPrepend { path, .. }
60        | Operation::DocUpdate { path, .. }
61        | Operation::DocMove { path, .. }
62        | Operation::DocEnsure { path, .. }
63        | Operation::DocDeleteWhere { path, .. } => {
64            *path = dest.into();
65        }
66        _ => {}
67    }
68}
69
70#[cfg(not(any(feature = "cli", feature = "files")))]
71fn doc_write(
72    op: Operation,
73    path: &Path,
74    mode: ApplyMode,
75    guard: Option<&PathGuard>,
76    action: &'static str,
77) -> anyhow::Result<EditResult> {
78    use crate::ops::doc::MutationResult;
79    use crate::write::WritePolicy;
80
81    // Extract the mutation from the operation.
82    let (_, mutation) = crate::plan::op_to_doc_mutation(&op)
83        .ok_or_else(|| anyhow::anyhow!("doc_write called with non-doc operation"))?;
84
85    let display = path.to_string_lossy().into_owned();
86    let path_owned = super::library_abs_path(path, guard)?;
87    let path = path_owned.as_path();
88    let path_str = display;
89    let format = ops::doc::detect_format(&path_str)?;
90    let if_exists_set = matches!(
91        op,
92        Operation::DocSet {
93            if_exists: true,
94            ..
95        }
96    );
97    let original = match crate::files::load_text_strict(path, &path_str) {
98        Ok(s) => s,
99        Err(e) if if_exists_set && crate::exit::is_io_not_found(&e) => {
100            return Ok(super::build_edit_result(
101                &path_str,
102                String::new(),
103                String::new(),
104                false,
105                action,
106                None,
107            ));
108        }
109        Err(e) => return Err(e),
110    };
111    let value = ops::doc::parse_doc(&original, &format)?;
112    if if_exists_set
113        && let Operation::DocSet { selector, .. } = &op
114        && !ops::doc::query::query_has(&value, selector)?
115    {
116        return Ok(super::build_edit_result(
117            &path_str,
118            original.clone(),
119            original,
120            false,
121            action,
122            None,
123        ));
124    }
125    let mut new_value = value.clone();
126
127    let result = ops::doc::apply_doc_mutation(&mut new_value, mutation)?;
128    if let MutationResult::TypeError(msg) = result {
129        return Err(anyhow::Error::new(crate::exit::TypeErrorError { msg }));
130    }
131    let removed = match &result {
132        MutationResult::Removed(n) => *n,
133        MutationResult::NoMatch if matches!(action, "doc.delete" | "doc.delete_where") => 0,
134        _ => 0,
135    };
136
137    let new_content = ops::doc::serialize_value_preserving(&original, &value, &new_value, &format)?;
138    let policy = WritePolicy::default();
139    // Do not write (or report applied) when the mutation is a no-op.
140    let content_changed = original != new_content;
141    let (applied, backup_session) = if content_changed {
142        super::write_if_apply(path, &new_content, mode, &policy, guard)?
143    } else {
144        (false, None)
145    };
146    let mut edit =
147        super::build_edit_result(&path_str, original, new_content, applied, action, None);
148    edit.removed = removed;
149    edit.backup_session = backup_session;
150    Ok(edit)
151}
152
153/// Set a value at a selector path in a JSON, YAML, or TOML file.
154///
155/// The file format is detected from the extension. The selector uses
156/// patchloom's selector syntax (e.g., `"database.host"`, `"items[0].name"`).
157pub fn doc_set(
158    path: &Path,
159    selector: &str,
160    value: serde_json::Value,
161    mode: ApplyMode,
162    guard: Option<&PathGuard>,
163) -> anyhow::Result<EditResult> {
164    let op = Operation::DocSet {
165        path: path.to_string_lossy().into(),
166        selector: selector.into(),
167        value,
168        if_exists: false,
169    };
170    doc_write(op, path, mode, guard, "doc.set")
171}
172
173/// Delete a value at a selector path in a JSON, YAML, or TOML file.
174pub fn doc_delete(
175    path: &Path,
176    selector: &str,
177    mode: ApplyMode,
178    guard: Option<&PathGuard>,
179) -> anyhow::Result<EditResult> {
180    let op = Operation::DocDelete {
181        path: path.to_string_lossy().into(),
182        selector: selector.into(),
183    };
184    doc_write(op, path, mode, guard, "doc.delete")
185}
186
187/// Deep-merge a value into a JSON, YAML, or TOML file.
188///
189/// When `selector` is [`None`], merges into the document root (single-document
190/// files). For multi-document YAML (top-level array of documents), pass
191/// `Some("0")` or `Some("[0]")` to merge into the first document without
192/// replacing the whole stream. Merging a non-array overlay into a multi-doc
193/// **root** returns [`crate::exit::TypeErrorError`] (peels to
194/// [`crate::fallback::EditErrorKind::TypeError`]).
195///
196/// # Example
197///
198/// ```rust,no_run
199/// use patchloom::api::{self, ApplyMode};
200/// use std::path::Path;
201///
202/// // Root merge (single-doc JSON/YAML/TOML)
203/// let _ = api::doc_merge(
204///     Path::new("config.json"),
205///     serde_json::json!({"debug": true}),
206///     ApplyMode::Apply,
207///     None,
208///     None,
209/// )?;
210///
211/// // Multi-doc YAML: merge into document 0 only
212/// let _ = api::doc_merge(
213///     Path::new("stream.yaml"),
214///     serde_json::json!({"c": 3}),
215///     ApplyMode::Apply,
216///     None,
217///     Some("0"),
218/// )?;
219/// # Ok::<(), anyhow::Error>(())
220/// ```
221pub fn doc_merge(
222    path: &Path,
223    value: serde_json::Value,
224    mode: ApplyMode,
225    guard: Option<&PathGuard>,
226    selector: Option<&str>,
227) -> anyhow::Result<EditResult> {
228    let op = Operation::DocMerge {
229        path: path.to_string_lossy().into(),
230        selector: selector.map(|s| s.into()),
231        value,
232    };
233    doc_write(op, path, mode, guard, "doc.merge")
234}
235
236/// Get a value at a selector path from a JSON, YAML, or TOML file.
237///
238/// Load-first: a missing file peels as `not_found`.
239pub fn doc_get(path: &Path, selector: &str) -> anyhow::Result<serde_json::Value> {
240    let value = load_doc_value(path)?;
241
242    match query_get(&value, selector)? {
243        QueryResult::NoMatch => Err(crate::exit::NoMatchError {
244            msg: crate::ops::doc::query::with_similar_object_key_hint(
245                format!("selector '{selector}' matched nothing"),
246                &value,
247                selector,
248            ),
249        }
250        .into()),
251        QueryResult::Values(vals) if vals.len() == 1 => Ok(vals
252            .into_iter()
253            .next()
254            .expect("len()==1 guarantees element")),
255        QueryResult::Values(vals) => Ok(serde_json::Value::Array(vals)),
256    }
257}
258
259/// Check whether a selector path exists in a JSON, YAML, or TOML file.
260pub fn doc_has(path: &Path, selector: &str) -> anyhow::Result<bool> {
261    let value = load_doc_value(path)?;
262    query_has(&value, selector)
263}
264
265/// List object keys at a selector path in a JSON, YAML, or TOML file.
266///
267/// Pass one object (`database`). Empty / `"."` lists keys of the document
268/// root. An array target (`items`) is [`crate::exit::TypeErrorError`] (use
269/// `items[0]` for one object, or [`doc_len`] on `items`). A missing selector
270/// is [`crate::exit::NoMatchError`]. A wildcard or predicate (`items[*]`) is
271/// [`crate::exit::AmbiguousError`] even on 0 or 1 match, and names
272/// `items[0]` / `items[1]`. A missing file peels as `not_found`.
273pub fn doc_keys(path: &Path, selector: &str) -> anyhow::Result<Vec<String>> {
274    let value = load_doc_value(path)?;
275    crate::ops::doc::query::keys_at(&value, selector)
276}
277
278/// Count items in an array or object at a selector path (`items`, `database`).
279///
280/// Empty / `"."` means the document root. A scalar (or other non-container)
281/// is [`crate::exit::TypeErrorError`]. A missing selector is
282/// [`crate::exit::NoMatchError`]. A wildcard or predicate (`items[*]`) is
283/// [`crate::exit::AmbiguousError`] even on 0 or 1 match, and names
284/// `items[0]` / `items[1]`. A missing file peels as `not_found`.
285pub fn doc_len(path: &Path, selector: &str) -> anyhow::Result<usize> {
286    let value = load_doc_value(path)?;
287    crate::ops::doc::query::len_at(&value, selector)
288}
289
290/// Append a value to an array at a selector path.
291pub fn doc_append(
292    path: &Path,
293    selector: &str,
294    value: serde_json::Value,
295    mode: ApplyMode,
296    guard: Option<&PathGuard>,
297) -> anyhow::Result<EditResult> {
298    let op = Operation::DocAppend {
299        path: path.to_string_lossy().into(),
300        selector: selector.into(),
301        value,
302    };
303    doc_write(op, path, mode, guard, "doc.append")
304}
305
306/// Prepend a value to an array at a selector path.
307pub fn doc_prepend(
308    path: &Path,
309    selector: &str,
310    value: serde_json::Value,
311    mode: ApplyMode,
312    guard: Option<&PathGuard>,
313) -> anyhow::Result<EditResult> {
314    let op = Operation::DocPrepend {
315        path: path.to_string_lossy().into(),
316        selector: selector.into(),
317        value,
318    };
319    doc_write(op, path, mode, guard, "doc.prepend")
320}
321
322/// Update all values matching a selector with a new value.
323///
324/// Returns an `EditResult`. The number of matches updated is reflected in
325/// whether the content changed.
326pub fn doc_update(
327    path: &Path,
328    selector: &str,
329    value: serde_json::Value,
330    mode: ApplyMode,
331    guard: Option<&PathGuard>,
332) -> anyhow::Result<EditResult> {
333    let op = Operation::DocUpdate {
334        path: path.to_string_lossy().into(),
335        selector: selector.into(),
336        value,
337    };
338    doc_write(op, path, mode, guard, "doc.update")
339}
340
341/// Ensure a value exists at a selector path; set it only if missing.
342pub fn doc_ensure(
343    path: &Path,
344    selector: &str,
345    value: serde_json::Value,
346    mode: ApplyMode,
347    guard: Option<&PathGuard>,
348) -> anyhow::Result<EditResult> {
349    let op = Operation::DocEnsure {
350        path: path.to_string_lossy().into(),
351        selector: selector.into(),
352        value,
353    };
354    doc_write(op, path, mode, guard, "doc.ensure")
355}
356
357/// Delete array elements matching a predicate (e.g., `"name=old"`).
358pub fn doc_delete_where(
359    path: &Path,
360    selector: &str,
361    predicate: &str,
362    mode: ApplyMode,
363    guard: Option<&PathGuard>,
364) -> anyhow::Result<EditResult> {
365    let op = Operation::DocDeleteWhere {
366        path: path.to_string_lossy().into(),
367        selector: selector.into(),
368        predicate: predicate.into(),
369    };
370    doc_write(op, path, mode, guard, "doc.delete_where")
371}
372
373/// Move a value from one selector path to another within the same file.
374pub fn doc_move(
375    path: &Path,
376    from_selector: &str,
377    to_selector: &str,
378    mode: ApplyMode,
379    guard: Option<&PathGuard>,
380) -> anyhow::Result<EditResult> {
381    let op = Operation::DocMove {
382        path: path.to_string_lossy().into(),
383        from: from_selector.into(),
384        to: to_selector.into(),
385    };
386    doc_write(op, path, mode, guard, "doc.move")
387}