1use anyhow::{Context, Result, bail};
13use chrono::TimeZone;
14
15use crate::system::system_command;
16
17pub const UNIT: &str = "scx.service";
20
21pub struct LogLine {
23 pub time: String,
25 pub priority: u8,
27 pub text: String,
28 pub continuation: bool,
30}
31
32const MAX_ENTRIES: usize = 5000;
37
38pub struct LogFetch {
41 pub lines: Vec<LogLine>,
42 pub truncated: bool,
43}
44
45pub fn fetch(unit: &str, previous_boot: bool) -> Result<LogFetch> {
49 let boot = if previous_boot { "-1" } else { "0" };
50 let limit = MAX_ENTRIES.to_string();
51 let output = system_command("journalctl")?
52 .args([
53 "--unit",
54 unit,
55 "--boot",
56 boot,
57 "--lines",
58 &limit,
59 "--output=json",
60 "--no-pager",
61 "--quiet",
62 ])
63 .output()
64 .context("failed to run journalctl — is this a systemd system?")?;
65
66 if !output.status.success() {
67 let stderr = String::from_utf8_lossy(&output.stderr);
68 let stderr = stderr.trim();
69 bail!(
72 "journalctl failed{}{}",
73 if stderr.is_empty() { "" } else { ": " },
74 stderr
75 );
76 }
77
78 let stdout = String::from_utf8_lossy(&output.stdout);
79 let entries = stdout.lines().filter(|line| !line.is_empty()).count();
83 Ok(LogFetch {
84 lines: parse_entries(&stdout),
85 truncated: entries >= MAX_ENTRIES,
86 })
87}
88
89fn parse_entries(stdout: &str) -> Vec<LogLine> {
93 let mut lines = Vec::new();
94 for raw in stdout.lines() {
95 if raw.is_empty() {
96 continue;
97 }
98 let Ok(entry) = serde_json::from_str::<serde_json::Value>(raw) else {
99 continue;
100 };
101
102 let priority = entry
103 .get("PRIORITY")
104 .and_then(|p| p.as_str())
105 .and_then(|p| p.parse().ok())
106 .unwrap_or(6);
107 let time = entry
108 .get("__REALTIME_TIMESTAMP")
109 .and_then(|t| t.as_str())
110 .and_then(|t| t.parse::<i64>().ok())
111 .map(format_local_time)
112 .unwrap_or_default();
113 let message = match entry.get("MESSAGE") {
116 Some(serde_json::Value::String(s)) => s.clone(),
117 Some(serde_json::Value::Array(bytes)) => {
118 let raw: Vec<u8> = bytes
119 .iter()
120 .filter_map(|b| b.as_u64().and_then(|b| u8::try_from(b).ok()))
121 .collect();
122 String::from_utf8_lossy(&raw).into_owned()
123 }
124 _ => continue,
125 };
126
127 for (i, text) in message.lines().enumerate() {
128 lines.push(LogLine {
129 time: if i == 0 { time.clone() } else { String::new() },
130 priority,
131 text: text.to_owned(),
132 continuation: i > 0,
133 });
134 }
135 }
136 lines
137}
138
139fn format_local_time(usec: i64) -> String {
140 chrono::Local
141 .timestamp_opt(usec / 1_000_000, 0)
142 .single()
143 .map(|dt| dt.format("%H:%M:%S").to_string())
144 .unwrap_or_default()
145}
146
147#[cfg(test)]
148mod tests {
149 use super::*;
150
151 #[test]
152 fn parses_priority_with_info_default() {
153 let entries = parse_entries(concat!(
154 r#"{"PRIORITY":"3","MESSAGE":"boom"}"#,
155 "\n",
156 r#"{"MESSAGE":"plain"}"#,
157 ));
158 assert_eq!(entries.len(), 2);
159 assert_eq!(entries[0].priority, 3);
160 assert_eq!(entries[1].priority, 6);
161 }
162
163 #[test]
164 fn flattens_multiline_messages() {
165 let entries = parse_entries(
166 r#"{"PRIORITY":"6","MESSAGE":"Opts {\n verbose: 0,\n}","__REALTIME_TIMESTAMP":"1753000000000000"}"#,
167 );
168 assert_eq!(entries.len(), 3);
169 assert!(!entries[0].continuation);
170 assert!(entries[1].continuation && entries[2].continuation);
171 assert_eq!(entries[1].time, "");
172 assert_eq!(entries[1].text, " verbose: 0,");
173 assert_eq!(entries[2].priority, 6);
175 }
176
177 #[test]
178 fn decodes_byte_array_messages() {
179 let entries = parse_entries(r#"{"MESSAGE":[104,105]}"#);
180 assert_eq!(entries.len(), 1);
181 assert_eq!(entries[0].text, "hi");
182 }
183
184 #[test]
185 fn skips_malformed_and_empty_lines() {
186 let entries = parse_entries("not json\n\n{\"MESSAGE\":\"ok\"}\n{\"NO_MESSAGE\":true}");
187 assert_eq!(entries.len(), 1);
188 assert_eq!(entries[0].text, "ok");
189 }
190}