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 the [`Timeline`] trait for use in [`Issue`][`Issue`].

use log::warn;

use super::history_step::{
    BodyHistoryStep, CommentHistoryStep, CommentItem, IssueHistoryStep, LabelHistoryStep,
    NonEmptyVec, StatusHistoryStep, TitleHistoryStep,
};
use crate::{
    entities::issue::{Issue, issue_operation::IssueOperationData},
    replica::entity::{
        Entity, id::entity_id::EntityId, operation::Operation, snapshot::timeline::Timeline,
    },
};

/// The timeline of changes of this [`Issue`].
#[derive(Debug, Clone)]
pub struct IssueTimeline {
    history: Vec<IssueHistoryStep>,
}

impl Timeline<Issue> for IssueTimeline {
    fn new() -> Self {
        Self {
            history: Vec::new(),
        }
    }

    fn from_root_operation(op: &Operation<Issue>) -> Self {
        let mut me = Self::new();
        let IssueOperationData::Create {
            title,
            message,
            files,
        } = &op.operation_data()
        else {
            // TODO(@bpeetz): Change the type for the root op. <2025-04-18>
            unreachable!("We should have assured that this call is impossible.");
        };

        me.add_title_history(TitleHistoryStep {
            author: op.author(),
            title: title.to_owned(),
            at: op.creation_time(),
        });

        me.add_body_history(BodyHistoryStep {
            author: op.author(),
            message: message.to_owned(),
            files: files.to_owned(),
            at: op.creation_time(),
        });

        me
    }

    fn add(&mut self, op: &Operation<Issue>) {
        match &op.operation_data() {
            IssueOperationData::AddComment { message, files } => {
                // self.add_actor(op.author());
                // self.add_participant(op.author());

                self.add_comment_history(
                    CommentHistoryStep {
                        author: op.author(),
                        message: message.to_owned(),
                        files: files.to_owned(),
                        at: op.creation_time(),
                    },
                    op.id(),
                );
            }
            IssueOperationData::Create { .. } => unreachable!("Already handled in constructor"),
            IssueOperationData::EditComment {
                target,
                message,
                files,
            } => {
                // TODO: currently any message can be edited, even by a different author. Some
                // signature validation is needed.

                // self.add_actor(op.author());
                self.add_comment_history(
                    CommentHistoryStep {
                        author: op.author(),
                        message: message.to_owned(),
                        files: files.to_owned(),
                        at: op.creation_time(),
                    },
                    *target,
                );
            }
            IssueOperationData::LabelChange { added, removed } => {
                // self.add_actor(op.author());
                self.add_label_history(LabelHistoryStep {
                    author: op.author(),
                    added: added.to_owned(),
                    removed: removed.to_owned(),
                    at: op.creation_time(),
                });
            }
            IssueOperationData::SetMetadata {
                target,
                new_metadata: _,
            } => {
                warn!("Skipping metadata op for target: {target}");
            }
            IssueOperationData::SetStatus { status } => {
                // self.add_actor(op.author());
                self.add_status_history(StatusHistoryStep {
                    author: op.author(),
                    status: *status,
                    at: op.creation_time(),
                });
            }
            IssueOperationData::SetTitle { title, was: _ } => {
                // self.add_actor(op.author());
                self.add_title_history(TitleHistoryStep {
                    author: op.author(),
                    title: title.to_owned(),
                    at: op.creation_time(),
                });
            }
            IssueOperationData::Noop {} => todo!(),
        }
    }

    fn history(&self) -> &[<Issue as Entity>::HistoryStep] {
        &self.history
    }
}

macro_rules! filter_history {
    ($history:expr, $type_name:ident) => {
        filter_history!(@iter $history, $type_name)
    };
    (@$mode:ident $history:expr, $type_name:ident) => {
        $history.$mode().filter_map(|h| {
            if let IssueHistoryStep::$type_name(a) = h {
                Some(a)
            } else {
                None
            }
        })
    };
}

impl IssueTimeline {
    /// Return an iterator over the [`BodyHistorySteps`][`BodyHistoryStep`].
    pub fn body_history(&self) -> impl Iterator<Item = &BodyHistoryStep> {
        filter_history!(self.history, Body)
    }

    /// Return an iterator over the [`CommentItems`][`CommentItem`].
    pub fn comments(&self) -> impl Iterator<Item = &CommentItem> {
        filter_history!(self.history, Comment)
    }

    /// Return an iterator over the [`LabelHistorySteps`][`LabelHistoryStep`].
    pub fn labels_history(&self) -> impl Iterator<Item = &LabelHistoryStep> {
        filter_history!(self.history, Label)
    }

    /// Return an iterator over the [`StatusHistorySteps`][`StatusHistoryStep`].
    pub fn status_history(&self) -> impl Iterator<Item = &StatusHistoryStep> {
        filter_history!(self.history, Status)
    }

    /// Return an iterator over the [`TitleHistorySteps`][`TitleHistoryStep`].
    pub fn title_history(&self) -> impl Iterator<Item = &TitleHistoryStep> {
        filter_history!(self.history, Title)
    }

    // Mutating

    /// Add an [`BodyHistoryStep`] to this [`Timeline`].
    pub fn add_body_history(&mut self, item: BodyHistoryStep) {
        self.history
            .push(<Issue as Entity>::HistoryStep::Body(item));
    }

    /// Add an [`TitleHistoryStep`] to this [`Timeline`].
    pub fn add_title_history(&mut self, item: TitleHistoryStep) {
        self.history
            .push(<Issue as Entity>::HistoryStep::Title(item));
    }

    /// Add an [`StatusHistoryStep`] to this [`Timeline`].
    pub fn add_status_history(&mut self, item: StatusHistoryStep) {
        self.history
            .push(<Issue as Entity>::HistoryStep::Status(item));
    }

    /// Add an [`LabelHistoryStep`] to this [`Timeline`].
    pub fn add_label_history(&mut self, item: LabelHistoryStep) {
        self.history
            .push(<Issue as Entity>::HistoryStep::Label(item));
    }

    /// Add an [`CommentHistoryStep`] to this [`Timeline`].
    ///
    /// This also needs the [`EntityId`] of the comment, this history step targets.
    /// If a comment with this Id is not yet recorded, it is added.
    pub fn add_comment_history(&mut self, item: CommentHistoryStep, id: EntityId<Issue>) {
        if let Some(comment) = filter_history!(@iter_mut self.history, Comment).find(|c| c.id == id)
        {
            comment.history.push(item);
        } else {
            self.history
                .push(<Issue as Entity>::HistoryStep::Comment(CommentItem {
                    id,
                    history: NonEmptyVec::new(item),
                }));
        }
    }
}