1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
//! Local audit log for OpenVTC operations.
//!
//! Provides a bounded FIFO log ([`Logs`]) that records timestamped messages
//! categorized by [`LogFamily`].
use std::{collections::VecDeque, fmt::Display};
use chrono::Utc;
use serde::{Deserialize, Serialize};
/// Category of a log message.
#[derive(Clone, Debug, Deserialize, Serialize)]
#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
pub enum LogFamily {
/// Relationship lifecycle events.
Relationship,
/// Contact management events.
Contact,
/// Task creation and completion events.
Task,
/// Configuration changes.
Config,
/// Community membership lifecycle: join submitted, admitted, rejected,
/// withdrawn, left; the credentials that carry those transitions.
///
/// Added because none of the families above covered the ceremony that
/// matters most. A join produced a detailed running commentary on the join
/// screen and *nothing* durable, so after a restart there was no record
/// that it had ever been attempted — which is exactly when you go looking.
Community,
}
impl Display for LogFamily {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let s = match self {
LogFamily::Relationship => "RELATIONSHIP",
LogFamily::Contact => "CONTACT",
LogFamily::Task => "TASK",
LogFamily::Config => "CONFIG",
LogFamily::Community => "COMMUNITY",
};
write!(f, "{}", s)
}
}
/// A single timestamped log entry.
#[derive(Clone, Debug, Deserialize, Serialize)]
#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
pub struct LogMessage {
/// When the log message was created.
pub created: chrono::DateTime<Utc>,
/// Category of this log entry.
pub type_: LogFamily,
/// Human-readable log message.
pub message: String,
}
/// Bounded FIFO log that evicts the oldest entries when the limit is reached.
#[derive(Clone, Debug, Deserialize, Serialize)]
#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
pub struct Logs {
/// Log entries in insertion order (oldest first).
pub messages: VecDeque<LogMessage>,
/// Maximum number of entries to retain.
///
/// **Not persisted**, deliberately. It is a policy constant — nothing sets
/// it but [`Default`] — and serializing it meant every config written
/// before a change pinned the *old* value forever: raising the default
/// would have silently done nothing for existing users, who are the only
/// ones with logs to lose. `skip` makes it come from code on every load.
///
/// A stored `limit` in an older config is simply ignored (nothing here
/// denies unknown fields), so no migration is needed.
#[serde(skip, default = "default_limit")]
pub limit: usize,
}
/// Retained-entry ceiling. Raised 100 → 200: at 100, a single busy session's
/// inbound traffic could evict the community-lifecycle entries that are the
/// reason to keep a log at all.
fn default_limit() -> usize {
200
}
impl Default for Logs {
fn default() -> Self {
Self {
messages: VecDeque::new(),
limit: default_limit(),
}
}
}
impl Logs {
/// Appends a new log entry, evicting the oldest entry if the limit is exceeded.
pub fn insert(&mut self, type_: LogFamily, message: String) {
self.messages.push_back(LogMessage {
created: Utc::now(),
type_,
message,
});
if self.messages.len() > self.limit {
self.messages.pop_front();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_logs_default_empty() {
let logs = Logs::default();
assert!(
logs.messages.is_empty(),
"Default Logs should have no messages"
);
assert_eq!(logs.limit, 200, "Default limit should be 200");
}
#[test]
fn test_logs_insert() {
let mut logs = Logs::default();
logs.insert(LogFamily::Config, "test message".to_string());
assert_eq!(logs.messages.len(), 1);
assert_eq!(logs.messages[0].message, "test message");
}
#[test]
fn test_logs_fifo_limit() {
let mut logs = Logs {
messages: VecDeque::new(),
limit: 3,
};
logs.insert(LogFamily::Task, "first".to_string());
logs.insert(LogFamily::Task, "second".to_string());
logs.insert(LogFamily::Task, "third".to_string());
assert_eq!(logs.messages.len(), 3);
// Inserting a fourth should evict the first (FIFO)
logs.insert(LogFamily::Task, "fourth".to_string());
assert_eq!(logs.messages.len(), 3, "Should not exceed limit");
assert_eq!(
logs.messages[0].message, "second",
"Oldest message should have been removed"
);
assert_eq!(logs.messages[2].message, "fourth");
}
#[test]
fn test_log_family_display() {
assert_eq!(format!("{}", LogFamily::Relationship), "RELATIONSHIP");
assert_eq!(format!("{}", LogFamily::Contact), "CONTACT");
assert_eq!(format!("{}", LogFamily::Task), "TASK");
assert_eq!(format!("{}", LogFamily::Config), "CONFIG");
assert_eq!(format!("{}", LogFamily::Community), "COMMUNITY");
}
/// An existing config picks up the current ceiling rather than the one it
/// was written with.
///
/// This is the whole reason `limit` is `skip`ped. It used to serialize, so
/// every config already on disk carried `"limit": 100` — and raising the
/// default would have changed nothing for exactly the users who had logs to
/// lose, while passing every test written against a fresh `Default`.
#[test]
fn a_stored_limit_does_not_pin_an_old_ceiling() {
let stored = r#"{"messages": [], "limit": 100}"#;
let logs: Logs = serde_json::from_str(stored).expect("an older config still parses");
assert_eq!(
logs.limit, 200,
"the ceiling comes from code, not from what the config was written with"
);
}
/// And it is no longer written back out, so this cannot regress.
#[test]
fn the_limit_is_not_persisted() {
let json = serde_json::to_value(Logs::default()).expect("serialises");
assert!(
json.get("limit").is_none(),
"limit is policy, not user data — persisting it is what caused the bug above"
);
}
}