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>.

//! Implementation of [`Queryable`] for [`Issue`]

use std::str::FromStr;

use super::{
    Issue,
    data::{label::Label, status::Status},
};
use crate::{
    entities::identity::Identity,
    query::queryable::{QueryKeyValue, Queryable},
    replica::{
        Replica,
        entity::{Entity, identity::IdentityStub, snapshot::Snapshot},
    },
};

impl Queryable for Snapshot<Issue> {
    type KeyValue = MatchKeyValue;

    fn matches(&self, key: &Self::KeyValue) -> bool {
        match key {
            MatchKeyValue::Status(status) => self.status() == *status,
            MatchKeyValue::Author { resolved_id, .. } => self.author() == *resolved_id,
            MatchKeyValue::Participant { resolved_id, .. } => {
                self.participants().any(|other| other == *resolved_id)
            }
            MatchKeyValue::Label(label) => self.labels().contains(label),
            MatchKeyValue::Title(search) => self.title().contains(search),
            MatchKeyValue::Empty(value) => match value {
                EmptyValue::Labels => self.labels().is_empty(),
            },
            MatchKeyValue::Search(search) => {
                self.body().contains(search) || self.title().contains(search)
            }
            MatchKeyValue::Body(search) => self.body().contains(search),
        }
    }
}

/// The possible keys, that are usable in a query for issues.
///
/// ## Following pairs are supported
/// ### `status`
/// possible values: [open, closed]
///
/// This only matches issues where the status is the same.
///
/// ### `author`
/// value: pattern.
///
/// This only matches issues where the value contains the author's name or login
/// name.
///
/// ### `participant`
/// value: pattern.
///
/// This only matches issues where one of the participating user's name or login
/// names contains the value.
///
/// ### `label`
/// value: string.
///
/// This only matches issues where one of the labels equals the value.
///
/// ### `title`
/// value: pattern.
///
/// This only matches issues where the title contains the value.
///
/// ### `empty`
/// possible values: \[label\]
///
/// Matches issues where the value is empty (e.g., `no:label` matches issues
/// without label)
// /// ### `sort`
// /// possible values: [edit-{desc,asc}, creation-{desc,asc}, id-{desc,asc}]
/// ### `search`
/// value: pattern.
///
/// Matches issue where either the body or the title contain the value.
/// This is the implied default if a value does not have a key.
#[derive(Debug, Clone)]
pub enum MatchKeyValue {
    /// Filter by issue status
    Status(Status),

    /// Filter by issue author
    Author {
        /// The [`IdentityStub`] of the specified
        /// [`Identity`][`crate::entities::identity::Identity`] we are searching
        /// for.
        resolved_id: IdentityStub,

        /// The resolved name of the author.
        ///
        /// This is needed for the query normalization.
        name: String,
    },

    /// Filter by issue participant
    Participant {
        /// The [`IdentityStub`] of the specified
        /// [`Identity`][`crate::entities::identity::Identity`] we are searching
        /// for.
        resolved_id: IdentityStub,

        /// The resolved name of the participant.
        ///
        /// This is needed for the query normalization.
        name: String,
    },

    /// Filter by issue label
    Label(Label),
    /// Filter by empty field
    Empty(EmptyValue),

    /// Filter by issue title
    Title(String),

    /// Filter by string in issue body
    Body(String),

    // TODO(@bpeetz): This is impossible to implement via the one-by-one API. <2025-05-10>
    // /// Sort by field
    // Sort,
    /// Filter by string in issue body or title
    Search(String),
}

#[allow(missing_docs)]
pub mod decode {
    use crate::entities::{identity::Identity, issue::data::status};

    #[derive(Debug, thiserror::Error)]
    pub enum Error {
        #[error("Unknown Issue match key: {0}")]
        UnknownKey(String),

        #[error("Failed to parse the status value: {0}")]
        UnknowStatusValue(#[from] status::decode::Error),

        #[error("Failed to parse the empty value: {0} (valid ones are: 'labels')")]
        UnknownEmptyValue(String),

        #[error("Failed to read an Identity specified via the author or participant key: {0}")]
        IdentityRead(#[from] crate::replica::entity::read::Error<Identity>),

        #[error(
            "Failed to get the reference of an Identity specified via the author or participant \
             key: {0}"
        )]
        IdentityGet(#[from] crate::replica::get::Error),

        #[error("The identity ('{0}') name for the author or participant key was not found.")]
        NoIdentityMatches(String),
    }
}

/// What is the value to check for emptiness.
#[derive(Debug, Clone, Copy)]
pub enum EmptyValue {
    /// Search for no labels
    Labels,
}
impl FromStr for EmptyValue {
    type Err = decode::Error;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        let value = match s {
            "labels" => Self::Labels,
            other => return Err(decode::Error::UnknownEmptyValue(other.to_owned())),
        };
        Ok(value)
    }
}

impl QueryKeyValue for MatchKeyValue {
    type Err = decode::Error;
    type UserState = Replica;

    fn from_key_value(
        user_state: &Self::UserState,
        key: &str,
        value: String,
    ) -> Result<Self, Self::Err> {
        fn get_identity_stub_by_name(
            user_state: &Replica,
            value: &str,
        ) -> Result<(IdentityStub, String), decode::Error> {
            match user_state
                .get_all::<Identity>()?
                .find_map(|mm_identity| match mm_identity {
                    Ok(m_identity) => match m_identity {
                        Ok(identity) => {
                            let snapshot = identity.snapshot();
                            if snapshot.name().contains(value)
                                || snapshot.login_name().is_some_and(|v| v.contains(value))
                            {
                                Some(Ok::<_, decode::Error>((
                                    IdentityStub { id: identity.id() },
                                    snapshot.name().to_owned(),
                                )))
                            } else {
                                None
                            }
                        }
                        Err(err) => Some(Err(err.into())),
                    },
                    Err(err) => Some(Err(err.into())),
                }) {
                Some(val) => val,
                None => Err(decode::Error::NoIdentityMatches(value.to_owned())),
            }
        }

        match key {
            "status" => Ok(MatchKeyValue::Status(Status::from_str(&value)?)),
            "author" => {
                let (resolved_id, name) = get_identity_stub_by_name(user_state, &value)?;
                Ok(MatchKeyValue::Author { resolved_id, name })
            }
            "participant" => {
                let (resolved_id, name) = get_identity_stub_by_name(user_state, &value)?;
                Ok(MatchKeyValue::Participant { resolved_id, name })
            }
            "label" => Ok(MatchKeyValue::Label(Label::from(value.as_str()))),
            "title" => Ok(MatchKeyValue::Title(value)),
            "empty" => Ok(MatchKeyValue::Empty(EmptyValue::from_str(&value)?)),
            "search" => Ok(MatchKeyValue::Search(value)),
            "body" => Ok(MatchKeyValue::Body(value)),
            _ => Err(decode::Error::UnknownKey(key.to_owned())),
        }
    }

    fn from_value(user_state: &Self::UserState, value: String) -> Result<Self, Self::Err>
    where
        Self: Sized,
    {
        Self::from_key_value(user_state, "search", value)
    }

    fn to_key_and_value(&self) -> (&str, &str)
    where
        Self: Sized,
    {
        match self {
            MatchKeyValue::Status(status) => {
                let status_str = match status {
                    Status::Open => "open",
                    Status::Closed => "closed",
                };

                ("status", status_str)
            }
            MatchKeyValue::Author { name, .. } => ("author", name.as_str()),
            MatchKeyValue::Participant { name, .. } => ("participant", name.as_str()),
            MatchKeyValue::Label(label) => ("label", label.0.as_str()),
            MatchKeyValue::Title(search) => ("title", search.as_str()),
            MatchKeyValue::Empty(empty_value) => {
                let empty_value_str = match empty_value {
                    EmptyValue::Labels => "labels",
                };
                ("empty", empty_value_str)
            }
            MatchKeyValue::Body(search) => ("body", search.as_str()),
            MatchKeyValue::Search(search) => ("search", search.as_str()),
        }
    }
}