use serde_json::{Value, json};
use crate::context::types::{EvidenceKind, EvidenceRecord};
use crate::observability::trajectory::TrajectoryStep;
use super::super::{
AVG_FULL_RULE_TOKENS, McpState, build_cost_meta, emit_trajectory_step, estimate_tokens,
};
use super::evidence::{build_timeline_evidence, origin_to_kind, rule_preview, truncate_chars};
pub(crate) const RULE_TIMELINE_MAX_DEPTH: u32 = 20;
pub(crate) const TIMELINE_PREVIEW_MAX_CHARS: usize = 120;
#[derive(Debug, Clone, serde::Serialize)]
pub(crate) struct TimelineRow {
id: String,
ts: String,
kind: &'static str,
source: String,
preview: String,
evidence: Vec<EvidenceRecord>,
}
#[derive(sqlx::FromRow)]
pub(crate) struct TimelineSkillRow {
id: String,
name: String,
description: String,
origin: String,
installed_at: String,
source_repo: Option<String>,
captured_by_client: Option<String>,
}
#[derive(sqlx::FromRow)]
pub(crate) struct TimelineExampleRow {
id: String,
bad_code: String,
good_code: String,
description: Option<String>,
source: String,
created_at: String,
}
#[derive(sqlx::FromRow)]
pub(crate) struct TimelineEventRow {
id: String,
kind: String,
source: String,
confidence_before: Option<f64>,
confidence_after: Option<f64>,
reason: Option<String>,
created_at: String,
}
pub(crate) async fn tool_rule_timeline(
state: &McpState,
args: &Value,
) -> Result<Value, (i32, String)> {
let rule_id = args
.get("rule_id")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.ok_or((-32602, "Missing required parameter: rule_id".to_owned()))?
.to_owned();
let depth_before = args
.get("depth_before")
.and_then(Value::as_u64)
.map_or(5, |n| n.min(u64::from(RULE_TIMELINE_MAX_DEPTH)) as usize);
let depth_after = args
.get("depth_after")
.and_then(Value::as_u64)
.map_or(5, |n| n.min(u64::from(RULE_TIMELINE_MAX_DEPTH)) as usize);
let skill: Option<TimelineSkillRow> = sqlx::query_as(
"SELECT id, name, description, origin, installed_at, source_repo, captured_by_client \
FROM skills WHERE id = ?1 AND status = 'active'",
)
.bind(&rule_id)
.fetch_optional(&state.db)
.await
.map_err(|e| (-32603, format!("Failed to look up rule: {e}")))?;
let Some(skill) = skill else {
return Err((
-32602,
format!(
"rule '{rule_id}' not found; run `difflore status --json` to inspect local memory."
),
));
};
let mut rows: Vec<TimelineRow> = Vec::new();
let created_preview = rule_preview(&skill.description, TIMELINE_PREVIEW_MAX_CHARS);
let created_kind = origin_to_kind(&skill.origin);
rows.push(TimelineRow {
id: skill.id.clone(),
ts: skill.installed_at.clone(),
kind: created_kind,
source: skill.origin.clone(),
preview: format!("Rule created: {}", truncate_chars(&skill.name, 80))
.chars()
.take(TIMELINE_PREVIEW_MAX_CHARS)
.collect::<String>(),
evidence: vec![build_timeline_evidence(
EvidenceKind::RuleCreated,
&skill.origin,
&skill.installed_at,
&created_preview,
)],
});
let examples: Vec<TimelineExampleRow> = sqlx::query_as!(
TimelineExampleRow,
"SELECT id, bad_code, good_code, description, source, created_at \
FROM rule_examples WHERE skill_id = ?1 ORDER BY created_at ASC",
rule_id
)
.fetch_all(&state.db)
.await
.map_err(|e| (-32603, format!("Failed to load rule examples: {e}")))?;
let examples_count = examples.len();
for ex in examples {
let raw = ex
.description
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| {
format!(
"bad: {} -> good: {}",
truncate_chars(&ex.bad_code, 40),
truncate_chars(&ex.good_code, 40),
)
});
let preview = rule_preview(&raw, TIMELINE_PREVIEW_MAX_CHARS);
let kind = {
#[allow(clippy::match_same_arms)]
match ex.source.as_str() {
"pr_review" => "pr_review",
"extracted" => "extracted",
"conversation" => "remember",
"manual" => "manual",
_ => "extracted",
}
};
let evidence = vec![build_timeline_evidence(
EvidenceKind::RuleExample,
&ex.source,
&ex.created_at,
&preview,
)];
rows.push(TimelineRow {
id: ex.id,
ts: ex.created_at,
kind,
source: ex.source,
preview,
evidence,
});
}
let feedback_events: Vec<TimelineEventRow> = sqlx::query_as!(
TimelineEventRow,
"SELECT id, kind, source, confidence_before, confidence_after, reason, created_at \
FROM rule_events WHERE skill_id = ?1 ORDER BY created_at ASC",
rule_id
)
.fetch_all(&state.db)
.await
.map_err(|e| (-32603, format!("Failed to load rule events: {e}")))?;
for ev in feedback_events {
let preview_raw = match (ev.confidence_before, ev.confidence_after) {
(Some(before), Some(after)) => {
format!(
"{}: confidence {:.2} -> {:.2}",
ev.kind.replace('_', " "),
before,
after
)
}
_ => ev.reason.unwrap_or_else(|| ev.kind.replace('_', " ")),
};
let kind = match ev.kind.as_str() {
"feedback_accept" => "feedback_accept",
"feedback_dismiss" => "feedback_dismiss",
_ => "updated",
};
let evidence = vec![build_timeline_evidence(
EvidenceKind::RuleUpdated,
&ev.source,
&ev.created_at,
&preview_raw,
)];
rows.push(TimelineRow {
id: ev.id,
ts: ev.created_at,
kind,
source: ev.source,
preview: rule_preview(&preview_raw, TIMELINE_PREVIEW_MAX_CHARS),
evidence,
});
}
rows.sort_by(|a, b| a.ts.cmp(&b.ts).then(a.id.cmp(&b.id)));
let focal_ts = skill.installed_at.clone();
let focal_idx = rows
.iter()
.position(|r| r.ts == focal_ts && r.id == skill.id)
.unwrap_or(0);
let before_start = focal_idx.saturating_sub(depth_before);
let after_end = (focal_idx + 1 + depth_after).min(rows.len());
let events: Vec<TimelineRow> = rows.drain(before_start..after_end).collect();
let source_repo = skill
.source_repo
.clone()
.filter(|r: &String| !r.trim().is_empty());
let captured_by_client = skill
.captured_by_client
.clone()
.filter(|c: &String| !c.trim().is_empty());
let mut body = json!({
"rule_id": skill.id,
"rule_name": skill.name,
"focal_ts": focal_ts,
"events": events,
});
if let Some(repo) = source_repo
&& let Some(object) = body.as_object_mut()
{
object.insert("source_repo".to_owned(), Value::String(repo));
}
if let Some(client) = captured_by_client
&& let Some(object) = body.as_object_mut()
{
object.insert("captured_by_client".to_owned(), Value::String(client));
}
let text = serde_json::to_string(&body).map_err(|e| {
(
-32603,
format!("Failed to serialise rule_timeline response: {e}"),
)
})?;
let tokens_used = estimate_tokens(&text);
emit_trajectory_step(&TrajectoryStep::McpResponseSize {
tool: "rule_timeline".to_owned(),
total_tokens: tokens_used,
rules_injected: 0,
});
let referenced_rules = 1 + examples_count;
let tokens_if_full = Some(AVG_FULL_RULE_TOKENS * referenced_rules);
Ok(json!({
"content": [{ "type": "text", "text": text }],
"_meta": {
"cost": build_cost_meta(tokens_used, tokens_if_full),
"impact": {
"kind": "rule_timeline",
"events": events.len(),
}
}
}))
}