use crate::AppState; use axum::extract::{Query, State}; use std::collections::HashMap; use std::sync::Arc; #[derive(serde::Deserialize, serde::Serialize, Debug, Clone, PartialEq)] pub struct ProjectedTechDebt { pub id: String, pub description: String, pub severity: Option, pub line_range: Option, pub file_path: Option, pub ideal_solution: String, } #[derive(serde::Deserialize, serde::Serialize, Debug, Clone, PartialEq)] pub struct ProjectedAdr { pub id: String, pub title: String, pub decision: String, pub status: String, pub affected_components: Vec, } pub fn find_projected_knowledge( state: &crate::state::MemoryState, file_path: &str, ) -> (Vec, Vec) { let normalized_file = file_path.replace('\\', "/").to_lowercase(); // An empty file path matches everything due to `ends_with("")` behavior. // Return empty results immediately for [No Name] buffers. if normalized_file.trim().is_empty() { return (Vec::new(), Vec::new()); } let file_name = std::path::Path::new(&normalized_file) .file_name() .and_then(|n| n.to_str()) .unwrap_or("") .to_string(); let debts = state.code.tech_debts.read_with(|all_debts| { all_debts .iter() .filter(|d| !d.is_resolved) .filter(|d| { if let Some(ref dfp) = d.file_path { let ndfp = dfp.replace('\\', "/").to_lowercase(); if normalized_file == ndfp || normalized_file.ends_with(&format!("/{}", ndfp.trim_start_matches('/'))) || ndfp.ends_with(&format!("/{}", normalized_file.trim_start_matches('/'))) || (!file_name.is_empty() && (ndfp == file_name || ndfp.ends_with(&format!("/{}", file_name)))) { return true; } } if !file_name.is_empty() && file_name.len() > 3 && d.description.to_lowercase().contains(&file_name) { return true; } false }) .map(|d| ProjectedTechDebt { id: d.id.clone(), description: d.description.clone(), severity: d.severity.clone(), line_range: d.line_range.clone(), file_path: d.file_path.clone(), ideal_solution: d.ideal_solution.clone(), }) .collect() }); let adrs = state.code.adrs.read_with(|all_adrs| { all_adrs .iter() .filter(|a| { a.status.eq_ignore_ascii_case("accepted") || a.status.eq_ignore_ascii_case("active") }) .filter(|a| { for comp in &a.affected_components { let ncomp = comp.replace('\\', "/").to_lowercase(); if !ncomp.is_empty() && (normalized_file.ends_with(&ncomp) || ncomp.ends_with(&normalized_file) || (!file_name.is_empty() && ncomp.contains(&file_name))) { return true; } } if !file_name.is_empty() && file_name.len() > 3 && (a.title.to_lowercase().contains(&file_name) || a.decision.to_lowercase().contains(&file_name)) { return true; } false }) .map(|a| ProjectedAdr { id: a.id.clone(), title: a.title.clone(), decision: a.decision.clone(), status: a.status.clone(), affected_components: a.affected_components.clone(), }) .collect() }); (debts, adrs) } #[derive(serde::Deserialize, serde::Serialize, Debug)] pub struct NvimTelemetry { pub session_id: String, pub event: String, pub file: Option, pub line: Option, pub col: Option, // New interactive fields pub prompt: Option, pub response: Option, pub index: Option, pub request_id: Option, pub diff_id: Option, pub start_row: Option, pub end_row: Option, } pub async fn nvim_telemetry_handler( State(state): State>, axum::Json(payload): axum::Json, ) -> impl axum::response::IntoResponse { // 1. Update active_nvim.txt if FocusGained, BufEnter, or VimEnter if payload.event == "FocusGained" || payload.event == "BufEnter" || payload.event == "VimEnter" { let session = &payload.session_id; let is_unix_socket = session.starts_with('/') || session.starts_with('~'); if is_unix_socket { let wsl_path = "\\\\wsl.localhost\\Ubuntu\\home\\riz\\.gemini\\active_nvim.txt"; let _ = tokio::fs::write(wsl_path, session).await; } else { let profile = std::env::var("USERPROFILE").unwrap_or_else(|_| "C:\\Users\\reazul.ashraf".into()); let win_path = format!("{}\\.gemini\\active_nvim.txt", profile); let _ = tokio::fs::write(&win_path, session).await; } } // 2. Query Knowledge Graph for matching Tech Debts and ADRs (ADR-0103) let (tech_debts, adrs) = if let Some(ref f) = payload.file { find_projected_knowledge(&state.handler.state, f) } else { (Vec::new(), Vec::new()) }; // 3. Broadcast to UI WebSockets let ws_msg = serde_json::json!({ "type": "nvim_telemetry", "data": payload, "tech_debts": tech_debts, "adrs": adrs }); let msg_str = ws_msg.to_string(); let senders: Vec<_> = state .clients .read() .unwrap_or_else(|e| e.into_inner()) .values() .cloned() .collect(); for tx in senders { let _ = tx.try_send(msg_str.clone()); } if payload.event == "BufWritePost" && let Some(ref file_path) = payload.file { let normalized_file = file_path.replace("\\", "/"); let topic = format!("nvim:save:{}", normalized_file); let event = crate::state::GenericEvent { topic, session_id: Some(payload.session_id.clone()), payload: serde_json::json!(&payload), }; let _ = state.handler.state.event_bus_tx.send(event); } if payload.event.starts_with("agent_") || payload.event.starts_with("diff_") { let payload_val = serde_json::json!(&payload); let _ = state .handler .state .event_bus_tx .send(crate::state::GenericEvent { topic: format!("nvim:ui:{}", payload.event), session_id: Some(payload.session_id.clone()), payload: payload_val.clone(), }); if let Some(ref req_id) = payload.request_id { let _ = state .handler .state .event_bus_tx .send(crate::state::GenericEvent { topic: format!("nvim:ui:{}:{}", payload.event, req_id), session_id: Some(payload.session_id.clone()), payload: payload_val.clone(), }); } if let Some(ref diff_id) = payload.diff_id { let _ = state .handler .state .event_bus_tx .send(crate::state::GenericEvent { topic: format!("nvim:ui:{}:{}", payload.event, diff_id), session_id: Some(payload.session_id.clone()), payload: payload_val.clone(), }); let _ = state .handler .state .event_bus_tx .send(crate::state::GenericEvent { topic: format!("nvim:ui:diff_decision:{}", diff_id), session_id: Some(payload.session_id.clone()), payload: payload_val.clone(), }); } } if let Some(ref f) = payload.file { state.handler.state.record_activity( "editor", &format!("Neovim {}: {}", payload.event, f), None, ); } // 4. Extract active task & push gate for zero-polling statusline let active_task = state.handler.state.project.tasks.read_with(|tasks| { tasks .iter() .find(|t| t.is_active()) .or_else(|| { tasks .iter() .find(|t| t.status == "pending" && t.parent_id.is_none()) }) .map(|t| t.title.clone()) }); let active_gate = state.handler.state.env.gates.read_with(|gates| { gates .iter() .find(|g| g.action == "push" || g.gate_type.as_deref() == Some("quality_gate")) .map(|g| g.status.clone()) }); axum::Json(serde_json::json!({ "status": "ok", "tech_debts": tech_debts, "adrs": adrs, "active_task": active_task, "active_gate": active_gate })) } pub async fn nvim_knowledge_projection_handler( State(state): State>, Query(params): Query>, ) -> impl axum::response::IntoResponse { let (tech_debts, adrs) = if let Some(file_path) = params.get("file") { find_projected_knowledge(&state.handler.state, file_path) } else { (Vec::new(), Vec::new()) }; axum::Json(serde_json::json!({ "status": "ok", "tech_debts": tech_debts, "adrs": adrs })) } use crate::models::TerminalHistory; pub async fn get_terminal_history_handler( State(state): State>, ) -> impl axum::response::IntoResponse { let history_json = state .handler .state .telemetry .terminal_history .read_with(|h| serde_json::to_string(h).unwrap_or_else(|_| "[]".to_string())); ( [(axum::http::header::CONTENT_TYPE, "application/json")], history_json, ) } pub async fn terminal_telemetry_handler( State(state): State>, axum::Json(payload): axum::Json, ) -> impl axum::response::IntoResponse { state.handler.state.record_activity( "terminal", &format!("Ran command: {}", payload.command), Some(&format!( "exit_code: {}, cwd: {}", payload.exit_code, payload.cwd )), ); state.handler.state.record_terminal_history(payload.clone()); let ws_msg = serde_json::json!({ "type": "terminal_telemetry", "data": payload }); let msg_str = ws_msg.to_string(); let senders: Vec<_> = state .clients .read() .unwrap_or_else(|e| e.into_inner()) .values() .cloned() .collect(); for tx in senders { let _ = tx.try_send(msg_str.clone()); } axum::Json(serde_json::json!({"status": "ok"})) } #[cfg(test)] mod tests { use super::*; use crate::router::MemoryHandler; use crate::state::MemoryState; use axum::http::Request; use axum::routing::get; use axum::routing::post; use std::collections::HashMap; use std::sync::atomic::AtomicUsize; use tempfile::tempdir; use tower::ServiceExt; #[tokio::test] async fn test_terminal_history() { let dir = tempdir().unwrap(); let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap())); let (shutdown_tx, _) = tokio::sync::oneshot::channel(); let app_state = Arc::new(AppState { handler: Arc::new(MemoryHandler::new(state.clone())), clients: std::sync::RwLock::new(HashMap::new()), next_id: AtomicUsize::new(1), shutdown_tx: std::sync::Mutex::new(Some(shutdown_tx)), }); let app = axum::Router::new() .route("/api/terminal/history", get(get_terminal_history_handler)) .route("/terminal/telemetry", post(terminal_telemetry_handler)) .with_state(app_state.clone()); // Create a fake terminal telemetry payload let payload = TerminalHistory { command: "echo test".to_string(), exit_code: 0, cwd: "C:/Users/reazul.ashraf/workspace".to_string(), os: "windows".to_string(), timestamp: 12345, error_output: None, ..Default::default() }; // Post the telemetry let req = Request::builder() .method("POST") .uri("/terminal/telemetry") .header("content-type", "application/json") .body(axum::body::Body::from( serde_json::to_string(&payload).unwrap(), )) .unwrap(); let response = app.clone().oneshot(req).await.unwrap(); assert_eq!(response.status(), 200); // Get the telemetry let req2 = Request::builder() .method("GET") .uri("/api/terminal/history") .body(axum::body::Body::empty()) .unwrap(); let response2 = app.oneshot(req2).await.unwrap(); assert_eq!(response2.status(), 200); let body_bytes = axum::body::to_bytes(response2.into_body(), usize::MAX) .await .unwrap(); let history: Vec = serde_json::from_slice(&body_bytes).unwrap(); assert_eq!(history.len(), 1); assert_eq!(history[0].command, "echo test"); } #[tokio::test] async fn test_find_projected_knowledge_matching() { let dir = tempdir().unwrap(); let state = MemoryState::new(dir.path().to_str().unwrap()); // Add 1 unresolved Tech Debt for server/src/lib.rs state.code.tech_debts.modify(|debts| { debts.push(crate::models::TechDebt { id: "td-001".to_string(), namespace: "default".to_string(), description: "Refactor router dispatch in lib.rs".to_string(), ideal_solution: "Use modular sub-routers".to_string(), is_resolved: false, created_at: 1000, file_path: Some("server/src/lib.rs".to_string()), line_range: Some("100-150".to_string()), severity: Some("high".to_string()), ..Default::default() }); // Add 1 resolved Tech Debt which should NOT be projected debts.push(crate::models::TechDebt { id: "td-002".to_string(), namespace: "default".to_string(), description: "Old resolved debt".to_string(), ideal_solution: "Done".to_string(), is_resolved: true, created_at: 500, file_path: Some("server/src/lib.rs".to_string()), ..Default::default() }); }); // Add 1 accepted ADR for server/src/lib.rs state.code.adrs.modify(|adrs| { adrs.push(crate::models::Adr { id: "ADR-0103".to_string(), title: "Virtual Text Knowledge Projection".to_string(), context: "Context".to_string(), decision: "Project tech debt and ADRs into active buffer".to_string(), consequence: "High developer awareness".to_string(), status: "accepted".to_string(), timestamp: 2000, affected_components: vec![ "server/src/lib.rs".to_string(), "gemini-ui.lua".to_string(), ], ..Default::default() }); // Add 1 superseded ADR which should NOT be projected adrs.push(crate::models::Adr { id: "ADR-0099".to_string(), title: "Old decision".to_string(), context: "Context".to_string(), decision: "Old".to_string(), consequence: "Old".to_string(), status: "superseded".to_string(), timestamp: 1000, affected_components: vec!["server/src/lib.rs".to_string()], ..Default::default() }); }); // Test with Windows path format let (debts, adrs) = find_projected_knowledge( &state, "C:\\Users\\reazul.ashraf\\workspace\\rust\\mcp-memory\\server\\src\\lib.rs", ); assert_eq!(debts.len(), 1); assert_eq!(debts[0].id, "td-001"); assert_eq!(debts[0].severity.as_deref(), Some("high")); assert_eq!(adrs.len(), 1); assert_eq!(adrs[0].id, "ADR-0103"); // Test with unrelated file let (debts_unrelated, adrs_unrelated) = find_projected_knowledge(&state, "crates/unrelated/src/foo.rs"); assert!(debts_unrelated.is_empty()); assert!(adrs_unrelated.is_empty()); } #[tokio::test] async fn test_nvim_telemetry_and_projection_endpoints() { let dir = tempdir().unwrap(); let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap())); state.code.tech_debts.modify(|debts| { debts.push(crate::models::TechDebt { id: "td-active".to_string(), namespace: "default".to_string(), description: "Fix concurrency lock".to_string(), ideal_solution: "Use tokio rwlock".to_string(), is_resolved: false, created_at: 1000, file_path: Some("server/src/api/telemetry.rs".to_string()), line_range: Some("25".to_string()), severity: Some("medium".to_string()), ..Default::default() }); }); state.code.adrs.modify(|adrs| { adrs.push(crate::models::Adr { id: "ADR-0102".to_string(), title: "Neovim RPC Circuit Breaker".to_string(), context: "Context".to_string(), decision: "Fail fast".to_string(), consequence: "No hangs".to_string(), status: "accepted".to_string(), timestamp: 1000, affected_components: vec!["telemetry.rs".to_string()], ..Default::default() }); }); let (shutdown_tx, _) = tokio::sync::oneshot::channel(); let app_state = Arc::new(AppState { handler: Arc::new(MemoryHandler::new(state.clone())), clients: std::sync::RwLock::new(HashMap::new()), next_id: AtomicUsize::new(1), shutdown_tx: std::sync::Mutex::new(Some(shutdown_tx)), }); let app = axum::Router::new() .route("/nvim/telemetry", post(nvim_telemetry_handler)) .route( "/api/nvim/projection", get(nvim_knowledge_projection_handler), ) .with_state(app_state.clone()); // 1. Post BufEnter telemetry let payload = NvimTelemetry { session_id: "test-session".to_string(), event: "BufEnter".to_string(), file: Some( "C:/Users/reazul.ashraf/workspace/rust/mcp-memory/server/src/api/telemetry.rs" .to_string(), ), line: Some(25), col: Some(1), prompt: None, response: None, index: None, request_id: None, diff_id: None, start_row: None, end_row: None, }; let req = Request::builder() .method("POST") .uri("/nvim/telemetry") .header("content-type", "application/json") .body(axum::body::Body::from( serde_json::to_string(&payload).unwrap(), )) .unwrap(); let response = app.clone().oneshot(req).await.unwrap(); assert_eq!(response.status(), 200); let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX) .await .unwrap(); let body: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap(); assert_eq!(body["status"], "ok"); assert_eq!(body["tech_debts"].as_array().unwrap().len(), 1); assert_eq!(body["tech_debts"][0]["id"], "td-active"); assert_eq!(body["adrs"].as_array().unwrap().len(), 1); assert_eq!(body["adrs"][0]["id"], "ADR-0102"); // 2. Query GET /api/nvim/projection let req2 = Request::builder() .method("GET") .uri("/api/nvim/projection?file=server/src/api/telemetry.rs") .body(axum::body::Body::empty()) .unwrap(); let response2 = app.oneshot(req2).await.unwrap(); assert_eq!(response2.status(), 200); let body_bytes2 = axum::body::to_bytes(response2.into_body(), usize::MAX) .await .unwrap(); let body2: serde_json::Value = serde_json::from_slice(&body_bytes2).unwrap(); assert_eq!(body2["status"], "ok"); assert_eq!(body2["tech_debts"].as_array().unwrap().len(), 1); assert_eq!(body2["adrs"].as_array().unwrap().len(), 1); } }