Skip to content

Commit 6050a15

Browse files
committed
feat: add database, security, and VCS distillers with registry integration and snapshot tests
1 parent 6af5efc commit 6050a15

9 files changed

Lines changed: 343 additions & 16 deletions

File tree

src/distillers/database.rs

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
use crate::distillers::Distiller;
2+
use crate::pipeline::{OutputSegment, SignalTier};
3+
4+
pub struct DatabaseDistiller;
5+
6+
impl Distiller for DatabaseDistiller {
7+
fn distill(
8+
&self,
9+
segments: &[OutputSegment],
10+
input: &str,
11+
_session: Option<&crate::pipeline::SessionState>,
12+
) -> String {
13+
// Detect apakah ini query result, error, atau migration output
14+
if input.contains("ERROR:") || input.contains("FATAL:") || input.contains("error:") {
15+
distill_db_error(input)
16+
} else if input.contains("rows)") || input.contains("row)") || looks_like_table(input) {
17+
distill_query_result(input)
18+
} else {
19+
distill_db_generic(segments, input)
20+
}
21+
}
22+
}
23+
24+
fn distill_db_error(input: &str) -> String {
25+
let mut errors: Vec<String> = vec![];
26+
let mut hint: Option<String> = None;
27+
let mut position: Option<String> = None;
28+
29+
for line in input.lines() {
30+
let l = line.trim();
31+
if l.contains("ERROR:") || l.contains("FATAL:") || l.contains("error:") {
32+
errors.push(l.to_string());
33+
} else if l.starts_with("HINT:") || l.starts_with("DETAIL:") {
34+
hint = Some(l.to_string());
35+
} else if l.starts_with("LINE ") || l.starts_with("POSITION:") {
36+
position = Some(l.to_string());
37+
}
38+
}
39+
40+
let mut out = format!("DB Error ({} found):\n", errors.len());
41+
for e in errors.iter().take(3) {
42+
out.push_str(e);
43+
out.push('\n');
44+
}
45+
if let Some(p) = position {
46+
out.push_str(&p);
47+
out.push('\n');
48+
}
49+
if let Some(h) = hint {
50+
out.push_str(&h);
51+
out.push('\n');
52+
}
53+
out.trim().to_string()
54+
}
55+
56+
fn distill_query_result(input: &str) -> String {
57+
let lines: Vec<&str> = input.lines().collect();
58+
let total = lines.len();
59+
60+
// Cari baris "N rows"
61+
let row_summary = lines
62+
.iter()
63+
.rev()
64+
.take(5)
65+
.find(|l| l.contains("row") && (l.contains('(') || l.trim().parse::<usize>().is_ok()))
66+
.map(|l| l.trim().to_string());
67+
68+
// Header (kolom) biasanya baris pertama non-empty
69+
let header = lines
70+
.iter()
71+
.find(|l| !l.trim().is_empty() && !l.starts_with('-') && !l.starts_with('('))
72+
.map(|l| l.trim().to_string());
73+
74+
let mut out = String::new();
75+
if let Some(h) = &header {
76+
out.push_str(&format!("Query result columns: {}\n", h));
77+
}
78+
if let Some(summary) = row_summary {
79+
out.push_str(&format!("Result: {}\n", summary));
80+
} else {
81+
out.push_str(&format!("Result: {} lines output\n", total));
82+
}
83+
// Show first 3 data rows as sample
84+
let data_rows: Vec<&str> = lines
85+
.iter()
86+
.filter(|l| !l.trim().is_empty() && !l.starts_with('-') && !l.starts_with('('))
87+
.skip(1) // skip header
88+
.take(3)
89+
.copied()
90+
.collect();
91+
if !data_rows.is_empty() {
92+
out.push_str("Sample rows:\n");
93+
for row in &data_rows {
94+
out.push_str(row);
95+
out.push('\n');
96+
}
97+
if total > data_rows.len() + 2 {
98+
out.push_str(&format!(
99+
" ... [{} more rows]\n",
100+
total - data_rows.len() - 2
101+
));
102+
}
103+
}
104+
out.trim().to_string()
105+
}
106+
107+
fn distill_db_generic(segments: &[OutputSegment], _input: &str) -> String {
108+
let errors: Vec<&str> = segments
109+
.iter()
110+
.filter(|s| s.tier == SignalTier::Critical)
111+
.map(|s| s.content.as_str())
112+
.collect();
113+
if errors.is_empty() {
114+
format!("DB: ok ({} lines output)", segments.len())
115+
} else {
116+
format!(
117+
"DB errors: {}\n{}",
118+
errors.len(),
119+
errors
120+
.iter()
121+
.take(5)
122+
.cloned()
123+
.collect::<Vec<_>>()
124+
.join("\n")
125+
)
126+
}
127+
}
128+
129+
fn looks_like_table(input: &str) -> bool {
130+
input
131+
.lines()
132+
.take(5)
133+
.any(|l| l.contains(" | ") || l.starts_with("---"))
134+
}

src/distillers/mod.rs

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,14 @@ use crate::pipeline::OutputSegment;
22

33
pub mod build;
44
pub mod cloud;
5+
pub mod database;
56
pub mod generic;
67
pub mod git;
78
pub mod jsts;
9+
pub mod security;
810
pub mod system_ops;
911
pub mod test;
12+
pub mod vcs;
1013

1114
pub trait Distiller: Send + Sync {
1215
fn distill(
@@ -42,6 +45,43 @@ pub fn distill_with_command(
4245
return git::GitDistiller.distill(segments, input, session);
4346
}
4447

48+
// Database tools
49+
if matches!(base, "psql" | "mysql" | "sqlite3" | "pg_dump" | "redis-cli") {
50+
return database::DatabaseDistiller.distill(segments, input, session);
51+
}
52+
53+
// Security scanners
54+
if matches!(
55+
base,
56+
"semgrep" | "trivy" | "snyk" | "hadolint" | "gosec" | "bandit"
57+
) {
58+
return security::SecurityDistiller.distill(segments, input, session);
59+
}
60+
61+
// GitHub/VCS CLIs
62+
if matches!(base, "gh" | "hub" | "glab") {
63+
return vcs::VcsDistiller.distill(segments, input, session);
64+
}
65+
66+
// Java/JVM — use BuildDistiller (sudah ada)
67+
if matches!(
68+
base,
69+
"java" | "javac" | "mvn" | "mvnw" | "gradle" | "gradlew"
70+
) {
71+
if cmd_lower.contains("test") {
72+
return test::TestDistiller.distill(segments, input, session);
73+
}
74+
return build::BuildDistiller.distill(segments, input, session);
75+
}
76+
77+
// Flutter/Dart
78+
if matches!(base, "flutter" | "dart") {
79+
if cmd_lower.contains("test") || cmd_lower.contains("analyze") {
80+
return test::TestDistiller.distill(segments, input, session);
81+
}
82+
return build::BuildDistiller.distill(segments, input, session);
83+
}
84+
4585
// Build tools → BuildDistiller
4686
if matches!(
4787
base,
@@ -238,4 +278,15 @@ mod tests {
238278
"playwright test"
239279
);
240280
snapshot_test!(test_jsts_eslint, "eslint_errors.txt", "eslint");
281+
282+
snapshot_test!(
283+
test_database_psql_error,
284+
"psql_error.txt",
285+
"psql -U postgres mydb"
286+
);
287+
snapshot_test!(
288+
test_security_trivy_scan,
289+
"trivy_output.txt",
290+
"trivy image myapp:latest"
291+
);
241292
}

src/distillers/security.rs

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
use crate::distillers::Distiller;
2+
use crate::pipeline::OutputSegment;
3+
4+
pub struct SecurityDistiller;
5+
6+
impl Distiller for SecurityDistiller {
7+
fn distill(
8+
&self,
9+
_segments: &[OutputSegment],
10+
input: &str,
11+
_session: Option<&crate::pipeline::SessionState>,
12+
) -> String {
13+
let mut critical_findings: Vec<String> = vec![];
14+
let mut high_findings: Vec<String> = vec![];
15+
let mut medium_count = 0usize;
16+
let mut low_count = 0usize;
17+
18+
for line in input.lines() {
19+
let l = line.trim();
20+
// Semgrep, trivy, snyk format detection
21+
if l.contains("CRITICAL") || l.contains("critical") {
22+
critical_findings.push(l.to_string());
23+
} else if l.contains("HIGH") || l.contains("high") {
24+
high_findings.push(l.to_string());
25+
} else if l.contains("MEDIUM") || l.contains("medium") {
26+
medium_count += 1;
27+
} else if l.contains("LOW") || l.contains("low") {
28+
low_count += 1;
29+
}
30+
}
31+
32+
let total_critical = critical_findings.len();
33+
let total_high = high_findings.len();
34+
35+
if total_critical == 0 && total_high == 0 && medium_count == 0 {
36+
return "Security scan: no issues found ✓".to_string();
37+
}
38+
39+
let mut out = format!(
40+
"Security scan: {} CRITICAL, {} HIGH, {} MEDIUM, {} LOW\n",
41+
total_critical, total_high, medium_count, low_count
42+
);
43+
44+
for f in critical_findings.iter().take(5) {
45+
out.push_str(&format!(" 🔴 {}\n", f));
46+
}
47+
for f in high_findings.iter().take(3) {
48+
out.push_str(&format!(" 🟠 {}\n", f));
49+
}
50+
if total_critical + total_high > 8 {
51+
out.push_str(&format!(
52+
" ... [{} more findings]\n",
53+
total_critical + total_high - 8
54+
));
55+
}
56+
out.trim().to_string()
57+
}
58+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
source: src/distillers/mod.rs
3+
expression: output
4+
---
5+
DB Error (1 found):
6+
psql: error: connection to server on socket "/var/run/postgresql/.s.PGSQL.5432" failed: FATAL: role "myuser" does not exist
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
source: src/distillers/mod.rs
3+
expression: output
4+
---
5+
Security scan: 2 CRITICAL, 1 HIGH, 0 MEDIUM, 1 LOW
6+
🔴 Total: 3 (CRITICAL: 1, HIGH: 1, MEDIUM: 0, LOW: 1)
7+
🔴 CRITICAL CVE-2023-1234 libssl vulnerabilities found
8+
🟠 HIGH CVE-2023-5678 libcrypto issue detected

src/distillers/vcs.rs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
use crate::distillers::Distiller;
2+
use crate::pipeline::OutputSegment;
3+
4+
pub struct VcsDistiller;
5+
6+
impl Distiller for VcsDistiller {
7+
fn distill(
8+
&self,
9+
_segments: &[OutputSegment],
10+
input: &str,
11+
_session: Option<&crate::pipeline::SessionState>,
12+
) -> String {
13+
let lines: Vec<&str> = input.lines().filter(|l| !l.trim().is_empty()).collect();
14+
let total = lines.len();
15+
16+
if total <= 10 {
17+
return input.trim().to_string();
18+
}
19+
20+
// PR/Issue list — show first 10, summarize rest
21+
let shown: Vec<&str> = lines.iter().take(10).copied().collect();
22+
let mut out = shown.join("\n");
23+
if total > 10 {
24+
out.push_str(&format!(
25+
"\n... [{} more items — use --limit to see more]",
26+
total - 10
27+
));
28+
}
29+
out
30+
}
31+
}

0 commit comments

Comments
 (0)