buildlog_consultant/
lib.rs

1//! Buildlog-consultant provides tools for analyzing build logs to identify problems.
2//!
3//! This crate contains functionality for parsing and analyzing build logs from various
4//! build systems, primarily focusing on Debian package building tools.
5
6#![deny(missing_docs)]
7
8use std::borrow::Cow;
9use std::ops::Index;
10
11/// Module for handling apt-related logs and problems.
12pub mod apt;
13/// Module for processing autopkgtest logs.
14pub mod autopkgtest;
15/// Module for Bazaar (brz) version control system logs.
16pub mod brz;
17/// Module for Common Upgradeability Description Format (CUDF) logs.
18pub mod cudf;
19/// Module for line-level processing.
20pub mod lines;
21/// Module containing problem definitions for various build systems.
22pub mod problems;
23
24#[cfg(feature = "chatgpt")]
25/// Module for interacting with ChatGPT for log analysis.
26pub mod chatgpt;
27
28/// Common utilities and helpers for build log analysis.
29pub mod common;
30
31/// Match-related functionality for finding patterns in logs.
32pub mod r#match;
33
34/// Module for handling sbuild logs and related problems.
35pub mod sbuild;
36
37#[cfg(test)]
38mod tests {
39    use super::*;
40
41    #[test]
42    fn test_singlelinematch_line() {
43        let m = SingleLineMatch {
44            origin: Origin("test".to_string()),
45            offset: 10,
46            line: "test line".to_string(),
47        };
48        assert_eq!(m.line(), "test line");
49    }
50
51    #[test]
52    fn test_singlelinematch_origin() {
53        let m = SingleLineMatch {
54            origin: Origin("test".to_string()),
55            offset: 10,
56            line: "test line".to_string(),
57        };
58        let origin = m.origin();
59        assert_eq!(origin.as_str(), "test");
60    }
61
62    #[test]
63    fn test_singlelinematch_offset() {
64        let m = SingleLineMatch {
65            origin: Origin("test".to_string()),
66            offset: 10,
67            line: "test line".to_string(),
68        };
69        assert_eq!(m.offset(), 10);
70    }
71
72    #[test]
73    fn test_singlelinematch_lineno() {
74        let m = SingleLineMatch {
75            origin: Origin("test".to_string()),
76            offset: 10,
77            line: "test line".to_string(),
78        };
79        assert_eq!(m.lineno(), 11);
80    }
81
82    #[test]
83    fn test_singlelinematch_linenos() {
84        let m = SingleLineMatch {
85            origin: Origin("test".to_string()),
86            offset: 10,
87            line: "test line".to_string(),
88        };
89        assert_eq!(m.linenos(), vec![11]);
90    }
91
92    #[test]
93    fn test_singlelinematch_offsets() {
94        let m = SingleLineMatch {
95            origin: Origin("test".to_string()),
96            offset: 10,
97            line: "test line".to_string(),
98        };
99        assert_eq!(m.offsets(), vec![10]);
100    }
101
102    #[test]
103    fn test_singlelinematch_lines() {
104        let m = SingleLineMatch {
105            origin: Origin("test".to_string()),
106            offset: 10,
107            line: "test line".to_string(),
108        };
109        assert_eq!(m.lines(), vec!["test line"]);
110    }
111
112    #[test]
113    fn test_singlelinematch_add_offset() {
114        let m = SingleLineMatch {
115            origin: Origin("test".to_string()),
116            offset: 10,
117            line: "test line".to_string(),
118        };
119        let new_m = m.add_offset(5);
120        assert_eq!(new_m.offset(), 15);
121    }
122
123    #[test]
124    fn test_multilinelmatch_line() {
125        let m = MultiLineMatch {
126            origin: Origin("test".to_string()),
127            offsets: vec![10, 11, 12],
128            lines: vec![
129                "line 1".to_string(),
130                "line 2".to_string(),
131                "line 3".to_string(),
132            ],
133        };
134        assert_eq!(m.line(), "line 3");
135    }
136
137    #[test]
138    fn test_multilinelmatch_origin() {
139        let m = MultiLineMatch {
140            origin: Origin("test".to_string()),
141            offsets: vec![10, 11, 12],
142            lines: vec![
143                "line 1".to_string(),
144                "line 2".to_string(),
145                "line 3".to_string(),
146            ],
147        };
148        let origin = m.origin();
149        assert_eq!(origin.as_str(), "test");
150    }
151
152    #[test]
153    fn test_multilinelmatch_offset() {
154        let m = MultiLineMatch {
155            origin: Origin("test".to_string()),
156            offsets: vec![10, 11, 12],
157            lines: vec![
158                "line 1".to_string(),
159                "line 2".to_string(),
160                "line 3".to_string(),
161            ],
162        };
163        assert_eq!(m.offset(), 12);
164    }
165
166    #[test]
167    fn test_multilinelmatch_lineno() {
168        let m = MultiLineMatch {
169            origin: Origin("test".to_string()),
170            offsets: vec![10, 11, 12],
171            lines: vec![
172                "line 1".to_string(),
173                "line 2".to_string(),
174                "line 3".to_string(),
175            ],
176        };
177        assert_eq!(m.lineno(), 13);
178    }
179
180    #[test]
181    fn test_multilinelmatch_offsets() {
182        let m = MultiLineMatch {
183            origin: Origin("test".to_string()),
184            offsets: vec![10, 11, 12],
185            lines: vec![
186                "line 1".to_string(),
187                "line 2".to_string(),
188                "line 3".to_string(),
189            ],
190        };
191        assert_eq!(m.offsets(), vec![10, 11, 12]);
192    }
193
194    #[test]
195    fn test_multilinelmatch_lines() {
196        let m = MultiLineMatch {
197            origin: Origin("test".to_string()),
198            offsets: vec![10, 11, 12],
199            lines: vec![
200                "line 1".to_string(),
201                "line 2".to_string(),
202                "line 3".to_string(),
203            ],
204        };
205        assert_eq!(m.lines(), vec!["line 1", "line 2", "line 3"]);
206    }
207
208    #[test]
209    fn test_multilinelmatch_add_offset() {
210        let m = MultiLineMatch {
211            origin: Origin("test".to_string()),
212            offsets: vec![10, 11, 12],
213            lines: vec![
214                "line 1".to_string(),
215                "line 2".to_string(),
216                "line 3".to_string(),
217            ],
218        };
219        let new_m = m.add_offset(5);
220        assert_eq!(new_m.offsets(), vec![15, 16, 17]);
221    }
222
223    #[test]
224    fn test_highlight_lines() {
225        let lines = vec!["line 1", "line 2", "line 3", "line 4", "line 5"];
226        let m = SingleLineMatch {
227            origin: Origin("test".to_string()),
228            offset: 2,
229            line: "line 3".to_string(),
230        };
231        // This test just ensures the function doesn't panic
232        highlight_lines(&lines, &m, 1);
233    }
234}
235
236/// Trait for representing a match of content in a log file.
237///
238/// This trait defines the interface for working with matched content in logs,
239/// providing methods to access the content and its location information.
240pub trait Match: Send + Sync + std::fmt::Debug + std::fmt::Display {
241    /// Returns the matched line of text.
242    fn line(&self) -> &str;
243
244    /// Returns the origin information for this match.
245    fn origin(&self) -> &Origin;
246
247    /// Returns the 0-based offset of the match in the source.
248    fn offset(&self) -> usize;
249
250    /// Returns the 1-based line number of the match in the source.
251    fn lineno(&self) -> usize {
252        self.offset() + 1
253    }
254
255    /// Returns all 1-based line numbers for this match.
256    fn linenos(&self) -> Vec<usize> {
257        self.offsets().iter().map(|&x| x + 1).collect()
258    }
259
260    /// Returns all 0-based offsets for this match.
261    fn offsets(&self) -> Vec<usize>;
262
263    /// Returns all lines of text in this match.
264    fn lines(&self) -> Vec<&str>;
265
266    /// Creates a new match with all offsets shifted by the given amount.
267    fn add_offset(&self, offset: usize) -> Box<dyn Match>;
268}
269
270/// Source identifier for a match.
271///
272/// This struct represents the source/origin of a match, typically a file name or other identifier.
273#[derive(Clone, Debug)]
274pub struct Origin(String);
275
276impl Origin {
277    /// Returns the inner string value.
278    pub fn as_str(&self) -> &str {
279        &self.0
280    }
281}
282
283impl std::fmt::Display for Origin {
284    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
285        f.write_str(&self.0)
286    }
287}
288
289/// A match for a single line in a log file.
290///
291/// This struct implements the `Match` trait for single-line matches.
292#[derive(Clone, Debug)]
293pub struct SingleLineMatch {
294    /// Source identifier for the match.
295    pub origin: Origin,
296    /// Zero-based line offset in the source.
297    pub offset: usize,
298    /// The matched line content.
299    pub line: String,
300}
301
302impl Match for SingleLineMatch {
303    fn line(&self) -> &str {
304        &self.line
305    }
306
307    fn origin(&self) -> &Origin {
308        &self.origin
309    }
310
311    fn offset(&self) -> usize {
312        self.offset
313    }
314
315    fn offsets(&self) -> Vec<usize> {
316        vec![self.offset]
317    }
318
319    fn lines(&self) -> Vec<&str> {
320        vec![&self.line]
321    }
322
323    fn add_offset(&self, offset: usize) -> Box<dyn Match> {
324        Box::new(Self {
325            origin: self.origin.clone(),
326            offset: self.offset + offset,
327            line: self.line.clone(),
328        })
329    }
330}
331
332impl std::fmt::Display for SingleLineMatch {
333    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
334        write!(f, "{}:{}: {}", self.origin.0, self.lineno(), self.line)
335    }
336}
337
338impl SingleLineMatch {
339    /// Creates a new `SingleLineMatch` from a collection of lines, an offset, and an optional origin.
340    ///
341    /// # Arguments
342    /// * `lines` - Collection of lines that can be indexed
343    /// * `offset` - Zero-based offset of the line to match
344    /// * `origin` - Optional source identifier
345    ///
346    /// # Returns
347    /// A new `SingleLineMatch` instance
348    pub fn from_lines<'a>(
349        lines: &impl Index<usize, Output = &'a str>,
350        offset: usize,
351        origin: Option<&str>,
352    ) -> Self {
353        let line = &lines[offset];
354        let origin = origin
355            .map(|s| Origin(s.to_string()))
356            .unwrap_or_else(|| Origin("".to_string()));
357        Self {
358            origin,
359            offset,
360            line: line.to_string(),
361        }
362    }
363}
364
365/// A match for multiple consecutive lines in a log file.
366///
367/// This struct implements the `Match` trait for multi-line matches.
368#[derive(Clone, Debug)]
369pub struct MultiLineMatch {
370    /// Source identifier for the match.
371    pub origin: Origin,
372    /// Zero-based line offsets for each matching line.
373    pub offsets: Vec<usize>,
374    /// The matched line contents.
375    pub lines: Vec<String>,
376}
377
378impl MultiLineMatch {
379    /// Creates a new `MultiLineMatch` with the specified origin, offsets, and lines.
380    ///
381    /// # Arguments
382    /// * `origin` - The source identifier
383    /// * `offsets` - Vector of zero-based line offsets
384    /// * `lines` - Vector of matched line contents
385    ///
386    /// # Returns
387    /// A new `MultiLineMatch` instance
388    pub fn new(origin: Origin, offsets: Vec<usize>, lines: Vec<String>) -> Self {
389        assert!(!offsets.is_empty());
390        assert!(offsets.len() == lines.len());
391        Self {
392            origin,
393            offsets,
394            lines,
395        }
396    }
397
398    /// Creates a new `MultiLineMatch` from a collection of lines, a vector of offsets, and an optional origin.
399    ///
400    /// # Arguments
401    /// * `lines` - Collection of lines that can be indexed
402    /// * `offsets` - Vector of zero-based line offsets to match
403    /// * `origin` - Optional source identifier
404    ///
405    /// # Returns
406    /// A new `MultiLineMatch` instance
407    pub fn from_lines<'a>(
408        lines: &impl Index<usize, Output = &'a str>,
409        offsets: Vec<usize>,
410        origin: Option<&str>,
411    ) -> Self {
412        let lines = offsets
413            .iter()
414            .map(|&offset| lines[offset].to_string())
415            .collect();
416        let origin = origin
417            .map(|s| Origin(s.to_string()))
418            .unwrap_or_else(|| Origin("".to_string()));
419        Self::new(origin, offsets, lines)
420    }
421}
422
423impl Match for MultiLineMatch {
424    fn line(&self) -> &str {
425        self.lines
426            .last()
427            .expect("MultiLineMatch should have at least one line")
428    }
429
430    fn origin(&self) -> &Origin {
431        &self.origin
432    }
433
434    fn offset(&self) -> usize {
435        *self
436            .offsets
437            .last()
438            .expect("MultiLineMatch should have at least one offset")
439    }
440
441    fn lineno(&self) -> usize {
442        self.offset() + 1
443    }
444
445    fn offsets(&self) -> Vec<usize> {
446        self.offsets.clone()
447    }
448
449    fn lines(&self) -> Vec<&str> {
450        self.lines.iter().map(|s| s.as_str()).collect()
451    }
452
453    fn add_offset(&self, extra: usize) -> Box<dyn Match> {
454        let offsets = self.offsets.iter().map(|&offset| offset + extra).collect();
455        Box::new(Self {
456            origin: self.origin.clone(),
457            offsets,
458            lines: self.lines.clone(),
459        })
460    }
461}
462
463impl std::fmt::Display for MultiLineMatch {
464    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
465        write!(f, "{}:{}: {}", self.origin.0, self.lineno(), self.line())
466    }
467}
468
469/// Trait for representing a problem found in build logs.
470///
471/// This trait defines the interface for working with problems identified in build logs,
472/// providing methods to access problem information and properties.
473pub trait Problem: std::fmt::Display + Send + Sync + std::fmt::Debug {
474    /// Returns the kind/type of problem.
475    fn kind(&self) -> Cow<'_, str>;
476
477    /// Returns the problem details as a JSON value.
478    fn json(&self) -> serde_json::Value;
479
480    /// Returns the problem as a trait object that can be downcast.
481    fn as_any(&self) -> &dyn std::any::Any;
482
483    /// Is this problem universal, i.e. applicable to all build steps?
484    ///
485    /// Good examples of universal problems are e.g. disk full, out of memory, etc.
486    fn is_universal(&self) -> bool {
487        false
488    }
489}
490
491impl PartialEq for dyn Problem {
492    fn eq(&self, other: &Self) -> bool {
493        self.kind() == other.kind() && self.json() == other.json()
494    }
495}
496
497impl Eq for dyn Problem {}
498
499impl serde::Serialize for dyn Problem {
500    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
501        let mut map = serde_json::Map::new();
502        map.insert(
503            "kind".to_string(),
504            serde_json::Value::String(self.kind().to_string()),
505        );
506        map.insert("details".to_string(), self.json());
507        map.serialize(serializer)
508    }
509}
510
511impl std::hash::Hash for dyn Problem {
512    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
513        self.kind().hash(state);
514        self.json().hash(state);
515    }
516}
517
518/// Prints highlighted lines from a match with surrounding context.
519///
520/// # Arguments
521/// * `lines` - All lines from the source
522/// * `m` - The match to highlight
523/// * `context` - Number of lines of context to display before and after the match
524pub fn highlight_lines(lines: &[&str], m: &dyn Match, context: usize) {
525    use std::cmp::{max, min};
526    if m.linenos().len() == 1 {
527        println!("Issue found at line {}:", m.lineno());
528    } else {
529        println!(
530            "Issue found at lines {}-{}:",
531            m.linenos().first().unwrap(),
532            m.linenos().last().unwrap()
533        );
534    }
535    let start = max(0, m.offsets()[0].saturating_sub(context));
536    let end = min(lines.len(), m.offsets().last().unwrap() + context + 1);
537
538    for (i, line) in lines.iter().enumerate().take(end).skip(start) {
539        println!(
540            " {}  {}",
541            if m.offsets().contains(&i) { ">" } else { " " },
542            line.trim_end_matches('\n')
543        );
544    }
545}