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 index = self.semantic_index().borrow();
76 match index.as_ref() {
77 Some(idx) => {
78 serde_json::json!({
79 "status": idx.status_label(),
80 "entries": idx.entry_count(),
81 "dimension": idx.dimension(),
82 "backend": idx.backend_label().unwrap_or(config.semantic_backend_label()),
83 "model": idx.model_label().unwrap_or(config.semantic.model.as_str()),
84 })
85 }
86 None => match &*self.semantic_index_status().borrow() {
87 SemanticIndexStatus::Disabled => serde_json::json!({
88 "status": "disabled",
89 "backend": config.semantic_backend_label(),
90 "model": config.semantic.model.as_str(),
91 }),
92 SemanticIndexStatus::Building {
93 stage,
94 files,
95 entries_done,
96 entries_total,
97 } => serde_json::json!({
98 "status": "loading",
99 "stage": stage,
100 "files": files,
101 "entries_done": entries_done,
102 "entries_total": entries_total,
103 "backend": config.semantic_backend_label(),
104 "model": config.semantic.model.as_str(),
105 }),
106 SemanticIndexStatus::Ready => serde_json::json!({
107 "status": "ready",
108 "backend": config.semantic_backend_label(),
109 "model": config.semantic.model.as_str(),
110 }),
111 SemanticIndexStatus::Failed(error) => serde_json::json!({
112 "status": "failed",
113 "error": error,
114 "backend": config.semantic_backend_label(),
115 "model": config.semantic.model.as_str(),
116 }),
117 },
118 }
119 };
120
121 let storage_dir = config.storage_dir.as_ref().map(|d| d.display().to_string());
137 let disk_info = match (&config.storage_dir, &config.project_root) {
138 (Some(dir), Some(root)) => {
139 let key = crate::search_index::project_cache_key(root);
140 let trigram_size = dir_size(&dir.join("index").join(&key));
141 let semantic_size = dir_size(&dir.join("semantic").join(&key));
142 serde_json::json!({
143 "storage_dir": dir.display().to_string(),
144 "project_cache_key": key,
145 "trigram_disk_bytes": trigram_size,
146 "semantic_disk_bytes": semantic_size,
147 })
148 }
149 (Some(dir), None) => serde_json::json!({
150 "storage_dir": dir.display().to_string(),
151 "project_cache_key": null,
152 "trigram_disk_bytes": 0,
153 "semantic_disk_bytes": 0,
154 }),
155 _ => serde_json::json!({
156 "storage_dir": null,
157 "project_cache_key": null,
158 "trigram_disk_bytes": 0,
159 "semantic_disk_bytes": 0,
160 }),
161 };
162
163 let lsp_count = self.lsp_server_count();
165
166 let symbol_cache_stats = self.symbol_cache_stats();
168
169 let checkpoint_total = self.checkpoint().borrow().total_count();
173 let session_checkpoints = self.checkpoint().borrow().list(session_id).len();
174 let session_tracked_files = self.backup().borrow().tracked_files(session_id).len();
175 let compression = self.compression_stats_for_session(session_id);
176
177 let degraded_reasons = self.degraded_reasons();
185 let degraded = !degraded_reasons.is_empty();
186
187 serde_json::json!({
188 "version": env!("CARGO_PKG_VERSION"),
189 "project_root": config.project_root.as_ref().map(|p| p.display().to_string()),
190 "canonical_root": self.canonical_cache_root_opt().map(|p| p.display().to_string()),
191 "cache_role": self.cache_role(),
192 "degraded": degraded,
193 "degraded_reasons": degraded_reasons,
194 "features": {
195 "format_on_edit": config.format_on_edit,
196 "validate_on_edit": config.validate_on_edit.as_deref().unwrap_or("off"),
197 "restrict_to_project_root": config.restrict_to_project_root,
198 "search_index": config.search_index,
199 "semantic_search": config.semantic_search,
200 },
201 "search_index": search_index_info,
202 "semantic_index": semantic_index_info,
203 "disk": disk_info,
204 "lsp_servers": lsp_count,
205 "symbol_cache": symbol_cache_stats,
206 "compression": compression,
207 "storage_dir": storage_dir,
208 "checkpoints_total": checkpoint_total,
210 "session": {
212 "id": session_id,
213 "tracked_files": session_tracked_files,
214 "checkpoints": session_checkpoints,
215 },
216 })
217 }
218
219 fn compression_stats_for_session(&self, session_id: &str) -> CompressionStats {
220 let mut compression = CompressionStats::default();
221 let Some(project_root) = self.config().project_root.clone() else {
222 return compression;
223 };
224 let Some(db) = self.db() else {
225 return compression;
226 };
227 let Ok(conn) = db.lock() else {
228 return compression;
229 };
230
231 let harness = self.harness().as_str();
232 let project_key = crate::search_index::project_cache_key(&project_root);
233 if let Ok(project_agg) =
234 crate::db::compression_events::aggregate_for_project(&conn, harness, &project_key)
235 {
236 compression.project = project_agg.into();
237 }
238 if let Ok(session_agg) = crate::db::compression_events::aggregate_for_session(
239 &conn,
240 harness,
241 &project_key,
242 session_id,
243 ) {
244 compression.session = session_agg.into();
245 }
246
247 compression
248 }
249}
250
251fn dir_size(path: &std::path::Path) -> u64 {
253 if !path.exists() {
254 return 0;
255 }
256 dir_size_recursive(path)
257}
258
259fn dir_size_recursive(path: &std::path::Path) -> u64 {
260 let mut total = 0u64;
261 let entries = match std::fs::read_dir(path) {
262 Ok(e) => e,
263 Err(_) => return 0,
264 };
265 for entry in entries.flatten() {
266 let ft = match entry.file_type() {
267 Ok(ft) => ft,
268 Err(_) => continue,
269 };
270 if ft.is_file() {
271 total += entry.metadata().map(|m| m.len()).unwrap_or(0);
272 } else if ft.is_dir() {
273 total += dir_size_recursive(&entry.path());
274 }
275 }
276 total
277}
278
279#[cfg(test)]
280mod tests {
281 use super::handle_status;
282 use crate::config::Config;
283 use crate::context::AppContext;
284 use crate::parser::TreeSitterProvider;
285 use crate::protocol::RawRequest;
286 use serde_json::json;
287
288 fn request() -> RawRequest {
289 RawRequest {
290 id: "status".to_string(),
291 command: "status".to_string(),
292 lsp_hints: None,
293 session_id: None,
294 params: json!({}),
295 }
296 }
297
298 #[test]
299 fn status_exposes_cache_role_and_canonical_root() {
300 let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
301 let response = handle_status(&request(), &ctx);
302 assert_eq!(response.data["cache_role"], "not_initialized");
303 assert!(response.data["canonical_root"].is_null());
304
305 let temp = tempfile::tempdir().unwrap();
306 ctx.config_mut().project_root = Some(temp.path().to_path_buf());
307 ctx.set_canonical_cache_root(std::fs::canonicalize(temp.path()).unwrap());
308 ctx.set_cache_role(false, None);
309 let response = handle_status(&request(), &ctx);
310 assert_eq!(response.data["cache_role"], "main");
311 assert!(response.data["canonical_root"].as_str().is_some());
312
313 ctx.set_cache_role(true, None);
314 let response = handle_status(&request(), &ctx);
315 assert_eq!(response.data["cache_role"], "worktree");
316 }
317}