1use crate::model::JobSpec;
2use crate::secrets::SecretsStore;
3use anyhow::Result;
4use owo_colors::OwoColorize;
5use sha2::{Digest, Sha256};
6use std::borrow::Cow;
7use std::io::Write;
8use std::path::{Path, PathBuf};
9
10pub struct LogFormatter<'a> {
11 use_color: bool,
12 line_prefix: String,
13 secrets: Option<&'a SecretsStore>,
14}
15
16impl<'a> LogFormatter<'a> {
17 pub fn new(use_color: bool) -> Self {
18 let line_prefix = if use_color {
19 format!("{}", " │".dimmed())
20 } else {
21 " │".to_string()
22 };
23 Self {
24 use_color,
25 line_prefix,
26 secrets: None,
27 }
28 }
29
30 pub fn with_secrets(mut self, secrets: &'a SecretsStore) -> Self {
31 self.secrets = Some(secrets);
32 self
33 }
34
35 pub fn line_prefix(&self) -> &str {
36 &self.line_prefix
37 }
38
39 pub fn use_color(&self) -> bool {
40 self.use_color
41 }
42
43 pub fn mask<'b>(&self, text: &'b str) -> Cow<'b, str> {
44 if let Some(secrets) = self.secrets {
45 secrets.mask_fragment(text)
46 } else {
47 Cow::Borrowed(text)
48 }
49 }
50
51 pub fn format_masked(&self, timestamp: &str, line_no: usize, masked_text: &str) -> String {
52 let number = format!("{:04}", line_no);
53 let timestamp = if self.use_color {
54 format!("{}", timestamp.bold().blue())
55 } else {
56 timestamp.to_string()
57 };
58 let number = if self.use_color {
59 format!("{}", number.bold().green())
60 } else {
61 number
62 };
63 format!("[{} {}] {}", timestamp, number, masked_text)
64 }
65
66 pub fn format(&self, timestamp: &str, line_no: usize, text: &str) -> String {
67 let masked = self.mask(text);
68 self.format_masked(timestamp, line_no, masked.as_ref())
69 }
70}
71
72pub fn sanitize_fragments(line: &str) -> Vec<String> {
73 expand_carriage_returns(line)
74 .into_iter()
75 .map(|fragment| strip_control_sequences(&fragment))
76 .collect()
77}
78
79fn expand_carriage_returns(line: &str) -> Vec<String> {
80 let mut parts = Vec::new();
81 for fragment in line.split('\r') {
82 if fragment.is_empty() {
83 continue;
84 }
85 parts.push(fragment.to_string());
86 }
87 if parts.is_empty() {
88 parts.push(String::new());
89 }
90 parts
91}
92
93fn strip_control_sequences(line: &str) -> String {
94 let mut iter = line.bytes().peekable();
95 let mut output = Vec::with_capacity(line.len());
96 while let Some(b) = iter.next() {
97 if b == 0x1b {
98 match iter.peek().copied() {
99 Some(b'[') => {
100 iter.next();
101 #[allow(clippy::while_let_on_iterator)]
102 while let Some(c) = iter.next() {
103 if (0x40..=0x7E).contains(&c) {
104 break;
105 }
106 }
107 continue;
108 }
109 Some(b']') => {
110 iter.next();
111 #[allow(clippy::while_let_on_iterator)]
112 while let Some(c) = iter.next() {
113 if c == 0x07 {
114 break;
115 }
116 if c == 0x1b && iter.peek().copied() == Some(b'\\') {
117 iter.next();
118 break;
119 }
120 }
121 continue;
122 }
123 Some(_) => {
124 iter.next();
125 continue;
126 }
127 None => break,
128 }
129 } else if b == b'\x08' {
130 output.pop();
131 } else {
132 output.push(b);
133 }
134 }
135
136 String::from_utf8_lossy(&output).into_owned()
137}
138
139pub fn job_log_info(logs_dir: &Path, run_id: &str, job: &JobSpec) -> (PathBuf, String) {
140 let mut hasher = Sha256::new();
141 hasher.update(run_id.as_bytes());
142 hasher.update(job.stage.as_bytes());
143 hasher.update(job.name.as_bytes());
144 let digest = hasher.finalize();
145 let hex = format!("{:x}", digest);
146 let short = &hex[..12];
147 let log_path = logs_dir.join(format!("{short}.log"));
148 (log_path, short.to_string())
149}
150
151pub fn format_plain_log_line(timestamp: &str, line_no: usize, text: &str) -> String {
152 format!("[{} {:04}] {}", timestamp, line_no, text)
153}
154
155pub fn write_log_line(
156 writer: &mut dyn Write,
157 timestamp: &str,
158 line_no: usize,
159 text: &str,
160) -> Result<()> {
161 writeln!(
162 writer,
163 "{}",
164 format_plain_log_line(timestamp, line_no, text)
165 )?;
166 Ok(())
167}