1use 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
20struct ProjectSummary {
22 name: String,
24 file_count: usize,
26 convention_count: usize,
28 db_size: u64,
30 db_path: PathBuf,
32 last_scan_time: Option<String>,
34}
35
36struct ProjectEntry {
38 root: ProjectSummary,
40 submodules: Vec<SubmoduleSummary>,
42}
43
44struct SubmoduleSummary {
46 mount_path: String,
48 summary: Option<ProjectSummary>,
50 db_exists: bool,
52}
53
54pub 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
83fn 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, };
96
97 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
109fn 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 let file_count = meta_repo
133 .get("file_count")
134 .ok()
135 .flatten()
136 .and_then(|v| v.parse::<usize>().ok())
137 .unwrap_or_else(|| crate::db::count_files_any_schema(&db, "main"));
139
140 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
161fn 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
197fn format_last_scan(value: &str) -> String {
202 if let Ok(epoch) = value.parse::<i64>() {
204 let diff = chrono::Utc::now().timestamp() - epoch;
205 if diff < 60 {
206 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 value.to_string()
219}
220
221fn 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 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 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
266fn print_project_entry(entry: &ProjectEntry, _is_last: bool, verbose: bool, color: bool) {
268 let root = &entry.root;
269
270 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 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 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 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 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 let detail_indent = if is_last_sub {
352 " "
353 } else {
354 " │ "
355 };
356
357 if color {
359 eprintln!(" {connector}{}", sub.mount_path.bold(),);
360 } else {
361 eprintln!(" {connector}{}", sub.mount_path);
362 }
363
364 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#[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 let db_path = repos.join("test-project.db");
470 let db = Database::open(&db_path).expect("create db");
471
472 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 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 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 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 let entries = discover_projects(&repos).expect("should succeed");
526 assert_eq!(entries.len(), 1);
527 assert_eq!(entries[0].root.name, "my-project");
528 assert_eq!(entries[0].submodules.len(), 1);
530 assert_eq!(entries[0].submodules[0].mount_path, "vendor-lib");
531
532 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 let result = format_last_scan("not-a-number");
567 assert_eq!(result, "not-a-number");
568 }
569
570 #[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 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 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 #[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 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 assert_eq!(
622 summary.file_count, 1,
623 "fallback COUNT(*) should find the file"
624 );
625 }
626}