git_bug/entities/issue/query/
mod.rs

1// git-bug-rs - A rust library for interfacing with git-bug repositories
2//
3// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de>
4// SPDX-License-Identifier: GPL-3.0-or-later
5//
6// This file is part of git-bug-rs/git-gub.
7//
8// You should have received a copy of the License along with this program.
9// If not, see <https://www.gnu.org/licenses/agpl.txt>.
10
11//! Implementation of [`Queryable`] for [`Issue`]
12
13use std::str::FromStr;
14
15use super::{
16    Issue,
17    data::{label::Label, status::Status},
18};
19use crate::{
20    entities::identity::Identity,
21    query::queryable::{QueryKeyValue, Queryable},
22    replica::{
23        Replica,
24        entity::{Entity, identity::IdentityStub, snapshot::Snapshot},
25    },
26};
27
28impl Queryable for Snapshot<Issue> {
29    type KeyValue = MatchKeyValue;
30
31    fn matches(&self, key: &Self::KeyValue) -> bool {
32        match key {
33            MatchKeyValue::Status(status) => self.status() == *status,
34            MatchKeyValue::Author { resolved_id, .. } => self.author() == *resolved_id,
35            MatchKeyValue::Participant { resolved_id, .. } => {
36                self.participants().any(|other| other == *resolved_id)
37            }
38            MatchKeyValue::Label(label) => self.labels().contains(label),
39            MatchKeyValue::Title(search) => self.title().contains(search),
40            MatchKeyValue::Empty(value) => match value {
41                EmptyValue::Labels => self.labels().is_empty(),
42            },
43            MatchKeyValue::Search(search) => {
44                self.body().contains(search) || self.title().contains(search)
45            }
46            MatchKeyValue::Body(search) => self.body().contains(search),
47        }
48    }
49}
50
51/// The possible keys, that are usable in a query for issues.
52///
53/// ## Following pairs are supported
54/// ### `status`
55/// possible values: [open, closed]
56///
57/// This only matches issues where the status is the same.
58///
59/// ### `author`
60/// value: pattern.
61///
62/// This only matches issues where the value contains the author's name or login
63/// name.
64///
65/// ### `participant`
66/// value: pattern.
67///
68/// This only matches issues where one of the participating user's name or login
69/// names contains the value.
70///
71/// ### `label`
72/// value: string.
73///
74/// This only matches issues where one of the labels equals the value.
75///
76/// ### `title`
77/// value: pattern.
78///
79/// This only matches issues where the title contains the value.
80///
81/// ### `empty`
82/// possible values: \[label\]
83///
84/// Matches issues where the value is empty (e.g., `no:label` matches issues
85/// without label)
86// /// ### `sort`
87// /// possible values: [edit-{desc,asc}, creation-{desc,asc}, id-{desc,asc}]
88/// ### `search`
89/// value: pattern.
90///
91/// Matches issue where either the body or the title contain the value.
92/// This is the implied default if a value does not have a key.
93#[derive(Debug, Clone)]
94pub enum MatchKeyValue {
95    /// Filter by issue status
96    Status(Status),
97
98    /// Filter by issue author
99    Author {
100        /// The [`IdentityStub`] of the specified
101        /// [`Identity`][`crate::entities::identity::Identity`] we are searching
102        /// for.
103        resolved_id: IdentityStub,
104
105        /// The resolved name of the author.
106        ///
107        /// This is needed for the query normalization.
108        name: String,
109    },
110
111    /// Filter by issue participant
112    Participant {
113        /// The [`IdentityStub`] of the specified
114        /// [`Identity`][`crate::entities::identity::Identity`] we are searching
115        /// for.
116        resolved_id: IdentityStub,
117
118        /// The resolved name of the participant.
119        ///
120        /// This is needed for the query normalization.
121        name: String,
122    },
123
124    /// Filter by issue label
125    Label(Label),
126    /// Filter by empty field
127    Empty(EmptyValue),
128
129    /// Filter by issue title
130    Title(String),
131
132    /// Filter by string in issue body
133    Body(String),
134
135    // TODO(@bpeetz): This is impossible to implement via the one-by-one API. <2025-05-10>
136    // /// Sort by field
137    // Sort,
138    /// Filter by string in issue body or title
139    Search(String),
140}
141
142#[allow(missing_docs)]
143pub mod decode {
144    use crate::entities::{identity::Identity, issue::data::status};
145
146    #[derive(Debug, thiserror::Error)]
147    pub enum Error {
148        #[error("Unknown Issue match key: {0}")]
149        UnknownKey(String),
150
151        #[error("Failed to parse the status value: {0}")]
152        UnknowStatusValue(#[from] status::decode::Error),
153
154        #[error("Failed to parse the empty value: {0} (valid ones are: 'labels')")]
155        UnknownEmptyValue(String),
156
157        #[error("Failed to read an Identity specified via the author or participant key: {0}")]
158        IdentityRead(#[from] crate::replica::entity::read::Error<Identity>),
159
160        #[error(
161            "Failed to get the reference of an Identity specified via the author or participant \
162             key: {0}"
163        )]
164        IdentityGet(#[from] crate::replica::get::Error),
165
166        #[error("The identity ('{0}') name for the author or participant key was not found.")]
167        NoIdentityMatches(String),
168    }
169}
170
171/// What is the value to check for emptiness.
172#[derive(Debug, Clone, Copy)]
173pub enum EmptyValue {
174    /// Search for no labels
175    Labels,
176}
177impl FromStr for EmptyValue {
178    type Err = decode::Error;
179
180    fn from_str(s: &str) -> Result<Self, Self::Err> {
181        let value = match s {
182            "labels" => Self::Labels,
183            other => return Err(decode::Error::UnknownEmptyValue(other.to_owned())),
184        };
185        Ok(value)
186    }
187}
188
189impl QueryKeyValue for MatchKeyValue {
190    type Err = decode::Error;
191    type UserState = Replica;
192
193    fn from_key_value(
194        user_state: &Self::UserState,
195        key: &str,
196        value: String,
197    ) -> Result<Self, Self::Err> {
198        fn get_identity_stub_by_name(
199            user_state: &Replica,
200            value: &str,
201        ) -> Result<(IdentityStub, String), decode::Error> {
202            match user_state
203                .get_all::<Identity>()?
204                .find_map(|mm_identity| match mm_identity {
205                    Ok(m_identity) => match m_identity {
206                        Ok(identity) => {
207                            let snapshot = identity.snapshot();
208                            if snapshot.name().contains(value)
209                                || snapshot.login_name().is_some_and(|v| v.contains(value))
210                            {
211                                Some(Ok::<_, decode::Error>((
212                                    IdentityStub { id: identity.id() },
213                                    snapshot.name().to_owned(),
214                                )))
215                            } else {
216                                None
217                            }
218                        }
219                        Err(err) => Some(Err(err.into())),
220                    },
221                    Err(err) => Some(Err(err.into())),
222                }) {
223                Some(val) => val,
224                None => Err(decode::Error::NoIdentityMatches(value.to_owned())),
225            }
226        }
227
228        match key {
229            "status" => Ok(MatchKeyValue::Status(Status::from_str(&value)?)),
230            "author" => {
231                let (resolved_id, name) = get_identity_stub_by_name(user_state, &value)?;
232                Ok(MatchKeyValue::Author { resolved_id, name })
233            }
234            "participant" => {
235                let (resolved_id, name) = get_identity_stub_by_name(user_state, &value)?;
236                Ok(MatchKeyValue::Participant { resolved_id, name })
237            }
238            "label" => Ok(MatchKeyValue::Label(Label::from(value.as_str()))),
239            "title" => Ok(MatchKeyValue::Title(value)),
240            "empty" => Ok(MatchKeyValue::Empty(EmptyValue::from_str(&value)?)),
241            "search" => Ok(MatchKeyValue::Search(value)),
242            "body" => Ok(MatchKeyValue::Body(value)),
243            _ => Err(decode::Error::UnknownKey(key.to_owned())),
244        }
245    }
246
247    fn from_value(user_state: &Self::UserState, value: String) -> Result<Self, Self::Err>
248    where
249        Self: Sized,
250    {
251        Self::from_key_value(user_state, "search", value)
252    }
253
254    fn to_key_and_value(&self) -> (&str, &str)
255    where
256        Self: Sized,
257    {
258        match self {
259            MatchKeyValue::Status(status) => {
260                let status_str = match status {
261                    Status::Open => "open",
262                    Status::Closed => "closed",
263                };
264
265                ("status", status_str)
266            }
267            MatchKeyValue::Author { name, .. } => ("author", name.as_str()),
268            MatchKeyValue::Participant { name, .. } => ("participant", name.as_str()),
269            MatchKeyValue::Label(label) => ("label", label.0.as_str()),
270            MatchKeyValue::Title(search) => ("title", search.as_str()),
271            MatchKeyValue::Empty(empty_value) => {
272                let empty_value_str = match empty_value {
273                    EmptyValue::Labels => "labels",
274                };
275                ("empty", empty_value_str)
276            }
277            MatchKeyValue::Body(search) => ("body", search.as_str()),
278            MatchKeyValue::Search(search) => ("search", search.as_str()),
279        }
280    }
281}