Skip to main content

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