1#![allow(dead_code)]
25
26use crate::{FrameRate, Timecode, TimecodeError};
27use std::fmt;
28
29#[derive(
31 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
32)]
33pub enum LogLevel {
34 Debug = 0,
36 Info = 1,
38 Warning = 2,
40 Error = 3,
42 Critical = 4,
44}
45
46impl fmt::Display for LogLevel {
47 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
48 match self {
49 LogLevel::Debug => write!(f, "DEBUG"),
50 LogLevel::Info => write!(f, "INFO"),
51 LogLevel::Warning => write!(f, "WARN"),
52 LogLevel::Error => write!(f, "ERROR"),
53 LogLevel::Critical => write!(f, "CRITICAL"),
54 }
55 }
56}
57
58#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
60pub struct LogEntry {
61 pub timecode: Timecode,
63 pub level: LogLevel,
65 pub message: String,
67 pub category: Option<String>,
69 pub metadata: std::collections::HashMap<String, String>,
71 pub wall_clock_secs: Option<i64>,
73}
74
75impl LogEntry {
76 #[must_use]
78 pub fn new(timecode: Timecode, level: LogLevel, message: impl Into<String>) -> Self {
79 Self {
80 timecode,
81 level,
82 message: message.into(),
83 category: None,
84 metadata: std::collections::HashMap::new(),
85 wall_clock_secs: None,
86 }
87 }
88
89 #[must_use]
91 pub fn with_category(mut self, category: impl Into<String>) -> Self {
92 self.category = Some(category.into());
93 self
94 }
95
96 #[must_use]
98 pub fn with_meta(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
99 self.metadata.insert(key.into(), value.into());
100 self
101 }
102
103 #[must_use]
105 pub fn with_wall_clock(mut self, secs: i64) -> Self {
106 self.wall_clock_secs = Some(secs);
107 self
108 }
109
110 #[must_use]
112 pub fn format_line(&self) -> String {
113 let cat = self
114 .category
115 .as_deref()
116 .map(|c| format!("[{c}] "))
117 .unwrap_or_default();
118 format!("{} {}{} {}", self.timecode, cat, self.level, self.message)
119 }
120}
121
122impl fmt::Display for LogEntry {
123 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
124 write!(f, "{}", self.format_line())
125 }
126}
127
128#[derive(Debug, Default)]
130pub struct LogFilter {
131 pub min_level: Option<LogLevel>,
133 pub category: Option<String>,
135 pub message_contains: Option<String>,
137 pub range_start: Option<Timecode>,
139 pub range_end: Option<Timecode>,
141}
142
143impl LogFilter {
144 #[must_use]
146 pub fn all() -> Self {
147 Self::default()
148 }
149
150 #[must_use]
152 pub fn with_min_level(mut self, level: LogLevel) -> Self {
153 self.min_level = Some(level);
154 self
155 }
156
157 #[must_use]
159 pub fn with_category(mut self, cat: impl Into<String>) -> Self {
160 self.category = Some(cat.into());
161 self
162 }
163
164 #[must_use]
166 pub fn with_message(mut self, msg: impl Into<String>) -> Self {
167 self.message_contains = Some(msg.into());
168 self
169 }
170
171 #[must_use]
173 pub fn with_range(mut self, start: Timecode, end: Timecode) -> Self {
174 self.range_start = Some(start);
175 self.range_end = Some(end);
176 self
177 }
178
179 #[must_use]
181 pub fn matches(&self, entry: &LogEntry) -> bool {
182 if let Some(min) = self.min_level {
183 if entry.level < min {
184 return false;
185 }
186 }
187 if let Some(ref cat) = self.category {
188 if entry.category.as_deref() != Some(cat.as_str()) {
189 return false;
190 }
191 }
192 if let Some(ref needle) = self.message_contains {
193 if !entry.message.contains(needle.as_str()) {
194 return false;
195 }
196 }
197 if let Some(ref start) = self.range_start {
198 if entry.timecode < *start {
199 return false;
200 }
201 }
202 if let Some(ref end) = self.range_end {
203 if entry.timecode > *end {
204 return false;
205 }
206 }
207 true
208 }
209}
210
211#[derive(Debug)]
216pub struct TimecodeLog {
217 pub name: String,
219 entries: Vec<LogEntry>,
221}
222
223impl TimecodeLog {
224 #[must_use]
226 pub fn new(name: impl Into<String>) -> Self {
227 Self {
228 name: name.into(),
229 entries: Vec::new(),
230 }
231 }
232
233 pub fn record(&mut self, timecode: Timecode, level: LogLevel, message: impl Into<String>) {
235 let entry = LogEntry::new(timecode, level, message);
236 self.insert_sorted(entry);
237 }
238
239 pub fn insert(&mut self, entry: LogEntry) {
241 self.insert_sorted(entry);
242 }
243
244 fn insert_sorted(&mut self, entry: LogEntry) {
245 let pos = self
246 .entries
247 .partition_point(|e| e.timecode <= entry.timecode);
248 self.entries.insert(pos, entry);
249 }
250
251 #[must_use]
253 pub fn query(&self, filter: &LogFilter) -> Vec<&LogEntry> {
254 self.entries.iter().filter(|e| filter.matches(e)).collect()
255 }
256
257 #[must_use]
259 pub fn all_entries(&self) -> &[LogEntry] {
260 &self.entries
261 }
262
263 #[must_use]
265 pub fn len(&self) -> usize {
266 self.entries.len()
267 }
268
269 #[must_use]
271 pub fn is_empty(&self) -> bool {
272 self.entries.is_empty()
273 }
274
275 pub fn clear(&mut self) {
277 self.entries.clear();
278 }
279
280 #[must_use]
282 pub fn to_text(&self) -> String {
283 let mut out = format!("# Timecode Log: {}\n", self.name);
284 out.push_str(&format!("# Entries: {}\n\n", self.entries.len()));
285 for entry in &self.entries {
286 out.push_str(&format!("{}\n", entry.format_line()));
287 }
288 out
289 }
290
291 #[must_use]
293 pub fn to_csv(&self) -> String {
294 let mut out = String::from("timecode,level,category,message\n");
295 for entry in &self.entries {
296 let cat = entry.category.as_deref().unwrap_or("");
297 let msg = entry.message.replace('"', "\"\"");
299 out.push_str(&format!(
300 "{},{},{},\"{}\"\n",
301 entry.timecode, entry.level, cat, msg
302 ));
303 }
304 out
305 }
306
307 #[must_use]
309 pub fn first_at_or_after(&self, tc: &Timecode) -> Option<&LogEntry> {
310 let pos = self.entries.partition_point(|e| &e.timecode < tc);
311 self.entries.get(pos)
312 }
313
314 #[must_use]
316 pub fn entries_in_range(&self, start: &Timecode, end: &Timecode) -> Vec<&LogEntry> {
317 self.entries
318 .iter()
319 .filter(|e| &e.timecode >= start && &e.timecode <= end)
320 .collect()
321 }
322
323 pub fn from_csv(
329 name: impl Into<String>,
330 csv: &str,
331 frame_rate: FrameRate,
332 ) -> Result<Self, TimecodeError> {
333 let mut log = Self::new(name);
334 for (line_num, line) in csv.lines().enumerate() {
335 if line_num == 0 || line.trim().is_empty() {
336 continue; }
338 let parts: Vec<&str> = line.splitn(4, ',').collect();
339 if parts.len() < 4 {
340 continue;
341 }
342 let tc_str = parts[0].trim();
343 let level_str = parts[1].trim();
344 let cat_str = parts[2].trim();
345 let msg = parts[3].trim().trim_matches('"').replace("\"\"", "\"");
346
347 let tc = Timecode::from_string(tc_str, frame_rate)?;
348 let level = match level_str {
349 "DEBUG" => LogLevel::Debug,
350 "INFO" => LogLevel::Info,
351 "WARN" => LogLevel::Warning,
352 "ERROR" => LogLevel::Error,
353 "CRITICAL" => LogLevel::Critical,
354 _ => LogLevel::Info,
355 };
356 let mut entry = LogEntry::new(tc, level, msg);
357 if !cat_str.is_empty() {
358 entry.category = Some(cat_str.to_string());
359 }
360 log.insert_sorted(entry);
361 }
362 Ok(log)
363 }
364}
365
366#[cfg(test)]
367mod tests {
368 use super::*;
369 use crate::FrameRate;
370
371 fn tc(h: u8, m: u8, s: u8, f: u8) -> Timecode {
372 Timecode::new(h, m, s, f, FrameRate::Fps25).expect("valid tc")
373 }
374
375 #[test]
376 fn test_record_and_query() {
377 let mut log = TimecodeLog::new("Test");
378 log.record(tc(1, 0, 0, 0), LogLevel::Info, "A");
379 log.record(tc(0, 0, 0, 0), LogLevel::Warning, "B");
380 assert_eq!(log.len(), 2);
381 assert_eq!(log.all_entries()[0].timecode, tc(0, 0, 0, 0));
383 }
384
385 #[test]
386 fn test_filter_by_level() {
387 let mut log = TimecodeLog::new("Test");
388 log.record(tc(0, 0, 1, 0), LogLevel::Debug, "debug");
389 log.record(tc(0, 0, 2, 0), LogLevel::Warning, "warn");
390 let filter = LogFilter::all().with_min_level(LogLevel::Warning);
391 let results = log.query(&filter);
392 assert_eq!(results.len(), 1);
393 assert_eq!(results[0].level, LogLevel::Warning);
394 }
395
396 #[test]
397 fn test_csv_round_trip() {
398 let mut log = TimecodeLog::new("Trip");
399 log.record(tc(1, 2, 3, 4), LogLevel::Info, "hello");
400 let csv = log.to_csv();
401 let log2 = TimecodeLog::from_csv("Trip", &csv, FrameRate::Fps25).expect("csv parse ok");
402 assert_eq!(log2.len(), 1);
403 assert_eq!(log2.all_entries()[0].message, "hello");
404 }
405
406 #[test]
407 fn test_to_text_contains_header() {
408 let log = TimecodeLog::new("MyProd");
409 let text = log.to_text();
410 assert!(text.contains("MyProd"));
411 }
412
413 #[test]
414 fn test_entries_in_range() {
415 let mut log = TimecodeLog::new("Range");
416 log.record(tc(0, 0, 1, 0), LogLevel::Info, "in");
417 log.record(tc(0, 0, 2, 0), LogLevel::Info, "also-in");
418 log.record(tc(0, 0, 5, 0), LogLevel::Info, "out");
419 let results = log.entries_in_range(&tc(0, 0, 0, 0), &tc(0, 0, 3, 0));
420 assert_eq!(results.len(), 2);
421 }
422
423 #[test]
424 fn test_filter_by_category() {
425 let mut log = TimecodeLog::new("Cat");
426 let e1 = LogEntry::new(tc(0, 0, 0, 0), LogLevel::Info, "a").with_category("QC");
427 let e2 = LogEntry::new(tc(0, 0, 1, 0), LogLevel::Info, "b").with_category("EDITORIAL");
428 log.insert(e1);
429 log.insert(e2);
430 let filter = LogFilter::all().with_category("QC");
431 let results = log.query(&filter);
432 assert_eq!(results.len(), 1);
433 assert_eq!(results[0].message, "a");
434 }
435
436 #[test]
437 fn test_filter_by_message() {
438 let mut log = TimecodeLog::new("Msg");
439 log.record(tc(0, 0, 0, 0), LogLevel::Info, "scene start");
440 log.record(tc(0, 0, 1, 0), LogLevel::Info, "cut here");
441 let filter = LogFilter::all().with_message("scene");
442 let results = log.query(&filter);
443 assert_eq!(results.len(), 1);
444 }
445
446 #[test]
447 fn test_first_at_or_after() {
448 let mut log = TimecodeLog::new("First");
449 log.record(tc(0, 0, 1, 0), LogLevel::Info, "first");
450 log.record(tc(0, 0, 5, 0), LogLevel::Info, "second");
451 let found = log.first_at_or_after(&tc(0, 0, 3, 0));
452 assert!(found.is_some());
453 assert_eq!(found.map(|e| e.message.as_str()), Some("second"));
454 }
455
456 #[test]
457 fn test_log_entry_format_line() {
458 let e = LogEntry::new(tc(1, 0, 0, 0), LogLevel::Error, "bad frame").with_category("QC");
459 let line = e.format_line();
460 assert!(line.contains("01:00:00:00"));
461 assert!(line.contains("[QC]"));
462 assert!(line.contains("ERROR"));
463 assert!(line.contains("bad frame"));
464 }
465
466 #[test]
467 fn test_log_clear() {
468 let mut log = TimecodeLog::new("Clear");
469 log.record(tc(0, 0, 0, 0), LogLevel::Info, "hello");
470 assert!(!log.is_empty());
471 log.clear();
472 assert!(log.is_empty());
473 assert_eq!(log.len(), 0);
474 }
475
476 #[test]
477 fn test_log_entry_with_metadata() {
478 let e = LogEntry::new(tc(0, 0, 0, 0), LogLevel::Info, "take 1")
479 .with_meta("camera", "A")
480 .with_meta("lens", "50mm");
481 assert_eq!(e.metadata.len(), 2);
482 assert_eq!(e.metadata.get("camera").map(String::as_str), Some("A"));
483 }
484
485 #[test]
486 fn test_log_entry_wall_clock() {
487 let e = LogEntry::new(tc(0, 0, 0, 0), LogLevel::Info, "x").with_wall_clock(1_700_000_000);
488 assert_eq!(e.wall_clock_secs, Some(1_700_000_000));
489 }
490}