Skip to content

Commit 544d7ad

Browse files
authored
[log_format] add support for specifying the thread ID in the log format (#17)
Also, give a detailed error if there is anything wrong with the format.
1 parent 1636eb0 commit 544d7ad

15 files changed

+176
-85
lines changed

src/lib.rs

Lines changed: 38 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ use crate::progress::WorkGuard;
2525
use crate::source_hier::{ScanEvent, SourceFileID, SourceHierContent, SourceHierTree};
2626
use crate::source_ref::FormatArgument;
2727
pub use code_source::CodeSource;
28-
use log_format::LogFormat;
28+
pub use log_format::LogFormat;
2929
pub use progress::ProgressTracker;
3030
pub use progress::ProgressUpdate;
3131
pub use progress::WorkInfo;
@@ -35,6 +35,16 @@ pub use source_ref::SourceRef;
3535

3636
#[derive(Error, Debug, Diagnostic, Clone)]
3737
pub enum LogError {
38+
#[error("invalid log format regular expression")]
39+
InvalidFormatRegex { source: regex::Error },
40+
#[error("unknown capture in log format: {name}")]
41+
#[diagnostic(help(
42+
"The supported captures are: timestamp, thread, level, file, line, method, and body"
43+
))]
44+
UnknownFormatCapture { name: String },
45+
#[error("log format is missing capture: {name}")]
46+
#[diagnostic(help("A log format must have a 'body' capture at a minimum"))]
47+
FormatMissingCapture { name: String },
3848
#[error("\"{path}\" is already covered by \"{root}\"")]
3949
PathExists { path: PathBuf, root: PathBuf },
4050
#[error("cannot read source file \"{path}\"")]
@@ -421,23 +431,29 @@ pub struct VariablePair {
421431

422432
#[derive(Serialize)]
423433
pub struct LogMapping<'a> {
424-
#[serde(skip_serializing)]
434+
#[serde(rename(serialize = "logRef"))]
425435
pub log_ref: LogRef<'a>,
426436
#[serde(rename(serialize = "srcRef"))]
427437
pub src_ref: Option<SourceRef>,
428438
pub variables: Vec<VariablePair>,
429439
}
430440

431-
#[derive(Copy, Clone, Debug, PartialEq)]
441+
#[derive(Copy, Clone, Debug, PartialEq, Serialize)]
432442
pub struct LogRef<'a> {
443+
#[serde(skip_serializing)]
433444
pub line: &'a str,
434445
pub details: Option<LogDetails<'a>>,
435446
}
436447

437-
#[derive(Copy, Clone, Debug, PartialEq)]
448+
#[derive(Copy, Clone, Debug, PartialEq, Serialize)]
438449
pub struct LogDetails<'a> {
450+
#[serde(skip_serializing_if = "Option::is_none")]
451+
pub thread: Option<&'a str>,
452+
#[serde(skip_serializing_if = "Option::is_none")]
439453
pub file: Option<&'a str>,
454+
#[serde(skip_serializing_if = "Option::is_none")]
440455
pub lineno: Option<u32>,
456+
#[serde(skip_serializing)]
441457
pub body: Option<&'a str>,
442458
}
443459

@@ -449,28 +465,17 @@ impl<'a> LogRef<'a> {
449465
}
450466
}
451467

452-
pub fn from_parsed(file: Option<&'a str>, lineno: Option<u32>, body: &'a str) -> Self {
453-
let details = Some(LogDetails {
454-
file,
455-
lineno,
456-
body: Some(body),
457-
});
458-
Self {
459-
line: body,
460-
details,
461-
}
462-
}
463-
464468
pub fn with_format(line: &'a str, log_format: LogFormat) -> Self {
465469
let captures = log_format.captures(line);
470+
let thread = captures.name("thread").map(|thread_match| thread_match.as_str());
466471
let file = captures.name("file").map(|file_match| file_match.as_str());
467472
let lineno = captures
468473
.name("line")
469474
.and_then(|lineno| lineno.as_str().parse::<u32>().ok());
470475
let body = captures.name("body").map(|body| body.as_str());
471476
Self {
472477
line,
473-
details: Some(LogDetails { file, lineno, body }),
478+
details: Some(LogDetails { thread, file, lineno, body }),
474479
}
475480
}
476481

@@ -542,11 +547,10 @@ pub fn extract_variables<'a>(log_ref: &LogRef<'a>, src_ref: &'a SourceRef) -> Ve
542547
variables
543548
}
544549

545-
pub fn filter_log<R>(buffer: &str, filter: R, log_format: Option<String>) -> Vec<LogRef<'_>>
550+
pub fn filter_log<R>(buffer: &str, filter: R, log_format: Option<LogFormat>) -> Vec<LogRef<'_>>
546551
where
547552
R: RangeBounds<usize>,
548553
{
549-
let log_format = log_format.map(LogFormat::new);
550554
buffer
551555
.lines()
552556
.enumerate()
@@ -659,12 +663,10 @@ mod tests {
659663
let buffer = String::from(
660664
"2025-04-10 22:12:52 INFO JvmPauseMonitor:146 - JvmPauseMonitor-n0: Started",
661665
);
662-
let regex = String::from(
663-
r"^(?<timestamp>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) (?<level>\w+)\s+ (?<file>[\w$.]+):(?<line>\d+) - (?<body>.*)$",
664-
);
665-
let log_format = Some(regex);
666-
let result = filter_log(&buffer, .., log_format);
666+
let regex = r"^(?<timestamp>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) (?<level>\w+)\s+ (?<file>[\w$.]+):(?<line>\d+) - (?<body>.*)$";
667+
let result = filter_log(&buffer, .., Some(regex.try_into().unwrap()));
667668
let details = Some(LogDetails {
669+
thread: None,
668670
file: Some("JvmPauseMonitor"),
669671
lineno: Some(146),
670672
body: Some("JvmPauseMonitor-n0: Started"),
@@ -724,9 +726,9 @@ fn namedarg2(salutation: &str, name: &str) {
724726

725727
#[test]
726728
fn test_link_to_source() {
727-
let lf = LogFormat::new(
728-
r#"^\[\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z \w+ \w+\]\s+(?<body>.*)"#.to_string(),
729-
);
729+
let lf = r#"^\[\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z \w+ \w+\]\s+(?<body>.*)"#
730+
.try_into()
731+
.unwrap();
730732
let log_ref = LogRef::with_format(
731733
"[2024-05-09T19:58:53Z DEBUG main] you're only as funky as your last cut",
732734
lf,
@@ -743,9 +745,9 @@ fn namedarg2(salutation: &str, name: &str) {
743745

744746
#[test]
745747
fn test_link_to_quality_source() {
746-
let lf = LogFormat::new(
747-
r#"^\[\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z \w+ \w+\]\s+(?<body>.*)"#.to_string(),
748-
);
748+
let lf = r#"^\[\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z \w+ \w+\]\s+(?<body>.*)"#
749+
.try_into()
750+
.unwrap();
749751
let log_ref = LogRef::with_format("[2024-05-09T19:58:53Z DEBUG main] Hello, Leander!", lf);
750752
let code = CodeSource::from_string(&Path::new("in-mem.rs"), TEST_SOURCE);
751753
let src_refs = extract_logging(&[code], &ProgressTracker::new())
@@ -768,9 +770,9 @@ fn main() {
768770
"#;
769771
#[test]
770772
fn test_link_multiline() {
771-
let lf = LogFormat::new(
772-
r#"^\[\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z \w+ \w+\]\s+(?<body>.*)"#.to_string(),
773-
);
773+
let lf = r#"^\[\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z \w+ \w+\]\s+(?<body>.*)"#
774+
.try_into()
775+
.unwrap();
774776
let log_ref = LogRef::with_format(
775777
"[2024-05-09T19:58:53Z DEBUG main] you're only as funky\n as your last cut",
776778
lf,
@@ -864,13 +866,11 @@ fn main() {
864866
"""#;
865867
#[test]
866868
fn test_extract_var_punctuation() {
867-
let regex = String::from(
868-
r"^(?<timestamp>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) (?<level>\w+)\s+ (?<file>[\w$.]+):(?<line>\d+) - (?<body>.*)$",
869-
);
870-
let log_format = LogFormat::new(regex);
869+
let lf =
870+
r"^(?<timestamp>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) (?<level>\w+)\s+ (?<file>[\w$.]+):(?<line>\d+) - (?<body>.*)$".try_into().unwrap();
871871
let log_ref = LogRef::with_format(
872872
"2025-04-10 22:12:52 INFO JvmPauseMonitor:146 - JvmPauseMonitor-n0: Started",
873-
log_format,
873+
lf,
874874
);
875875
let code = CodeSource::from_string(&PathBuf::from("in-mem.java"), TEST_PUNC_SRC);
876876
let src_refs = extract_logging(&[code], &ProgressTracker::new())

src/log_format.rs

Lines changed: 80 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,13 @@
11
use regex::{Captures, Regex, RegexBuilder};
22

3-
use crate::LogRef;
3+
use crate::{LogError, LogRef};
44

55
#[derive(Clone, Debug)]
66
pub struct LogFormat {
77
regex: Regex,
88
}
99

1010
impl LogFormat {
11-
pub fn new(format: String) -> LogFormat {
12-
LogFormat {
13-
// TODO handle more gracefully if wrong format
14-
regex: RegexBuilder::new(&format)
15-
// XXX: This is kinda a hack to support multiline matching in lnav, but
16-
// not really useful for log2src atm because its still filtering line-by-line,
17-
// so this case would never come up
18-
.dot_matches_new_line(true)
19-
.build()
20-
.unwrap(),
21-
}
22-
}
23-
2411
pub fn has_src_hint(self: LogFormat) -> bool {
2512
let mut flatten = self.regex.capture_names().flatten();
2613
flatten.any(|name| name == "line") && flatten.any(|name| name == "file")
@@ -44,6 +31,82 @@ impl LogFormat {
4431
}
4532
}
4633

47-
// TODO finish these tests
48-
#[test]
49-
fn test_has_line_support() {}
34+
impl TryFrom<&str> for LogFormat {
35+
type Error = LogError;
36+
37+
fn try_from(value: &str) -> Result<Self, Self::Error> {
38+
fn check_captures(regex: &Regex) -> Result<(), LogError> {
39+
let mut seen = Vec::new();
40+
for name in regex.capture_names().filter_map(|x| x) {
41+
match name {
42+
"timestamp" | "thread" | "method" | "file" | "line" | "body" | "level" => {
43+
seen.push(name)
44+
}
45+
_ => {
46+
return Err(LogError::UnknownFormatCapture {
47+
name: name.to_string(),
48+
})
49+
}
50+
}
51+
}
52+
if !seen.contains(&"body") {
53+
return Err(LogError::FormatMissingCapture {
54+
name: "body".to_string(),
55+
});
56+
}
57+
Ok(())
58+
}
59+
60+
RegexBuilder::new(&value)
61+
// XXX: This is kinda a hack to support multiline matching in lnav, but
62+
// not really useful for log2src atm because its still filtering line-by-line,
63+
// so this case would never come up
64+
.dot_matches_new_line(true)
65+
.build()
66+
.map_err(|source| LogError::InvalidFormatRegex { source })
67+
.and_then(|regex| {
68+
check_captures(&regex)?;
69+
Ok(LogFormat { regex })
70+
})
71+
}
72+
}
73+
74+
#[cfg(test)]
75+
mod tests {
76+
use crate::LogFormat;
77+
use insta::assert_snapshot;
78+
use miette::{IntoDiagnostic, NarratableReportHandler, Report};
79+
80+
fn get_pretty_report_string(error: Report) -> String {
81+
let mut buffer = String::new();
82+
let handler = NarratableReportHandler::new();
83+
let handler = if let Some(help) = error.help() {
84+
handler.with_footer(help.to_string())
85+
} else {
86+
handler
87+
};
88+
let _ = handler.render_report(&mut buffer, error.as_ref());
89+
buffer
90+
}
91+
92+
#[test]
93+
fn test_invalid_regex() {
94+
let res = Report::from(LogFormat::try_from("abc(").unwrap_err());
95+
let rep = get_pretty_report_string(res);
96+
assert_snapshot!(rep);
97+
}
98+
99+
#[test]
100+
fn test_no_body() {
101+
let res = LogFormat::try_from("abc").into_diagnostic();
102+
let rep = get_pretty_report_string(res.unwrap_err());
103+
assert_snapshot!(rep);
104+
}
105+
106+
#[test]
107+
fn test_unknown_cap() {
108+
let res = LogFormat::try_from("abc(?<extra>def)").into_diagnostic();
109+
let rep = get_pretty_report_string(res.unwrap_err());
110+
assert_snapshot!(rep);
111+
}
112+
}

src/main.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,12 @@ fn main() -> miette::Result<()> {
7777
});
7878
}
7979

80+
let format_re = if let Some(format) = args.format {
81+
Some(format.as_str().try_into()?)
82+
} else {
83+
None
84+
};
85+
8086
let input = args.log;
8187
let mut reader: Box<dyn io::Read> = match input {
8288
None => Box::new(io::stdin()),
@@ -87,7 +93,7 @@ fn main() -> miette::Result<()> {
8793
reader.read_to_string(&mut buffer).into_diagnostic()?;
8894
let filter = args.start.unwrap_or(0)..args.end.unwrap_or(usize::MAX);
8995

90-
let filtered = filter_log(&buffer, filter, args.format.clone());
96+
let filtered = filter_log(&buffer, filter, format_re);
9197
let mut log_matcher = LogMatcher::new();
9298
log_matcher
9399
.add_root(&PathBuf::from(args.sources))
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
source: src/log_format.rs
3+
expression: rep
4+
---
5+
invalid log format regular expression
6+
Diagnostic severity: error
7+
Caused by: regex parse error:
8+
abc(
9+
^
10+
error: unclosed group
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
source: src/log_format.rs
3+
expression: rep
4+
---
5+
log format is missing capture: body
6+
Diagnostic severity: error
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
source: src/log_format.rs
3+
expression: rep
4+
---
5+
unknown capture in log format: extra
6+
Diagnostic severity: error
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
2024-05-08 14:46:47 Application starting
2-
2024-05-08 14:46:47 Debug message: args length = 0
1+
2024-05-08 14:46:47 123 Application starting
2+
2024-05-08 14:46:47 123 Debug message: args length = 0

tests/snapshots/test_java__basic.snap

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,9 @@ info:
1313
success: true
1414
exit_code: 0
1515
----- stdout -----
16-
{"srcRef":{"sourcePath":"{java_dir}/Basic.java","language":"Java","lineNumber":18,"endLineNumber":18,"column":16,"name":"main","text":"\"Hello from main\"","quality":13,"pattern":"(?s)^Hello from main$","args":[],"vars":[]},"variables":[]}
17-
{"srcRef":{"sourcePath":"{java_dir}/Basic.java","language":"Java","lineNumber":25,"endLineNumber":25,"column":20,"name":"foo","text":"\"Hello from foo i=\\{i}\"","quality":14,"pattern":"(?s)^Hello from foo i=(.+)$","args":[{"Named":"i"}],"vars":[]},"variables":[{"expr":"i","value":"0"}]}
18-
{"srcRef":{"sourcePath":"{java_dir}/Basic.java","language":"Java","lineNumber":25,"endLineNumber":25,"column":20,"name":"foo","text":"\"Hello from foo i=\\{i}\"","quality":14,"pattern":"(?s)^Hello from foo i=(.+)$","args":[{"Named":"i"}],"vars":[]},"variables":[{"expr":"i","value":"1"}]}
19-
{"srcRef":{"sourcePath":"{java_dir}/Basic.java","language":"Java","lineNumber":25,"endLineNumber":25,"column":20,"name":"foo","text":"\"Hello from foo i=\\{i}\"","quality":14,"pattern":"(?s)^Hello from foo i=(.+)$","args":[{"Named":"i"}],"vars":[]},"variables":[{"expr":"i","value":"2"}]}
16+
{"logRef":{"details":{}},"srcRef":{"sourcePath":"{java_dir}/Basic.java","language":"Java","lineNumber":18,"endLineNumber":18,"column":16,"name":"main","text":"\"Hello from main\"","quality":13,"pattern":"(?s)^Hello from main$","args":[],"vars":[]},"variables":[]}
17+
{"logRef":{"details":{}},"srcRef":{"sourcePath":"{java_dir}/Basic.java","language":"Java","lineNumber":25,"endLineNumber":25,"column":20,"name":"foo","text":"\"Hello from foo i=\\{i}\"","quality":14,"pattern":"(?s)^Hello from foo i=(.+)$","args":[{"Named":"i"}],"vars":[]},"variables":[{"expr":"i","value":"0"}]}
18+
{"logRef":{"details":{}},"srcRef":{"sourcePath":"{java_dir}/Basic.java","language":"Java","lineNumber":25,"endLineNumber":25,"column":20,"name":"foo","text":"\"Hello from foo i=\\{i}\"","quality":14,"pattern":"(?s)^Hello from foo i=(.+)$","args":[{"Named":"i"}],"vars":[]},"variables":[{"expr":"i","value":"1"}]}
19+
{"logRef":{"details":{}},"srcRef":{"sourcePath":"{java_dir}/Basic.java","language":"Java","lineNumber":25,"endLineNumber":25,"column":20,"name":"foo","text":"\"Hello from foo i=\\{i}\"","quality":14,"pattern":"(?s)^Hello from foo i=(.+)$","args":[{"Named":"i"}],"vars":[]},"variables":[{"expr":"i","value":"2"}]}
2020

2121
----- stderr -----

tests/snapshots/test_java__basic_slf4j.snap

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,12 @@ info:
88
- "-l"
99
- tests/resources/java/basic-slf4j.log
1010
- "-f"
11-
- "^(?<timestamp>\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}) (?<body>.*)$"
11+
- "^(?<timestamp>\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}) (?<thread>\\d+) (?<body>.*)$"
1212
---
1313
success: true
1414
exit_code: 0
1515
----- stdout -----
16-
{"srcRef":{"sourcePath":"{java_dir}/BasicSlf4j.java","language":"Java","lineNumber":10,"endLineNumber":10,"column":20,"name":"main","text":"\"Application starting\"","quality":19,"pattern":"(?s)^Application starting$","args":[],"vars":[]},"variables":[]}
17-
{"srcRef":{"sourcePath":"{java_dir}/BasicSlf4j.java","language":"Java","lineNumber":12,"endLineNumber":13,"column":21,"name":"main","text":"\"Debug message: args length = {}\"","quality":24,"pattern":"(?s)^Debug message: args length = (.+)$","args":["Placeholder"],"vars":["args.length"]},"variables":[{"expr":"args.length","value":"0"}]}
16+
{"logRef":{"details":{"thread":"123"}},"srcRef":{"sourcePath":"{java_dir}/BasicSlf4j.java","language":"Java","lineNumber":10,"endLineNumber":10,"column":20,"name":"main","text":"\"Application starting\"","quality":19,"pattern":"(?s)^Application starting$","args":[],"vars":[]},"variables":[]}
17+
{"logRef":{"details":{"thread":"123"}},"srcRef":{"sourcePath":"{java_dir}/BasicSlf4j.java","language":"Java","lineNumber":12,"endLineNumber":13,"column":21,"name":"main","text":"\"Debug message: args length = {}\"","quality":24,"pattern":"(?s)^Debug message: args length = (.+)$","args":["Placeholder"],"vars":["args.length"]},"variables":[{"expr":"args.length","value":"0"}]}
1818

1919
----- stderr -----

tests/snapshots/test_java__basic_with_log.snap

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,9 @@ info:
1313
success: true
1414
exit_code: 0
1515
----- stdout -----
16-
{"srcRef":{"sourcePath":"{java_dir}/BasicWithLog.java","language":"Java","lineNumber":18,"endLineNumber":18,"column":13,"name":"main","text":"\"Hello from main\"","quality":13,"pattern":"(?s)^Hello from main$","args":[],"vars":[]},"variables":[]}
17-
{"srcRef":{"sourcePath":"{java_dir}/BasicWithLog.java","language":"Java","lineNumber":25,"endLineNumber":25,"column":17,"name":"foo","text":"\"Hello from foo i=\\{i}\"","quality":14,"pattern":"(?s)^Hello from foo i=(.+)$","args":[{"Named":"i"}],"vars":[]},"variables":[{"expr":"i","value":"0"}]}
18-
{"srcRef":{"sourcePath":"{java_dir}/BasicWithLog.java","language":"Java","lineNumber":25,"endLineNumber":25,"column":17,"name":"foo","text":"\"Hello from foo i=\\{i}\"","quality":14,"pattern":"(?s)^Hello from foo i=(.+)$","args":[{"Named":"i"}],"vars":[]},"variables":[{"expr":"i","value":"1"}]}
19-
{"srcRef":{"sourcePath":"{java_dir}/BasicWithLog.java","language":"Java","lineNumber":25,"endLineNumber":25,"column":17,"name":"foo","text":"\"Hello from foo i=\\{i}\"","quality":14,"pattern":"(?s)^Hello from foo i=(.+)$","args":[{"Named":"i"}],"vars":[]},"variables":[{"expr":"i","value":"2"}]}
16+
{"logRef":{"details":{}},"srcRef":{"sourcePath":"{java_dir}/BasicWithLog.java","language":"Java","lineNumber":18,"endLineNumber":18,"column":13,"name":"main","text":"\"Hello from main\"","quality":13,"pattern":"(?s)^Hello from main$","args":[],"vars":[]},"variables":[]}
17+
{"logRef":{"details":{}},"srcRef":{"sourcePath":"{java_dir}/BasicWithLog.java","language":"Java","lineNumber":25,"endLineNumber":25,"column":17,"name":"foo","text":"\"Hello from foo i=\\{i}\"","quality":14,"pattern":"(?s)^Hello from foo i=(.+)$","args":[{"Named":"i"}],"vars":[]},"variables":[{"expr":"i","value":"0"}]}
18+
{"logRef":{"details":{}},"srcRef":{"sourcePath":"{java_dir}/BasicWithLog.java","language":"Java","lineNumber":25,"endLineNumber":25,"column":17,"name":"foo","text":"\"Hello from foo i=\\{i}\"","quality":14,"pattern":"(?s)^Hello from foo i=(.+)$","args":[{"Named":"i"}],"vars":[]},"variables":[{"expr":"i","value":"1"}]}
19+
{"logRef":{"details":{}},"srcRef":{"sourcePath":"{java_dir}/BasicWithLog.java","language":"Java","lineNumber":25,"endLineNumber":25,"column":17,"name":"foo","text":"\"Hello from foo i=\\{i}\"","quality":14,"pattern":"(?s)^Hello from foo i=(.+)$","args":[{"Named":"i"}],"vars":[]},"variables":[{"expr":"i","value":"2"}]}
2020

2121
----- stderr -----

0 commit comments

Comments
 (0)