Skip to main content

evault_core/model/
audit.rs

1//! [`AuditEntry`] records every state-changing operation on the registry.
2
3use std::fmt;
4
5use serde::{Deserialize, Serialize};
6use time::OffsetDateTime;
7use uuid::Uuid;
8
9use crate::model::{ProjectId, VarId};
10
11/// Stable identifier of an [`AuditEntry`].
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
13pub struct AuditId(Uuid);
14
15impl AuditId {
16    /// Generate a fresh identifier.
17    #[must_use]
18    pub fn new_v4() -> Self {
19        Self(Uuid::new_v4())
20    }
21
22    /// Wrap an existing [`Uuid`].
23    #[must_use]
24    pub const fn from_uuid(id: Uuid) -> Self {
25        Self(id)
26    }
27
28    /// Borrow the inner [`Uuid`].
29    #[must_use]
30    pub const fn as_uuid(&self) -> &Uuid {
31        &self.0
32    }
33}
34
35impl fmt::Display for AuditId {
36    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
37        fmt::Display::fmt(&self.0, f)
38    }
39}
40
41/// Classification of state-changing operations.
42///
43/// Each variant maps to a high-level user-visible action; the dashboard's
44/// audit view groups entries by action.
45#[non_exhaustive]
46#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
47pub enum AuditAction {
48    /// A new [`crate::model::Var`] was created.
49    Created,
50    /// An existing [`crate::model::Var`] was modified.
51    Updated,
52    /// A [`crate::model::Var`] was deleted.
53    Deleted,
54    /// A [`crate::model::Var`] was linked to a [`crate::model::Project`].
55    Linked,
56    /// A [`crate::model::Var`] was unlinked from a [`crate::model::Project`].
57    Unlinked,
58    /// A `.env` file was materialized for a project.
59    Materialized,
60    /// `evault run` injected env vars into a child process.
61    Run,
62    /// A [`crate::model::Var`] value was copied to the clipboard.
63    Copied,
64    /// A custom action recorded by an extension.
65    Custom(String),
66}
67
68impl AuditAction {
69    /// Returns the canonical short name used for display.
70    #[must_use]
71    pub const fn as_str(&self) -> &str {
72        match self {
73            Self::Created => "created",
74            Self::Updated => "updated",
75            Self::Deleted => "deleted",
76            Self::Linked => "linked",
77            Self::Unlinked => "unlinked",
78            Self::Materialized => "materialized",
79            Self::Run => "run",
80            Self::Copied => "copied",
81            Self::Custom(s) => s.as_str(),
82        }
83    }
84}
85
86/// A single auditable event.
87///
88/// Audit entries are append-only. They are persisted in the
89/// [`AuditSink`](crate::traits::AuditSink) and may be displayed by the TUI.
90///
91/// # Examples
92/// ```
93/// use evault_core::model::{AuditAction, AuditEntry, VarId};
94/// let entry = AuditEntry::for_var(VarId::new_v4(), AuditAction::Created);
95/// assert_eq!(entry.action(), &AuditAction::Created);
96/// ```
97#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
98pub struct AuditEntry {
99    id: AuditId,
100    action: AuditAction,
101    var_id: Option<VarId>,
102    project_id: Option<ProjectId>,
103    note: Option<String>,
104    #[serde(with = "time::serde::rfc3339")]
105    at: OffsetDateTime,
106}
107
108impl AuditEntry {
109    /// Create a new audit entry referencing a single variable.
110    #[must_use]
111    pub fn for_var(var_id: VarId, action: AuditAction) -> Self {
112        Self {
113            id: AuditId::new_v4(),
114            action,
115            var_id: Some(var_id),
116            project_id: None,
117            note: None,
118            at: OffsetDateTime::now_utc(),
119        }
120    }
121
122    /// Create a new audit entry referencing a project (and optionally a var).
123    #[must_use]
124    pub fn for_project(project_id: ProjectId, var_id: Option<VarId>, action: AuditAction) -> Self {
125        Self {
126            id: AuditId::new_v4(),
127            action,
128            var_id,
129            project_id: Some(project_id),
130            note: None,
131            at: OffsetDateTime::now_utc(),
132        }
133    }
134
135    /// Attach a free-form note (e.g. the command that was executed).
136    #[must_use]
137    pub fn with_note(mut self, note: impl Into<String>) -> Self {
138        self.note = Some(note.into());
139        self
140    }
141
142    /// Rehydrate an [`AuditEntry`] from already-stored fields.
143    #[must_use]
144    #[allow(clippy::too_many_arguments)]
145    pub const fn from_parts(
146        id: AuditId,
147        action: AuditAction,
148        var_id: Option<VarId>,
149        project_id: Option<ProjectId>,
150        note: Option<String>,
151        at: OffsetDateTime,
152    ) -> Self {
153        Self {
154            id,
155            action,
156            var_id,
157            project_id,
158            note,
159            at,
160        }
161    }
162
163    /// Returns the entry's stable identifier.
164    #[must_use]
165    pub const fn id(&self) -> AuditId {
166        self.id
167    }
168
169    /// Returns the action recorded by this entry.
170    #[must_use]
171    pub const fn action(&self) -> &AuditAction {
172        &self.action
173    }
174
175    /// Returns the referenced variable, if any.
176    #[must_use]
177    pub const fn var_id(&self) -> Option<VarId> {
178        self.var_id
179    }
180
181    /// Returns the referenced project, if any.
182    #[must_use]
183    pub const fn project_id(&self) -> Option<ProjectId> {
184        self.project_id
185    }
186
187    /// Returns the free-form note, if any.
188    #[must_use]
189    pub fn note(&self) -> Option<&str> {
190        self.note.as_deref()
191    }
192
193    /// Returns the timestamp the entry was recorded (UTC).
194    #[must_use]
195    pub const fn at(&self) -> OffsetDateTime {
196        self.at
197    }
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203
204    #[test]
205    fn for_var_records_action_and_var() {
206        let v = VarId::new_v4();
207        let e = AuditEntry::for_var(v, AuditAction::Created);
208        assert_eq!(e.var_id(), Some(v));
209        assert_eq!(e.project_id(), None);
210        assert_eq!(e.action(), &AuditAction::Created);
211    }
212
213    #[test]
214    fn for_project_records_both_ids() {
215        let p = ProjectId::new_v4();
216        let v = VarId::new_v4();
217        let e = AuditEntry::for_project(p, Some(v), AuditAction::Linked);
218        assert_eq!(e.project_id(), Some(p));
219        assert_eq!(e.var_id(), Some(v));
220    }
221
222    #[test]
223    fn with_note_attaches_text() {
224        let e =
225            AuditEntry::for_var(VarId::new_v4(), AuditAction::Updated).with_note("changed length");
226        assert_eq!(e.note(), Some("changed length"));
227    }
228
229    #[test]
230    fn action_as_str_uses_canonical_names() {
231        assert_eq!(AuditAction::Created.as_str(), "created");
232        assert_eq!(AuditAction::Custom("imported".into()).as_str(), "imported");
233    }
234}