Skip to main content

opal/
logging.rs

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}