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::protocol::{RawRequest, Response, StatusPayload, DEFAULT_SESSION_ID};
6
7pub fn handle_status(req: &RawRequest, ctx: &AppContext) -> Response {
8    Response::success(
9        &req.id,
10        ctx.build_status_snapshot_for_session(req.session()),
11    )
12}
13
14impl AppContext {
15    pub fn build_status_snapshot(&self) -> StatusPayload {
16        self.build_status_snapshot_for_session(DEFAULT_SESSION_ID)
17    }
18
19    pub fn build_status_snapshot_for_session(&self, session_id: &str) -> StatusPayload {
20        let config = self.config();
21
22        // Search index status
23        let search_index_info = {
24            let index = self.search_index().borrow();
25            match index.as_ref() {
26                Some(idx) if idx.ready => {
27                    let file_count = idx.file_count();
28                    let trigram_count = idx.trigram_count();
29                    serde_json::json!({
30                        "status": "ready",
31                        "files": file_count,
32                        "trigrams": trigram_count,
33                    })
34                }
35                Some(_) => serde_json::json!({ "status": "building" }),
36                None => {
37                    let status = if self.config().search_index {
38                        "loading"
39                    } else {
40                        "disabled"
41                    };
42                    serde_json::json!({ "status": status })
43                }
44            }
45        };
46
47        // Semantic index status
48        let semantic_index_info = {
49            let index = self.semantic_index().borrow();
50            match index.as_ref() {
51                Some(idx) => {
52                    serde_json::json!({
53                        "status": idx.status_label(),
54                        "entries": idx.entry_count(),
55                        "dimension": idx.dimension(),
56                        "backend": idx.backend_label().unwrap_or(config.semantic_backend_label()),
57                        "model": idx.model_label().unwrap_or(config.semantic.model.as_str()),
58                    })
59                }
60                None => match &*self.semantic_index_status().borrow() {
61                    SemanticIndexStatus::Disabled => serde_json::json!({
62                        "status": "disabled",
63                        "backend": config.semantic_backend_label(),
64                        "model": config.semantic.model.as_str(),
65                    }),
66                    SemanticIndexStatus::Building {
67                        stage,
68                        files,
69                        entries_done,
70                        entries_total,
71                    } => serde_json::json!({
72                        "status": "loading",
73                        "stage": stage,
74                        "files": files,
75                        "entries_done": entries_done,
76                        "entries_total": entries_total,
77                        "backend": config.semantic_backend_label(),
78                        "model": config.semantic.model.as_str(),
79                    }),
80                    SemanticIndexStatus::Ready => serde_json::json!({
81                        "status": "ready",
82                        "backend": config.semantic_backend_label(),
83                        "model": config.semantic.model.as_str(),
84                    }),
85                    SemanticIndexStatus::Failed(error) => serde_json::json!({
86                        "status": "failed",
87                        "error": error,
88                        "backend": config.semantic_backend_label(),
89                        "model": config.semantic.model.as_str(),
90                    }),
91                },
92            }
93        };
94
95        // Disk cache sizes — scoped to the **current project** only.
96        //
97        // Both trigram (`<storage_dir>/index/<key>/`) and semantic
98        // (`<storage_dir>/semantic/<key>/`) caches are partitioned per project by
99        // `project_cache_key(project_root)`. Earlier this function reported the
100        // recursive size of the entire `index/` and `semantic/` directories,
101        // which summed disk usage across **every** project the user had ever
102        // opened. The TUI sidebar surfaced that total as if it were the current
103        // project's footprint, which was misleading (e.g. a 4.8 MB project with
104        // 9 sibling projects appeared to use 16+ GB).
105        //
106        // We now resolve the per-project key from `config.project_root` and
107        // size only that project's slice. When the project key can't be
108        // resolved (no project_root), fall back to zeros — the cross-project
109        // total is never the right answer to display per-session.
110        let storage_dir = config.storage_dir.as_ref().map(|d| d.display().to_string());
111        let disk_info = match (&config.storage_dir, &config.project_root) {
112            (Some(dir), Some(root)) => {
113                let key = crate::search_index::project_cache_key(root);
114                let trigram_size = dir_size(&dir.join("index").join(&key));
115                let semantic_size = dir_size(&dir.join("semantic").join(&key));
116                serde_json::json!({
117                    "storage_dir": dir.display().to_string(),
118                    "project_cache_key": key,
119                    "trigram_disk_bytes": trigram_size,
120                    "semantic_disk_bytes": semantic_size,
121                })
122            }
123            (Some(dir), None) => serde_json::json!({
124                "storage_dir": dir.display().to_string(),
125                "project_cache_key": null,
126                "trigram_disk_bytes": 0,
127                "semantic_disk_bytes": 0,
128            }),
129            _ => serde_json::json!({
130                "storage_dir": null,
131                "project_cache_key": null,
132                "trigram_disk_bytes": 0,
133                "semantic_disk_bytes": 0,
134            }),
135        };
136
137        // LSP servers
138        let lsp_count = self.lsp_server_count();
139
140        // Symbol cache stats
141        let symbol_cache_stats = self.symbol_cache_stats();
142
143        // Per-session undo/checkpoint counts (issue #14 — one shared bridge serves
144        // many sessions; surface both the global footprint and the current
145        // session's own slice so `/aft-status` can split them in the UI).
146        let checkpoint_total = self.checkpoint().borrow().total_count();
147        let session_checkpoints = self.checkpoint().borrow().list(session_id).len();
148        let session_tracked_files = self.backup().borrow().tracked_files(session_id).len();
149
150        // Degraded-mode reasons recorded by `handle_configure` when the
151        // project root doesn't look like a real project (`home_root`) or the
152        // file count exceeds the search-index threshold
153        // (`search_too_many_files:N`). Heavy subsystems are auto-disabled in
154        // these modes; the plugin / TUI sidebar surface the reasons so users
155        // know why and can decide whether to open a project subdirectory.
156        // Empty list = full-featured mode.
157        let degraded_reasons = self.degraded_reasons();
158        let degraded = !degraded_reasons.is_empty();
159
160        serde_json::json!({
161            "version": env!("CARGO_PKG_VERSION"),
162            "project_root": config.project_root.as_ref().map(|p| p.display().to_string()),
163            "canonical_root": self.canonical_cache_root_opt().map(|p| p.display().to_string()),
164            "cache_role": self.cache_role(),
165            "degraded": degraded,
166            "degraded_reasons": degraded_reasons,
167            "features": {
168                "format_on_edit": config.format_on_edit,
169                "validate_on_edit": config.validate_on_edit.as_deref().unwrap_or("off"),
170                "restrict_to_project_root": config.restrict_to_project_root,
171                "search_index": config.search_index,
172                "semantic_search": config.semantic_search,
173            },
174            "search_index": search_index_info,
175            "semantic_index": semantic_index_info,
176            "disk": disk_info,
177            "lsp_servers": lsp_count,
178            "symbol_cache": symbol_cache_stats,
179            "storage_dir": storage_dir,
180            // Project-wide (all sessions): total in-memory checkpoint count.
181            "checkpoints_total": checkpoint_total,
182            // Current session slice: only when the caller passed `session_id`.
183            "session": {
184                "id": session_id,
185                "tracked_files": session_tracked_files,
186                "checkpoints": session_checkpoints,
187            },
188        })
189    }
190}
191
192/// Recursively compute the total size of a directory.
193fn dir_size(path: &std::path::Path) -> u64 {
194    if !path.exists() {
195        return 0;
196    }
197    dir_size_recursive(path)
198}
199
200fn dir_size_recursive(path: &std::path::Path) -> u64 {
201    let mut total = 0u64;
202    let entries = match std::fs::read_dir(path) {
203        Ok(e) => e,
204        Err(_) => return 0,
205    };
206    for entry in entries.flatten() {
207        let ft = match entry.file_type() {
208            Ok(ft) => ft,
209            Err(_) => continue,
210        };
211        if ft.is_file() {
212            total += entry.metadata().map(|m| m.len()).unwrap_or(0);
213        } else if ft.is_dir() {
214            total += dir_size_recursive(&entry.path());
215        }
216    }
217    total
218}
219
220#[cfg(test)]
221mod tests {
222    use super::handle_status;
223    use crate::config::Config;
224    use crate::context::AppContext;
225    use crate::parser::TreeSitterProvider;
226    use crate::protocol::RawRequest;
227    use serde_json::json;
228
229    fn request() -> RawRequest {
230        RawRequest {
231            id: "status".to_string(),
232            command: "status".to_string(),
233            lsp_hints: None,
234            session_id: None,
235            params: json!({}),
236        }
237    }
238
239    #[test]
240    fn status_exposes_cache_role_and_canonical_root() {
241        let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
242        let response = handle_status(&request(), &ctx);
243        assert_eq!(response.data["cache_role"], "not_initialized");
244        assert!(response.data["canonical_root"].is_null());
245
246        let temp = tempfile::tempdir().unwrap();
247        ctx.config_mut().project_root = Some(temp.path().to_path_buf());
248        ctx.set_canonical_cache_root(std::fs::canonicalize(temp.path()).unwrap());
249        ctx.set_cache_role(false, None);
250        let response = handle_status(&request(), &ctx);
251        assert_eq!(response.data["cache_role"], "main");
252        assert!(response.data["canonical_root"].as_str().is_some());
253
254        ctx.set_cache_role(true, None);
255        let response = handle_status(&request(), &ctx);
256        assert_eq!(response.data["cache_role"], "worktree");
257    }
258}