git_bug/entities/issue/snapshot/
timeline.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 the [`Timeline`] trait for use in [`Issue`][`Issue`].
12
13use log::warn;
14
15use super::history_step::{
16    BodyHistoryStep, CommentHistoryStep, CommentItem, IssueHistoryStep, LabelHistoryStep,
17    NonEmptyVec, StatusHistoryStep, TitleHistoryStep,
18};
19use crate::{
20    entities::issue::{Issue, issue_operation::IssueOperationData},
21    replica::entity::{
22        Entity, id::entity_id::EntityId, operation::Operation, snapshot::timeline::Timeline,
23    },
24};
25
26/// The timeline of changes of this [`Issue`].
27#[derive(Debug, Clone)]
28pub struct IssueTimeline {
29    history: Vec<IssueHistoryStep>,
30}
31
32impl Timeline<Issue> for IssueTimeline {
33    fn new() -> Self {
34        Self {
35            history: Vec::new(),
36        }
37    }
38
39    fn from_root_operation(op: &Operation<Issue>) -> Self {
40        let mut me = Self::new();
41        let IssueOperationData::Create {
42            title,
43            message,
44            files,
45        } = &op.operation_data()
46        else {
47            // TODO(@bpeetz): Change the type for the root op. <2025-04-18>
48            unreachable!("We should have assured that this call is impossible.");
49        };
50
51        me.add_title_history(TitleHistoryStep {
52            author: op.author(),
53            title: title.to_owned(),
54            at: op.creation_time(),
55        });
56
57        me.add_body_history(BodyHistoryStep {
58            author: op.author(),
59            message: message.to_owned(),
60            files: files.to_owned(),
61            at: op.creation_time(),
62        });
63
64        me
65    }
66
67    fn add(&mut self, op: &Operation<Issue>) {
68        match &op.operation_data() {
69            IssueOperationData::AddComment { message, files } => {
70                // self.add_actor(op.author());
71                // self.add_participant(op.author());
72
73                self.add_comment_history(
74                    CommentHistoryStep {
75                        author: op.author(),
76                        message: message.to_owned(),
77                        files: files.to_owned(),
78                        at: op.creation_time(),
79                    },
80                    op.id(),
81                );
82            }
83            IssueOperationData::Create { .. } => unreachable!("Already handled in constructor"),
84            IssueOperationData::EditComment {
85                target,
86                message,
87                files,
88            } => {
89                // TODO: currently any message can be edited, even by a different author. Some
90                // signature validation is needed.
91
92                // self.add_actor(op.author());
93                self.add_comment_history(
94                    CommentHistoryStep {
95                        author: op.author(),
96                        message: message.to_owned(),
97                        files: files.to_owned(),
98                        at: op.creation_time(),
99                    },
100                    *target,
101                );
102            }
103            IssueOperationData::LabelChange { added, removed } => {
104                // self.add_actor(op.author());
105                self.add_label_history(LabelHistoryStep {
106                    author: op.author(),
107                    added: added.to_owned(),
108                    removed: removed.to_owned(),
109                    at: op.creation_time(),
110                });
111            }
112            IssueOperationData::SetMetadata {
113                target,
114                new_metadata: _,
115            } => {
116                warn!("Skipping metadata op for target: {target}");
117            }
118            IssueOperationData::SetStatus { status } => {
119                // self.add_actor(op.author());
120                self.add_status_history(StatusHistoryStep {
121                    author: op.author(),
122                    status: *status,
123                    at: op.creation_time(),
124                });
125            }
126            IssueOperationData::SetTitle { title, was: _ } => {
127                // self.add_actor(op.author());
128                self.add_title_history(TitleHistoryStep {
129                    author: op.author(),
130                    title: title.to_owned(),
131                    at: op.creation_time(),
132                });
133            }
134            IssueOperationData::Noop {} => todo!(),
135        }
136    }
137
138    fn history(&self) -> &[<Issue as Entity>::HistoryStep] {
139        &self.history
140    }
141}
142
143macro_rules! filter_history {
144    ($history:expr, $type_name:ident) => {
145        filter_history!(@iter $history, $type_name)
146    };
147    (@$mode:ident $history:expr, $type_name:ident) => {
148        $history.$mode().filter_map(|h| {
149            if let IssueHistoryStep::$type_name(a) = h {
150                Some(a)
151            } else {
152                None
153            }
154        })
155    };
156}
157
158impl IssueTimeline {
159    /// Return an iterator over the [`BodyHistorySteps`][`BodyHistoryStep`].
160    pub fn body_history(&self) -> impl Iterator<Item = &BodyHistoryStep> {
161        filter_history!(self.history, Body)
162    }
163
164    /// Return an iterator over the [`CommentItems`][`CommentItem`].
165    pub fn comments(&self) -> impl Iterator<Item = &CommentItem> {
166        filter_history!(self.history, Comment)
167    }
168
169    /// Return an iterator over the [`LabelHistorySteps`][`LabelHistoryStep`].
170    pub fn labels_history(&self) -> impl Iterator<Item = &LabelHistoryStep> {
171        filter_history!(self.history, Label)
172    }
173
174    /// Return an iterator over the [`StatusHistorySteps`][`StatusHistoryStep`].
175    pub fn status_history(&self) -> impl Iterator<Item = &StatusHistoryStep> {
176        filter_history!(self.history, Status)
177    }
178
179    /// Return an iterator over the [`TitleHistorySteps`][`TitleHistoryStep`].
180    pub fn title_history(&self) -> impl Iterator<Item = &TitleHistoryStep> {
181        filter_history!(self.history, Title)
182    }
183
184    // Mutating
185
186    /// Add an [`BodyHistoryStep`] to this [`Timeline`].
187    pub fn add_body_history(&mut self, item: BodyHistoryStep) {
188        self.history
189            .push(<Issue as Entity>::HistoryStep::Body(item));
190    }
191
192    /// Add an [`TitleHistoryStep`] to this [`Timeline`].
193    pub fn add_title_history(&mut self, item: TitleHistoryStep) {
194        self.history
195            .push(<Issue as Entity>::HistoryStep::Title(item));
196    }
197
198    /// Add an [`StatusHistoryStep`] to this [`Timeline`].
199    pub fn add_status_history(&mut self, item: StatusHistoryStep) {
200        self.history
201            .push(<Issue as Entity>::HistoryStep::Status(item));
202    }
203
204    /// Add an [`LabelHistoryStep`] to this [`Timeline`].
205    pub fn add_label_history(&mut self, item: LabelHistoryStep) {
206        self.history
207            .push(<Issue as Entity>::HistoryStep::Label(item));
208    }
209
210    /// Add an [`CommentHistoryStep`] to this [`Timeline`].
211    ///
212    /// This also needs the [`EntityId`] of the comment, this history step targets.
213    /// If a comment with this Id is not yet recorded, it is added.
214    pub fn add_comment_history(&mut self, item: CommentHistoryStep, id: EntityId<Issue>) {
215        if let Some(comment) = filter_history!(@iter_mut self.history, Comment).find(|c| c.id == id)
216        {
217            comment.history.push(item);
218        } else {
219            self.history
220                .push(<Issue as Entity>::HistoryStep::Comment(CommentItem {
221                    id,
222                    history: NonEmptyVec::new(item),
223                }));
224        }
225    }
226}