Skip to main content

seshat_cli/
status.rs

1//! Implementation of the `seshat status` command.
2//!
3//! Scans the XDG repos directory for `.db` files, identifies root projects vs
4//! submodules, reads `repo_metadata` from each DB for summary info, and
5//! displays a tree view with aligned columns.
6
7use std::path::{Path, PathBuf};
8
9use owo_colors::OwoColorize;
10
11use seshat_storage::{
12    Database, RepoMetadataRepository, SqliteRepoMetadataRepository, SqliteSubmoduleRepository,
13    SubmoduleRepository, SubmoduleRow,
14};
15
16use crate::db;
17use crate::error::CliError;
18use crate::format::color_enabled;
19
20/// Summary info extracted from a project or submodule database.
21struct ProjectSummary {
22    /// Display name (project name or mount path for submodules).
23    name: String,
24    /// Number of indexed files.
25    file_count: usize,
26    /// Number of detected conventions.
27    convention_count: usize,
28    /// Database file size in bytes.
29    db_size: u64,
30    /// Database path on disk.
31    db_path: PathBuf,
32    /// Last scan timestamp from repo_metadata (ISO-8601 or epoch string).
33    last_scan_time: Option<String>,
34}
35
36/// A root project with its optional submodules.
37struct ProjectEntry {
38    /// Root project summary.
39    root: ProjectSummary,
40    /// Submodule summaries (from the submodules table in root DB).
41    submodules: Vec<SubmoduleSummary>,
42}
43
44/// A submodule entry — may have a valid DB or be orphaned/missing.
45struct SubmoduleSummary {
46    /// Mount path (relative_path from submodules table).
47    mount_path: String,
48    /// Summary from the submodule DB (None if DB is missing/broken).
49    summary: Option<ProjectSummary>,
50    /// Whether this submodule DB exists on disk.
51    db_exists: bool,
52}
53
54/// Run the `seshat status` command.
55///
56/// Scans the XDG repos directory, identifies root projects and submodules,
57/// and displays a tree with summary information.
58pub fn run_status(verbose: bool) -> Result<(), CliError> {
59    let color = color_enabled();
60    let repos_dir = db::xdg_repos_dir()?;
61
62    if !repos_dir.is_dir() {
63        eprintln!("No Seshat databases found.");
64        eprintln!();
65        eprintln!("hint: run `seshat scan <path>` to index a project");
66        return Ok(());
67    }
68
69    let entries = discover_projects(&repos_dir)?;
70
71    if entries.is_empty() {
72        eprintln!("No Seshat databases found.");
73        eprintln!();
74        eprintln!("hint: run `seshat scan <path>` to index a project");
75        return Ok(());
76    }
77
78    print_status_tree(&entries, verbose, color);
79
80    Ok(())
81}
82
83/// Discover all root projects and their submodules from the repos directory.
84///
85/// Root projects are `.db` files directly in the repos dir.
86/// Submodules are tracked in each root DB's `submodules` table.
87fn discover_projects(repos_dir: &Path) -> Result<Vec<ProjectEntry>, CliError> {
88    let root_dbs = db::list_available_projects(repos_dir)?;
89    let mut entries = Vec::new();
90
91    for (db_path, project_name) in &root_dbs {
92        let root_summary = match load_project_summary(db_path, project_name) {
93            Some(s) => s,
94            None => continue, // Skip DBs that can't be opened
95        };
96
97        // Load submodule rows from root DB and resolve each.
98        let submodules = load_submodule_summaries(db_path, project_name);
99
100        entries.push(ProjectEntry {
101            root: root_summary,
102            submodules,
103        });
104    }
105
106    Ok(entries)
107}
108
109/// Load summary info from a database file.
110///
111/// `file_count` and `convention_count` are read from `repo_metadata` (written
112/// by the scanner at the end of every scan) rather than from `files_ir` with
113/// an `ir_schema_version` filter.  This avoids displaying `0 files` when a
114/// database was scanned with an older IR schema version — the metadata values
115/// reflect what was actually indexed at scan time regardless of schema version.
116///
117/// Falls back to a direct `COUNT(*)` query when `repo_metadata` does not yet
118/// contain the keys (e.g., for very old databases created before the metadata
119/// writes were introduced).
120fn load_project_summary(db_path: &Path, name: &str) -> Option<ProjectSummary> {
121    let db = Database::open(db_path).ok()?;
122
123    let meta_repo = SqliteRepoMetadataRepository::new(db.connection().clone());
124
125    // File count: prefer repo_metadata["file_count"] written by the scanner.
126    //
127    // We deliberately do NOT use get_file_hashes_by_branch() here, because
128    // that query filters on the current IR_SCHEMA_VERSION and would return 0
129    // for databases scanned with an older schema version — even when those
130    // databases contain hundreds of files.  The repo_metadata value is written
131    // at the end of every scan and is version-agnostic.
132    let file_count = meta_repo
133        .get("file_count")
134        .ok()
135        .flatten()
136        .and_then(|v| v.parse::<usize>().ok())
137        // Fallback for very old DBs that pre-date the metadata write.
138        .unwrap_or_else(|| crate::db::count_files_any_schema(&db, "main"));
139
140    // Convention count: prefer repo_metadata["convention_count"].
141    let convention_count = meta_repo
142        .get("convention_count")
143        .ok()
144        .flatten()
145        .and_then(|v| v.parse::<usize>().ok())
146        .unwrap_or_else(|| crate::db::count_conventions(&db, "main"));
147
148    let db_size = std::fs::metadata(db_path).map(|m| m.len()).unwrap_or(0);
149    let last_scan_time = meta_repo.get("last_scan_time").ok().flatten();
150
151    Some(ProjectSummary {
152        name: name.to_string(),
153        file_count,
154        convention_count,
155        db_size,
156        db_path: db_path.to_path_buf(),
157        last_scan_time,
158    })
159}
160
161/// Load submodule summaries from a root project's database.
162fn load_submodule_summaries(root_db_path: &Path, project_name: &str) -> Vec<SubmoduleSummary> {
163    let db = match Database::open(root_db_path) {
164        Ok(d) => d,
165        Err(_) => return Vec::new(),
166    };
167
168    let sub_repo = SqliteSubmoduleRepository::new(db.connection().clone());
169    let rows: Vec<SubmoduleRow> = match sub_repo.list() {
170        Ok(r) => r,
171        Err(_) => return Vec::new(),
172    };
173
174    rows.into_iter()
175        .map(|row| {
176            let sub_db_path = db::resolve_submodule_db_path(project_name, &row.relative_path).ok();
177
178            let db_exists = sub_db_path.as_ref().is_some_and(|p| p.exists());
179
180            let summary = if db_exists {
181                sub_db_path
182                    .as_ref()
183                    .and_then(|p| load_project_summary(p, &row.relative_path))
184            } else {
185                None
186            };
187
188            SubmoduleSummary {
189                mount_path: row.relative_path,
190                summary,
191                db_exists,
192            }
193        })
194        .collect()
195}
196
197/// Format a last-scan timestamp for display.
198///
199/// If the value looks like a Unix epoch (all digits), format as a
200/// human-readable date. Otherwise return as-is (likely already ISO-8601).
201fn format_last_scan(value: &str) -> String {
202    // Try parsing as Unix timestamp (seconds).
203    if let Ok(epoch) = value.parse::<i64>() {
204        let diff = chrono::Utc::now().timestamp() - epoch;
205        if diff < 60 {
206            // Covers negative diff (clock skew) and very recent scans.
207            return "just now".to_string();
208        } else if diff < 3600 {
209            return format!("{}m ago", diff / 60);
210        } else if diff < 86400 {
211            return format!("{}h ago", diff / 3600);
212        } else {
213            return format!("{}d ago", diff / 86400);
214        }
215    }
216
217    // Already a readable string (ISO-8601 or similar).
218    value.to_string()
219}
220
221/// Print the status tree to stderr.
222fn print_status_tree(entries: &[ProjectEntry], verbose: bool, color: bool) {
223    let total_projects = entries.len();
224    let total_submodules: usize = entries.iter().map(|e| e.submodules.len()).sum();
225
226    // Header
227    if color {
228        eprintln!(
229            "{}",
230            format!("seshat status — {total_projects} project(s)").bold()
231        );
232    } else {
233        eprintln!("seshat status — {total_projects} project(s)");
234    }
235    eprintln!();
236
237    for (i, entry) in entries.iter().enumerate() {
238        let is_last_project = i == entries.len() - 1;
239        print_project_entry(entry, is_last_project, verbose, color);
240    }
241
242    // Footer summary
243    eprintln!();
244    let total_files: usize = entries.iter().map(|e| e.root.file_count).sum();
245    let total_conventions: usize = entries.iter().map(|e| e.root.convention_count).sum();
246    if color {
247        eprintln!(
248            "{}  {} files, {} conventions across {} project(s) and {} submodule(s)",
249            "Total:".dimmed(),
250            crate::format::format_number(total_files as u64),
251            crate::format::format_number(total_conventions as u64),
252            total_projects,
253            total_submodules,
254        );
255    } else {
256        eprintln!(
257            "Total:  {} files, {} conventions across {} project(s) and {} submodule(s)",
258            crate::format::format_number(total_files as u64),
259            crate::format::format_number(total_conventions as u64),
260            total_projects,
261            total_submodules,
262        );
263    }
264}
265
266/// Print a single project entry (root + submodules).
267fn print_project_entry(entry: &ProjectEntry, _is_last: bool, verbose: bool, color: bool) {
268    let root = &entry.root;
269
270    // Project name line
271    let name_display = if color {
272        root.name.bold().to_string()
273    } else {
274        root.name.clone()
275    };
276
277    eprintln!("  {name_display}");
278
279    // Details line
280    let files_str = crate::format::format_number(root.file_count as u64);
281    let conventions_str = crate::format::format_number(root.convention_count as u64);
282    let size_str = crate::format::format_human_size(root.db_size);
283
284    let last_scan_str = root
285        .last_scan_time
286        .as_ref()
287        .map(|t| format_last_scan(t))
288        .unwrap_or_else(|| "never".to_string());
289
290    if color {
291        eprintln!(
292            "    {} {files_str}  {} {conventions_str}  {} {size_str}  {} {last_scan_str}",
293            "files:".dimmed(),
294            "conventions:".dimmed(),
295            "size:".dimmed(),
296            "scanned:".dimmed(),
297        );
298    } else {
299        eprintln!(
300            "    files: {files_str}  conventions: {conventions_str}  size: {size_str}  scanned: {last_scan_str}",
301        );
302    }
303
304    // Verbose: full DB path
305    if verbose {
306        if color {
307            eprintln!("    {} {}", "db:".dimmed(), root.db_path.display());
308        } else {
309            eprintln!("    db: {}", root.db_path.display());
310        }
311    }
312
313    // Submodules
314    for (j, sub) in entry.submodules.iter().enumerate() {
315        let is_last_sub = j == entry.submodules.len() - 1;
316        let connector = if is_last_sub {
317            "└── "
318        } else {
319            "├── "
320        };
321
322        if !sub.db_exists {
323            // Orphaned / missing DB
324            let warn = if color {
325                format!(
326                    "    {connector}{} {}",
327                    sub.mount_path,
328                    "(DB missing)".yellow()
329                )
330            } else {
331                format!("    {connector}{} (DB missing)", sub.mount_path)
332            };
333            eprintln!("{warn}");
334            continue;
335        }
336
337        match &sub.summary {
338            Some(summary) => {
339                let sub_files = crate::format::format_number(summary.file_count as u64);
340                let sub_convs = crate::format::format_number(summary.convention_count as u64);
341                let sub_size = crate::format::format_human_size(summary.db_size);
342
343                let sub_scan = summary
344                    .last_scan_time
345                    .as_ref()
346                    .map(|t| format_last_scan(t))
347                    .unwrap_or_else(|| "never".to_string());
348
349                // Indents for the details line and optional verbose line.
350                // Chosen so the content aligns under the submodule name.
351                let detail_indent = if is_last_sub {
352                    "        "
353                } else {
354                    "    │   "
355                };
356
357                // Line 1: name
358                if color {
359                    eprintln!("    {connector}{}", sub.mount_path.bold(),);
360                } else {
361                    eprintln!("    {connector}{}", sub.mount_path);
362                }
363
364                // Line 2: details — identical labels and layout as root project.
365                if color {
366                    eprintln!(
367                        "{detail_indent}{} {sub_files}  {} {sub_convs}  {} {sub_size}  {} {sub_scan}",
368                        "files:".dimmed(),
369                        "conventions:".dimmed(),
370                        "size:".dimmed(),
371                        "scanned:".dimmed(),
372                    );
373                } else {
374                    eprintln!(
375                        "{detail_indent}files: {sub_files}  conventions: {sub_convs}  size: {sub_size}  scanned: {sub_scan}",
376                    );
377                }
378
379                if verbose {
380                    if color {
381                        eprintln!(
382                            "{detail_indent}{} {}",
383                            "db:".dimmed(),
384                            summary.db_path.display()
385                        );
386                    } else {
387                        eprintln!("{detail_indent}db: {}", summary.db_path.display());
388                    }
389                }
390            }
391            None => {
392                let warn = if color {
393                    format!(
394                        "    {connector}{} {}",
395                        sub.mount_path,
396                        "(could not read DB)".yellow()
397                    )
398                } else {
399                    format!("    {connector}{} (could not read DB)", sub.mount_path)
400                };
401                eprintln!("{warn}");
402            }
403        }
404    }
405
406    eprintln!();
407}
408
409// ══════════════════════════════════════════════════════════════════════
410// Tests
411// ══════════════════════════════════════════════════════════════════════
412
413#[cfg(test)]
414mod tests {
415    use super::*;
416    use std::fs;
417
418    #[test]
419    fn format_last_scan_epoch_just_now() {
420        let now = chrono::Utc::now().timestamp();
421        let result = format_last_scan(&now.to_string());
422        assert_eq!(result, "just now");
423    }
424
425    #[test]
426    fn format_last_scan_epoch_minutes_ago() {
427        let five_min_ago = chrono::Utc::now().timestamp() - 300;
428        let result = format_last_scan(&five_min_ago.to_string());
429        assert_eq!(result, "5m ago");
430    }
431
432    #[test]
433    fn format_last_scan_epoch_hours_ago() {
434        let two_hours_ago = chrono::Utc::now().timestamp() - 7200;
435        let result = format_last_scan(&two_hours_ago.to_string());
436        assert_eq!(result, "2h ago");
437    }
438
439    #[test]
440    fn format_last_scan_epoch_days_ago() {
441        let three_days_ago = chrono::Utc::now().timestamp() - 259200;
442        let result = format_last_scan(&three_days_ago.to_string());
443        assert_eq!(result, "3d ago");
444    }
445
446    #[test]
447    fn format_last_scan_iso_string_passthrough() {
448        let result = format_last_scan("2026-04-03T22:00:00");
449        assert_eq!(result, "2026-04-03T22:00:00");
450    }
451
452    #[test]
453    fn discover_projects_empty_dir() {
454        let tmp = tempfile::tempdir().expect("create temp dir");
455        let repos = tmp.path().join("repos");
456        fs::create_dir_all(&repos).expect("create repos dir");
457
458        let entries = discover_projects(&repos).expect("should succeed");
459        assert!(entries.is_empty());
460    }
461
462    #[test]
463    fn discover_projects_with_root_db() {
464        let tmp = tempfile::tempdir().expect("create temp dir");
465        let repos = tmp.path().join("repos");
466        fs::create_dir_all(&repos).expect("create repos dir");
467
468        // Create a real DB file
469        let db_path = repos.join("test-project.db");
470        let db = Database::open(&db_path).expect("create db");
471
472        // Write some repo_metadata
473        let meta_repo = SqliteRepoMetadataRepository::new(db.connection().clone());
474        meta_repo
475            .set("last_scan_time", "1700000000")
476            .expect("set metadata");
477        drop(db);
478
479        let entries = discover_projects(&repos).expect("should succeed");
480        assert_eq!(entries.len(), 1);
481        assert_eq!(entries[0].root.name, "test-project");
482        assert_eq!(entries[0].root.file_count, 0);
483        assert_eq!(entries[0].root.convention_count, 0);
484        assert!(entries[0].root.db_size > 0);
485        assert_eq!(
486            entries[0].root.last_scan_time,
487            Some("1700000000".to_string())
488        );
489    }
490
491    #[test]
492    fn discover_projects_with_submodule() {
493        let tmp = tempfile::tempdir().expect("create temp dir");
494        let repos = tmp.path().join("repos");
495        fs::create_dir_all(&repos).expect("create repos dir");
496
497        // Create root DB with a submodule entry
498        let root_db_path = repos.join("my-project.db");
499        let root_db = Database::open(&root_db_path).expect("create root db");
500
501        let sub_repo = SqliteSubmoduleRepository::new(root_db.connection().clone());
502        // Create submodule directory structure and DB
503        let sub_dir = repos.join("my-project");
504        fs::create_dir_all(&sub_dir).expect("create sub dir");
505        let sub_db_path = sub_dir.join("vendor-lib.db");
506        let sub_db = Database::open(&sub_db_path).expect("create sub db");
507        drop(sub_db);
508
509        // Insert submodule row pointing to the real DB path
510        use seshat_storage::SubmoduleInput;
511        sub_repo
512            .insert(&SubmoduleInput {
513                relative_path: "vendor-lib".to_string(),
514                name: "lib".to_string(),
515                db_path: sub_db_path.to_string_lossy().to_string(),
516                commit_hash: Some("abc123".to_string()),
517            })
518            .expect("insert submodule");
519        drop(root_db);
520
521        // discover_projects uses resolve_submodule_db_path which uses XDG,
522        // so this test verifies the row-loading path but the sub DB resolution
523        // will differ. That's OK — the submodule will appear as "DB missing"
524        // unless the XDG path happens to match.
525        let entries = discover_projects(&repos).expect("should succeed");
526        assert_eq!(entries.len(), 1);
527        assert_eq!(entries[0].root.name, "my-project");
528        // Submodule row was loaded (1 entry)
529        assert_eq!(entries[0].submodules.len(), 1);
530        assert_eq!(entries[0].submodules[0].mount_path, "vendor-lib");
531
532        // Clean up: resolve_submodule_db_path creates dirs in the real XDG
533        // data directory as a side effect.
534        if let Ok(xdg_repos) = db::xdg_repos_dir() {
535            let _ = fs::remove_dir_all(xdg_repos.join("my-project"));
536        }
537    }
538
539    #[test]
540    fn load_project_summary_returns_none_for_bad_path() {
541        let result = load_project_summary(Path::new("/nonexistent/path.db"), "test");
542        assert!(result.is_none());
543    }
544
545    #[test]
546    fn load_project_summary_reads_metadata() {
547        let tmp = tempfile::tempdir().expect("create temp dir");
548        let db_path = tmp.path().join("test.db");
549        let db = Database::open(&db_path).expect("create db");
550
551        let meta_repo = SqliteRepoMetadataRepository::new(db.connection().clone());
552        meta_repo.set("last_scan_time", "1700000000").expect("set");
553        drop(db);
554
555        let summary = load_project_summary(&db_path, "test").expect("should load");
556        assert_eq!(summary.name, "test");
557        assert_eq!(summary.last_scan_time, Some("1700000000".to_string()));
558        assert!(summary.db_size > 0);
559    }
560
561    #[test]
562    fn run_status_no_repos_dir() {
563        // When XDG dir doesn't exist, run_status should succeed gracefully.
564        // We can't easily mock XDG, but we can verify format_last_scan handles
565        // edge cases which is the testable pure logic.
566        let result = format_last_scan("not-a-number");
567        assert_eq!(result, "not-a-number");
568    }
569
570    /// Regression test: file_count must be read from repo_metadata, not from
571    /// get_file_hashes_by_branch (which filters on ir_schema_version and would
572    /// return 0 for databases scanned with an older schema version).
573    #[test]
574    fn load_project_summary_reads_file_count_from_repo_metadata() {
575        let tmp = tempfile::tempdir().expect("create temp dir");
576        let db_path = tmp.path().join("test.db");
577        let db = Database::open(&db_path).expect("create db");
578
579        let meta_repo = SqliteRepoMetadataRepository::new(db.connection().clone());
580        // Simulate what the scanner writes at the end of a scan.
581        meta_repo.set("file_count", "370").expect("set file_count");
582        meta_repo
583            .set("convention_count", "552")
584            .expect("set convention_count");
585        meta_repo.set("last_scan_time", "1700000000").expect("set");
586        // Note: we deliberately write NO rows to files_ir, simulating a DB
587        // where all rows have an older ir_schema_version that would be filtered
588        // out by get_file_hashes_by_branch.
589        drop(db);
590
591        let summary = load_project_summary(&db_path, "test").expect("should load");
592        assert_eq!(
593            summary.file_count, 370,
594            "must read from repo_metadata, not files_ir"
595        );
596        assert_eq!(summary.convention_count, 552);
597    }
598
599    /// Regression test: when repo_metadata has no file_count (old DB), fall back
600    /// to COUNT(*) without ir_schema_version filter.
601    #[test]
602    fn load_project_summary_falls_back_to_count_when_no_metadata() {
603        use seshat_core::test_helpers::make_project_file;
604        use seshat_storage::{FileIRRepository, SqliteFileIRRepository};
605
606        let tmp = tempfile::tempdir().expect("create temp dir");
607        let db_path = tmp.path().join("test.db");
608        let db = Database::open(&db_path).expect("create db");
609        let conn = db.connection().clone();
610
611        // Insert a file without setting repo_metadata["file_count"].
612        let branch = seshat_core::BranchId::from("main");
613        let file = make_project_file(seshat_core::Language::Rust);
614        SqliteFileIRRepository::new(conn)
615            .upsert(&branch, &file, None)
616            .expect("upsert");
617        drop(db);
618
619        let summary = load_project_summary(&db_path, "test").expect("should load");
620        // Should fall back to COUNT(*) and find the 1 row we inserted.
621        assert_eq!(
622            summary.file_count, 1,
623            "fallback COUNT(*) should find the file"
624        );
625    }
626}