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.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        // Semantic index status
74        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        // Disk cache sizes — scoped to the **current project** only.
122        //
123        // Both trigram (`<storage_dir>/index/<key>/`) and semantic
124        // (`<storage_dir>/semantic/<key>/`) caches are partitioned per project by
125        // `project_cache_key(project_root)`. Earlier this function reported the
126        // recursive size of the entire `index/` and `semantic/` directories,
127        // which summed disk usage across **every** project the user had ever
128        // opened. The TUI sidebar surfaced that total as if it were the current
129        // project's footprint, which was misleading (e.g. a 4.8 MB project with
130        // 9 sibling projects appeared to use 16+ GB).
131        //
132        // We now resolve the per-project key from `config.project_root` and
133        // size only that project's slice. When the project key can't be
134        // resolved (no project_root), fall back to zeros — the cross-project
135        // total is never the right answer to display per-session.
136        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        // LSP servers
164        let lsp_count = self.lsp_server_count();
165
166        // Symbol cache stats
167        let symbol_cache_stats = self.symbol_cache_stats();
168
169        // Per-session undo/checkpoint counts (issue #14 — one shared bridge serves
170        // many sessions; surface both the global footprint and the current
171        // session's own slice so `/aft-status` can split them in the UI).
172        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        // Degraded-mode reasons recorded by `handle_configure` when the
178        // project root doesn't look like a real project (`home_root`) or the
179        // file count exceeds the search-index threshold
180        // (`search_too_many_files:N`). Heavy subsystems are auto-disabled in
181        // these modes; the plugin / TUI sidebar surface the reasons so users
182        // know why and can decide whether to open a project subdirectory.
183        // Empty list = full-featured mode.
184        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            // Project-wide (all sessions): total in-memory checkpoint count.
209            "checkpoints_total": checkpoint_total,
210            // Current session slice: only when the caller passed `session_id`.
211            "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
251/// Recursively compute the total size of a directory.
252fn 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}