use std::sync::Mutex;
use std::time::Instant;
use crate::adapters::insight_utils;
use crate::domain::composite_graph::CompositeGraph;
use crate::domain::graph::KnowledgeGraph;
use crate::domain::types::UserEntity;
use crate::ports::embeddings::EmbeddingProvider;
pub struct AnalysisEntityParams<'a> {
pub smell_id: &'a str,
pub smell_name: &'a str,
pub location: &'a str,
pub function_name: &'a str,
pub confidence: f64,
pub reasons: &'a [String],
pub language: &'a str,
}
pub struct RefactoringEntityParams<'a> {
pub smell_id: &'a str,
pub smell_name: &'a str,
pub location: &'a str,
pub function_name: &'a str,
pub confidence: f64,
pub refactoring_ids: &'a [String],
pub language: &'a str,
}
pub struct EpistemeMCP {
graph: KnowledgeGraph,
db: Option<Mutex<rusqlite::Connection>>,
embedding_provider: Option<Box<dyn EmbeddingProvider>>,
composite: Option<Mutex<CompositeGraph>>,
}
impl EpistemeMCP {
pub fn new(graph: KnowledgeGraph) -> Self {
Self {
graph,
db: None,
embedding_provider: None,
composite: None,
}
}
pub fn with_rag(
graph: KnowledgeGraph,
db: rusqlite::Connection,
provider: Box<dyn EmbeddingProvider>,
) -> Self {
Self {
graph,
db: Some(Mutex::new(db)),
embedding_provider: Some(provider),
composite: None,
}
}
pub fn with_composite(graph: KnowledgeGraph, composite: CompositeGraph) -> Self {
Self {
graph,
db: None,
embedding_provider: None,
composite: Some(Mutex::new(composite)),
}
}
pub fn try_attach_rag(&mut self) {
let db_path = crate::adapters::paths::db_path();
if !db_path.exists() {
return;
}
if let Ok(rw_conn) = rusqlite::Connection::open(&db_path) {
let has_fts: bool = rw_conn
.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='chunks_fts'",
[],
|row| row.get(0),
)
.unwrap_or(0i64)
> 0;
if !has_fts {
let _ = crate::adapters::search_engines::build_fts_index(&rw_conn);
}
}
if let Ok(conn) = rusqlite::Connection::open_with_flags(
&db_path,
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
) {
#[cfg(feature = "openai-embeddings")]
{
let cfg = crate::adapters::config::EpistemeConfig::load().unwrap_or_default();
let provider_pref = cfg.embedding_provider.to_lowercase();
let key = std::env::var("EPISTEME_OPENAI_API_KEY")
.ok()
.filter(|k| !k.is_empty())
.or_else(|| {
if cfg.openai_api_key.is_empty() {
None
} else {
Some(cfg.openai_api_key.clone())
}
});
if provider_pref == "openai"
&& let Some(key) = key
{
let model = std::env::var("EPISTEME_OPENAI_EMBED_MODEL")
.ok()
.filter(|m| !m.is_empty())
.unwrap_or_else(|| cfg.openai_embed_model.clone());
match crate::adapters::embedding_providers::create_openai_provider(key, model) {
Ok(provider) => {
self.embedding_provider = Some(provider);
self.db = Some(Mutex::new(conn));
return;
}
Err(e) => {
tracing::warn!(
"Failed to create OpenAI provider, falling back to local: {e}"
);
}
}
}
}
self.embedding_provider =
Some(crate::adapters::embedding_providers::create_configured_local_provider());
self.db = Some(Mutex::new(conn));
}
}
pub fn graph(&self) -> &KnowledgeGraph {
&self.graph
}
pub fn all_entity_ids(&self) -> Vec<String> {
if let Some(c) = &self.composite {
c.lock()
.map(|g| {
use crate::ports::graph::GraphRepository;
g.all_entity_ids()
})
.unwrap_or_else(|_| self.graph.all_entity_ids())
} else {
self.graph.all_entity_ids()
}
}
pub fn get_entity_merged(&self, id: &str) -> Option<crate::domain::types::Entity> {
if let Some(c) = &self.composite {
c.lock().ok().and_then(|g| {
use crate::ports::graph::GraphRepository;
g.get_entity(id).cloned()
})
} else {
self.graph.get_entity(id).cloned()
}
}
pub fn get_all_edges_merged(&self, id: &str) -> Vec<crate::domain::types::GraphEdge> {
if let Some(c) = &self.composite {
c.lock()
.map(|g| {
use crate::ports::graph::GraphRepository;
g.get_all_edges(id)
})
.unwrap_or_else(|_| self.graph.get_all_edges(id))
} else {
self.graph.get_all_edges(id)
}
}
pub fn user_entity_count(&self) -> usize {
self.composite
.as_ref()
.map(|c| c.lock().map(|g| g.user_entity_count()).unwrap_or(0))
.unwrap_or(0)
}
pub fn has_db(&self) -> bool {
self.db.is_some()
}
pub fn has_embedding_provider(&self) -> bool {
self.embedding_provider.is_some()
}
pub fn embedding_info(&self) -> Option<crate::server::api_models::EmbeddingInfo> {
use crate::adapters::config::EpistemeConfig;
use crate::adapters::infra::sqlite_db;
let db = self.db.as_ref()?;
let conn = db.lock().ok()?;
let stored_model = sqlite_db::get_meta(&conn, "embedding_model").ok().flatten();
let stored_dim = sqlite_db::get_meta(&conn, "embedding_dim")
.ok()
.flatten()
.and_then(|v| v.parse::<usize>().ok());
let cfg = EpistemeConfig::load().unwrap_or_default();
let configured_model = match cfg.embedding_provider.to_lowercase().as_str() {
"openai" => Some(format!("openai:{}", cfg.openai_embed_model)),
_ => Some(cfg.embedding_model.clone()),
};
let mismatch = match (&stored_model, &configured_model) {
(Some(stored), Some(configured)) => stored != configured,
_ => false,
};
Some(crate::server::api_models::EmbeddingInfo {
stored_model,
stored_dim,
configured_model,
mismatch,
})
}
}
impl EpistemeMCP {
pub fn search_knowledge(
&self,
query: &str,
limit: Option<usize>,
entity_type: Option<&str>,
) -> serde_json::Value {
super::mcp_search::search_knowledge(
&self.graph,
self.db.as_ref(),
self.embedding_provider.as_deref(),
query,
limit,
entity_type,
)
}
pub fn get_entity(&self, entity_id: &str, detail_level: Option<&str>) -> serde_json::Value {
use crate::ports::graph::GraphRepository;
if let Some(c) = &self.composite
&& let Ok(g) = c.lock()
&& g.get_entity(entity_id).is_some()
{
return super::mcp_graph::get_entity_from_repo(&*g, entity_id, detail_level);
}
super::mcp_graph::get_entity(&self.graph, entity_id, detail_level)
}
pub fn get_neighbors(&self, entity_id: &str, relation_type: Option<&str>) -> serde_json::Value {
use crate::ports::graph::GraphRepository;
if let Some(c) = &self.composite
&& let Ok(g) = c.lock()
&& g.get_entity(entity_id).is_some()
{
return super::mcp_graph::get_neighbors_from_repo(&*g, entity_id, relation_type);
}
super::mcp_graph::get_neighbors(&self.graph, entity_id, relation_type)
}
pub fn find_path(
&self,
from_id: &str,
to_id: &str,
max_depth: Option<usize>,
) -> serde_json::Value {
super::mcp_graph::find_path(&self.graph, from_id, to_id, max_depth)
}
pub fn analyze_code(&self, code: &str, language: Option<&str>) -> serde_json::Value {
super::mcp_analysis::analyze_code(code, language)
}
pub fn suggest_refactorings(
&self,
code: &str,
language: Option<&str>,
top_k: Option<usize>,
) -> serde_json::Value {
super::mcp_analysis::suggest_refactorings(&self.graph, code, language, top_k)
}
pub fn add_insight(
&self,
text: &str,
tags: Option<Vec<String>>,
linked_entities: Option<Vec<String>>,
project: Option<&str>,
) -> serde_json::Value {
let Some(composite_mutex) = &self.composite else {
return serde_json::json!({"error": "tacit knowledge not enabled (no composite graph)"});
};
let Ok(mut composite) = composite_mutex.lock() else {
return serde_json::json!({"error": "failed to acquire composite lock"});
};
super::mcp_insight::add_insight(
&mut composite,
&self.graph,
text,
tags,
linked_entities,
project,
)
}
pub fn confirm_links(
&self,
insight_id: &str,
accepted: Vec<String>,
rejected: Vec<String>,
merged_with: Option<&str>,
) -> serde_json::Value {
let Some(composite_mutex) = &self.composite else {
return serde_json::json!({"error": "tacit knowledge not enabled (no composite graph)"});
};
let Ok(mut composite) = composite_mutex.lock() else {
return serde_json::json!({"error": "failed to acquire composite lock"});
};
super::mcp_insight::confirm_links(
&mut composite,
insight_id,
accepted,
rejected,
merged_with,
)
}
pub fn add_analysis_entity(&self, params: AnalysisEntityParams<'_>) -> Result<(), String> {
let AnalysisEntityParams {
smell_id,
smell_name,
location,
function_name,
confidence,
reasons,
language,
} = params;
let composite_mutex = self
.composite
.as_ref()
.ok_or_else(|| "tacit knowledge not enabled".to_owned())?;
let composite = composite_mutex
.lock()
.map_err(|e| format!("mutex poisoned: {e}"))?;
let dedup_key = format!("found_in:{location}:{function_name}:{smell_id}");
let already_exists = composite
.user_store()
.all_user_entities()
.into_iter()
.any(|e| e.author == "analysis" && e.tags.contains(&dedup_key));
if already_exists {
return Ok(());
}
let id = composite.user_store().next_insight_id()?;
let now = insight_utils::format_timestamp();
let mut relations = std::collections::HashMap::new();
if self.graph.get_entity(smell_id).is_some() {
relations.insert("derived_from".to_owned(), vec![smell_id.to_owned()]);
}
let content = format!(
"Smell: {smell_name}\nLocation: {location}\nFunction: {function_name}\nConfidence: {confidence:.2}\nReasons: {}",
reasons.join("; ")
);
let entity = UserEntity {
id,
title: format!("{smell_name} in {function_name}"),
content,
author: "analysis".to_owned(),
confidence,
evidence_count: 1,
last_validated: now.clone(),
tags: vec![
"source:analysis".to_owned(),
format!("smell:{smell_id}"),
format!("lang:{language}"),
dedup_key,
],
relations,
link_provenance: std::collections::HashMap::new(),
created_at: now.clone(),
updated_at: now,
};
composite.user_store().add_entity(entity)
}
pub fn add_refactoring_entity(
&self,
params: RefactoringEntityParams<'_>,
) -> Result<(), String> {
let RefactoringEntityParams {
smell_id,
smell_name,
location,
function_name,
confidence,
refactoring_ids,
language,
} = params;
let composite_mutex = self
.composite
.as_ref()
.ok_or_else(|| "tacit knowledge not enabled".to_owned())?;
let composite = composite_mutex
.lock()
.map_err(|e| format!("mutex poisoned: {e}"))?;
let dedup_key = format!("found_in:{location}:{function_name}:{smell_id}:refactor");
let already_exists = composite
.user_store()
.all_user_entities()
.into_iter()
.any(|e| e.author == "analysis" && e.tags.contains(&dedup_key));
if already_exists {
return Ok(());
}
let id = composite.user_store().next_insight_id()?;
let now = insight_utils::format_timestamp();
let mut relations = std::collections::HashMap::new();
if self.graph.get_entity(smell_id).is_some() {
relations.insert("derived_from".to_owned(), vec![smell_id.to_owned()]);
}
let valid_refactorings: Vec<String> = refactoring_ids
.iter()
.filter(|rid| self.graph.get_entity(rid).is_some())
.cloned()
.collect();
if !valid_refactorings.is_empty() {
relations.insert("suggests".to_owned(), valid_refactorings);
}
let entity = UserEntity {
id,
title: format!("Refactoring: {smell_name} in {function_name}"),
content: format!(
"Smell: {smell_name}\nLocation: {location}\nFunction: {function_name}\nConfidence: {confidence:.2}\nSuggested refactorings: {}",
refactoring_ids.join(", ")
),
author: "analysis".to_owned(),
confidence,
evidence_count: 1,
last_validated: now.clone(),
tags: vec![
"source:analysis".to_owned(),
format!("smell:{smell_id}"),
format!("lang:{language}"),
"refactor:suggested".to_owned(),
dedup_key,
],
relations,
link_provenance: std::collections::HashMap::new(),
created_at: now.clone(),
updated_at: now,
};
composite.user_store().add_entity(entity)
}
pub fn list_insights(
&self,
limit: usize,
source: Option<&str>,
) -> Vec<crate::domain::types::UserEntity> {
let Some(composite_mutex) = &self.composite else {
return Vec::new();
};
let Ok(composite) = composite_mutex.lock() else {
return Vec::new();
};
let all = composite.user_store().all_user_entities();
let filtered: Vec<_> = match source {
Some("user") => all.into_iter().filter(|e| e.author != "analysis").collect(),
Some("analysis") => all.into_iter().filter(|e| e.author == "analysis").collect(),
_ => all,
};
filtered.into_iter().take(limit).collect()
}
pub fn search_insights(&self, query: &str, limit: Option<usize>) -> serde_json::Value {
let Some(composite_mutex) = &self.composite else {
return serde_json::json!({"error": "tacit knowledge not enabled (no composite graph)"});
};
let Ok(composite) = composite_mutex.lock() else {
return serde_json::json!({"error": "failed to acquire composite lock"});
};
super::mcp_insight::search_insights(composite.user_store(), query, limit)
}
}
impl EpistemeMCP {
pub fn handle_resource_read(&self, uri: &str) -> serde_json::Value {
match uri {
"episteme://stats" => {
let stats = self.graph.stats();
serde_json::to_value(stats)
.unwrap_or(serde_json::json!({"error": "serialization failed"}))
}
"episteme://categories" => {
let entity_types: Vec<&str> = ["pattern", "refactoring", "law", "smell"].to_vec();
let categories: Vec<&str> = [
"teams",
"planning",
"architecture",
"quality",
"scalability",
"design",
"decisions",
]
.to_vec();
serde_json::json!({
"entity_types": entity_types,
"categories": categories,
})
}
"episteme://contradictions" => {
let contradictions = self.graph.find_contradictions();
serde_json::json!(contradictions)
}
_ => serde_json::json!({
"error": format!("Unknown resource '{}'.", uri)
}),
}
}
}
impl EpistemeMCP {
pub fn handle_tool_call(&self, name: &str, args: &serde_json::Value) -> serde_json::Value {
let telemetry_tool = Self::telemetry_tool_for(name);
if let Some(tool) = telemetry_tool {
crate::adapters::telemetry::track_tool_called(tool);
}
let started_at = Instant::now();
let result = match name {
"search_knowledge" => self.dispatch_search_knowledge(args),
"get_entity" => self.dispatch_get_entity(args),
"get_neighbors" => self.dispatch_get_neighbors(args),
"find_path" => self.dispatch_find_path(args),
"analyze_code" => self.dispatch_analyze_code(args),
"suggest_refactorings" => self.dispatch_suggest_refactorings(args),
"add_insight" => self.dispatch_add_insight(args),
"confirm_links" => self.dispatch_confirm_links(args),
"search_insights" => self.dispatch_search_insights(args),
_ => serde_json::json!({
"error": format!("Unknown tool '{}'.", name)
}),
};
if let Some(tool) = telemetry_tool {
Self::record_telemetry(tool, &result, started_at);
}
result
}
fn dispatch_search_knowledge(&self, args: &serde_json::Value) -> serde_json::Value {
let query = args.get("query").and_then(|v| v.as_str()).unwrap_or("");
let limit = args
.get("limit")
.and_then(|v| v.as_u64())
.map(|v| v as usize);
let entity_type = args.get("entity_type").and_then(|v| v.as_str());
self.search_knowledge(query, limit, entity_type)
}
fn dispatch_get_entity(&self, args: &serde_json::Value) -> serde_json::Value {
let entity_id = args.get("entity_id").and_then(|v| v.as_str()).unwrap_or("");
let detail_level = args.get("detail_level").and_then(|v| v.as_str());
self.get_entity(entity_id, detail_level)
}
fn dispatch_get_neighbors(&self, args: &serde_json::Value) -> serde_json::Value {
let entity_id = args.get("entity_id").and_then(|v| v.as_str()).unwrap_or("");
let relation_type = args.get("relation_type").and_then(|v| v.as_str());
self.get_neighbors(entity_id, relation_type)
}
fn dispatch_find_path(&self, args: &serde_json::Value) -> serde_json::Value {
let from_id = args.get("from_id").and_then(|v| v.as_str()).unwrap_or("");
let to_id = args.get("to_id").and_then(|v| v.as_str()).unwrap_or("");
let max_depth = args
.get("max_depth")
.and_then(|v| v.as_u64())
.map(|v| v as usize);
self.find_path(from_id, to_id, max_depth)
}
fn dispatch_analyze_code(&self, args: &serde_json::Value) -> serde_json::Value {
let code = args.get("code").and_then(|v| v.as_str()).unwrap_or("");
let language = args.get("language").and_then(|v| v.as_str());
self.analyze_code(code, language)
}
fn dispatch_suggest_refactorings(&self, args: &serde_json::Value) -> serde_json::Value {
let code = args.get("code").and_then(|v| v.as_str()).unwrap_or("");
let language = args.get("language").and_then(|v| v.as_str());
let top_k = args
.get("top_k")
.and_then(|v| v.as_u64())
.map(|v| v as usize);
self.suggest_refactorings(code, language, top_k)
}
fn dispatch_add_insight(&self, args: &serde_json::Value) -> serde_json::Value {
let text = args.get("text").and_then(|v| v.as_str()).unwrap_or("");
let tags = args.get("tags").and_then(|v| v.as_array()).map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(|s| s.to_owned()))
.collect()
});
let linked_entities = args
.get("linked_entities")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(|s| s.to_owned()))
.collect()
});
let project = args.get("project").and_then(|v| v.as_str());
self.add_insight(text, tags, linked_entities, project)
}
fn dispatch_confirm_links(&self, args: &serde_json::Value) -> serde_json::Value {
let insight_id = args
.get("insight_id")
.and_then(|v| v.as_str())
.unwrap_or("");
let accepted = args
.get("accepted")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(|s| s.to_owned()))
.collect()
})
.unwrap_or_default();
let rejected = args
.get("rejected")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(|s| s.to_owned()))
.collect()
})
.unwrap_or_default();
let merged_with = args.get("merged_with").and_then(|v| v.as_str());
self.confirm_links(insight_id, accepted, rejected, merged_with)
}
fn dispatch_search_insights(&self, args: &serde_json::Value) -> serde_json::Value {
let query = args.get("query").and_then(|v| v.as_str()).unwrap_or("");
let limit = args
.get("limit")
.and_then(|v| v.as_u64())
.map(|v| v as usize);
self.search_insights(query, limit)
}
fn telemetry_tool_for(name: &str) -> Option<crate::adapters::telemetry::Tool> {
match name {
"search_knowledge" => Some(crate::adapters::telemetry::Tool::SearchKnowledge),
"get_entity" => Some(crate::adapters::telemetry::Tool::GetEntity),
"get_neighbors" => Some(crate::adapters::telemetry::Tool::GetNeighbors),
"find_path" => Some(crate::adapters::telemetry::Tool::FindPath),
"analyze_code" => Some(crate::adapters::telemetry::Tool::AnalyzeCode),
"suggest_refactorings" => Some(crate::adapters::telemetry::Tool::SuggestRefactorings),
"add_insight" => Some(crate::adapters::telemetry::Tool::AddInsight),
"confirm_links" => Some(crate::adapters::telemetry::Tool::ConfirmLinks),
"search_insights" => Some(crate::adapters::telemetry::Tool::SearchInsights),
_ => None,
}
}
fn record_telemetry(
tool: crate::adapters::telemetry::Tool,
result: &serde_json::Value,
started_at: Instant,
) {
if result.get("error").is_some() {
crate::adapters::telemetry::track_tool_failed(
tool,
crate::adapters::telemetry::FailureClass::Unknown,
);
} else {
let count = result
.get("count")
.and_then(|v| v.as_u64())
.map(|v| v as usize)
.unwrap_or_else(|| {
if result.get("results").and_then(|v| v.as_array()).is_some() {
result["results"].as_array().map(|a| a.len()).unwrap_or(0)
} else if result.get("smells").and_then(|v| v.as_array()).is_some() {
result["smells"].as_array().map(|a| a.len()).unwrap_or(0)
} else if result.get("analyses").and_then(|v| v.as_array()).is_some() {
result["analyses"].as_array().map(|a| a.len()).unwrap_or(0)
} else {
1
}
});
crate::adapters::telemetry::track_tool_completed(
tool,
started_at.elapsed().as_millis(),
count.into(),
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::adapters::UserGraphStore;
use crate::domain::composite_graph::CompositeGraph;
use crate::domain::graph::tests::build_graph_from_entities;
fn make_mcp_with_composite() -> EpistemeMCP {
let kg = build_graph_from_entities(vec![]);
let store = UserGraphStore::open_in_memory().unwrap();
let composite = CompositeGraph::new(kg.clone(), Box::new(store));
EpistemeMCP::with_composite(kg, composite)
}
fn analysis_params_static() -> AnalysisEntityParams<'static> {
AnalysisEntityParams {
smell_id: "SMELL-01",
smell_name: "Long Method",
location: "src/lib.rs",
function_name: "do_work",
confidence: 0.9,
reasons: &[],
language: "rust",
}
}
#[test]
fn add_analysis_entity_creates_tk_entity() {
let mcp = make_mcp_with_composite();
assert!(mcp.add_analysis_entity(analysis_params_static()).is_ok());
let insights = mcp.list_insights(10, None);
assert_eq!(insights.len(), 1);
let e = &insights[0];
assert_eq!(e.author, "analysis");
assert!(e.tags.iter().any(|t| t == "source:analysis"));
assert!(e.tags.iter().any(|t| t == "smell:SMELL-01"));
assert!(e.tags.iter().any(|t| t == "lang:rust"));
}
#[test]
fn add_analysis_entity_no_composite_returns_err() {
let mcp = EpistemeMCP::new(build_graph_from_entities(vec![]));
let result = mcp.add_analysis_entity(analysis_params_static());
assert!(result.is_err());
assert!(result.unwrap_err().contains("tacit knowledge not enabled"));
}
#[test]
fn add_refactoring_entity_creates_tk_entity() {
let mcp = make_mcp_with_composite();
let ids = vec!["RF-001".to_owned()];
let result = mcp.add_refactoring_entity(RefactoringEntityParams {
smell_id: "SMELL-01",
smell_name: "Long Method",
location: "src/lib.rs",
function_name: "do_work",
confidence: 0.85,
refactoring_ids: &ids,
language: "rust",
});
assert!(result.is_ok());
let insights = mcp.list_insights(10, None);
assert_eq!(insights.len(), 1);
let e = &insights[0];
assert_eq!(e.author, "analysis");
assert!(e.tags.iter().any(|t| t == "refactor:suggested"));
}
#[test]
fn list_insights_source_analysis_filters_correctly() {
let mcp = make_mcp_with_composite();
mcp.add_analysis_entity(analysis_params_static()).unwrap();
mcp.add_insight("manual user note", None, None, None);
let analysis_only = mcp.list_insights(10, Some("analysis"));
assert_eq!(analysis_only.len(), 1);
assert!(analysis_only.iter().all(|e| e.author == "analysis"));
let user_only = mcp.list_insights(10, Some("user"));
assert_eq!(user_only.len(), 1);
assert!(user_only.iter().all(|e| e.author != "analysis"));
let all = mcp.list_insights(10, None);
assert_eq!(all.len(), 2);
}
#[test]
fn add_analysis_entity_deduplicates_identical_calls() {
let mcp = make_mcp_with_composite();
mcp.add_analysis_entity(analysis_params_static()).unwrap();
mcp.add_analysis_entity(analysis_params_static()).unwrap();
let insights = mcp.list_insights(10, Some("analysis"));
assert_eq!(
insights.len(),
1,
"duplicate smell entity must not be created"
);
}
#[test]
fn add_refactoring_entity_deduplicates_identical_calls() {
let mcp = make_mcp_with_composite();
let ids = vec!["RF-001".to_owned()];
let make = || RefactoringEntityParams {
smell_id: "SMELL-01",
smell_name: "Long Method",
location: "src/lib.rs",
function_name: "do_work",
confidence: 0.85,
refactoring_ids: &ids,
language: "rust",
};
mcp.add_refactoring_entity(make()).unwrap();
mcp.add_refactoring_entity(make()).unwrap();
let insights = mcp.list_insights(10, Some("analysis"));
assert_eq!(
insights.len(),
1,
"duplicate refactoring entity must not be created"
);
}
#[test]
fn analysis_and_refactoring_for_same_smell_are_distinct_entities() {
let mcp = make_mcp_with_composite();
let ids = vec!["RF-001".to_owned()];
mcp.add_analysis_entity(analysis_params_static()).unwrap();
mcp.add_refactoring_entity(RefactoringEntityParams {
smell_id: "SMELL-01",
smell_name: "Long Method",
location: "src/lib.rs",
function_name: "do_work",
confidence: 0.85,
refactoring_ids: &ids,
language: "rust",
})
.unwrap();
let insights = mcp.list_insights(10, Some("analysis"));
assert_eq!(
insights.len(),
2,
"analysis and refactoring entities for the same smell must be separate"
);
}
}