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
51 .search_index()
52 .read()
53 .unwrap_or_else(std::sync::PoisonError::into_inner);
54 match index.as_ref() {
55 Some(idx) if idx.ready => {
56 let file_count = idx.file_count();
57 let trigram_count = idx.trigram_count();
58 serde_json::json!({
59 "status": "ready",
60 "files": file_count,
61 "trigrams": trigram_count,
62 })
63 }
64 Some(_) => serde_json::json!({ "status": "building" }),
65 None => {
66 let status = if config.search_index {
67 "loading"
68 } else {
69 "disabled"
70 };
71 serde_json::json!({ "status": status })
72 }
73 }
74 };
75
76 let semantic_index_info = {
78 let status = self
79 .semantic_index_status()
80 .read()
81 .unwrap_or_else(std::sync::PoisonError::into_inner)
82 .clone();
83 let refreshing_count = status.refreshing_count();
84 let index = self
85 .semantic_index()
86 .read()
87 .unwrap_or_else(std::sync::PoisonError::into_inner);
88 match index.as_ref() {
89 Some(idx) => {
90 let status_label = match status {
91 SemanticIndexStatus::Ready { .. } => "ready",
92 _ => idx.status_label(),
93 };
94 serde_json::json!({
95 "status": status_label,
96 "state": status_label,
97 "refreshing_count": refreshing_count,
98 "entries": idx.entry_count(),
99 "dimension": idx.dimension(),
100 "backend": idx.backend_label().unwrap_or(config.semantic_backend_label()),
101 "model": idx.model_label().unwrap_or(config.semantic.model.as_str()),
102 })
103 }
104 None => match status {
105 SemanticIndexStatus::Disabled => serde_json::json!({
106 "status": "disabled",
107 "state": "disabled",
108 "refreshing_count": 0,
109 "backend": config.semantic_backend_label(),
110 "model": config.semantic.model.as_str(),
111 }),
112 SemanticIndexStatus::Building {
113 stage,
114 files,
115 entries_done,
116 entries_total,
117 } => serde_json::json!({
118 "status": "loading",
119 "state": "loading",
120 "refreshing_count": 0,
121 "stage": stage,
122 "files": files,
123 "entries_done": entries_done,
124 "entries_total": entries_total,
125 "backend": config.semantic_backend_label(),
126 "model": config.semantic.model.as_str(),
127 }),
128 SemanticIndexStatus::Ready { refreshing, .. } => serde_json::json!({
129 "status": "ready",
130 "state": "ready",
131 "refreshing_count": refreshing.len(),
132 "backend": config.semantic_backend_label(),
133 "model": config.semantic.model.as_str(),
134 }),
135 SemanticIndexStatus::Failed(error) => serde_json::json!({
136 "status": "failed",
137 "state": "failed",
138 "refreshing_count": 0,
139 "error": error,
140 "backend": config.semantic_backend_label(),
141 "model": config.semantic.model.as_str(),
142 }),
143 },
144 }
145 };
146
147 let storage_dir = config.storage_dir.as_ref().map(|d| d.display().to_string());
163 let disk_info = match (&config.storage_dir, &config.project_root) {
164 (Some(dir), Some(root)) => {
165 let key_root = self
166 .canonical_cache_root_opt()
167 .unwrap_or_else(|| root.clone());
168 let key = self.memoized_artifact_cache_key(&key_root);
169 let trigram_size = dir_size(&dir.join("index").join(&key));
170 let semantic_size = dir_size(&dir.join("semantic").join(&key));
171 serde_json::json!({
172 "storage_dir": dir.display().to_string(),
173 "project_cache_key": key,
174 "trigram_disk_bytes": trigram_size,
175 "semantic_disk_bytes": semantic_size,
176 })
177 }
178 (Some(dir), None) => serde_json::json!({
179 "storage_dir": dir.display().to_string(),
180 "project_cache_key": null,
181 "trigram_disk_bytes": 0,
182 "semantic_disk_bytes": 0,
183 }),
184 _ => serde_json::json!({
185 "storage_dir": null,
186 "project_cache_key": null,
187 "trigram_disk_bytes": 0,
188 "semantic_disk_bytes": 0,
189 }),
190 };
191
192 let lsp_count = self.lsp_server_count();
194
195 let symbol_cache_stats = self.symbol_cache_stats();
197
198 let backups_enabled = config.backup.enabled.unwrap_or(true);
202 let checkpoint_total = if backups_enabled {
203 self.checkpoint().lock().total_count()
204 } else {
205 0
206 };
207 let session_checkpoints = if backups_enabled {
208 self.checkpoint().lock().list(session_id).len()
209 } else {
210 0
211 };
212 let session_tracked_files = if backups_enabled {
213 self.backup().lock().tracked_files(session_id).len()
214 } else {
215 0
216 };
217 let compression = self.compression_stats_for_session(session_id);
218
219 let degraded_reasons = self.degraded_reasons();
225 let degraded = !degraded_reasons.is_empty();
226 let artifact_owner = self
227 .artifact_owner_status()
228 .map(|status| serde_json::to_value(status).unwrap_or(serde_json::Value::Null))
229 .unwrap_or(serde_json::Value::Null);
230
231 let status_bar = match self.status_bar_counts() {
237 Some(counts) => serde_json::json!({
238 "errors": counts.errors,
239 "warnings": counts.warnings,
240 "dead_code": counts.dead_code,
241 "unused_exports": counts.unused_exports,
242 "duplicates": counts.duplicates,
243 "todos": counts.todos,
244 "tier2_stale": counts.tier2_stale,
245 }),
246 None => serde_json::Value::Null,
247 };
248
249 serde_json::json!({
250 "version": env!("CARGO_PKG_VERSION"),
251 "project_root": config.project_root.as_ref().map(|p| p.display().to_string()),
252 "canonical_root": self.canonical_cache_root_opt().map(|p| p.display().to_string()),
253 "cache_role": self.cache_role(),
254 "artifact_owner": artifact_owner,
255 "degraded": degraded,
256 "degraded_reasons": degraded_reasons,
257 "features": {
258 "format_on_edit": config.format_on_edit,
259 "validate_on_edit": config.validate_on_edit.as_deref().unwrap_or("off"),
260 "restrict_to_project_root": config.restrict_to_project_root,
261 "search_index": config.search_index,
262 "semantic_search": config.semantic_search,
263 "callgraph_store": config.callgraph_store,
264 "backup": backups_enabled,
265 },
266 "search_index": search_index_info,
267 "semantic_index": semantic_index_info,
268 "status_bar": status_bar,
269 "disk": disk_info,
270 "lsp_servers": lsp_count,
271 "symbol_cache": symbol_cache_stats,
272 "compression": compression,
273 "storage_dir": storage_dir,
274 "checkpoints_total": checkpoint_total,
276 "session": {
278 "id": session_id,
279 "tracked_files": session_tracked_files,
280 "checkpoints": session_checkpoints,
281 },
282 })
283 }
284
285 fn compression_stats_for_session(&self, session_id: &str) -> CompressionStats {
286 let mut compression = CompressionStats::default();
287 let Some(project_root) = self.config().project_root.clone() else {
288 return compression;
289 };
290 let Some(db) = self.db() else {
291 return compression;
292 };
293 let Ok(conn) = db.lock() else {
294 return compression;
295 };
296
297 let harness = self.harness().storage_segment();
298 let project_key = crate::path_identity::project_scope_key(&project_root);
299 if let Ok(project_agg) =
300 crate::db::compression_events::aggregate_for_project(&conn, &harness, &project_key)
301 {
302 compression.project = project_agg.into();
303 }
304 if let Ok(session_agg) = crate::db::compression_events::aggregate_for_session(
305 &conn,
306 &harness,
307 &project_key,
308 session_id,
309 ) {
310 compression.session = session_agg.into();
311 }
312
313 compression
314 }
315}
316
317fn dir_size(path: &std::path::Path) -> u64 {
319 if !path.exists() {
320 return 0;
321 }
322 dir_size_recursive(path)
323}
324
325fn dir_size_recursive(path: &std::path::Path) -> u64 {
326 let mut total = 0u64;
327 let entries = match std::fs::read_dir(path) {
328 Ok(e) => e,
329 Err(_) => return 0,
330 };
331 for entry in entries.flatten() {
332 let ft = match entry.file_type() {
333 Ok(ft) => ft,
334 Err(_) => continue,
335 };
336 if ft.is_file() {
337 total += entry.metadata().map(|m| m.len()).unwrap_or(0);
338 } else if ft.is_dir() {
339 total += dir_size_recursive(&entry.path());
340 }
341 }
342 total
343}
344
345#[cfg(test)]
346mod tests {
347 use super::handle_status;
348 use crate::config::Config;
349 use crate::context::AppContext;
350 use crate::parser::TreeSitterProvider;
351 use crate::protocol::RawRequest;
352 use serde_json::json;
353
354 fn request() -> RawRequest {
355 RawRequest {
356 id: "status".to_string(),
357 command: "status".to_string(),
358 lsp_hints: None,
359 session_id: None,
360 params: json!({}),
361 }
362 }
363
364 #[test]
365 fn status_exposes_cache_role_and_canonical_root() {
366 let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
367 let response = handle_status(&request(), &ctx);
368 assert_eq!(response.data["cache_role"], "not_initialized");
369 assert!(response.data["canonical_root"].is_null());
370
371 let temp = tempfile::tempdir().unwrap();
372 ctx.update_config(|config| {
373 config.project_root = Some(temp.path().to_path_buf());
374 });
375 ctx.set_canonical_cache_root(std::fs::canonicalize(temp.path()).unwrap());
376 ctx.set_cache_role(false, None);
377 let response = handle_status(&request(), &ctx);
378 assert_eq!(response.data["cache_role"], "main");
379 assert!(response.data["canonical_root"].as_str().is_some());
380
381 ctx.set_cache_role(true, None);
382 let response = handle_status(&request(), &ctx);
383 assert_eq!(response.data["cache_role"], "worktree");
384 }
385
386 #[test]
387 fn status_status_bar_is_null_until_tier2_populated() {
388 let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
389 let response = handle_status(&request(), &ctx);
390 assert!(response.data.get("status_bar").is_some());
394 assert!(response.data["status_bar"].is_null());
395
396 ctx.update_status_bar_tier2(Some(3), Some(2), Some(1), Some(5), false);
398 let response = handle_status(&request(), &ctx);
399 assert_eq!(response.data["status_bar"]["dead_code"], 3);
400 assert_eq!(response.data["status_bar"]["unused_exports"], 2);
401 assert_eq!(response.data["status_bar"]["duplicates"], 1);
402 assert_eq!(response.data["status_bar"]["tier2_stale"], false);
403 }
404}