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 todos: Option<TicketedCurrent<u64>>,
pub watcher_events: Option<TicketedCurrent<u64>>,
}
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, "todos", values.todos.as_ref());
insert_ticketed(
&mut fields,
"watcher_events",
values.watcher_events.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("root"), req.params.get("since"));
Response::success(
&req.id,
render_current_values(&DigestCurrentValues::default()),
)
}
#[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, 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 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,
},
)),
..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(), 2);
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![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());
}
}