govctl 0.16.0

Project governance CLI for RFC, ADR, and Work Item management
//! Edit command implementation - modify artifacts.
//!
//! Implements canonical mutation semantics from [[RFC-0002:C-EDIT-FIELD-CONTRACT]].

pub mod adapter;
mod add;
mod artifact;
mod delete;
mod delete_referrers;
mod doc_adapter;
mod doc_target;
pub mod engine;
mod get;
pub(crate) mod matching;
pub mod path;
mod refs;
mod remove;
mod request;
pub mod rules;
pub mod runtime;
mod set;
mod tags;
mod target_doc;
mod target_doc_remove;
mod tick;
mod toml_adapter;
mod toml_target;
mod value_codec;

use self::add::{AddFieldRequest, add_to_field};
pub use self::artifact::ArtifactType;
pub use self::get::get_field;
pub use self::remove::remove_from_field;
pub use self::request::{EditFieldRequest, OwnedEditAction};
use self::set::apply_set_field;
pub(crate) use self::set::set_field_direct;
pub use self::tick::tick_item;
use self::{engine as edit_engine, path::FieldPath, rules as edit_rules};
use crate::diagnostic::{Diagnostic, DiagnosticCode, DiagnosticResult};
use crate::ui;
pub use delete::{delete_clause, delete_work_item};
pub use matching::{MatchOptions, MatchOptionsOwned};

use self::request::resolve_owned_value;
pub(crate) use value_codec::parse_requirement_binding as parse_conformance_requirement;

// Field normalization is centralized in edit_engine::plan_request.

pub(super) fn serialize_edit_doc<T: serde::Serialize>(
    value: &T,
    id: &str,
) -> DiagnosticResult<serde_json::Value> {
    serde_json::to_value(value).map_err(|err| {
        Diagnostic::new(
            DiagnosticCode::E0903UnexpectedError,
            format!("Failed to serialize editable document: {err}"),
            id,
        )
    })
}

pub(super) fn deserialize_edit_doc<T: serde::de::DeserializeOwned>(
    value: serde_json::Value,
    id: &str,
) -> DiagnosticResult<T> {
    serde_json::from_value(value).map_err(|err| {
        Diagnostic::new(
            DiagnosticCode::E0820InvalidFieldValue,
            format!("Edited document failed schema conversion: {err}"),
            id,
        )
    })
}

pub(super) fn unexpected_edit_state(id: &str, message: impl Into<String>) -> Diagnostic {
    Diagnostic::new(DiagnosticCode::E0903UnexpectedError, message, id)
}

pub(super) struct PlannedMutation {
    pub(super) artifact: ArtifactType,
    pub(super) field_path: FieldPath,
    pub(super) target: edit_engine::ResolvedTarget,
}

pub(super) fn plan_mutation_target(
    id: &str,
    field: &str,
    verb: edit_rules::Verb,
) -> DiagnosticResult<PlannedMutation> {
    let plan = edit_engine::plan_mutation_request(id, field, verb)?;
    let field_path = plan.field_path.ok_or_else(|| {
        Diagnostic::new(
            DiagnosticCode::E0801MissingRequiredArg,
            "Field path required",
            id,
        )
    })?;
    let target = plan
        .target
        .ok_or_else(|| unexpected_edit_state(id, "mutation planning should produce target"))?;
    Ok(PlannedMutation {
        artifact: plan.artifact,
        field_path,
        target,
    })
}

pub fn edit_field(request: EditFieldRequest<'_>) -> DiagnosticResult<Vec<Diagnostic>> {
    let EditFieldRequest {
        config,
        id,
        path,
        action,
        op,
    } = request;

    match action {
        OwnedEditAction::Set { value, stdin } => {
            let value = resolve_owned_value(value.as_ref(), *stdin)?;
            let plan = plan_mutation_target(id, path, edit_rules::Verb::Set)?;
            apply_set_field(
                config,
                id,
                &plan.target,
                plan.artifact,
                value.as_str(),
                op,
                true,
            )?;
            if !op.is_preview() {
                ui::field_set(id, &plan.target.display_path(), value.as_str());
            }
            Ok(vec![])
        }
        OwnedEditAction::Add { value, stdin } => {
            let value = resolve_owned_value(value.as_ref(), *stdin)?;
            add_to_field(AddFieldRequest {
                config,
                id,
                field: path,
                value: value.as_str(),
                op,
            })
        }
        OwnedEditAction::Remove { match_opts } => {
            remove_from_field(config, id, path, &match_opts.as_match_options(), op)
        }
        OwnedEditAction::Tick { match_opts, status } => tick_item(
            config,
            id,
            path,
            &match_opts.as_match_options(),
            *status,
            op,
        ),
    }
}

fn reject_match_flags_for_indexed_target(
    id: &str,
    target: &edit_engine::ResolvedTarget,
    opts: &MatchOptions,
) -> DiagnosticResult<()> {
    let pattern_provided = opts.pattern.is_some_and(|pattern| !pattern.is_empty());
    let edit_engine::ResolvedTarget::IndexedItem { .. } = target else {
        return Ok(());
    };
    if pattern_provided || opts.regex || opts.all {
        return Err(Diagnostic::new(
            DiagnosticCode::E0818PathIndexConflict,
            "Cannot combine an indexed path with a value, --regex, or --all",
            id,
        ));
    }
    Ok(())
}