Skip to main content

aft/commands/
status.rs

1//! AFT status command — returns the current state of indexes, features, and configuration.
2
3use 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        // Search index status
49        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        // Semantic index status
77        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        // Disk cache sizes — scoped to the **current project** only.
148        //
149        // Both trigram (`<storage_dir>/index/<key>/`) and semantic
150        // (`<storage_dir>/semantic/<key>/`) caches are partitioned per project by
151        // `project_cache_key(project_root)`. Earlier this function reported the
152        // recursive size of the entire `index/` and `semantic/` directories,
153        // which summed disk usage across **every** project the user had ever
154        // opened. The TUI sidebar surfaced that total as if it were the current
155        // project's footprint, which was misleading (e.g. a 4.8 MB project with
156        // 9 sibling projects appeared to use 16+ GB).
157        //
158        // We now resolve the per-project key from `config.project_root` and
159        // size only that project's slice. When the project key can't be
160        // resolved (no project_root), fall back to zeros — the cross-project
161        // total is never the right answer to display per-session.
162        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        // LSP servers
193        let lsp_count = self.lsp_server_count();
194
195        // Symbol cache stats
196        let symbol_cache_stats = self.symbol_cache_stats();
197
198        // Per-session undo/checkpoint counts (issue #14 — one shared bridge serves
199        // many sessions; surface both the global footprint and the current
200        // session's own slice so `/aft-status` can split them in the UI).
201        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        // Degraded-mode reasons recorded by `handle_configure` when the
220        // project root doesn't look like a real project (`home_root`). Heavy
221        // subsystems are auto-disabled in that mode; the plugin / TUI sidebar
222        // surface the reason so users know why and can decide whether to open a
223        // project subdirectory. Empty list = full-featured mode.
224        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        // Agent status-bar counts (the `[AFT E· W· | D· U· C· | T·]` glance).
232        // Surfaced for the TUI sidebar so users see the same code-health view
233        // agents get. `None` until the Tier-2 cache is populated at least once
234        // (so we never render fabricated zeros) — emitted as JSON null then,
235        // and the sidebar hides the section.
236        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            // Project-wide (all sessions): total in-memory checkpoint count.
275            "checkpoints_total": checkpoint_total,
276            // Current session slice: only when the caller passed `session_id`.
277            "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
317/// Recursively compute the total size of a directory.
318fn 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        // No Tier-2 scan has run yet, so the status-bar glance must be null
391        // (never fabricated zeros). The key is always present so the TS
392        // coercion can distinguish "field absent" from "not populated".
393        assert!(response.data.get("status_bar").is_some());
394        assert!(response.data["status_bar"].is_null());
395
396        // Once Tier-2 counts are populated, the snapshot carries the glance.
397        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}