use std::path::{Path, PathBuf};
use owo_colors::OwoColorize;
use seshat_storage::{
Database, RepoMetadataRepository, SqliteRepoMetadataRepository, SqliteSubmoduleRepository,
SubmoduleRepository, SubmoduleRow,
};
use crate::db;
use crate::error::CliError;
use crate::format::color_enabled;
struct ProjectSummary {
name: String,
file_count: usize,
convention_count: usize,
db_size: u64,
db_path: PathBuf,
last_scan_time: Option<String>,
}
struct ProjectEntry {
root: ProjectSummary,
submodules: Vec<SubmoduleSummary>,
}
struct SubmoduleSummary {
mount_path: String,
summary: Option<ProjectSummary>,
db_exists: bool,
}
pub fn run_status(verbose: bool) -> Result<(), CliError> {
let color = color_enabled();
let repos_dir = db::xdg_repos_dir()?;
if !repos_dir.is_dir() {
eprintln!("No Seshat databases found.");
eprintln!();
eprintln!("hint: run `seshat scan <path>` to index a project");
return Ok(());
}
let entries = discover_projects(&repos_dir)?;
if entries.is_empty() {
eprintln!("No Seshat databases found.");
eprintln!();
eprintln!("hint: run `seshat scan <path>` to index a project");
return Ok(());
}
print_status_tree(&entries, verbose, color);
Ok(())
}
fn discover_projects(repos_dir: &Path) -> Result<Vec<ProjectEntry>, CliError> {
let root_dbs = db::list_available_projects(repos_dir)?;
let mut entries = Vec::new();
for (db_path, project_name) in &root_dbs {
let root_summary = match load_project_summary(db_path, project_name) {
Some(s) => s,
None => continue, };
let submodules = load_submodule_summaries(db_path, project_name);
entries.push(ProjectEntry {
root: root_summary,
submodules,
});
}
Ok(entries)
}
fn load_project_summary(db_path: &Path, name: &str) -> Option<ProjectSummary> {
let db = Database::open(db_path).ok()?;
let meta_repo = SqliteRepoMetadataRepository::new(db.connection().clone());
let file_count = meta_repo
.get("file_count")
.ok()
.flatten()
.and_then(|v| v.parse::<usize>().ok())
.unwrap_or_else(|| crate::db::count_files_any_schema(&db, "main"));
let convention_count = meta_repo
.get("convention_count")
.ok()
.flatten()
.and_then(|v| v.parse::<usize>().ok())
.unwrap_or_else(|| crate::db::count_conventions(&db, "main"));
let db_size = std::fs::metadata(db_path).map(|m| m.len()).unwrap_or(0);
let last_scan_time = meta_repo.get("last_scan_time").ok().flatten();
Some(ProjectSummary {
name: name.to_string(),
file_count,
convention_count,
db_size,
db_path: db_path.to_path_buf(),
last_scan_time,
})
}
fn load_submodule_summaries(root_db_path: &Path, project_name: &str) -> Vec<SubmoduleSummary> {
let db = match Database::open(root_db_path) {
Ok(d) => d,
Err(_) => return Vec::new(),
};
let sub_repo = SqliteSubmoduleRepository::new(db.connection().clone());
let rows: Vec<SubmoduleRow> = match sub_repo.list() {
Ok(r) => r,
Err(_) => return Vec::new(),
};
rows.into_iter()
.map(|row| {
let sub_db_path = db::resolve_submodule_db_path(project_name, &row.relative_path).ok();
let db_exists = sub_db_path.as_ref().is_some_and(|p| p.exists());
let summary = if db_exists {
sub_db_path
.as_ref()
.and_then(|p| load_project_summary(p, &row.relative_path))
} else {
None
};
SubmoduleSummary {
mount_path: row.relative_path,
summary,
db_exists,
}
})
.collect()
}
fn format_last_scan(value: &str) -> String {
if let Ok(epoch) = value.parse::<i64>() {
let diff = chrono::Utc::now().timestamp() - epoch;
if diff < 60 {
return "just now".to_string();
} else if diff < 3600 {
return format!("{}m ago", diff / 60);
} else if diff < 86400 {
return format!("{}h ago", diff / 3600);
} else {
return format!("{}d ago", diff / 86400);
}
}
value.to_string()
}
fn print_status_tree(entries: &[ProjectEntry], verbose: bool, color: bool) {
let total_projects = entries.len();
let total_submodules: usize = entries.iter().map(|e| e.submodules.len()).sum();
if color {
eprintln!(
"{}",
format!("seshat status — {total_projects} project(s)").bold()
);
} else {
eprintln!("seshat status — {total_projects} project(s)");
}
eprintln!();
for (i, entry) in entries.iter().enumerate() {
let is_last_project = i == entries.len() - 1;
print_project_entry(entry, is_last_project, verbose, color);
}
eprintln!();
let total_files: usize = entries.iter().map(|e| e.root.file_count).sum();
let total_conventions: usize = entries.iter().map(|e| e.root.convention_count).sum();
if color {
eprintln!(
"{} {} files, {} conventions across {} project(s) and {} submodule(s)",
"Total:".dimmed(),
crate::format::format_number(total_files as u64),
crate::format::format_number(total_conventions as u64),
total_projects,
total_submodules,
);
} else {
eprintln!(
"Total: {} files, {} conventions across {} project(s) and {} submodule(s)",
crate::format::format_number(total_files as u64),
crate::format::format_number(total_conventions as u64),
total_projects,
total_submodules,
);
}
}
fn print_project_entry(entry: &ProjectEntry, _is_last: bool, verbose: bool, color: bool) {
let root = &entry.root;
let name_display = if color {
root.name.bold().to_string()
} else {
root.name.clone()
};
eprintln!(" {name_display}");
let files_str = crate::format::format_number(root.file_count as u64);
let conventions_str = crate::format::format_number(root.convention_count as u64);
let size_str = crate::format::format_human_size(root.db_size);
let last_scan_str = root
.last_scan_time
.as_ref()
.map(|t| format_last_scan(t))
.unwrap_or_else(|| "never".to_string());
if color {
eprintln!(
" {} {files_str} {} {conventions_str} {} {size_str} {} {last_scan_str}",
"files:".dimmed(),
"conventions:".dimmed(),
"size:".dimmed(),
"scanned:".dimmed(),
);
} else {
eprintln!(
" files: {files_str} conventions: {conventions_str} size: {size_str} scanned: {last_scan_str}",
);
}
if verbose {
if color {
eprintln!(" {} {}", "db:".dimmed(), root.db_path.display());
} else {
eprintln!(" db: {}", root.db_path.display());
}
}
for (j, sub) in entry.submodules.iter().enumerate() {
let is_last_sub = j == entry.submodules.len() - 1;
let connector = if is_last_sub {
"└── "
} else {
"├── "
};
if !sub.db_exists {
let warn = if color {
format!(
" {connector}{} {}",
sub.mount_path,
"(DB missing)".yellow()
)
} else {
format!(" {connector}{} (DB missing)", sub.mount_path)
};
eprintln!("{warn}");
continue;
}
match &sub.summary {
Some(summary) => {
let sub_files = crate::format::format_number(summary.file_count as u64);
let sub_convs = crate::format::format_number(summary.convention_count as u64);
let sub_size = crate::format::format_human_size(summary.db_size);
let sub_scan = summary
.last_scan_time
.as_ref()
.map(|t| format_last_scan(t))
.unwrap_or_else(|| "never".to_string());
let detail_indent = if is_last_sub {
" "
} else {
" │ "
};
if color {
eprintln!(" {connector}{}", sub.mount_path.bold(),);
} else {
eprintln!(" {connector}{}", sub.mount_path);
}
if color {
eprintln!(
"{detail_indent}{} {sub_files} {} {sub_convs} {} {sub_size} {} {sub_scan}",
"files:".dimmed(),
"conventions:".dimmed(),
"size:".dimmed(),
"scanned:".dimmed(),
);
} else {
eprintln!(
"{detail_indent}files: {sub_files} conventions: {sub_convs} size: {sub_size} scanned: {sub_scan}",
);
}
if verbose {
if color {
eprintln!(
"{detail_indent}{} {}",
"db:".dimmed(),
summary.db_path.display()
);
} else {
eprintln!("{detail_indent}db: {}", summary.db_path.display());
}
}
}
None => {
let warn = if color {
format!(
" {connector}{} {}",
sub.mount_path,
"(could not read DB)".yellow()
)
} else {
format!(" {connector}{} (could not read DB)", sub.mount_path)
};
eprintln!("{warn}");
}
}
}
eprintln!();
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn format_last_scan_epoch_just_now() {
let now = chrono::Utc::now().timestamp();
let result = format_last_scan(&now.to_string());
assert_eq!(result, "just now");
}
#[test]
fn format_last_scan_epoch_minutes_ago() {
let five_min_ago = chrono::Utc::now().timestamp() - 300;
let result = format_last_scan(&five_min_ago.to_string());
assert_eq!(result, "5m ago");
}
#[test]
fn format_last_scan_epoch_hours_ago() {
let two_hours_ago = chrono::Utc::now().timestamp() - 7200;
let result = format_last_scan(&two_hours_ago.to_string());
assert_eq!(result, "2h ago");
}
#[test]
fn format_last_scan_epoch_days_ago() {
let three_days_ago = chrono::Utc::now().timestamp() - 259200;
let result = format_last_scan(&three_days_ago.to_string());
assert_eq!(result, "3d ago");
}
#[test]
fn format_last_scan_iso_string_passthrough() {
let result = format_last_scan("2026-04-03T22:00:00");
assert_eq!(result, "2026-04-03T22:00:00");
}
#[test]
fn discover_projects_empty_dir() {
let tmp = tempfile::tempdir().expect("create temp dir");
let repos = tmp.path().join("repos");
fs::create_dir_all(&repos).expect("create repos dir");
let entries = discover_projects(&repos).expect("should succeed");
assert!(entries.is_empty());
}
#[test]
fn discover_projects_with_root_db() {
let tmp = tempfile::tempdir().expect("create temp dir");
let repos = tmp.path().join("repos");
fs::create_dir_all(&repos).expect("create repos dir");
let db_path = repos.join("test-project.db");
let db = Database::open(&db_path).expect("create db");
let meta_repo = SqliteRepoMetadataRepository::new(db.connection().clone());
meta_repo
.set("last_scan_time", "1700000000")
.expect("set metadata");
drop(db);
let entries = discover_projects(&repos).expect("should succeed");
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].root.name, "test-project");
assert_eq!(entries[0].root.file_count, 0);
assert_eq!(entries[0].root.convention_count, 0);
assert!(entries[0].root.db_size > 0);
assert_eq!(
entries[0].root.last_scan_time,
Some("1700000000".to_string())
);
}
#[test]
fn discover_projects_with_submodule() {
let tmp = tempfile::tempdir().expect("create temp dir");
let repos = tmp.path().join("repos");
fs::create_dir_all(&repos).expect("create repos dir");
let root_db_path = repos.join("my-project.db");
let root_db = Database::open(&root_db_path).expect("create root db");
let sub_repo = SqliteSubmoduleRepository::new(root_db.connection().clone());
let sub_dir = repos.join("my-project");
fs::create_dir_all(&sub_dir).expect("create sub dir");
let sub_db_path = sub_dir.join("vendor-lib.db");
let sub_db = Database::open(&sub_db_path).expect("create sub db");
drop(sub_db);
use seshat_storage::SubmoduleInput;
sub_repo
.insert(&SubmoduleInput {
relative_path: "vendor-lib".to_string(),
name: "lib".to_string(),
db_path: sub_db_path.to_string_lossy().to_string(),
commit_hash: Some("abc123".to_string()),
})
.expect("insert submodule");
drop(root_db);
let entries = discover_projects(&repos).expect("should succeed");
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].root.name, "my-project");
assert_eq!(entries[0].submodules.len(), 1);
assert_eq!(entries[0].submodules[0].mount_path, "vendor-lib");
if let Ok(xdg_repos) = db::xdg_repos_dir() {
let _ = fs::remove_dir_all(xdg_repos.join("my-project"));
}
}
#[test]
fn load_project_summary_returns_none_for_bad_path() {
let result = load_project_summary(Path::new("/nonexistent/path.db"), "test");
assert!(result.is_none());
}
#[test]
fn load_project_summary_reads_metadata() {
let tmp = tempfile::tempdir().expect("create temp dir");
let db_path = tmp.path().join("test.db");
let db = Database::open(&db_path).expect("create db");
let meta_repo = SqliteRepoMetadataRepository::new(db.connection().clone());
meta_repo.set("last_scan_time", "1700000000").expect("set");
drop(db);
let summary = load_project_summary(&db_path, "test").expect("should load");
assert_eq!(summary.name, "test");
assert_eq!(summary.last_scan_time, Some("1700000000".to_string()));
assert!(summary.db_size > 0);
}
#[test]
fn run_status_no_repos_dir() {
let result = format_last_scan("not-a-number");
assert_eq!(result, "not-a-number");
}
#[test]
fn load_project_summary_reads_file_count_from_repo_metadata() {
let tmp = tempfile::tempdir().expect("create temp dir");
let db_path = tmp.path().join("test.db");
let db = Database::open(&db_path).expect("create db");
let meta_repo = SqliteRepoMetadataRepository::new(db.connection().clone());
meta_repo.set("file_count", "370").expect("set file_count");
meta_repo
.set("convention_count", "552")
.expect("set convention_count");
meta_repo.set("last_scan_time", "1700000000").expect("set");
drop(db);
let summary = load_project_summary(&db_path, "test").expect("should load");
assert_eq!(
summary.file_count, 370,
"must read from repo_metadata, not files_ir"
);
assert_eq!(summary.convention_count, 552);
}
#[test]
fn load_project_summary_falls_back_to_count_when_no_metadata() {
use seshat_core::test_helpers::make_project_file;
use seshat_storage::{FileIRRepository, SqliteFileIRRepository};
let tmp = tempfile::tempdir().expect("create temp dir");
let db_path = tmp.path().join("test.db");
let db = Database::open(&db_path).expect("create db");
let conn = db.connection().clone();
let branch = seshat_core::BranchId::from("main");
let file = make_project_file(seshat_core::Language::Rust);
SqliteFileIRRepository::new(conn)
.upsert(&branch, &file, None)
.expect("upsert");
drop(db);
let summary = load_project_summary(&db_path, "test").expect("should load");
assert_eq!(
summary.file_count, 1,
"fallback COUNT(*) should find the file"
);
}
}