Skip to main content

crates/aube-manifest/src/workspace/edits.rs

//! Generic yaml/json edit helpers for workspace-level config.
//!
//! `package.json#pnpm.<key>` mutations (`remove_setting_entry`,
//! `edit_setting_map`), workspace-yaml round-trip editing
//! (`edit_workspace_yaml`, `workspace_yaml_submap`,
//! `write_workspace_yaml`), and the `upsert_map_entry` /
//! `remove_map_entry` pair that routes through `config_write_target`
//! to mutate whichever file holds the value today.

use super::config::{ConfigWriteTarget, config_write_target, workspace_yaml_existing};
use super::yaml_patch;
use std::path::{Path, PathBuf};

/// Drop `entry_key` from `pnpm.<key>` and `aube.<key>` in
/// `package.json`. Returns `Ok(true)` when at least one namespace held
/// it. Empty inner maps and empty namespaces are scrubbed too. The
/// rewrite is skipped entirely when nothing structural changes —
/// mirrors the no-op-skip guarantee of [`edit_workspace_yaml`].
///
/// Walking both namespaces matters because the read side merges them
/// (`aube.*` wins on conflict), so an entry recorded in either
/// location is live; a one-namespace removal would leave a stale
/// duplicate behind.
pub fn remove_setting_entry(cwd: &Path, key: &str, entry_key: &str) -> Result<bool, crate::Error> {
    let path = cwd.join("package.json");
    if !path.exists() {
        return Ok(false);
    }
    let raw = std::fs::read_to_string(&path).map_err(|e| crate::Error::Io(path.clone(), e))?;
    let mut value = crate::parse_json::<serde_json::Value>(&path, raw)?;
    let obj = value.as_object_mut().ok_or_else(|| {
        crate::Error::YamlParse(path.clone(), "package.json is not an object".to_string())
    })?;
    let before = obj.clone();

    let mut existed = false;
    for ns in ["pnpm", "aube"] {
        let mut ns_empty = false;
        if let Some(ns_obj) = obj.get_mut(ns).and_then(|v| v.as_object_mut()) {
            if let Some(inner) = ns_obj.get_mut(key).and_then(|v| v.as_object_mut()) {
                if inner.remove(entry_key).is_some() {
                    existed = true;
                }
                if inner.is_empty() {
                    ns_obj.remove(key);
                }
            }
            ns_empty = ns_obj.is_empty();
        }
        if ns_empty {
            obj.remove(ns);
        }
    }

    if *obj == before {
        return Ok(existed);
    }

    let mut out = serde_json::to_string_pretty(&value)
        .map_err(|e| crate::Error::YamlParse(path.clone(), format!("failed to serialize: {e}")))?;
    out.push('\n');
    std::fs::write(&path, out).map_err(|e| crate::Error::Io(path, e))?;
    Ok(existed)
}

/// Mutate a namespaced map setting (e.g. `patchedDependencies`,
/// `allowBuilds`) inside `package.json` and write back.
///
/// The closure receives a **merged** view of `pnpm.<key>` and
/// `aube.<key>`, with `aube.*` winning on key conflict — the same
/// precedence the read side already uses. After the closure runs,
/// the merged result is written to a single namespace and the other
/// is cleared, so a future read sees exactly one source of truth and
/// can never silently shadow a stale entry. This matters because
/// pnpm-aware tools (and pnpm itself) can introduce a `pnpm` key into
/// a manifest after aube has already populated `aube.<key>`; without
/// the merge-and-collapse, a re-record would leave the new value in
/// `pnpm.<key>` while the stale `aube.<key>` entry kept winning on
/// read.
///
/// The chosen namespace follows [`config_write_target`]'s rule:
/// `pnpm` if a `pnpm` namespace is already declared in the manifest,
/// `aube` otherwise. Empty namespaces and inner maps are scrubbed,
/// and the rewrite is skipped entirely when nothing structural
/// changes — mirrors the no-op-skip guarantee of [`edit_workspace_yaml`].
pub fn edit_setting_map<F>(cwd: &Path, key: &str, f: F) -> Result<(), crate::Error>
where
    F: FnOnce(&mut serde_json::Map<String, serde_json::Value>),
{
    let path = cwd.join("package.json");
    let raw = std::fs::read_to_string(&path).map_err(|e| crate::Error::Io(path.clone(), e))?;
    let mut value = crate::parse_json::<serde_json::Value>(&path, raw)?;

    let obj = value.as_object_mut().ok_or_else(|| {
        crate::Error::YamlParse(path.clone(), "package.json is not an object".to_string())
    })?;
    let before = obj.clone();

    // Build the merged view (pnpm first, aube overrides on conflict)
    // before mutating, so the closure sees the same map the install
    // path would.
    let mut merged: serde_json::Map<String, serde_json::Value> = serde_json::Map::new();
    for ns in ["pnpm", "aube"] {
        if let Some(inner) = obj
            .get(ns)
            .and_then(serde_json::Value::as_object)
            .and_then(|m| m.get(key))
            .and_then(serde_json::Value::as_object)
        {
            for (k, v) in inner {
                merged.insert(k.clone(), v.clone());
            }
        }
    }

    f(&mut merged);

    let chosen_ns = if obj.contains_key("pnpm") {
        "pnpm"
    } else {
        "aube"
    };
    let other_ns = if chosen_ns == "pnpm" { "aube" } else { "pnpm" };

    // Drop `<key>` from the other namespace so the post-write state
    // has one source of truth.
    let mut other_ns_empty_after = false;
    if let Some(other_obj) = obj.get_mut(other_ns).and_then(|v| v.as_object_mut()) {
        other_obj.remove(key);
        other_ns_empty_after = other_obj.is_empty();
    }
    if other_ns_empty_after {
        obj.remove(other_ns);
    }

    // Write merged into the chosen namespace, or scrub it if empty.
    if merged.is_empty() {
        let mut chosen_ns_empty_after = false;
        if let Some(chosen_obj) = obj.get_mut(chosen_ns).and_then(|v| v.as_object_mut()) {
            chosen_obj.remove(key);
            chosen_ns_empty_after = chosen_obj.is_empty();
        }
        if chosen_ns_empty_after {
            obj.remove(chosen_ns);
        }
    } else {
        let chosen_value = obj
            .entry(chosen_ns.to_string())
            .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
        let chosen_obj = chosen_value.as_object_mut().ok_or_else(|| {
            crate::Error::YamlParse(path.clone(), format!("`{chosen_ns}` is not an object"))
        })?;
        chosen_obj.insert(key.to_string(), serde_json::Value::Object(merged));
    }

    if *obj == before {
        return Ok(());
    }

    let mut out = serde_json::to_string_pretty(&value)
        .map_err(|e| crate::Error::YamlParse(path.clone(), format!("failed to serialize: {e}")))?;
    out.push('\n');
    std::fs::write(&path, out).map_err(|e| crate::Error::Io(path, e))?;
    Ok(())
}

/// Upsert a single `<map>.<entry>` pair into the project's
/// workspace-level config. Routes through [`config_write_target`]:
/// workspace yaml when one exists, otherwise `<pnpm|aube>.<map>` in
/// `package.json`. Returns the file that was written.
///
/// Used by `aube config set --local <map>.<entry> <value>` for any
/// object-typed aube setting (`allowBuilds`, `overrides`,
/// `packageExtensions`, …) so the dotted-key CLI syntax can write
/// directly into the same maps `aube approve-builds` /
/// install-time auto-deny seeding mutate. The value is passed in
/// both yaml and json forms so the caller can choose the right scalar
/// shape (bool vs string vs int) without this helper having to guess.
pub fn upsert_map_entry(
    project_dir: &Path,
    map_name: &str,
    entry_key: &str,
    yaml_value: yaml_serde::Value,
    json_value: serde_json::Value,
) -> Result<PathBuf, crate::Error> {
    match config_write_target(project_dir) {
        ConfigWriteTarget::WorkspaceYaml(path) => {
            edit_workspace_yaml(&path, |map| {
                let submap = workspace_yaml_submap(map, map_name, &path)?;
                submap.insert(yaml_serde::Value::String(entry_key.to_string()), yaml_value);
                Ok(())
            })?;
            Ok(path)
        }
        ConfigWriteTarget::PackageJson => {
            edit_setting_map(project_dir, map_name, |map| {
                map.insert(entry_key.to_string(), json_value);
            })?;
            Ok(project_dir.join("package.json"))
        }
    }
}

/// Remove a single `<map>.<entry>` pair from the project's
/// workspace-level config. Mirrors [`upsert_map_entry`]: sweeps both
/// the workspace yaml (when one exists) and
/// `<pnpm|aube>.<map>.<entry>` in `package.json` so a value set
/// through either file can be deleted regardless of which one the
/// current layout would have written to. Drops empty `<map>:`
/// containers behind it so a removal doesn't leave a `{}` stub.
///
/// Returns `true` when at least one location held the entry. Used by
/// `aube config delete --local <map>.<entry>` so dotted writes have
/// a symmetric round-trip.
pub fn remove_map_entry(
    project_dir: &Path,
    map_name: &str,
    entry_key: &str,
) -> Result<bool, crate::Error> {
    let mut existed = false;
    if let Some(yaml_path) = workspace_yaml_existing(project_dir) {
        edit_workspace_yaml(&yaml_path, |map| {
            let yaml_key = yaml_serde::Value::String(map_name.to_string());
            let Some(submap) = map.get_mut(&yaml_key).and_then(|v| v.as_mapping_mut()) else {
                return Ok(());
            };
            if submap.shift_remove(entry_key).is_some() {
                existed = true;
            }
            if submap.is_empty() {
                map.shift_remove(&yaml_key);
            }
            Ok(())
        })?;
    }
    if remove_setting_entry(project_dir, map_name, entry_key)? {
        existed = true;
    }
    Ok(existed)
}

/// Get the inner mapping for a top-level workspace-yaml key, creating
/// it if absent. Errors when the key exists but isn't a mapping (a
/// hand-edited file shape we shouldn't silently replace).
pub(super) fn workspace_yaml_submap<'a>(
    map: &'a mut yaml_serde::Mapping,
    key: &str,
    path: &Path,
) -> Result<&'a mut yaml_serde::Mapping, crate::Error> {
    let entry = map
        .entry(yaml_serde::Value::String(key.to_string()))
        .or_insert_with(|| yaml_serde::Value::Mapping(yaml_serde::Mapping::new()));
    entry.as_mapping_mut().ok_or_else(|| {
        crate::Error::YamlParse(path.to_path_buf(), format!("`{key}` must be a mapping"))
    })
}

/// Apply `f` to the parsed top-level mapping of the workspace yaml at
/// `path` and write it back. The helper exists so every workspace-yaml
/// writer (allowBuilds, patchedDependencies, catalog cleanup, future
/// settings) shares one comment-preserving rule: **user-authored
/// comments and formatting in the file survive every edit**.
///
/// The closure mutates a parsed `yaml_serde::Mapping`. After it runs,
/// the helper diffs before-vs-after and reduces the change set to a
/// minimal sequence of `yamlpatch` operations applied directly to the
/// original source. yamlpatch is comment- and format-preserving, so
/// keys, comments, and whitespace that the closure didn't touch land
/// back on disk byte-identical. A no-op closure produces an empty
/// patch list and the file isn't rewritten at all.
///
/// For brand-new or empty files there is no source to preserve, so the
/// helper falls back to `yaml_serde::to_string` for the initial write.
pub fn edit_workspace_yaml<F>(path: &Path, f: F) -> Result<PathBuf, crate::Error>
where
    F: FnOnce(&mut yaml_serde::Mapping) -> Result<(), crate::Error>,
{
    use yaml_serde::{Mapping, Value};

    let original_source: Option<String> = if path.exists() {
        let content =
            std::fs::read_to_string(path).map_err(|e| crate::Error::Io(path.to_path_buf(), e))?;
        if content.trim().is_empty() {
            None
        } else {
            Some(content)
        }
    } else {
        None
    };

    let mut doc: Value = match original_source.as_deref() {
        Some(content) => crate::parse_yaml(path, content.to_string())?,
        None => Value::Mapping(Mapping::new()),
    };

    let map = doc.as_mapping_mut().ok_or_else(|| {
        crate::Error::YamlParse(
            path.to_path_buf(),
            "top-level yaml must be a mapping".to_string(),
        )
    })?;

    let before = map.clone();
    f(map)?;
    if *map == before {
        return Ok(path.to_path_buf());
    }

    let after = std::mem::take(map);
    write_workspace_yaml(path, original_source.as_deref(), &before, &after)?;
    Ok(path.to_path_buf())
}

/// Persist a structural change against `path`. When `original_source`
/// is `Some`, the change is encoded as a list of `yamlpatch`
/// operations applied to the original text — comments and formatting
/// the closure didn't touch survive the round trip. When it is `None`
/// (fresh file or one that was empty), the after-state is serialized
/// directly via `yaml_serde::to_string`; there is no source to
/// preserve. Both paths atomic-write the result.
fn write_workspace_yaml(
    path: &Path,
    original_source: Option<&str>,
    before: &yaml_serde::Mapping,
    after: &yaml_serde::Mapping,
) -> Result<(), crate::Error> {
    let bytes: Vec<u8> = match original_source {
        Some(source) => yaml_patch::apply_diff(path, source, before, after)?,
        None => {
            let raw = yaml_serde::to_string(&yaml_serde::Value::Mapping(after.clone()))
                .map_err(|e| crate::Error::YamlParse(path.to_path_buf(), e.to_string()))?;
            indent_block_sequences(&raw).into_bytes()
        }
    };
    aube_util::fs_atomic::atomic_write(path, &bytes)
        .map_err(|e| crate::Error::Io(path.to_path_buf(), e))?;
    Ok(())
}

/// Bump every block-sequence item line (`- ...`) by two spaces. Leaves
/// already-indented lines and non-sequence lines alone. yaml_serde's
/// output uses a single indent step per nesting level, so this produces
/// the `parent:\n  - item` shape humans expect. Only used on the
/// fresh-file write path; yamlpatch preserves the user's existing
/// indentation otherwise.
fn indent_block_sequences(input: &str) -> String {
    let mut out = String::with_capacity(input.len() + 16);
    for line in input.split_inclusive('\n') {
        let stripped = line.trim_start_matches(' ');
        if stripped.starts_with("- ") || stripped == "-\n" || stripped == "-" {
            out.push_str("  ");
        }
        out.push_str(line);
    }
    out
}