use serde::Serialize;
use serde_json::{Map, Value};
use crate::context::AppContext;
use crate::protocol::{RawRequest, Response};
pub const HEALTH_DIGEST_OPERATION: &str = "health.digest";
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum FreshnessTicket {
DocumentVersion { version: i32 },
ArtifactGeneration { identity: String, generation: u64 },
WatcherJournal { identity: String, sequence: u64 },
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct TicketedCurrent<T> {
pub value: T,
pub ticket: FreshnessTicket,
}
impl<T> TicketedCurrent<T> {
pub fn new(value: T, ticket: FreshnessTicket) -> Self {
Self { value, ticket }
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct DigestCurrentValues {
pub errors: Option<TicketedCurrent<u64>>,
pub dead_code: Option<TicketedCurrent<u64>>,
pub unused_exports: Option<TicketedCurrent<u64>>,
pub duplicates: Option<TicketedCurrent<u64>>,
pub complexity_over_threshold: Option<TicketedCurrent<u64>>,
pub todos: Option<TicketedCurrent<u64>>,
pub watcher_events: Option<TicketedCurrent<u64>>,
pub views: Option<TicketedCurrent<crate::context::ViewHealthSnapshot>>,
}
pub fn render_current_values(values: &DigestCurrentValues) -> Value {
let mut fields = Map::new();
insert_ticketed(&mut fields, "errors", values.errors.as_ref());
insert_ticketed(&mut fields, "dead_code", values.dead_code.as_ref());
insert_ticketed(
&mut fields,
"unused_exports",
values.unused_exports.as_ref(),
);
insert_ticketed(&mut fields, "duplicates", values.duplicates.as_ref());
insert_ticketed(
&mut fields,
"complexity_over_threshold",
values.complexity_over_threshold.as_ref(),
);
insert_ticketed(&mut fields, "todos", values.todos.as_ref());
insert_ticketed(
&mut fields,
"watcher_events",
values.watcher_events.as_ref(),
);
insert_ticketed(&mut fields, "views", values.views.as_ref());
Value::Object(fields)
}
fn insert_ticketed<T: Serialize>(
fields: &mut Map<String, Value>,
name: &str,
value: Option<&TicketedCurrent<T>>,
) {
let Some(value) = value else {
return;
};
if let Ok(value) = serde_json::to_value(value) {
fields.insert(name.to_string(), value);
}
}
pub fn handle_health_digest(req: &RawRequest, ctx: &AppContext) -> Response {
let _conceptual_inputs = (
req.params
.get("project_root")
.or_else(|| req.params.get("root")),
req.params.get("since"),
);
let views = ticket_current_view(
ctx.view_health_snapshot(),
ctx.view_runtime_snapshot().map(|view| view.scope),
);
Response::success(
&req.id,
render_current_values(&DigestCurrentValues {
views,
..DigestCurrentValues::default()
}),
)
}
fn ticket_current_view(
value: Option<crate::context::ViewHealthSnapshot>,
identity: Option<String>,
) -> Option<TicketedCurrent<crate::context::ViewHealthSnapshot>> {
let value = value.filter(|value| value.generation > 0)?;
let identity = identity.filter(|identity| !identity.is_empty())?;
let generation = value.generation;
Some(TicketedCurrent::new(
value,
FreshnessTicket::ArtifactGeneration {
identity,
generation,
},
))
}
pub fn root_not_bound_response(req: &RawRequest, root: &str) -> Response {
Response::error(
&req.id,
"root_not_bound",
format!("health.digest root is not bound: {root}"),
)
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use std::time::{Duration, Instant};
use serde_json::json;
use super::{
handle_health_digest, render_current_values, ticket_current_view, DigestCurrentValues,
FreshnessTicket, TicketedCurrent, HEALTH_DIGEST_OPERATION,
};
use crate::config::Config;
use crate::context::{callgraph_cold_build_spawn_count_for_test, AppContext};
use crate::language::StubProvider;
use crate::protocol::RawRequest;
fn request() -> RawRequest {
serde_json::from_value(json!({
"id": "digest",
"command": HEALTH_DIGEST_OPERATION,
}))
.expect("digest request is valid")
}
#[test]
fn missing_view_identity_omits_the_ticket_instead_of_substituting_an_empty_name() {
let snapshot = crate::context::ViewHealthSnapshot {
generation: 7,
pinned: true,
pending_paths: 0,
failed_paths: 0,
};
assert_eq!(ticket_current_view(Some(snapshot.clone()), None), None);
assert_eq!(
ticket_current_view(Some(snapshot.clone()), Some(String::new())),
None
);
assert!(ticket_current_view(Some(snapshot), Some("view-scope".to_string())).is_some());
}
#[test]
fn renders_only_ticketed_current_values() {
let values = DigestCurrentValues {
errors: Some(TicketedCurrent::new(
2,
FreshnessTicket::DocumentVersion { version: 7 },
)),
dead_code: Some(TicketedCurrent::new(
3,
FreshnessTicket::ArtifactGeneration {
identity: "artifact-a".to_string(),
generation: 11,
},
)),
complexity_over_threshold: Some(TicketedCurrent::new(
1,
FreshnessTicket::ArtifactGeneration {
identity: "artifact-complexity".to_string(),
generation: 12,
},
)),
..DigestCurrentValues::default()
};
assert!(matches!(
values.errors.as_ref().map(|current| ¤t.ticket),
Some(FreshnessTicket::DocumentVersion { version: 7 })
));
assert!(matches!(
values.dead_code.as_ref().map(|current| ¤t.ticket),
Some(FreshnessTicket::ArtifactGeneration { generation: 11, .. })
));
let rendered = render_current_values(&values);
let entries = rendered
.as_object()
.expect("ticketed values render as a structured object");
assert_eq!(entries.len(), 3);
assert_eq!(
entries["complexity_over_threshold"]["value"].as_u64(),
Some(1)
);
let mut reported_values = entries
.values()
.filter_map(|entry| {
entry
.as_object()
.and_then(|fields| fields.values().find_map(serde_json::Value::as_u64))
})
.collect::<Vec<_>>();
reported_values.sort_unstable();
assert_eq!(reported_values, vec![1, 2, 3]);
assert!(entries.values().all(|entry| {
entry
.as_object()
.is_some_and(|fields| fields.values().any(serde_json::Value::is_object))
}));
}
#[test]
fn omits_unverified_categories_without_placeholder_values() {
let rendered = render_current_values(&DigestCurrentValues::default());
assert_eq!(rendered, json!({}));
let serialized = rendered.to_string();
assert!(!serialized.contains('~'));
assert!(!serialized.contains("delta"));
assert!(!serialized.contains("changed"));
assert!(!serialized.contains("resolved"));
assert!(!serialized.contains("new"));
}
#[test]
fn cold_context_is_passive_and_omits_diagnostics() {
let root = tempfile::tempdir().expect("create project root");
let mut config = Config::default();
config.project_root = Some(PathBuf::from(root.path()));
let ctx = AppContext::new(Box::new(StubProvider), config);
let request = request();
let server_count_before = ctx.lsp().server_count();
let cold_build_count_before = callgraph_cold_build_spawn_count_for_test();
let started = Instant::now();
let response = handle_health_digest(&request, &ctx);
assert!(
started.elapsed() < Duration::from_millis(250),
"a passive digest must not wait for quiescence"
);
assert_eq!(ctx.lsp().server_count(), server_count_before);
assert_eq!(
callgraph_cold_build_spawn_count_for_test(),
cold_build_count_before
);
assert_eq!(response.data, json!({}));
assert!(response.data.get("diagnostics").is_none());
}
}