elio 1.3.0

Snappy, batteries-included terminal file manager with rich previews, inline images, bulk actions, and trash support.
Documentation
use super::types::{LogSource, ParsedLogDocument, ParsedLogEntry};

pub(super) fn parse_access_log_document(text: &str) -> Option<ParsedLogDocument> {
    let mut entries = Vec::new();
    let mut non_empty = 0usize;
    let mut parsed = 0usize;

    for line in text.lines() {
        let trimmed = line.trim();
        if trimmed.is_empty() {
            continue;
        }
        non_empty += 1;
        let Some(entry) = parse_access_log_line(trimmed) else {
            continue;
        };
        parsed += 1;
        entries.push(entry);
    }

    if non_empty == 0 || parsed == 0 || parsed * 100 < non_empty * 70 {
        return None;
    }

    Some(ParsedLogDocument {
        source: LogSource::Access,
        entries,
    })
}

fn parse_access_log_line(line: &str) -> Option<ParsedLogEntry> {
    let mut rest = line.trim();
    let host = take_token(&mut rest)?;
    let ident = take_token(&mut rest)?;
    let user = take_token(&mut rest)?;
    let timestamp = take_wrapped(&mut rest, '[', ']')?;
    let request = take_wrapped(&mut rest, '"', '"')?;
    let status = take_token(&mut rest)?;
    let bytes = take_token(&mut rest)?;
    let referer = take_optional_wrapped(&mut rest, '"', '"');
    let user_agent = take_optional_wrapped(&mut rest, '"', '"');

    let method = request.split_whitespace().next().unwrap_or("REQUEST");
    let path = request
        .split_whitespace()
        .nth(1)
        .unwrap_or(request.as_str())
        .to_string();
    let level = access_status_level(&status);
    let mut fields = vec![
        ("host".to_string(), host),
        ("status".to_string(), status),
        ("bytes".to_string(), bytes),
    ];
    if ident != "-" {
        fields.push(("ident".to_string(), ident));
    }
    if user != "-" {
        fields.push(("user".to_string(), user));
    }
    if let Some(referer) = referer
        && referer != "-"
    {
        fields.push(("referer".to_string(), referer));
    }
    if let Some(user_agent) = user_agent
        && user_agent != "-"
    {
        fields.push(("user-agent".to_string(), user_agent));
    }

    Some(ParsedLogEntry {
        timestamp: Some(timestamp),
        level,
        message: format!("{method} {path}"),
        fields,
        continuations: Vec::new(),
    })
}

fn take_token(input: &mut &str) -> Option<String> {
    *input = input.trim_start();
    let end = input.find(char::is_whitespace).unwrap_or(input.len());
    if end == 0 {
        return None;
    }
    let token = input[..end].to_string();
    *input = &input[end..];
    Some(token)
}

fn take_wrapped(input: &mut &str, open: char, close: char) -> Option<String> {
    *input = input.trim_start();
    let trimmed = *input;
    if !trimmed.starts_with(open) {
        return None;
    }
    let end = trimmed[1..].find(close)? + 1;
    let content = trimmed[1..end].to_string();
    *input = &trimmed[end + 1..];
    Some(content)
}

fn take_optional_wrapped(input: &mut &str, open: char, close: char) -> Option<String> {
    let trimmed = input.trim_start();
    if trimmed.is_empty() || !trimmed.starts_with(open) {
        *input = trimmed;
        return None;
    }
    take_wrapped(input, open, close)
}

fn access_status_level(status: &str) -> Option<String> {
    match status.parse::<u16>().ok()? / 100 {
        5 => Some("ERROR".to_string()),
        4 => Some("WARN".to_string()),
        1..=3 => Some("INFO".to_string()),
        _ => None,
    }
}

#[cfg(test)]
mod tests {
    use super::super::render_log_preview;

    fn rendered_preview(text: &str) -> String {
        render_log_preview(text)
            .expect("access log preview should render")
            .lines
            .iter()
            .map(|line| line.to_string())
            .collect::<Vec<_>>()
            .join("\n")
    }

    #[test]
    fn access_logs_are_detected_and_summarized() {
        let rendered = rendered_preview(
            "127.0.0.1 - - [10/Mar/2026:12:00:00 +0000] \"GET /login HTTP/1.1\" 500 123 \"-\" \"curl/8.0\"\n",
        );

        assert!(rendered.contains("Access log"));
        assert!(rendered.contains("GET /login"));
        assert!(rendered.contains("status"));
        assert!(rendered.contains("500"));
    }

    #[test]
    fn access_logs_keep_optional_referer_and_user_agent_fields() {
        let rendered = rendered_preview(
            "127.0.0.1 app elio [10/Mar/2026:12:00:00 +0000] \"GET /login HTTP/1.1\" 404 321 \"https://elio.dev/docs\" \"Mozilla/5.0\"\n",
        );

        assert!(rendered.contains("WARN"));
        assert!(rendered.contains("ident"));
        assert!(rendered.contains("elio"));
        assert!(rendered.contains("referer"));
        assert!(rendered.contains("https://elio.dev/docs"));
        assert!(rendered.contains("user-agent"));
        assert!(rendered.contains("Mozilla/5.0"));
    }
}