Skip to main content

rust_serv/access_log/
formatter.rs

1//! Access log entry and formatting
2
3use std::time::SystemTime;
4
5/// Access log entry
6#[derive(Debug, Clone)]
7pub struct AccessLogEntry {
8    /// Client IP address
9    pub client_ip: String,
10    /// Request timestamp
11    pub timestamp: SystemTime,
12    /// HTTP method
13    pub method: String,
14    /// Request path
15    pub path: String,
16    /// HTTP version
17    pub version: String,
18    /// Response status code
19    pub status: u16,
20    /// Response size in bytes
21    pub size: usize,
22    /// Request duration in milliseconds
23    pub duration_ms: u64,
24    /// User-Agent header
25    pub user_agent: Option<String>,
26    /// Referer header
27    pub referer: Option<String>,
28}
29
30impl AccessLogEntry {
31    /// Create a new access log entry
32    pub fn new(
33        client_ip: impl Into<String>,
34        method: impl Into<String>,
35        path: impl Into<String>,
36    ) -> Self {
37        Self {
38            client_ip: client_ip.into(),
39            timestamp: SystemTime::now(),
40            method: method.into(),
41            path: path.into(),
42            version: "HTTP/1.1".to_string(),
43            status: 200,
44            size: 0,
45            duration_ms: 0,
46            user_agent: None,
47            referer: None,
48        }
49    }
50
51    /// Set HTTP version
52    pub fn with_version(mut self, version: impl Into<String>) -> Self {
53        self.version = version.into();
54        self
55    }
56
57    /// Set response status
58    pub fn with_status(mut self, status: u16) -> Self {
59        self.status = status;
60        self
61    }
62
63    /// Set response size
64    pub fn with_size(mut self, size: usize) -> Self {
65        self.size = size;
66        self
67    }
68
69    /// Set request duration
70    pub fn with_duration_ms(mut self, duration_ms: u64) -> Self {
71        self.duration_ms = duration_ms;
72        self
73    }
74
75    /// Set User-Agent
76    pub fn with_user_agent(mut self, user_agent: impl Into<String>) -> Self {
77        self.user_agent = Some(user_agent.into());
78        self
79    }
80
81    /// Set Referer
82    pub fn with_referer(mut self, referer: impl Into<String>) -> Self {
83        self.referer = Some(referer.into());
84        self
85    }
86}
87
88/// Log format type
89#[derive(Debug, Clone, Copy, PartialEq)]
90pub enum LogFormat {
91    /// Common Log Format (CLF)
92    Common,
93    /// Combined Log Format
94    Combined,
95    /// JSON format
96    Json,
97}
98
99impl Default for LogFormat {
100    fn default() -> Self {
101        Self::Combined
102    }
103}
104
105impl LogFormat {
106    /// Format a log entry
107    pub fn format(&self, entry: &AccessLogEntry) -> String {
108        match self {
109            LogFormat::Common => self.format_common(entry),
110            LogFormat::Combined => self.format_combined(entry),
111            LogFormat::Json => self.format_json(entry),
112        }
113    }
114
115    /// Common Log Format
116    fn format_common(&self, entry: &AccessLogEntry) -> String {
117        let timestamp = self.format_timestamp(&entry.timestamp);
118        format!(
119            "{} - - [{}] \"{} {} {}\" {} {}",
120            entry.client_ip,
121            timestamp,
122            entry.method,
123            entry.path,
124            entry.version,
125            entry.status,
126            entry.size
127        )
128    }
129
130    /// Combined Log Format
131    fn format_combined(&self, entry: &AccessLogEntry) -> String {
132        let timestamp = self.format_timestamp(&entry.timestamp);
133        let referer = entry.referer.as_deref().unwrap_or("-");
134        let user_agent = entry.user_agent.as_deref().unwrap_or("-");
135        format!(
136            "{} - - [{}] \"{} {} {}\" {} {} \"{}\" \"{}\"",
137            entry.client_ip,
138            timestamp,
139            entry.method,
140            entry.path,
141            entry.version,
142            entry.status,
143            entry.size,
144            referer,
145            user_agent
146        )
147    }
148
149    /// JSON format
150    fn format_json(&self, entry: &AccessLogEntry) -> String {
151        let timestamp = self.format_timestamp_iso(&entry.timestamp);
152        let referer = entry.referer.as_deref().unwrap_or("-");
153        let user_agent = entry.user_agent.as_deref().unwrap_or("-");
154        
155        format!(
156            r#"{{"client_ip":"{}","timestamp":"{}","method":"{}","path":"{}","version":"{}","status":{},"size":{},"duration_ms":{},"referer":"{}","user_agent":"{}"}}"#,
157            entry.client_ip,
158            timestamp,
159            entry.method,
160            entry.path,
161            entry.version,
162            entry.status,
163            entry.size,
164            entry.duration_ms,
165            referer,
166            user_agent
167        )
168    }
169
170    /// Format timestamp in CLF format
171    fn format_timestamp(&self, time: &SystemTime) -> String {
172        use std::time::UNIX_EPOCH;
173        let duration = time.duration_since(UNIX_EPOCH).unwrap_or_default();
174        let secs = duration.as_secs();
175        
176        // Simple date formatting (without chrono dependency)
177        let days = secs / 86400;
178        let years = 1970 + days / 365;
179        let day_of_year = days % 365;
180        let months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", 
181                      "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
182        
183        let hour = (secs % 86400) / 3600;
184        let min = (secs % 3600) / 60;
185        let sec = secs % 60;
186        
187        let month_idx = (day_of_year / 30) as usize;
188        let month = months.get(month_idx).unwrap_or(&"Jan");
189        let day = (day_of_year % 30) + 1;
190        
191        format!("{:02}/{}/{}:{:02}:{:02}:{:02} +0000", day, month, years, hour, min, sec)
192    }
193
194    /// Format timestamp in ISO 8601 format
195    fn format_timestamp_iso(&self, time: &SystemTime) -> String {
196        use std::time::UNIX_EPOCH;
197        let duration = time.duration_since(UNIX_EPOCH).unwrap_or_default();
198        let secs = duration.as_secs();
199        
200        let days = secs / 86400;
201        let years = 1970 + days / 365;
202        let day_of_year = days % 365;
203        let month = (day_of_year / 30) + 1;
204        let day = (day_of_year % 30) + 1;
205        let hour = (secs % 86400) / 3600;
206        let min = (secs % 3600) / 60;
207        let sec = secs % 60;
208        
209        format!("{}-{:02}-{:02}T{:02}:{:02}:{:02}Z", years, month, day, hour, min, sec)
210    }
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216
217    #[test]
218    fn test_access_log_entry_creation() {
219        let entry = AccessLogEntry::new("127.0.0.1", "GET", "/index.html");
220        assert_eq!(entry.client_ip, "127.0.0.1");
221        assert_eq!(entry.method, "GET");
222        assert_eq!(entry.path, "/index.html");
223        assert_eq!(entry.status, 200);
224        assert_eq!(entry.size, 0);
225    }
226
227    #[test]
228    fn test_access_log_entry_with_status() {
229        let entry = AccessLogEntry::new("127.0.0.1", "GET", "/test")
230            .with_status(404);
231        assert_eq!(entry.status, 404);
232    }
233
234    #[test]
235    fn test_access_log_entry_with_size() {
236        let entry = AccessLogEntry::new("127.0.0.1", "GET", "/test")
237            .with_size(1024);
238        assert_eq!(entry.size, 1024);
239    }
240
241    #[test]
242    fn test_access_log_entry_with_duration() {
243        let entry = AccessLogEntry::new("127.0.0.1", "GET", "/test")
244            .with_duration_ms(50);
245        assert_eq!(entry.duration_ms, 50);
246    }
247
248    #[test]
249    fn test_access_log_entry_with_user_agent() {
250        let entry = AccessLogEntry::new("127.0.0.1", "GET", "/test")
251            .with_user_agent("Mozilla/5.0");
252        assert_eq!(entry.user_agent, Some("Mozilla/5.0".to_string()));
253    }
254
255    #[test]
256    fn test_access_log_entry_with_referer() {
257        let entry = AccessLogEntry::new("127.0.0.1", "GET", "/test")
258            .with_referer("https://example.com");
259        assert_eq!(entry.referer, Some("https://example.com".to_string()));
260    }
261
262    #[test]
263    fn test_access_log_entry_with_version() {
264        let entry = AccessLogEntry::new("127.0.0.1", "GET", "/test")
265            .with_version("HTTP/2.0");
266        assert_eq!(entry.version, "HTTP/2.0");
267    }
268
269    #[test]
270    fn test_access_log_entry_chained() {
271        let entry = AccessLogEntry::new("127.0.0.1", "POST", "/api")
272            .with_status(201)
273            .with_size(256)
274            .with_duration_ms(10)
275            .with_user_agent("curl/7.0");
276        
277        assert_eq!(entry.client_ip, "127.0.0.1");
278        assert_eq!(entry.method, "POST");
279        assert_eq!(entry.path, "/api");
280        assert_eq!(entry.status, 201);
281        assert_eq!(entry.size, 256);
282        assert_eq!(entry.duration_ms, 10);
283        assert_eq!(entry.user_agent, Some("curl/7.0".to_string()));
284    }
285
286    #[test]
287    fn test_log_format_default() {
288        assert_eq!(LogFormat::default(), LogFormat::Combined);
289    }
290
291    #[test]
292    fn test_log_format_common() {
293        let entry = AccessLogEntry::new("127.0.0.1", "GET", "/index.html")
294            .with_status(200)
295            .with_size(1234);
296        
297        let format = LogFormat::Common;
298        let output = format.format(&entry);
299        
300        assert!(output.contains("127.0.0.1"));
301        assert!(output.contains("GET /index.html HTTP/1.1"));
302        assert!(output.contains("200 1234"));
303    }
304
305    #[test]
306    fn test_log_format_combined() {
307        let entry = AccessLogEntry::new("127.0.0.1", "GET", "/index.html")
308            .with_status(200)
309            .with_size(1234)
310            .with_user_agent("Mozilla/5.0")
311            .with_referer("https://example.com");
312        
313        let format = LogFormat::Combined;
314        let output = format.format(&entry);
315        
316        assert!(output.contains("127.0.0.1"));
317        assert!(output.contains("GET /index.html HTTP/1.1"));
318        assert!(output.contains("200 1234"));
319        assert!(output.contains("Mozilla/5.0"));
320        assert!(output.contains("https://example.com"));
321    }
322
323    #[test]
324    fn test_log_format_combined_without_headers() {
325        let entry = AccessLogEntry::new("127.0.0.1", "GET", "/index.html")
326            .with_status(200);
327        
328        let format = LogFormat::Combined;
329        let output = format.format(&entry);
330        
331        // Should have dashes for missing headers
332        assert!(output.contains("\"-\" \"-\""));
333    }
334
335    #[test]
336    fn test_log_format_json() {
337        let entry = AccessLogEntry::new("127.0.0.1", "GET", "/api")
338            .with_status(200)
339            .with_size(512)
340            .with_duration_ms(25)
341            .with_user_agent("TestAgent");
342        
343        let format = LogFormat::Json;
344        let output = format.format(&entry);
345        
346        assert!(output.contains("\"client_ip\":\"127.0.0.1\""));
347        assert!(output.contains("\"method\":\"GET\""));
348        assert!(output.contains("\"path\":\"/api\""));
349        assert!(output.contains("\"status\":200"));
350        assert!(output.contains("\"size\":512"));
351        assert!(output.contains("\"duration_ms\":25"));
352        assert!(output.contains("\"user_agent\":\"TestAgent\""));
353    }
354
355    #[test]
356    fn test_log_format_json_without_optional() {
357        let entry = AccessLogEntry::new("127.0.0.1", "GET", "/test");
358        
359        let format = LogFormat::Json;
360        let output = format.format(&entry);
361        
362        assert!(output.contains("\"referer\":\"-\""));
363        assert!(output.contains("\"user_agent\":\"-\""));
364    }
365
366    #[test]
367    fn test_format_timestamp() {
368        let format = LogFormat::Common;
369        let timestamp = SystemTime::now();
370        let output = format.format_timestamp(&timestamp);
371        
372        // Should contain date components
373        assert!(output.contains("/"));
374        assert!(output.contains(":"));
375        assert!(output.contains("+0000"));
376    }
377
378    #[test]
379    fn test_format_timestamp_iso() {
380        let format = LogFormat::Json;
381        let timestamp = SystemTime::now();
382        let output = format.format_timestamp_iso(&timestamp);
383        
384        // Should be ISO format
385        assert!(output.contains("T"));
386        assert!(output.contains("Z"));
387        assert!(output.contains("-"));
388    }
389}