1use crate::context::AppContext;
4use crate::context::SemanticIndexStatus;
5use crate::db::compression_events::CompressionAggregate;
6use crate::protocol::{RawRequest, Response, StatusPayload, DEFAULT_SESSION_ID};
7
8#[derive(Debug, Clone, Default, serde::Serialize)]
9pub struct CompressionStats {
10 pub project: CompressionAggregateSerde,
11 pub session: CompressionAggregateSerde,
12}
13
14#[derive(Debug, Clone, Default, serde::Serialize)]
15pub struct CompressionAggregateSerde {
16 pub events: u64,
17 pub original_tokens: u64,
18 pub compressed_tokens: u64,
19 pub savings_tokens: u64,
20}
21
22impl From<CompressionAggregate> for CompressionAggregateSerde {
23 fn from(agg: CompressionAggregate) -> Self {
24 Self {
25 events: agg.events,
26 original_tokens: agg.original_tokens,
27 compressed_tokens: agg.compressed_tokens,
28 savings_tokens: agg.savings_tokens(),
29 }
30 }
31}
32
33pub fn handle_status(req: &RawRequest, ctx: &AppContext) -> Response {
34 Response::success(
35 &req.id,
36 ctx.build_status_snapshot_for_session(req.session()),
37 )
38}
39
40impl AppContext {
41 pub fn build_status_snapshot(&self) -> StatusPayload {
42 self.build_status_snapshot_for_session(DEFAULT_SESSION_ID)
43 }
44
45 pub fn build_status_snapshot_for_session(&self, session_id: &str) -> StatusPayload {
46 let config = self.config();
47
48 let search_index_info = {
50 let index = self.search_index().borrow();
51 match index.as_ref() {
52 Some(idx) if idx.ready => {
53 let file_count = idx.file_count();
54 let trigram_count = idx.trigram_count();
55 serde_json::json!({
56 "status": "ready",
57 "files": file_count,
58 "trigrams": trigram_count,
59 })
60 }
61 Some(_) => serde_json::json!({ "status": "building" }),
62 None => {
63 let status = if self.config().search_index {
64 "loading"
65 } else {
66 "disabled"
67 };
68 serde_json::json!({ "status": status })
69 }
70 }
71 };
72
73 let semantic_index_info = {
75 let status = self.semantic_index_status().borrow().clone();
76 let refreshing_count = status.refreshing_count();
77 let index = self.semantic_index().borrow();
78 match index.as_ref() {
79 Some(idx) => {
80 let status_label = match status {
81 SemanticIndexStatus::Ready { .. } => "ready",
82 _ => idx.status_label(),
83 };
84 serde_json::json!({
85 "status": status_label,
86 "state": status_label,
87 "refreshing_count": refreshing_count,
88 "entries": idx.entry_count(),
89 "dimension": idx.dimension(),
90 "backend": idx.backend_label().unwrap_or(config.semantic_backend_label()),
91 "model": idx.model_label().unwrap_or(config.semantic.model.as_str()),
92 })
93 }
94 None => match status {
95 SemanticIndexStatus::Disabled => serde_json::json!({
96 "status": "disabled",
97 "state": "disabled",
98 "refreshing_count": 0,
99 "backend": config.semantic_backend_label(),
100 "model": config.semantic.model.as_str(),
101 }),
102 SemanticIndexStatus::Building {
103 stage,
104 files,
105 entries_done,
106 entries_total,
107 } => serde_json::json!({
108 "status": "loading",
109 "state": "loading",
110 "refreshing_count": 0,
111 "stage": stage,
112 "files": files,
113 "entries_done": entries_done,
114 "entries_total": entries_total,
115 "backend": config.semantic_backend_label(),
116 "model": config.semantic.model.as_str(),
117 }),
118 SemanticIndexStatus::Ready { refreshing } => serde_json::json!({
119 "status": "ready",
120 "state": "ready",
121 "refreshing_count": refreshing.len(),
122 "backend": config.semantic_backend_label(),
123 "model": config.semantic.model.as_str(),
124 }),
125 SemanticIndexStatus::Failed(error) => serde_json::json!({
126 "status": "failed",
127 "state": "failed",
128 "refreshing_count": 0,
129 "error": error,
130 "backend": config.semantic_backend_label(),
131 "model": config.semantic.model.as_str(),
132 }),
133 },
134 }
135 };
136
137 let storage_dir = config.storage_dir.as_ref().map(|d| d.display().to_string());
153 let disk_info = match (&config.storage_dir, &config.project_root) {
154 (Some(dir), Some(root)) => {
155 let key = crate::search_index::project_cache_key(root);
156 let trigram_size = dir_size(&dir.join("index").join(&key));
157 let semantic_size = dir_size(&dir.join("semantic").join(&key));
158 serde_json::json!({
159 "storage_dir": dir.display().to_string(),
160 "project_cache_key": key,
161 "trigram_disk_bytes": trigram_size,
162 "semantic_disk_bytes": semantic_size,
163 })
164 }
165 (Some(dir), None) => serde_json::json!({
166 "storage_dir": dir.display().to_string(),
167 "project_cache_key": null,
168 "trigram_disk_bytes": 0,
169 "semantic_disk_bytes": 0,
170 }),
171 _ => serde_json::json!({
172 "storage_dir": null,
173 "project_cache_key": null,
174 "trigram_disk_bytes": 0,
175 "semantic_disk_bytes": 0,
176 }),
177 };
178
179 let lsp_count = self.lsp_server_count();
181
182 let symbol_cache_stats = self.symbol_cache_stats();
184
185 let checkpoint_total = self.checkpoint().borrow().total_count();
189 let session_checkpoints = self.checkpoint().borrow().list(session_id).len();
190 let session_tracked_files = self.backup().borrow().tracked_files(session_id).len();
191 let compression = self.compression_stats_for_session(session_id);
192
193 let degraded_reasons = self.degraded_reasons();
201 let degraded = !degraded_reasons.is_empty();
202
203 let status_bar = match self.status_bar_counts() {
209 Some(counts) => serde_json::json!({
210 "errors": counts.errors,
211 "warnings": counts.warnings,
212 "dead_code": counts.dead_code,
213 "unused_exports": counts.unused_exports,
214 "duplicates": counts.duplicates,
215 "todos": counts.todos,
216 "tier2_stale": counts.tier2_stale,
217 }),
218 None => serde_json::Value::Null,
219 };
220
221 serde_json::json!({
222 "version": env!("CARGO_PKG_VERSION"),
223 "project_root": config.project_root.as_ref().map(|p| p.display().to_string()),
224 "canonical_root": self.canonical_cache_root_opt().map(|p| p.display().to_string()),
225 "cache_role": self.cache_role(),
226 "degraded": degraded,
227 "degraded_reasons": degraded_reasons,
228 "features": {
229 "format_on_edit": config.format_on_edit,
230 "validate_on_edit": config.validate_on_edit.as_deref().unwrap_or("off"),
231 "restrict_to_project_root": config.restrict_to_project_root,
232 "search_index": config.search_index,
233 "semantic_search": config.semantic_search,
234 "callgraph_store": config.callgraph_store,
235 },
236 "search_index": search_index_info,
237 "semantic_index": semantic_index_info,
238 "status_bar": status_bar,
239 "disk": disk_info,
240 "lsp_servers": lsp_count,
241 "symbol_cache": symbol_cache_stats,
242 "compression": compression,
243 "storage_dir": storage_dir,
244 "checkpoints_total": checkpoint_total,
246 "session": {
248 "id": session_id,
249 "tracked_files": session_tracked_files,
250 "checkpoints": session_checkpoints,
251 },
252 })
253 }
254
255 fn compression_stats_for_session(&self, session_id: &str) -> CompressionStats {
256 let mut compression = CompressionStats::default();
257 let Some(project_root) = self.config().project_root.clone() else {
258 return compression;
259 };
260 let Some(db) = self.db() else {
261 return compression;
262 };
263 let Ok(conn) = db.lock() else {
264 return compression;
265 };
266
267 let harness = self.harness().as_str();
268 let project_key = crate::search_index::project_cache_key(&project_root);
269 if let Ok(project_agg) =
270 crate::db::compression_events::aggregate_for_project(&conn, harness, &project_key)
271 {
272 compression.project = project_agg.into();
273 }
274 if let Ok(session_agg) = crate::db::compression_events::aggregate_for_session(
275 &conn,
276 harness,
277 &project_key,
278 session_id,
279 ) {
280 compression.session = session_agg.into();
281 }
282
283 compression
284 }
285}
286
287fn dir_size(path: &std::path::Path) -> u64 {
289 if !path.exists() {
290 return 0;
291 }
292 dir_size_recursive(path)
293}
294
295fn dir_size_recursive(path: &std::path::Path) -> u64 {
296 let mut total = 0u64;
297 let entries = match std::fs::read_dir(path) {
298 Ok(e) => e,
299 Err(_) => return 0,
300 };
301 for entry in entries.flatten() {
302 let ft = match entry.file_type() {
303 Ok(ft) => ft,
304 Err(_) => continue,
305 };
306 if ft.is_file() {
307 total += entry.metadata().map(|m| m.len()).unwrap_or(0);
308 } else if ft.is_dir() {
309 total += dir_size_recursive(&entry.path());
310 }
311 }
312 total
313}
314
315#[cfg(test)]
316mod tests {
317 use super::handle_status;
318 use crate::config::Config;
319 use crate::context::AppContext;
320 use crate::parser::TreeSitterProvider;
321 use crate::protocol::RawRequest;
322 use serde_json::json;
323
324 fn request() -> RawRequest {
325 RawRequest {
326 id: "status".to_string(),
327 command: "status".to_string(),
328 lsp_hints: None,
329 session_id: None,
330 params: json!({}),
331 }
332 }
333
334 #[test]
335 fn status_exposes_cache_role_and_canonical_root() {
336 let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
337 let response = handle_status(&request(), &ctx);
338 assert_eq!(response.data["cache_role"], "not_initialized");
339 assert!(response.data["canonical_root"].is_null());
340
341 let temp = tempfile::tempdir().unwrap();
342 ctx.config_mut().project_root = Some(temp.path().to_path_buf());
343 ctx.set_canonical_cache_root(std::fs::canonicalize(temp.path()).unwrap());
344 ctx.set_cache_role(false, None);
345 let response = handle_status(&request(), &ctx);
346 assert_eq!(response.data["cache_role"], "main");
347 assert!(response.data["canonical_root"].as_str().is_some());
348
349 ctx.set_cache_role(true, None);
350 let response = handle_status(&request(), &ctx);
351 assert_eq!(response.data["cache_role"], "worktree");
352 }
353
354 #[test]
355 fn status_status_bar_is_null_until_tier2_populated() {
356 let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
357 let response = handle_status(&request(), &ctx);
358 assert!(response.data.get("status_bar").is_some());
362 assert!(response.data["status_bar"].is_null());
363
364 ctx.update_status_bar_tier2(Some(3), Some(2), Some(1), Some(5), false);
366 let response = handle_status(&request(), &ctx);
367 assert_eq!(response.data["status_bar"]["dead_code"], 3);
368 assert_eq!(response.data["status_bar"]["unused_exports"], 2);
369 assert_eq!(response.data["status_bar"]["duplicates"], 1);
370 assert_eq!(response.data["status_bar"]["tier2_stale"], false);
371 }
372}