1use std::collections::HashMap;
9use std::path::{Path, PathBuf};
10use std::sync::Arc;
11use std::sync::atomic::{AtomicBool, Ordering};
12use std::time::Instant;
13
14use seshat_core::{BranchId, Language, ScanConfig};
15use seshat_mcp::{ProjectConnection, ScanState};
16use seshat_scanner::{read_and_parse_file, record_branch_scan_complete, scan_project};
17use seshat_storage::{
18 BranchRepository, Database, FileIRRepository, SqliteBranchRepository, SqliteFileIRRepository,
19 SqliteSubmoduleRepository, SubmoduleRepository, SubmoduleRow,
20};
21use seshat_watcher::{WatcherError, WatcherParams, start_watcher};
22use tokio::sync::oneshot;
23
24use crate::config::AppConfig;
25use crate::db::{ServeTarget, detect_branch, gc_branch_snapshots};
26use crate::error::CliError;
27
28pub struct GcHandle {
32 shutdown_tx: oneshot::Sender<()>,
33 task: tokio::task::JoinHandle<()>,
34}
35
36impl GcHandle {
37 pub async fn shutdown(self) {
39 let _ = self.shutdown_tx.send(());
40 let _ = tokio::time::timeout(std::time::Duration::from_secs(5), self.task).await;
41 }
42}
43
44struct RepoInfo {
46 name: String,
48 db_path: PathBuf,
50 branch: BranchId,
52 file_count: usize,
54 convention_count: usize,
56}
57
58fn resolve_call_log_path(cli_flag: Option<PathBuf>, config_value: Option<&str>) -> Option<PathBuf> {
66 match cli_flag {
67 Some(p) if p.as_os_str().is_empty() => {
68 let data_dir = dirs::data_dir().unwrap_or_else(|| PathBuf::from("."));
70 Some(data_dir.join("seshat").join("call-log.jsonl"))
71 }
72 Some(p) => Some(p),
73 None => config_value.map(PathBuf::from),
74 }
75}
76
77fn watcher_should_start(enabled: bool, state: &ScanState) -> bool {
91 enabled && state.error_message().is_none()
92}
93
94fn handle_branch_switch(
103 db: &Database,
104 detected_branch: &str,
105 current_branch: &BranchId,
106 _is_auto_scan: bool,
107) -> Result<BranchId, CliError> {
108 let branch_repo = SqliteBranchRepository::new(db.connection().clone());
109
110 if detected_branch == current_branch.0 {
112 return Ok(current_branch.clone());
113 }
114
115 let detected_id = BranchId::from(detected_branch);
116
117 let branches = branch_repo
119 .list_branches()
120 .map_err(|e| CliError::CommandFailed {
121 command: "serve".to_owned(),
122 reason: format!("failed to list branches: {e}"),
123 })?;
124
125 let target_has_data = branches.iter().any(|b| b.0 == detected_branch);
126
127 if !target_has_data {
128 let source_branch = current_branch.clone();
130
131 let source_branches = branch_repo
133 .list_branches()
134 .map_err(|e| CliError::CommandFailed {
135 command: "serve".to_owned(),
136 reason: format!("failed to list branches: {e}"),
137 })?;
138 let source_has_data = source_branches.iter().any(|b| b.0 == source_branch.0);
139
140 if !source_has_data {
141 tracing::info!(
142 source_branch = %source_branch.0,
143 target_branch = %detected_branch,
144 "Source branch has no data — switching without snapshot"
145 );
146 } else {
147 tracing::info!(
148 source_branch = %source_branch.0,
149 target_branch = %detected_branch,
150 "Target branch has no data — creating snapshot from source"
151 );
152 branch_repo
153 .create_snapshot(&source_branch, &detected_id)
154 .map_err(|e| CliError::CommandFailed {
155 command: "serve".to_owned(),
156 reason: format!("failed to create snapshot: {e}"),
157 })?;
158 }
159 }
160
161 tracing::info!(
163 from = %current_branch.0,
164 to = %detected_branch,
165 "Switching branch"
166 );
167 branch_repo
168 .switch_branch(&detected_id)
169 .map_err(|e| CliError::CommandFailed {
170 command: "serve".to_owned(),
171 reason: format!("failed to switch branch: {e}"),
172 })?;
173
174 Ok(detected_id)
175}
176
177fn handle_auto_scan_snapshot(db: &Database, detected_branch: &str) -> Result<BranchId, CliError> {
184 let branch_repo = SqliteBranchRepository::new(db.connection().clone());
185
186 if detected_branch == "main" {
187 return Ok(BranchId::from(detected_branch));
188 }
189
190 let detected_id = BranchId::from(detected_branch);
191
192 let branches = branch_repo
194 .list_branches()
195 .map_err(|e| CliError::CommandFailed {
196 command: "serve".to_owned(),
197 reason: format!("failed to list branches: {e}"),
198 })?;
199
200 let main_has_data = branches.iter().any(|b| b.0 == "main");
201
202 if !main_has_data {
203 return Ok(detected_id);
204 }
205
206 let main_branch = BranchId::from("main");
208 tracing::info!(
209 source_branch = "main",
210 target_branch = %detected_branch,
211 "Auto-scan on non-main branch — creating snapshot from main"
212 );
213 branch_repo
214 .create_snapshot(&main_branch, &detected_id)
215 .map_err(|e| CliError::CommandFailed {
216 command: "serve".to_owned(),
217 reason: format!("failed to create snapshot: {e}"),
218 })?;
219
220 branch_repo
222 .switch_branch(&detected_id)
223 .map_err(|e| CliError::CommandFailed {
224 command: "serve".to_owned(),
225 reason: format!("failed to switch branch: {e}"),
226 })?;
227
228 Ok(detected_id)
229}
230
231#[allow(clippy::too_many_arguments)]
237fn background_sync(
238 project_root: &Path,
239 sync_root: &Path,
240 old_branch: Option<&str>,
241 new_branch: &str,
242 db: &Database,
243 branch_id: &BranchId,
244 scan_config: &ScanConfig,
245 detection_config: &seshat_core::DetectionConfig,
246) {
247 incremental_sync_blocking(
248 project_root,
249 sync_root,
250 old_branch,
251 new_branch,
252 db,
253 branch_id,
254 scan_config,
255 detection_config,
256 None,
257 );
258}
259
260#[allow(clippy::too_many_arguments)]
289pub(crate) fn incremental_sync_blocking(
290 project_root: &Path,
291 sync_root: &Path,
292 old_branch: Option<&str>,
293 new_branch: &str,
294 db: &Database,
295 branch_id: &BranchId,
296 scan_config: &ScanConfig,
297 detection_config: &seshat_core::DetectionConfig,
298 progress: Option<&dyn Fn(usize, usize)>,
299) {
300 let new_paths = match resolve_branch_tree_paths(sync_root, new_branch) {
301 Some(p) => p,
302 None => {
303 tracing::warn!(
304 "incremental_sync_blocking: could not resolve new branch tree, falling back to full rescan"
305 );
306 fallback_rescan(project_root, db, branch_id, scan_config, detection_config);
307 return;
308 }
309 };
310
311 let old_paths = old_branch.and_then(|b| resolve_branch_tree_paths(sync_root, b));
312
313 let file_ir_repo = SqliteFileIRRepository::new(db.connection().clone());
314
315 let exclude_set = if scan_config.exclude_paths.is_empty() {
316 None
317 } else {
318 let mut builder = globset::GlobSetBuilder::new();
319 for p in &scan_config.exclude_paths {
320 match globset::Glob::new(p) {
321 Ok(g) => {
322 builder.add(g);
323 }
324 Err(e) => {
325 tracing::warn!(pattern = %p, error = %e, "incremental_sync_blocking: invalid exclude pattern");
326 }
327 }
328 }
329 match builder.build() {
330 Ok(set) => Some(set),
331 Err(e) => {
332 tracing::warn!(error = %e, "incremental_sync_blocking: failed to build exclude globset");
333 None
334 }
335 }
336 };
337
338 let total = new_paths.len();
339 let mut synced = 0usize;
340 let mut removed = 0usize;
341 let mut source_map: HashMap<PathBuf, String> = HashMap::with_capacity(total);
349
350 for (idx, (rel_path, oid)) in new_paths.iter().enumerate() {
351 if let Some(cb) = progress {
354 cb(idx, total);
355 }
356
357 let path_str = rel_path.as_str();
358 let abs_path = project_root.join(rel_path);
362 let stored_path = PathBuf::from(rel_path);
366
367 let ext = match abs_path.extension().and_then(|e| e.to_str()) {
368 Some(e) => e,
369 None => continue,
370 };
371 let language = match Language::from_extension(ext) {
372 Some(l) => l,
373 None => continue,
374 };
375
376 if let Some(ref exclude_set) = exclude_set {
377 if exclude_set.is_match(&abs_path) {
378 continue;
379 }
380 }
381
382 let max_bytes = scan_config.max_file_size_kb * 1024;
383 if max_bytes > 0 {
384 if let Ok(meta) = std::fs::metadata(&abs_path) {
385 if meta.len() > max_bytes {
386 continue;
387 }
388 }
389 }
390
391 let oid_unchanged = old_paths
396 .as_ref()
397 .is_some_and(|old| old.get(path_str) == Some(oid));
398
399 let (project_file, source) = match read_and_parse_file(
400 &abs_path,
401 &stored_path,
402 language,
403 &scan_config.local_packages,
404 ) {
405 Ok(pair) => pair,
406 Err(e) => {
407 tracing::warn!(path = %abs_path.display(), error = %e, "incremental_sync_blocking: cannot read file");
408 continue;
409 }
410 };
411
412 if !oid_unchanged {
413 if let Err(e) = file_ir_repo.upsert_with_symbol_index(branch_id, &project_file, None) {
419 tracing::error!(
420 path = %path_str,
421 error = %e,
422 "incremental_sync_blocking: upsert failed — symbol-index may be inconsistent for this file until next save",
423 );
424 }
425 synced += 1;
426 }
427 source_map.insert(stored_path, source);
430 }
431
432 if let Some(cb) = progress {
434 cb(total, total);
435 }
436
437 if let Some(ref old) = old_paths {
438 for rel_path in old.keys() {
439 if !new_paths.contains_key(rel_path.as_str()) {
440 let path_str = rel_path.as_str();
441 if let Err(e) = file_ir_repo.delete_with_symbol_index(branch_id, path_str) {
445 match &e {
446 seshat_storage::StorageError::NotFound { .. } => {}
447 _ => {
448 tracing::error!(
449 path = %path_str,
450 error = %e,
451 "incremental_sync_blocking: delete failed — orphan symbol-index rows may remain",
452 );
453 }
454 }
455 }
456 removed += 1;
457 }
458 }
459 }
460
461 tracing::info!(
462 synced = synced,
463 removed = removed,
464 new_total = new_paths.len(),
465 old_branch = ?old_branch,
466 new_branch = %new_branch,
467 "incremental_sync_blocking: completed diff-based sync"
468 );
469
470 if synced > 0 || removed > 0 {
475 let conn = db.connection().clone();
476 let file_dates = SqliteFileIRRepository::new(conn.clone())
477 .get_file_dates_by_branch(branch_id)
478 .unwrap_or_default()
479 .into_iter()
480 .collect::<HashMap<_, _>>();
481 match seshat_graph::run_detection_cycle(
482 &conn,
483 branch_id,
484 detection_config,
485 &file_dates,
486 &source_map,
487 ) {
488 Ok(_) => tracing::info!("incremental_sync_blocking: detection cycle complete"),
489 Err(e) => {
490 tracing::warn!(error = %e, "incremental_sync_blocking: detection cycle failed")
491 }
492 }
493 } else {
494 tracing::debug!("incremental_sync_blocking: no IR changes; skipping detection cycle");
495 }
496
497 let branch_repo = SqliteBranchRepository::new(db.connection().clone());
500 record_branch_scan_complete(&branch_repo, project_root, branch_id);
501}
502
503fn resolve_branch_tree_paths(
504 root: &Path,
505 branch_name: &str,
506) -> Option<HashMap<String, gix::ObjectId>> {
507 let git_root = crate::db::find_git_root(root)?;
508 let repo = gix::open(git_root).ok()?;
509
510 let object = {
511 let ref_name = format!("refs/heads/{branch_name}");
512 if let Some(id) = repo
513 .try_find_reference(&ref_name)
514 .ok()
515 .flatten()
516 .and_then(|r| r.into_fully_peeled_id().ok())
517 {
518 repo.find_object(id.detach()).ok()
519 } else {
520 gix::ObjectId::from_hex(branch_name.as_bytes())
521 .ok()
522 .and_then(|oid| repo.find_object(oid).ok())
523 }?
524 };
525
526 let tree = object.into_commit().tree().ok()?;
527
528 let mut recorder = gix::traverse::tree::Recorder::default();
529 tree.traverse().breadthfirst(&mut recorder).ok()?;
530
531 let mut paths = HashMap::new();
532 for entry in recorder.records {
533 if entry.mode.is_blob() {
534 paths.insert(entry.filepath.to_string(), entry.oid);
535 }
536 }
537 Some(paths)
538}
539
540fn fallback_rescan(
545 project_root: &Path,
546 db: &Database,
547 branch_id: &BranchId,
548 scan_config: &ScanConfig,
549 _detection_config: &seshat_core::DetectionConfig,
550) {
551 tracing::info!(root = %project_root.display(), "background_sync: falling back to full rescan");
552 if let Err(e) = scan_project(project_root, scan_config, db, branch_id.clone()) {
557 tracing::warn!(error = %e, "background_sync: full rescan scan_project failed");
558 }
559
560 let branch_repo = SqliteBranchRepository::new(db.connection().clone());
563 record_branch_scan_complete(&branch_repo, project_root, branch_id);
564}
565
566pub fn run_serve(
572 repo: Option<&Path>,
573 host: Option<String>,
574 port: Option<u16>,
575 call_log: Option<PathBuf>,
576) -> Result<(), CliError> {
577 let mut config = AppConfig::load().map_err(|e| CliError::CommandFailed {
579 command: "serve".to_owned(),
580 reason: format!("failed to load config: {e}"),
581 })?;
582
583 if let Some(h) = host {
585 config.server.host = h;
586 }
587 if let Some(p) = port {
588 config.server.port = p;
589 }
590
591 let target =
593 crate::db::resolve_serve_db_or_project_root(repo, &config.scan.additional_denylist_paths)?;
594
595 let (db_path, db, mut repo_info, scan_state, auto_scan_project_root, detected_branch) =
596 match target {
597 ServeTarget::ExistingDb {
598 db_path,
599 project_root,
600 } => {
601 let db = Database::open(&db_path).map_err(|e| CliError::CommandFailed {
602 command: "serve".to_owned(),
603 reason: format!("failed to open database: {e}"),
604 })?;
605 let detected = detect_branch(&project_root);
606 let repo_info = load_repo_info(&db, &db_path)?;
607 (
608 db_path,
609 db,
610 repo_info,
611 ScanState::not_needed(),
612 None,
613 detected,
614 )
615 }
616 ServeTarget::AutoScan {
617 project_root,
618 db_path,
619 } => {
620 let detected = detect_branch(&project_root);
622
623 let db = Database::open(&db_path).map_err(|e| CliError::CommandFailed {
625 command: "serve".to_owned(),
626 reason: format!("failed to create database: {e}"),
627 })?;
628 tracing::info!(
629 project_root = %project_root.display(),
630 db_path = %db_path.display(),
631 detected_branch = %detected,
632 "No existing DB found — starting auto-scan"
633 );
634
635 let scan_state = ScanState::in_progress();
638
639 let scan_config = config.scan.clone();
641 let auto_scan_limit = scan_config.auto_scan_limit;
642 match seshat_scanner::discover_files(&project_root, &scan_config) {
643 Ok(discovery_result) => {
644 let file_count = discovery_result.files.len();
645
646 if file_count > auto_scan_limit {
647 scan_state.mark_failed(format!(
648 "Project too large for auto-scan ({} files). Run: seshat scan --verbose",
649 file_count
650 ));
651 let repo_info = load_repo_info(&db, &db_path)?;
652 (db_path, db, repo_info, scan_state, None, detected)
653 } else {
654 let repo_info = load_repo_info(&db, &db_path)?;
655 (
656 db_path,
657 db,
658 repo_info,
659 scan_state,
660 Some(project_root),
661 detected,
662 )
663 }
664 }
665 Err(e) => {
666 scan_state.mark_failed(format!("auto-scan discovery failed: {e}"));
669 let repo_info = load_repo_info(&db, &db_path)?;
670 (db_path, db, repo_info, scan_state, None, detected)
671 }
672 }
673 }
674 };
675
676 let is_auto_scan = auto_scan_project_root.is_some();
678 let old_branch_for_sync = if is_auto_scan {
679 None
680 } else {
681 Some(repo_info.branch.0.clone())
682 };
683
684 let final_branch = if is_auto_scan {
685 handle_auto_scan_snapshot(&db, &detected_branch)?
686 } else {
687 handle_branch_switch(&db, &detected_branch, &repo_info.branch, is_auto_scan)?
688 };
689
690 repo_info.branch = final_branch.clone();
692
693 let sync_root = match &auto_scan_project_root {
698 Some(root) => root.clone(),
699 None => crate::db::sync_root_for(&std::env::current_dir().unwrap_or_default()),
700 };
701 let sync_project_root: PathBuf = match &auto_scan_project_root {
707 Some(root) => root.clone(),
708 None => std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
709 };
710
711 let head_change_hint: Option<String> = if is_auto_scan {
717 None
718 } else {
719 let branch_repo = SqliteBranchRepository::new(db.connection().clone());
720 match seshat_scanner::check_branch_freshness(
721 &branch_repo,
722 &sync_project_root,
723 &final_branch,
724 ) {
725 seshat_scanner::FreshnessCheck::UpToDate
726 | seshat_scanner::FreshnessCheck::GitUnavailable => None,
727 seshat_scanner::FreshnessCheck::Stale {
728 old_commit,
729 new_commit,
730 } => {
731 let old_short = old_commit
732 .as_deref()
733 .map(|c| c.chars().take(7).collect::<String>())
734 .unwrap_or_else(|| "(none)".to_owned());
735 let new_short: String = new_commit.chars().take(7).collect();
736 tracing::info!(
737 branch = %final_branch.0,
738 old_head = %old_short,
739 new_head = %new_short,
740 "serve: detected HEAD change since last scan — triggering background sync"
741 );
742 old_commit
743 }
744 }
745 };
746
747 let sync_in_progress = Arc::new(AtomicBool::new(false));
749 let switch_in_progress = Arc::new(AtomicBool::new(false));
751
752 let sync_old_branch = old_branch_for_sync.filter(|b| *b != final_branch.0);
754 let needs_sync = sync_old_branch.is_some() || head_change_hint.is_some();
755 let sync_old_hint: Option<String> =
762 sync_old_branch.clone().or_else(|| head_change_hint.clone());
763
764 if needs_sync {
765 let sync_root_clone = sync_root.clone();
766 let project_root_clone = sync_project_root.clone();
767 let sync_db_path = db_path.clone();
768 let sync_branch = final_branch.clone();
769 let sync_scan_config = config.scan.clone();
770 let sync_detection_config = config.detection.clone();
771 let sync_flag = sync_in_progress.clone();
772 std::thread::spawn(move || {
773 struct ClearOnDrop(Arc<AtomicBool>);
774 impl Drop for ClearOnDrop {
775 fn drop(&mut self) {
776 self.0.store(false, Ordering::Relaxed);
777 }
778 }
779 sync_flag.store(true, Ordering::Relaxed);
780 let _guard = ClearOnDrop(sync_flag);
781 let sync_db = match Database::open(&sync_db_path) {
782 Ok(d) => d,
783 Err(e) => {
784 tracing::error!(error = %e, "background_sync: failed to open DB");
785 return;
786 }
787 };
788 background_sync(
789 &project_root_clone,
790 &sync_root_clone,
791 sync_old_hint.as_deref(),
792 &sync_branch.0,
793 &sync_db,
794 &sync_branch,
795 &sync_scan_config,
796 &sync_detection_config,
797 );
798 });
799 }
800
801 let gc_repo_path = match &auto_scan_project_root {
805 Some(root) => root.clone(),
806 None => crate::db::sync_root_for(&std::env::current_dir().unwrap_or_default()),
807 };
808 if let Ok(deleted) = gc_branch_snapshots(&db, &gc_repo_path) {
809 if !deleted.is_empty() {
810 tracing::info!(
811 deleted_count = deleted.len(),
812 deleted_branches = ?deleted,
813 "Garbage collected orphan branch snapshots on startup"
814 );
815 }
816 }
817
818 let submodule_rows = load_submodule_rows(&db);
820 let submodules = open_submodule_connections(&submodule_rows, &repo_info.name);
821
822 let call_log_path = resolve_call_log_path(call_log, config.server.call_log.as_deref());
824
825 let embedding_provider: Option<Arc<dyn seshat_embedding::EmbeddingProvider>> =
827 config.embedding.as_ref().and_then(|emb_config| {
828 match seshat_embedding::create_provider(emb_config) {
829 Ok(provider) => {
830 tracing::info!("Embedding provider enabled: {emb_config}");
831 Some(Arc::from(provider))
832 }
833 Err(e) => {
834 tracing::warn!("Failed to create embedding provider: {e}");
835 eprintln!(" Warning: embedding provider unavailable: {e}");
836 None
837 }
838 }
839 });
840
841 let server_config = config.server.clone();
843 let _start = Instant::now();
844
845 let runtime = tokio::runtime::Runtime::new().map_err(|e| CliError::CommandFailed {
846 command: "serve".to_owned(),
847 reason: format!("failed to create tokio runtime: {e}"),
848 })?;
849
850 let root = ProjectConnection::new(
851 db.connection().clone(),
852 repo_info.name.clone(),
853 detected_branch.clone(),
854 );
855
856 let project_root = match &auto_scan_project_root {
862 Some(root) => root.clone(),
863 None => std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
864 };
865
866 let watcher_enabled = config.watcher.enabled;
867 let watcher_params = WatcherParams {
868 enabled: watcher_enabled,
869 debounce_ms: config.watcher.debounce_ms,
870 ignore_patterns: config.watcher.ignore_patterns.clone(),
871 warm_tier_interval_seconds: config.watcher.warm_tier_interval_seconds,
872 bulk_change_threshold: config.watcher.bulk_change_threshold,
873 };
874 let watcher_scan_config = config.scan.clone();
875 let watcher_detection_config = config.detection.clone();
876
877 let has_auto_scan = auto_scan_project_root.is_some();
878 let auto_scan_root = auto_scan_project_root.clone();
879
880 runtime
881 .block_on(async {
882 let scan_state_clone = scan_state.clone();
883
884 if let Some(scan_root) = auto_scan_root.clone() {
886 let scan_config = config.scan.clone();
887 let scan_db = db.clone();
888 let scan_branch = detected_branch.clone();
889 tokio::spawn(async move {
890 let branch = seshat_core::BranchId::from(scan_branch);
891 let result = tokio::task::spawn_blocking(move || {
892 scan_project(&scan_root, &scan_config, &scan_db, branch)
893 })
894 .await;
895 match result {
896 Ok(Ok(_scan_result)) => {
897 tracing::info!("Auto-scan completed successfully");
898 scan_state_clone.mark_complete();
899 }
900 Ok(Err(scan_err)) => {
901 tracing::error!("Auto-scan failed: {scan_err}");
902 scan_state_clone.mark_failed(scan_err.to_string());
903 }
904 Err(join_err) => {
905 tracing::error!("Auto-scan task panicked: {join_err}");
906 scan_state_clone.mark_failed(join_err.to_string());
907 }
908 }
909 });
910 }
911
912 let gc_db = db.clone();
914 let gc_repo_path = gc_repo_path.clone();
915 let (gc_shutdown_tx, mut gc_shutdown_rx) = oneshot::channel();
916 let gc_task = tokio::spawn(async move {
917 let mut interval = tokio::time::interval(std::time::Duration::from_secs(3600));
918 loop {
919 tokio::select! {
920 _ = interval.tick() => {
921 let db_clone = gc_db.clone();
922 let path_clone = gc_repo_path.clone();
923 match tokio::task::spawn_blocking(move || {
924 gc_branch_snapshots(&db_clone, &path_clone)
925 })
926 .await
927 {
928 Ok(Ok(deleted_list)) => {
929 if !deleted_list.is_empty() {
930 tracing::info!(
931 deleted_count = deleted_list.len(),
932 deleted_branches = ?deleted_list,
933 "Periodic branch snapshot garbage collection"
934 );
935 }
936 }
937 Ok(Err(e)) => {
938 tracing::error!(error = %e, "Periodic GC failed");
939 }
940 Err(join_err) => {
941 tracing::error!(error = %join_err, "Periodic GC task panicked");
942 }
943 }
944 }
945 _ = &mut gc_shutdown_rx => {
946 tracing::debug!("GC background task shutting down");
947 break;
948 }
949 }
950 }
951 });
952 let gc_handle = GcHandle {
953 shutdown_tx: gc_shutdown_tx,
954 task: gc_task,
955 };
956
957 let watcher_rx = if watcher_should_start(watcher_enabled, &scan_state) {
966 let (watcher_tx, watcher_rx) = tokio::sync::oneshot::channel();
967 let params = watcher_params;
968 let root = project_root.clone();
969 let db_p = db_path.clone();
970 let conn = db.connection().clone();
971 let branch = BranchId::from(detected_branch.as_str());
972 let wait_scan = scan_state.clone();
973
974 let on_branch_switch: Arc<dyn Fn() + Send + Sync + 'static> = {
975 let root_clone = project_root.clone();
976 let sync_root_clone = sync_root.clone();
977 let db_path_clone = db_path.clone();
978 let scan_cfg_clone = watcher_scan_config.clone();
979 let detect_cfg_clone = watcher_detection_config.clone();
980 let sync_flag = sync_in_progress.clone();
981 let switch_guard = switch_in_progress.clone();
982 Arc::new(move || {
983 if switch_guard
985 .compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed)
986 .is_err()
987 {
988 tracing::debug!("Branch switch already in progress — skipping duplicate event");
989 return;
990 }
991 let root = root_clone.clone();
992 let sync_root = sync_root_clone.clone();
993 let db_path = db_path_clone.clone();
994 let scan_cfg = scan_cfg_clone.clone();
995 let detect_cfg = detect_cfg_clone.clone();
996 let sync_flag = sync_flag.clone();
997 let switch_guard = switch_guard.clone();
998 std::thread::spawn(move || {
999 struct ClearOnDrop(Arc<AtomicBool>);
1000 impl Drop for ClearOnDrop {
1001 fn drop(&mut self) {
1002 self.0.store(false, Ordering::Relaxed);
1003 }
1004 }
1005 let _guard = ClearOnDrop(switch_guard);
1006 sync_flag.store(true, Ordering::Relaxed);
1007 let _flag_guard = ClearOnDrop(sync_flag);
1008 let start = Instant::now();
1009 let new_branch = detect_branch(&root);
1010 let db = match Database::open(&db_path) {
1011 Ok(d) => d,
1012 Err(e) => {
1013 tracing::error!(error = %e, "Failed to open DB for branch switch");
1014 return;
1015 }
1016 };
1017 let branch_repo = SqliteBranchRepository::new(db.connection().clone());
1018 let current_branch = branch_repo
1019 .get_current_branch()
1020 .map(|b| b.0.clone())
1021 .unwrap_or_else(|e| {
1022 tracing::debug!(error = %e, "Could not read current branch from DB, defaulting to 'main'");
1023 "main".to_string()
1024 });
1025
1026 tracing::info!(
1027 old_branch = %current_branch,
1028 new_branch = %new_branch,
1029 "Branch switch detected by watcher"
1030 );
1031 if new_branch == current_branch {
1032 tracing::debug!("Branch unchanged, no switch needed");
1033 return;
1034 }
1035 let new_id = BranchId::from(new_branch.as_str());
1036 let old_id = BranchId::from(current_branch.as_str());
1037
1038 let branches = match branch_repo.list_branches() {
1039 Ok(b) => b,
1040 Err(e) => {
1041 tracing::error!(error = %e, "Failed to list branches for switch");
1042 return;
1043 }
1044 };
1045 let snapshot_exists = branches.iter().any(|b| b.0 == new_branch);
1046 if snapshot_exists {
1047 match branch_repo.switch_branch(&new_id) {
1048 Ok(()) => {
1049 let elapsed = start.elapsed();
1050 tracing::info!(
1051 to = %new_branch,
1052 elapsed_ms = elapsed.as_millis(),
1053 "Branch switch completed (instant, snapshot existed)"
1054 );
1055 }
1056 Err(e) => {
1057 tracing::error!(error = %e, "Failed to switch branch");
1058 return;
1059 }
1060 }
1061 } else {
1062 tracing::info!(
1063 source = %current_branch,
1064 target = %new_branch,
1065 "No snapshot for target — creating"
1066 );
1067 match branch_repo.create_snapshot(&old_id, &new_id) {
1068 Ok(()) => {
1069 match branch_repo.switch_branch(&new_id) {
1070 Ok(()) => {
1071 let elapsed = start.elapsed();
1072 tracing::info!(
1073 to = %new_branch,
1074 elapsed_ms = elapsed.as_millis(),
1075 "Branch switch completed (snapshot created)"
1076 );
1077 }
1078 Err(e) => {
1079 tracing::error!(error = %e, "Failed to switch after snapshot");
1080 return;
1081 }
1082 }
1083 }
1084 Err(e) => {
1085 tracing::error!(error = %e, "Failed to create snapshot");
1086 return;
1087 }
1088 }
1089 }
1090
1091 let old_b = current_branch;
1092 background_sync(
1093 &root,
1094 &sync_root,
1095 Some(&old_b),
1096 &new_branch,
1097 &db,
1098 &new_id,
1099 &scan_cfg,
1100 &detect_cfg,
1101 );
1102 });
1103 })
1104 };
1105
1106 tokio::spawn(async move {
1107 wait_scan.wait_for_scan();
1110
1111 if let Some(msg) = wait_scan.error_message() {
1116 tracing::info!(
1117 error_message = %msg,
1118 "Auto-scan failed during watcher wait; not starting file watcher",
1119 );
1120 let _ = watcher_tx.send(Err(WatcherError::ScanFailed(msg)));
1121 return;
1122 }
1123
1124 let result = start_watcher(
1125 params,
1126 root,
1127 db_p,
1128 conn,
1129 branch,
1130 watcher_scan_config,
1131 watcher_detection_config,
1132 on_branch_switch,
1133 )
1134 .await;
1135 if let Err(ref e) = result {
1136 tracing::warn!(
1137 "File watcher failed to start: {e}. \
1138 Serving without incremental updates."
1139 );
1140 }
1141 let _ = watcher_tx.send(result);
1142 });
1143 Some(watcher_rx)
1144 } else {
1145 None
1146 };
1147
1148 let watcher_status: std::borrow::Cow<'_, str> = if !watcher_enabled {
1166 std::borrow::Cow::Borrowed("disabled")
1167 } else if let Some(msg) = scan_state.error_message() {
1168 debug_assert!(
1169 !has_auto_scan,
1170 "scan_state.error_message().is_some() should imply has_auto_scan=false \
1171 (the AutoScan failure branch sets auto_scan_project_root=None)"
1172 );
1173 std::borrow::Cow::Owned(format!("disabled (auto-scan failed: {msg})"))
1174 } else if has_auto_scan && !scan_state.auto_scanned() {
1175 std::borrow::Cow::Borrowed("starting (after scan)")
1176 } else {
1177 std::borrow::Cow::Borrowed("starting")
1178 };
1179 print_startup(
1180 &repo_info,
1181 &submodules,
1182 &config,
1183 call_log_path.as_deref(),
1184 &watcher_status,
1185 is_auto_scan,
1186 &detected_branch,
1187 );
1188
1189 let detached_head = final_branch.0.len() >= 7
1191 && final_branch.0.chars().all(|c| c.is_ascii_hexdigit());
1192
1193 let shutdown = async {
1194 tokio::signal::ctrl_c()
1195 .await
1196 .expect("failed to listen for Ctrl+C");
1197 eprintln!();
1198 eprintln!("Shutting down...");
1199 };
1200
1201 let result = seshat_mcp::start_stdio_with_shutdown(
1202 server_config,
1203 root,
1204 submodules,
1205 call_log_path,
1206 embedding_provider,
1207 scan_state,
1208 sync_in_progress.clone(),
1209 true,
1210 detached_head,
1211 project_root.clone(),
1212 shutdown,
1213 std::time::Duration::from_secs(5),
1214 )
1215 .await;
1216
1217 drop(gc_handle);
1219
1220 if let Some(mut rx) = watcher_rx {
1222 if let Ok(Ok(handle)) = rx.try_recv() {
1223 handle.shutdown().await;
1224 }
1225 }
1226
1227 result
1228 })
1229 .map_err(|e| CliError::CommandFailed {
1230 command: "serve".to_owned(),
1231 reason: format!("MCP server error: {e}"),
1232 })
1233}
1234
1235fn load_repo_info(db: &Database, db_path: &Path) -> Result<RepoInfo, CliError> {
1237 let name = db_path
1238 .file_stem()
1239 .map(|s| s.to_string_lossy().to_string())
1240 .unwrap_or_else(|| "unknown".to_owned());
1241
1242 let info = crate::db::load_project_info(db);
1243
1244 Ok(RepoInfo {
1245 name,
1246 db_path: db_path.to_path_buf(),
1247 branch: info.branch,
1248 file_count: info.file_count,
1249 convention_count: info.convention_count,
1250 })
1251}
1252
1253fn load_submodule_rows(db: &Database) -> Vec<SubmoduleRow> {
1258 let sub_repo = SqliteSubmoduleRepository::new(db.connection().clone());
1259 match sub_repo.list() {
1260 Ok(rows) => rows,
1261 Err(e) => {
1262 eprintln!(
1263 " Warning: could not read submodules table: {e}. Continuing without submodules."
1264 );
1265 Vec::new()
1266 }
1267 }
1268}
1269
1270fn open_submodule_connections(
1276 rows: &[SubmoduleRow],
1277 root_project_name: &str,
1278) -> HashMap<String, ProjectConnection> {
1279 let mut submodules = HashMap::new();
1280
1281 for row in rows {
1282 let db_path =
1283 match crate::db::resolve_submodule_db_path(root_project_name, &row.relative_path) {
1284 Ok(p) => p,
1285 Err(e) => {
1286 eprintln!(
1287 " Warning: could not resolve DB path for submodule '{}': {e}. Skipping.",
1288 row.relative_path
1289 );
1290 continue;
1291 }
1292 };
1293
1294 if !db_path.exists() {
1295 eprintln!(
1296 " Warning: submodule DB not found at '{}'. Skipping '{}'.",
1297 db_path.display(),
1298 row.relative_path
1299 );
1300 continue;
1301 }
1302
1303 let db = match Database::open(&db_path) {
1304 Ok(d) => d,
1305 Err(e) => {
1306 eprintln!(
1307 " Warning: failed to open submodule DB '{}': {e}. Skipping '{}'.",
1308 db_path.display(),
1309 row.relative_path
1310 );
1311 continue;
1312 }
1313 };
1314
1315 let branch_repo = SqliteBranchRepository::new(db.connection().clone());
1317 let branch = branch_repo.get_current_branch().unwrap_or_else(|_| {
1318 tracing::debug!("Could not detect submodule branch from DB, defaulting to 'main'");
1319 BranchId::from("main")
1320 });
1321
1322 let pc = ProjectConnection::new(
1323 db.connection().clone(),
1324 row.relative_path.clone(),
1325 branch.to_string(),
1326 );
1327
1328 submodules.insert(row.relative_path.clone(), pc);
1329 }
1330
1331 submodules
1332}
1333
1334fn print_startup(
1336 info: &RepoInfo,
1337 submodules: &HashMap<String, ProjectConnection>,
1338 config: &AppConfig,
1339 call_log_path: Option<&Path>,
1340 watcher_status: &str,
1341 auto_scanning: bool,
1342 detected_branch: &str,
1343) {
1344 eprintln!("seshat v{}", env!("CARGO_PKG_VERSION"));
1345 eprintln!();
1346 eprintln!(" Repo: {}", info.name);
1347 eprintln!(" Branch: {}", detected_branch);
1348 if auto_scanning {
1349 eprintln!(" Files: 0 (auto-scanning...)");
1350 } else {
1351 eprintln!(" Files: {}", info.file_count);
1352 }
1353 eprintln!(" Conventions: {}", info.convention_count);
1354 eprintln!(" Database: {}", info.db_path.display());
1355 eprintln!(" Watcher: {watcher_status}");
1356
1357 if submodules.is_empty() {
1358 eprintln!(" Submodules: none");
1359 } else {
1360 eprintln!(" Submodules: {}", submodules.len());
1361 let mut names: Vec<&String> = submodules.keys().collect();
1362 names.sort();
1363 for name in names {
1364 eprintln!(" - {name}");
1365 }
1366 }
1367
1368 if let Some(path) = call_log_path {
1369 eprintln!(" Call log: {}", path.display());
1370 }
1371
1372 eprintln!();
1373 eprintln!(
1374 " Transport: stdio ({}:{})",
1375 config.server.host, config.server.port
1376 );
1377 eprintln!();
1378 eprintln!("Ready. Waiting for MCP client connection...");
1379}
1380
1381#[cfg(test)]
1382mod tests {
1383 use super::*;
1384 use seshat_core::DetectionConfig;
1385 use std::collections::HashMap;
1386
1387 #[test]
1388 fn load_repo_info_empty_db() {
1389 let db = Database::open(":memory:").expect("in-memory db");
1391 let path = PathBuf::from("/tmp/test-seshat-project.db");
1392 let info = load_repo_info(&db, &path).expect("should succeed with empty db");
1393 assert_eq!(info.name, "test-seshat-project");
1394 assert_eq!(info.file_count, 0);
1395 assert_eq!(info.convention_count, 0);
1396 assert_eq!(info.branch, BranchId::from("main"));
1397 }
1398
1399 #[test]
1400 fn load_submodule_rows_empty_db() {
1401 let db = Database::open(":memory:").expect("in-memory db");
1402 let rows = load_submodule_rows(&db);
1403 assert!(rows.is_empty());
1404 }
1405
1406 #[test]
1407 fn load_submodule_rows_with_data() {
1408 use seshat_storage::{SqliteSubmoduleRepository, SubmoduleInput, SubmoduleRepository};
1409
1410 let db = Database::open(":memory:").expect("in-memory db");
1411 let sub_repo = SqliteSubmoduleRepository::new(db.connection().clone());
1412 sub_repo
1413 .insert(&SubmoduleInput {
1414 relative_path: "vendor/libfoo".to_string(),
1415 name: "libfoo".to_string(),
1416 db_path: "/data/seshat/repos/proj/vendor/libfoo.db".to_string(),
1417 commit_hash: Some("abc123".to_string()),
1418 })
1419 .expect("insert");
1420 sub_repo
1421 .insert(&SubmoduleInput {
1422 relative_path: "libs/core".to_string(),
1423 name: "core".to_string(),
1424 db_path: "/data/seshat/repos/proj/libs/core.db".to_string(),
1425 commit_hash: Some("def456".to_string()),
1426 })
1427 .expect("insert");
1428
1429 let rows = load_submodule_rows(&db);
1430 assert_eq!(rows.len(), 2);
1431 assert_eq!(rows[0].relative_path, "libs/core");
1433 assert_eq!(rows[1].relative_path, "vendor/libfoo");
1434 }
1435
1436 #[test]
1437 fn open_submodule_connections_empty_rows() {
1438 let submodules = open_submodule_connections(&[], "test-project");
1439 assert!(submodules.is_empty());
1440 }
1441
1442 #[test]
1443 fn open_submodule_connections_missing_db_skipped() {
1444 let project_name = "serve-test-missing-db";
1445
1446 let row = SubmoduleRow {
1447 id: 1,
1448 relative_path: "vendor/nonexistent".to_string(),
1449 name: "nonexistent".to_string(),
1450 db_path: "/no/such/path.db".to_string(),
1451 commit_hash: Some("abc123".to_string()),
1452 created_at: "2026-04-03T00:00:00".to_string(),
1453 updated_at: "2026-04-03T00:00:00".to_string(),
1454 };
1455
1456 let submodules = open_submodule_connections(&[row], project_name);
1457 assert!(submodules.is_empty());
1459
1460 if let Ok(repos) = crate::db::xdg_repos_dir() {
1462 let _ = std::fs::remove_dir_all(repos.join(project_name));
1463 }
1464 }
1465
1466 #[test]
1467 fn resolve_call_log_bare_flag_uses_default_path() {
1468 let result = resolve_call_log_path(Some(PathBuf::from("")), None);
1470 let path = result.expect("should resolve to default path");
1471 let normalized = path.to_string_lossy().replace('\\', "/");
1474 assert!(
1475 normalized.ends_with("seshat/call-log.jsonl"),
1476 "expected default path to end with seshat/call-log.jsonl, got {normalized}"
1477 );
1478 }
1479
1480 #[test]
1481 fn resolve_call_log_explicit_path() {
1482 let result = resolve_call_log_path(Some(PathBuf::from("/tmp/my-log.jsonl")), None);
1483 assert_eq!(result, Some(PathBuf::from("/tmp/my-log.jsonl")));
1484 }
1485
1486 #[test]
1487 fn resolve_call_log_from_config() {
1488 let result = resolve_call_log_path(None, Some("/config/path.jsonl"));
1489 assert_eq!(result, Some(PathBuf::from("/config/path.jsonl")));
1490 }
1491
1492 #[test]
1493 fn resolve_call_log_cli_overrides_config() {
1494 let result = resolve_call_log_path(
1495 Some(PathBuf::from("/cli/path.jsonl")),
1496 Some("/config/path.jsonl"),
1497 );
1498 assert_eq!(result, Some(PathBuf::from("/cli/path.jsonl")));
1499 }
1500
1501 #[test]
1502 fn resolve_call_log_disabled_when_no_flag_and_no_config() {
1503 let result = resolve_call_log_path(None, None);
1504 assert!(result.is_none());
1505 }
1506
1507 #[test]
1508 fn open_submodule_connections_with_real_dbs() {
1509 use std::fs;
1510
1511 let project_name = "serve-test-submod";
1512 let mount_path = "vendor/testlib";
1513
1514 let db_path =
1517 crate::db::resolve_submodule_db_path(project_name, mount_path).expect("resolve path");
1518
1519 struct Cleanup(PathBuf);
1521 impl Drop for Cleanup {
1522 fn drop(&mut self) {
1523 let _ = fs::remove_dir_all(&self.0);
1524 }
1525 }
1526 let repos_dir = crate::db::xdg_repos_dir().expect("xdg repos dir");
1527 let _guard = Cleanup(repos_dir.join(project_name));
1528
1529 let db = Database::open(&db_path).expect("create submodule DB");
1530 drop(db);
1531
1532 let row = SubmoduleRow {
1533 id: 1,
1534 relative_path: mount_path.to_string(),
1535 name: "testlib".to_string(),
1536 db_path: db_path.to_string_lossy().to_string(),
1537 commit_hash: Some("abc123".to_string()),
1538 created_at: "2026-04-03T00:00:00".to_string(),
1539 updated_at: "2026-04-03T00:00:00".to_string(),
1540 };
1541
1542 let submodules = open_submodule_connections(&[row], project_name);
1543 assert_eq!(submodules.len(), 1);
1544 assert!(submodules.contains_key(mount_path));
1545
1546 let pc = &submodules[mount_path];
1547 assert_eq!(pc.name, mount_path);
1548 assert_eq!(pc.branch, "main"); }
1551
1552 #[test]
1555 fn handle_auto_scan_snapshot_main_branch_no_op() {
1556 let db = Database::open(":memory:").expect("in-memory db");
1557 let result = handle_auto_scan_snapshot(&db, "main").expect("should succeed");
1558 assert_eq!(result, BranchId::from("main"));
1559 }
1560
1561 #[test]
1564 fn print_startup_does_not_panic() {
1565 let repos_dir = crate::db::xdg_repos_dir().expect("xdg repos dir");
1566 let _ = std::fs::create_dir_all(&repos_dir);
1567 let info = RepoInfo {
1568 name: "test-project".to_string(),
1569 db_path: PathBuf::from("/tmp/test.db"),
1570 file_count: 5,
1571 convention_count: 42,
1572 branch: BranchId::from("main"),
1573 };
1574 let config = AppConfig::load().unwrap_or_default();
1575 print_startup(
1576 &info,
1577 &HashMap::new(),
1578 &config,
1579 None,
1580 "running",
1581 false,
1582 "main",
1583 );
1584 }
1585
1586 #[test]
1589 fn repo_info_default_name_extraction() {
1590 let info = RepoInfo {
1591 name: "my-awesome-project".to_string(),
1592 db_path: PathBuf::from("/tmp/test.db"),
1593 file_count: 10,
1594 convention_count: 20,
1595 branch: BranchId::from("feat/bar"),
1596 };
1597 assert_eq!(info.name, "my-awesome-project");
1598 assert_eq!(info.file_count, 10);
1599 assert_eq!(info.convention_count, 20);
1600 assert_eq!(info.branch, BranchId::from("feat/bar"));
1601 }
1602
1603 #[test]
1606 fn fallback_rescan_empty_dir_handles_gracefully() {
1607 use tempfile::tempdir;
1608 let dir = tempdir().expect("tempdir");
1609 let db = Database::open(":memory:").expect("in-memory db");
1610 let branch = BranchId::from("main");
1611 fallback_rescan(
1613 dir.path(),
1614 &db,
1615 &branch,
1616 &ScanConfig::default(),
1617 &DetectionConfig::default(),
1618 );
1619 }
1620
1621 #[test]
1624 fn resolve_branch_tree_paths_not_a_git_repo_returns_none() {
1625 use tempfile::tempdir;
1626 let dir = tempdir().expect("tempdir");
1627 let result = resolve_branch_tree_paths(dir.path(), "main");
1628 assert!(result.is_none());
1629 }
1630
1631 fn seed_branch(db: &Database, branch_name: &str) -> BranchId {
1634 let branch = BranchId::from(branch_name);
1635 let br = SqliteBranchRepository::new(db.connection().clone());
1636 br.switch_branch(&branch).unwrap();
1637 let c = db.connection().lock().unwrap();
1639 c.execute(
1640 "INSERT INTO nodes (branch_id, nature, weight, confidence, adoption_count, total_count, description, ext_data)
1641 VALUES (?1, 'convention', 'strong', 0.9, 5, 10, 'test', '{\"source\":\"auto_detected\"}')",
1642 rusqlite::params![branch_name],
1643 ).unwrap();
1644 branch
1645 }
1646
1647 #[test]
1648 fn handle_branch_switch_same_branch_returns_current() {
1649 let db = Database::open(":memory:").expect("in-memory db");
1650 let current = BranchId::from("main");
1651 let result = handle_branch_switch(&db, "main", ¤t, false).unwrap();
1652 assert_eq!(result, current);
1653 }
1654
1655 #[test]
1656 fn handle_branch_switch_target_has_data_no_snapshot() {
1657 let db = Database::open(":memory:").expect("in-memory db");
1658 let current = BranchId::from("main");
1659 seed_branch(&db, "feat/test");
1660 let result = handle_branch_switch(&db, "feat/test", ¤t, false).unwrap();
1661 assert_eq!(result, BranchId::from("feat/test"));
1662 }
1663
1664 #[test]
1665 fn handle_branch_switch_source_no_data_still_switches() {
1666 let db = Database::open(":memory:").expect("in-memory db");
1667 let current = BranchId::from("main");
1668 let result = handle_branch_switch(&db, "feat/empty", ¤t, false).unwrap();
1669 assert_eq!(result, BranchId::from("feat/empty"));
1670 }
1671
1672 #[test]
1673 fn handle_branch_switch_source_has_data_creates_snapshot() {
1674 let db = Database::open(":memory:").expect("in-memory db");
1675 let current = BranchId::from("main");
1676 seed_branch(&db, "main");
1677 let result = handle_branch_switch(&db, "feat/snap", ¤t, false).unwrap();
1678 assert_eq!(result, BranchId::from("feat/snap"));
1679 let br = SqliteBranchRepository::new(db.connection().clone());
1681 let branches = br.list_branches().unwrap();
1682 assert!(branches.iter().any(|b| b.0 == "feat/snap"));
1683 }
1684
1685 #[test]
1688 fn auto_scan_snapshot_non_main_no_main_data_still_switches() {
1689 let db = Database::open(":memory:").expect("in-memory db");
1690 let result = handle_auto_scan_snapshot(&db, "feat/bar").unwrap();
1691 assert_eq!(result, BranchId::from("feat/bar"));
1692 }
1693
1694 #[test]
1695 fn auto_scan_snapshot_non_main_with_main_data_creates_snapshot() {
1696 let db = Database::open(":memory:").expect("in-memory db");
1697 seed_branch(&db, "main");
1698 let result = handle_auto_scan_snapshot(&db, "feat/baz").unwrap();
1699 assert_eq!(result, BranchId::from("feat/baz"));
1700 let br = SqliteBranchRepository::new(db.connection().clone());
1701 let branches = br.list_branches().unwrap();
1702 assert!(branches.iter().any(|b| b.0 == "feat/baz"));
1703 }
1704
1705 #[test]
1708 fn watcher_should_start_disabled_returns_false_regardless_of_scan_state() {
1709 let state_ok = ScanState::not_needed();
1711 assert!(!watcher_should_start(false, &state_ok));
1712
1713 let state_complete = ScanState::in_progress();
1714 state_complete.mark_complete();
1715 assert!(!watcher_should_start(false, &state_complete));
1716 }
1717
1718 #[test]
1719 fn watcher_should_start_enabled_with_no_scan_returns_true() {
1720 let state = ScanState::not_needed();
1723 assert!(watcher_should_start(true, &state));
1724 }
1725
1726 #[test]
1727 fn watcher_should_start_enabled_with_completed_scan_returns_true() {
1728 let state = ScanState::in_progress();
1730 state.mark_complete();
1731 assert!(watcher_should_start(true, &state));
1732 }
1733
1734 #[test]
1735 fn watcher_should_start_enabled_with_in_progress_scan_returns_true() {
1736 let state = ScanState::in_progress();
1740 assert!(watcher_should_start(true, &state));
1741 }
1742
1743 #[test]
1744 fn watcher_should_start_enabled_with_failed_scan_returns_false() {
1745 let state = ScanState::in_progress();
1748 state.mark_failed("project too large".to_owned());
1749 assert!(!watcher_should_start(true, &state));
1750 }
1751
1752 #[test]
1753 fn watcher_should_start_disabled_with_failed_scan_returns_false() {
1754 let state = ScanState::in_progress();
1756 state.mark_failed("scan timeout".to_owned());
1757 assert!(!watcher_should_start(false, &state));
1758 }
1759
1760 #[test]
1773 fn race_guard_pattern_detects_pre_wait_failure() {
1774 let state = ScanState::in_progress();
1777 state.mark_failed("simulated pre-wait failure".to_owned());
1778 state.wait_for_scan(); assert_eq!(
1780 state.error_message(),
1781 Some("simulated pre-wait failure".to_owned())
1782 );
1783 }
1784
1785 #[test]
1786 fn race_guard_pattern_returns_none_for_normal_completion() {
1787 let state = ScanState::in_progress();
1788 state.mark_complete();
1789 state.wait_for_scan();
1790 assert_eq!(state.error_message(), None);
1791 }
1792
1793 #[test]
1794 fn race_guard_pattern_observes_failure_set_during_wait() {
1795 use std::sync::Arc;
1799 use std::thread;
1800 use std::time::Duration;
1801
1802 let state = ScanState::in_progress();
1803 let waiter_state = state.clone();
1804 let observed: Arc<std::sync::Mutex<Option<String>>> = Arc::new(std::sync::Mutex::new(None));
1805 let observed_for_thread = Arc::clone(&observed);
1806 let waiter = thread::spawn(move || {
1807 waiter_state.wait_for_scan();
1808 *observed_for_thread.lock().expect("lock") = waiter_state.error_message();
1809 });
1810
1811 thread::sleep(Duration::from_millis(50));
1815 state.mark_failed("simulated late failure".to_owned());
1816
1817 waiter.join().expect("waiter thread join");
1818 let captured = observed.lock().expect("lock").clone();
1819 assert_eq!(captured, Some("simulated late failure".to_owned()));
1820 }
1821}