use std::path::{Path, PathBuf};
use serde::Serialize;
use tracing::warn;
use crate::daemon::event::{DaemonEvent, EventObserver};
use crate::index::IndexResult;
use crate::model::NodeLabel;
use crate::storage::capability::Storage;
use crate::storage::Repository;
pub const SYMBOL_CAP: usize = 200;
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct ImpactNotice {
pub file: String,
pub symbols: Vec<ImpactSymbol>,
pub total_callers: usize,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct ImpactSymbol {
pub name: String,
pub incoming: usize,
}
pub struct ImpactNotifyObserver {
db_path: PathBuf,
webhook: Option<String>,
}
impl ImpactNotifyObserver {
pub fn new(db_path: PathBuf) -> Self {
Self {
db_path,
webhook: None,
}
}
#[cfg(feature = "hub")]
pub fn with_webhook(mut self, webhook: Option<String>) -> Self {
self.webhook = webhook;
self
}
pub fn compute_notices(&self, changed_files: &[PathBuf]) -> Vec<ImpactNotice> {
let repo = match Repository::open(&self.db_path) {
Ok(repo) => repo,
Err(err) => {
warn!(
error = %err,
"{}",
crate::i18n::tr("impact-db-open-failed")
);
return Vec::new();
}
};
let mut notices = Vec::new();
let mut budget = SYMBOL_CAP;
for file in changed_files {
if budget == 0 {
break;
}
let Some(notice) = self.notice_for_file(&repo, file, &mut budget) else {
continue;
};
if !notice.symbols.is_empty() {
notices.push(notice);
}
}
notices
}
fn notice_for_file(
&self,
repo: &Repository,
file: &Path,
budget: &mut usize,
) -> Option<ImpactNotice> {
let file_str = file.to_string_lossy();
let escaped = crate::storage::schema::escape_cypher_string(&file_str);
let mut symbols = Vec::new();
let mut total_callers = 0usize;
for label in NodeLabel::all() {
if *budget == 0 {
break;
}
let cols = crate::storage::schema::node_table_columns(label);
if !cols.contains(&"filePath") {
continue;
}
let table = crate::storage::schema::escape_identifier(label.table_name());
let cypher = format!(
"MATCH (n:{table}) WHERE n.filePath = '{escaped}' RETURN n.id AS id, n.name AS name;"
);
let rows = match repo.query(&cypher) {
Ok(rows) => rows,
Err(_) => continue,
};
for row in rows {
if *budget == 0 {
break;
}
*budget -= 1;
let id = row.first().and_then(|v| v.as_str()).unwrap_or_default();
let name = row.get(1).and_then(|v| v.as_str()).unwrap_or_default();
let incoming = self.incoming_edges(repo, id);
total_callers += incoming;
symbols.push(ImpactSymbol {
name: name.to_string(),
incoming,
});
}
}
Some(ImpactNotice {
file: file_str.to_string(),
symbols,
total_callers,
})
}
fn incoming_edges(&self, repo: &Repository, id: &str) -> usize {
let escaped = crate::storage::schema::escape_cypher_string(id);
let cypher =
format!("MATCH (r:CodeRelation) WHERE r.target = '{escaped}' RETURN count(r) AS cnt;");
repo.query(&cypher)
.ok()
.and_then(|rows| {
rows.first()
.and_then(|r| r.first().and_then(|v| v.as_u64()))
})
.unwrap_or(0) as usize
}
}
impl EventObserver for ImpactNotifyObserver {
fn on_events(&mut self, _events: &[DaemonEvent]) {
}
fn on_index_complete(&mut self, result: &IndexResult, changed_files: &[PathBuf]) {
let notices = self.compute_notices(changed_files);
let truncated = notices.iter().any(|n| n.symbols.len() >= SYMBOL_CAP);
for notice in ¬ices {
warn!(
event = "impact_notice",
file = %notice.file,
symbols = notice.symbols.len(),
total_callers = notice.total_callers,
project = %result.project_id,
notice = %serde_json::to_string(notice).unwrap_or_default(),
truncated,
"{}",
crate::i18n::tr("impact-notice")
);
}
#[cfg(feature = "hub")]
if let Some(webhook) = &self.webhook {
if !notices.is_empty() {
post_webhook(
webhook.clone(),
serde_json::json!({
"event": "impact_notice",
"project": result.project_id,
"notices": notices,
"truncated": truncated,
}),
);
}
}
}
}
#[cfg(feature = "hub")]
fn post_webhook(url: String, payload: serde_json::Value) {
std::thread::spawn(move || {
let client = match reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
{
Ok(client) => client,
Err(err) => {
warn!(
error = %err,
"{}",
crate::i18n::tr("impact-webhook-client-failed")
);
return;
}
};
match client.post(&url).json(&payload).send() {
Ok(response) if !response.status().is_success() => {
warn!(
status = %response.status(),
url = %url,
"{}",
crate::i18n::tr("impact-webhook-non-2xx")
);
}
Ok(_) => {}
Err(err) => {
warn!(
error = %err,
url = %url,
"{}",
crate::i18n::tr("impact-webhook-send-failed")
);
}
}
});
}
#[cfg(test)]
mod tests {
use super::*;
use crate::storage::capability::Storage;
use tempfile::TempDir;
fn seeded_db() -> (TempDir, PathBuf) {
let dir = TempDir::new().unwrap();
let db = dir.path().join("notify_db.lbug");
let repo = Repository::open(&db).unwrap();
repo.execute("CREATE (:Function {id: 'f1', project: 'demo', name: 'hub', qualifiedName: 'demo.hub', filePath: '/src/hub.rs', startLine: 1, endLine: 5, signature: '', returnType: '', isExported: false, docstring: '', content: '', parentQn: ''});").unwrap();
repo.execute("CREATE (:Function {id: 'f2', project: 'demo', name: 'caller', qualifiedName: 'demo.caller', filePath: '/src/caller.rs', startLine: 1, endLine: 5, signature: '', returnType: '', isExported: false, docstring: '', content: '', parentQn: ''});").unwrap();
repo.execute("CREATE (:CodeRelation {id: 'e1', source: 'f2', target: 'f1', type: 'CALLS', confidence: 1.0, confidenceTier: 'High', reason: '', startLine: 2, project: 'demo'});").unwrap();
drop(repo);
(dir, db)
}
fn index_result() -> IndexResult {
IndexResult {
project_id: "demo".to_string(),
files_indexed: 1,
files_skipped: 0,
nodes_created: 0,
edges_created: 0,
duration_ms: 1,
}
}
#[test]
fn computes_notice_for_changed_file_with_incoming_edges() {
let (_dir, db) = seeded_db();
let observer = ImpactNotifyObserver::new(db);
let notices = observer.compute_notices(&[PathBuf::from("/src/hub.rs")]);
assert_eq!(notices.len(), 1);
assert_eq!(notices[0].total_callers, 1);
let hub = notices[0].symbols.iter().find(|s| s.name == "hub").unwrap();
assert_eq!(hub.incoming, 1);
assert!(
!notices[0].symbols.iter().any(|s| s.name == "caller"),
"symbols from other files must not leak into this notice"
);
}
#[test]
fn unchanged_files_produce_no_notices() {
let (_dir, db) = seeded_db();
let observer = ImpactNotifyObserver::new(db);
let notices = observer.compute_notices(&[PathBuf::from("/src/untouched.rs")]);
assert!(notices.is_empty(), "no symbols → no notice emitted");
}
#[test]
fn symbol_budget_truncates() {
let (_dir, db) = seeded_db();
let observer = ImpactNotifyObserver::new(db);
let notices = observer.compute_notices(&[
PathBuf::from("/src/hub.rs"),
PathBuf::from("/src/caller.rs"),
]);
let total: usize = notices.iter().map(|n| n.symbols.len()).sum();
assert!(total >= 1);
assert!(total <= SYMBOL_CAP, "per-batch cap holds");
}
#[test]
fn webhook_config_none_keeps_offline_behavior() {
let (_dir, db) = seeded_db();
let mut observer = ImpactNotifyObserver::new(db);
observer.on_index_complete(&index_result(), &[PathBuf::from("/src/hub.rs")]);
}
#[test]
#[cfg(feature = "hub")]
fn webhook_url_with_invalid_host_does_not_panic() {
let (_dir, db) = seeded_db();
let mut observer =
ImpactNotifyObserver::new(db).with_webhook(Some("http://127.0.0.1:1/hook".to_string()));
observer.on_index_complete(&index_result(), &[PathBuf::from("/src/hub.rs")]);
}
#[test]
fn missing_db_returns_empty_notices() {
let observer = ImpactNotifyObserver::new(PathBuf::from("/nonexistent/notify_db.lbug"));
let notices = observer.compute_notices(&[PathBuf::from("/src/hub.rs")]);
assert!(notices.is_empty(), "unopenable DB degrades to silence");
}
#[test]
fn on_index_complete_emits_and_does_not_panic() {
let (_dir, db) = seeded_db();
let mut observer = ImpactNotifyObserver::new(db);
observer.on_index_complete(&index_result(), &[PathBuf::from("/src/hub.rs")]);
}
}