git-bug 0.2.4

A rust library for interfacing with git-bug repositories
Documentation
// git-bug-rs - A rust library for interfacing with git-bug repositories
//
// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de>
// SPDX-License-Identifier: GPL-3.0-or-later
//
// This file is part of git-bug-rs/git-gub.
//
// You should have received a copy of the License along with this program.
// If not, see <https://www.gnu.org/licenses/agpl.txt>.

//! Operations that affect an [`Issue`][`super::Issue`]

use std::{fmt::Display, str::FromStr};

use gix::ObjectId;
use operation_type::IssueOperationType;
use serde::{Deserialize, Serialize};
use simd_json::{base::ValueAsScalar, derived::ValueTryIntoObject, owned};

use super::{
    Issue,
    data::{label::Label, status::Status},
};
use crate::replica::entity::{
    id::{Id, entity_id::EntityId},
    operation::operation_data::OperationData,
};

mod op;
pub mod operation_type;

/// A file that is attached to an Issue or Issue comment.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct File {
    git_id: Vec<u8>,
}
impl TryFrom<&owned::Value> for File {
    type Error = file_parse::Error;

    fn try_from(value: &owned::Value) -> Result<Self, Self::Error> {
        let s = value
            .as_str()
            .ok_or_else(|| file_parse::Error::ExpectedStr {
                data: value.to_owned(),
            })?;

        let object_id = ObjectId::from_str(s)?;

        Ok(Self {
            git_id: object_id.as_slice().to_owned(),
        })
    }
}

#[allow(missing_docs)]
pub mod file_parse {
    use simd_json::owned;

    #[derive(Debug, thiserror::Error)]
    pub enum Error {
        #[error("Expected the '{data}' data, to be a string, but was not.")]
        ExpectedStr { data: owned::Value },

        #[error("Failed to parse the object id from json data for file: {0}")]
        ObjectIdParse(#[from] gix::hash::decode::Error),
    }
}

impl Display for File {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        ObjectId::from_bytes_or_panic(self.git_id.as_slice()).fmt(f)
    }
}

/// The specific operations that affect only [`Issues`][`super::Issue`].
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
pub enum IssueOperationData {
    /// Adding a comment.
    AddComment {
        /// Which message to add to the newly created comment.
        message: String,
        /// Which files to add to newly created comment.
        files: Vec<File>,
    },
    /// Creating an Issue.
    Create {
        /// Which title to start this issue on.
        title: String,
        /// Which body to start this issue on.
        message: String,
        /// Which files are associated with the issue.
        files: Vec<File>,
    },
    /// Editing a comment.
    EditComment {
        /// The comment to edit.
        target: EntityId<Issue>,
        /// The message after editing.
        message: String,
        /// Which files this comment now has.
        files: Vec<File>,
    },
    /// Changing labels.
    LabelChange {
        /// Added labels.
        added: Vec<Label>,
        /// Removed labels.
        removed: Vec<Label>,
    },
    /// Setting metadata.
    SetMetadata {
        /// Which operation to add metadata to.
        target: Id,
        /// Which metadata to add.
        // Use a vec, to keep stable ordering.
        new_metadata: Vec<(String, String)>,
    },
    /// Setting the status.
    SetStatus {
        /// The new status.
        status: Status,
    },
    /// Setting the title.
    SetTitle {
        /// The new title.
        title: String,
        /// The previous title.
        was: String,
    },

    /// Doing nothing.
    ///
    /// # Note
    /// This does nothing, and is as such very useful, for attaching metadata to it.
    Noop {},
}

impl OperationData for IssueOperationData {
    type DecodeError = decode::Error;

    fn is_root(&self) -> bool {
        matches!(self, IssueOperationData::Create { .. })
    }

    fn from_value(raw: owned::Value, predicted_type: u64) -> Result<Self, Self::DecodeError>
    where
        Self: Sized,
    {
        let r#type = IssueOperationType::try_from(predicted_type)?;

        let object = raw
            .try_into_object()
            .map_err(|err| decode::Error::ObjectExpected { err })?;

        match r#type {
            IssueOperationType::AddComment => op::add_comment(object),
            IssueOperationType::Create => op::create(object),
            IssueOperationType::EditComment => op::edit_comment(object),
            IssueOperationType::LabelChange => op::label_change(object),
            IssueOperationType::NoOp => Ok(op::noop()),
            IssueOperationType::SetMetadata => op::set_metadata(object),
            IssueOperationType::SetStatus => op::set_status(object),
            IssueOperationType::SetTitle => op::set_title(object),
        }
    }

    fn as_value(&self) -> simd_json::borrowed::Object<'_> {
        match self {
            IssueOperationData::AddComment { message, files } => {
                op::add_comment_value(message, files)
            }
            IssueOperationData::Create {
                title,
                message,
                files,
            } => op::create_value(title, message, files),
            IssueOperationData::EditComment {
                target,
                message,
                files,
            } => op::edit_comment_value(target, message, files),
            IssueOperationData::LabelChange { added, removed } => {
                op::label_change_value(added, removed)
            }
            IssueOperationData::SetMetadata {
                target,
                new_metadata,
            } => op::set_metadata_value(target, new_metadata),
            IssueOperationData::SetStatus { status } => op::set_status_value(status),
            IssueOperationData::SetTitle { title, was } => op::set_title_value(title, was),
            IssueOperationData::Noop {} => op::noop_value(),
        }
    }

    fn to_json_type(&self) -> u64 {
        IssueOperationType::from(self).into()
    }
}

#[allow(missing_docs)]
pub mod decode {

    use super::file_parse;
    use crate::{
        entities::issue::data::{label, status},
        replica::entity::id,
    };

    #[derive(Debug, thiserror::Error)]
    pub enum Error {
        #[error("The json value did not contain the expected field '{field}'.")]
        MissingJsonField { field: &'static str },

        #[error("Expected to parse the '{field}' field in the json data: {err}")]
        WrongJsonType {
            err: simd_json::TryTypeError,
            field: &'static str,
        },

        #[error("Failed to decode an object id (e.g., for a file blob): {0}")]
        ObjectIdDecode(#[from] gix::hash::decode::Error),

        #[error("Failed to decode an entity id (e.g., for a target field): {0}")]
        IdDecode(#[from] id::decode::Error),

        #[error(transparent)]
        InvalidType(#[from] crate::entities::issue::issue_operation::operation_type::decode::Error),

        #[error("Failed to parse the `files` field: {0}")]
        FileParse(#[from] file_parse::Error),
        #[error("Failed to parse the `added` or `removed` label fields: {0}")]
        LabelParse(#[from] label::value_parse::Error),
        #[error("Failed to parse the status fields: {0}")]
        StatusParse(#[from] status::decode::Error),

        #[error("Expected this operation json data to be a object, but was not: {err}")]
        ObjectExpected { err: simd_json::TryTypeError },
    }
}