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
231fn background_sync(
237 root: &Path,
238 old_branch: Option<&str>,
239 new_branch: &str,
240 db: &Database,
241 branch_id: &BranchId,
242 scan_config: &ScanConfig,
243 detection_config: &seshat_core::DetectionConfig,
244) {
245 incremental_sync_blocking(
246 root,
247 old_branch,
248 new_branch,
249 db,
250 branch_id,
251 scan_config,
252 detection_config,
253 None,
254 );
255}
256
257#[allow(clippy::too_many_arguments)]
276pub(crate) fn incremental_sync_blocking(
277 root: &Path,
278 old_branch: Option<&str>,
279 new_branch: &str,
280 db: &Database,
281 branch_id: &BranchId,
282 scan_config: &ScanConfig,
283 detection_config: &seshat_core::DetectionConfig,
284 progress: Option<&dyn Fn(usize, usize)>,
285) {
286 let new_paths = match resolve_branch_tree_paths(root, new_branch) {
287 Some(p) => p,
288 None => {
289 tracing::warn!(
290 "incremental_sync_blocking: could not resolve new branch tree, falling back to full rescan"
291 );
292 fallback_rescan(root, db, branch_id, scan_config, detection_config);
293 return;
294 }
295 };
296
297 let old_paths = old_branch.and_then(|b| resolve_branch_tree_paths(root, b));
298
299 let file_ir_repo = SqliteFileIRRepository::new(db.connection().clone());
300
301 let exclude_set = if scan_config.exclude_paths.is_empty() {
302 None
303 } else {
304 let mut builder = globset::GlobSetBuilder::new();
305 for p in &scan_config.exclude_paths {
306 match globset::Glob::new(p) {
307 Ok(g) => {
308 builder.add(g);
309 }
310 Err(e) => {
311 tracing::warn!(pattern = %p, error = %e, "incremental_sync_blocking: invalid exclude pattern");
312 }
313 }
314 }
315 match builder.build() {
316 Ok(set) => Some(set),
317 Err(e) => {
318 tracing::warn!(error = %e, "incremental_sync_blocking: failed to build exclude globset");
319 None
320 }
321 }
322 };
323
324 let total = new_paths.len();
325 let mut synced = 0usize;
326 let mut removed = 0usize;
327 let mut source_map: HashMap<PathBuf, String> = HashMap::with_capacity(total);
335
336 for (idx, (rel_path, oid)) in new_paths.iter().enumerate() {
337 if let Some(cb) = progress {
340 cb(idx, total);
341 }
342
343 let path_str = rel_path.as_str();
344 let abs_path = root.join(rel_path);
345 let stored_path = PathBuf::from(rel_path);
349
350 let ext = match abs_path.extension().and_then(|e| e.to_str()) {
351 Some(e) => e,
352 None => continue,
353 };
354 let language = match Language::from_extension(ext) {
355 Some(l) => l,
356 None => continue,
357 };
358
359 if let Some(ref exclude_set) = exclude_set {
360 if exclude_set.is_match(&abs_path) {
361 continue;
362 }
363 }
364
365 let max_bytes = scan_config.max_file_size_kb * 1024;
366 if max_bytes > 0 {
367 if let Ok(meta) = std::fs::metadata(&abs_path) {
368 if meta.len() > max_bytes {
369 continue;
370 }
371 }
372 }
373
374 let oid_unchanged = old_paths
379 .as_ref()
380 .is_some_and(|old| old.get(path_str) == Some(oid));
381
382 let (project_file, source) = match read_and_parse_file(
383 &abs_path,
384 &stored_path,
385 language,
386 &scan_config.local_packages,
387 ) {
388 Ok(pair) => pair,
389 Err(e) => {
390 tracing::warn!(path = %abs_path.display(), error = %e, "incremental_sync_blocking: cannot read file");
391 continue;
392 }
393 };
394
395 if !oid_unchanged {
396 if let Err(e) = file_ir_repo.upsert(branch_id, &project_file, None) {
397 tracing::warn!(path = %path_str, error = %e, "incremental_sync_blocking: upsert failed");
398 }
399 synced += 1;
400 }
401 source_map.insert(stored_path, source);
404 }
405
406 if let Some(cb) = progress {
408 cb(total, total);
409 }
410
411 if let Some(ref old) = old_paths {
412 for rel_path in old.keys() {
413 if !new_paths.contains_key(rel_path.as_str()) {
414 let path_str = rel_path.as_str();
415 if let Err(e) = file_ir_repo.delete_by_path(branch_id, path_str) {
416 match &e {
417 seshat_storage::StorageError::NotFound { .. } => {}
418 _ => {
419 tracing::warn!(path = %path_str, error = %e, "incremental_sync_blocking: delete failed")
420 }
421 }
422 }
423 removed += 1;
424 }
425 }
426 }
427
428 tracing::info!(
429 synced = synced,
430 removed = removed,
431 new_total = new_paths.len(),
432 old_branch = ?old_branch,
433 new_branch = %new_branch,
434 "incremental_sync_blocking: completed diff-based sync"
435 );
436
437 if synced > 0 || removed > 0 {
442 let conn = db.connection().clone();
443 let file_dates = SqliteFileIRRepository::new(conn.clone())
444 .get_file_dates_by_branch(branch_id)
445 .unwrap_or_default()
446 .into_iter()
447 .collect::<HashMap<_, _>>();
448 match seshat_graph::run_detection_cycle(
449 &conn,
450 branch_id,
451 detection_config,
452 &file_dates,
453 &source_map,
454 ) {
455 Ok(_) => tracing::info!("incremental_sync_blocking: detection cycle complete"),
456 Err(e) => {
457 tracing::warn!(error = %e, "incremental_sync_blocking: detection cycle failed")
458 }
459 }
460 } else {
461 tracing::debug!("incremental_sync_blocking: no IR changes; skipping detection cycle");
462 }
463
464 let branch_repo = SqliteBranchRepository::new(db.connection().clone());
467 record_branch_scan_complete(&branch_repo, root, branch_id);
468}
469
470fn resolve_branch_tree_paths(
471 root: &Path,
472 branch_name: &str,
473) -> Option<HashMap<String, gix::ObjectId>> {
474 let git_root = crate::db::find_git_root(root)?;
475 let repo = gix::open(git_root).ok()?;
476
477 let object = {
478 let ref_name = format!("refs/heads/{branch_name}");
479 if let Some(id) = repo
480 .try_find_reference(&ref_name)
481 .ok()
482 .flatten()
483 .and_then(|r| r.into_fully_peeled_id().ok())
484 {
485 repo.find_object(id.detach()).ok()
486 } else {
487 gix::ObjectId::from_hex(branch_name.as_bytes())
488 .ok()
489 .and_then(|oid| repo.find_object(oid).ok())
490 }?
491 };
492
493 let tree = object.into_commit().tree().ok()?;
494
495 let mut recorder = gix::traverse::tree::Recorder::default();
496 tree.traverse().breadthfirst(&mut recorder).ok()?;
497
498 let mut paths = HashMap::new();
499 for entry in recorder.records {
500 if entry.mode.is_blob() {
501 paths.insert(entry.filepath.to_string(), entry.oid);
502 }
503 }
504 Some(paths)
505}
506
507fn fallback_rescan(
508 root: &Path,
509 db: &Database,
510 branch_id: &BranchId,
511 scan_config: &ScanConfig,
512 _detection_config: &seshat_core::DetectionConfig,
513) {
514 tracing::info!(root = %root.display(), "background_sync: falling back to full rescan");
515 if let Err(e) = scan_project(root, scan_config, db, branch_id.clone()) {
520 tracing::warn!(error = %e, "background_sync: full rescan scan_project failed");
521 }
522
523 let branch_repo = SqliteBranchRepository::new(db.connection().clone());
526 record_branch_scan_complete(&branch_repo, root, branch_id);
527}
528
529pub fn run_serve(
535 repo: Option<&Path>,
536 host: Option<String>,
537 port: Option<u16>,
538 call_log: Option<PathBuf>,
539) -> Result<(), CliError> {
540 let mut config = AppConfig::load().map_err(|e| CliError::CommandFailed {
542 command: "serve".to_owned(),
543 reason: format!("failed to load config: {e}"),
544 })?;
545
546 if let Some(h) = host {
548 config.server.host = h;
549 }
550 if let Some(p) = port {
551 config.server.port = p;
552 }
553
554 let target =
556 crate::db::resolve_serve_db_or_project_root(repo, &config.scan.additional_denylist_paths)?;
557
558 let (db_path, db, mut repo_info, scan_state, auto_scan_project_root, detected_branch) =
559 match target {
560 ServeTarget::ExistingDb {
561 db_path,
562 project_root,
563 } => {
564 let db = Database::open(&db_path).map_err(|e| CliError::CommandFailed {
565 command: "serve".to_owned(),
566 reason: format!("failed to open database: {e}"),
567 })?;
568 let detected = detect_branch(&project_root);
569 let repo_info = load_repo_info(&db, &db_path)?;
570 (
571 db_path,
572 db,
573 repo_info,
574 ScanState::not_needed(),
575 None,
576 detected,
577 )
578 }
579 ServeTarget::AutoScan {
580 project_root,
581 db_path,
582 } => {
583 let detected = detect_branch(&project_root);
585
586 let db = Database::open(&db_path).map_err(|e| CliError::CommandFailed {
588 command: "serve".to_owned(),
589 reason: format!("failed to create database: {e}"),
590 })?;
591 tracing::info!(
592 project_root = %project_root.display(),
593 db_path = %db_path.display(),
594 detected_branch = %detected,
595 "No existing DB found — starting auto-scan"
596 );
597
598 let scan_state = ScanState::in_progress();
601
602 let scan_config = config.scan.clone();
604 let auto_scan_limit = scan_config.auto_scan_limit;
605 match seshat_scanner::discover_files(&project_root, &scan_config) {
606 Ok(discovery_result) => {
607 let file_count = discovery_result.files.len();
608
609 if file_count > auto_scan_limit {
610 scan_state.mark_failed(format!(
611 "Project too large for auto-scan ({} files). Run: seshat scan --verbose",
612 file_count
613 ));
614 let repo_info = load_repo_info(&db, &db_path)?;
615 (db_path, db, repo_info, scan_state, None, detected)
616 } else {
617 let repo_info = load_repo_info(&db, &db_path)?;
618 (
619 db_path,
620 db,
621 repo_info,
622 scan_state,
623 Some(project_root),
624 detected,
625 )
626 }
627 }
628 Err(e) => {
629 scan_state.mark_failed(format!("auto-scan discovery failed: {e}"));
632 let repo_info = load_repo_info(&db, &db_path)?;
633 (db_path, db, repo_info, scan_state, None, detected)
634 }
635 }
636 }
637 };
638
639 let is_auto_scan = auto_scan_project_root.is_some();
641 let old_branch_for_sync = if is_auto_scan {
642 None
643 } else {
644 Some(repo_info.branch.0.clone())
645 };
646
647 let final_branch = if is_auto_scan {
648 handle_auto_scan_snapshot(&db, &detected_branch)?
649 } else {
650 handle_branch_switch(&db, &detected_branch, &repo_info.branch, is_auto_scan)?
651 };
652
653 repo_info.branch = final_branch.clone();
655
656 let sync_root = match &auto_scan_project_root {
661 Some(root) => root.clone(),
662 None => crate::db::sync_root_for(&std::env::current_dir().unwrap_or_default()),
663 };
664
665 let head_change_hint: Option<String> = if is_auto_scan {
671 None
672 } else {
673 let branch_repo = SqliteBranchRepository::new(db.connection().clone());
674 match seshat_scanner::check_branch_freshness(&branch_repo, &sync_root, &final_branch) {
675 seshat_scanner::FreshnessCheck::UpToDate
676 | seshat_scanner::FreshnessCheck::GitUnavailable => None,
677 seshat_scanner::FreshnessCheck::Stale {
678 old_commit,
679 new_commit,
680 } => {
681 let old_short = old_commit
682 .as_deref()
683 .map(|c| c.chars().take(7).collect::<String>())
684 .unwrap_or_else(|| "(none)".to_owned());
685 let new_short: String = new_commit.chars().take(7).collect();
686 tracing::info!(
687 branch = %final_branch.0,
688 old_head = %old_short,
689 new_head = %new_short,
690 "serve: detected HEAD change since last scan — triggering background sync"
691 );
692 old_commit
693 }
694 }
695 };
696
697 let sync_in_progress = Arc::new(AtomicBool::new(false));
699 let switch_in_progress = Arc::new(AtomicBool::new(false));
701
702 let sync_old_branch = old_branch_for_sync.filter(|b| *b != final_branch.0);
704 let needs_sync = sync_old_branch.is_some() || head_change_hint.is_some();
705 let sync_old_hint: Option<String> =
712 sync_old_branch.clone().or_else(|| head_change_hint.clone());
713
714 if needs_sync {
715 let sync_root_clone = sync_root.clone();
716 let sync_db_path = db_path.clone();
717 let sync_branch = final_branch.clone();
718 let sync_scan_config = config.scan.clone();
719 let sync_detection_config = config.detection.clone();
720 let sync_flag = sync_in_progress.clone();
721 std::thread::spawn(move || {
722 struct ClearOnDrop(Arc<AtomicBool>);
723 impl Drop for ClearOnDrop {
724 fn drop(&mut self) {
725 self.0.store(false, Ordering::Relaxed);
726 }
727 }
728 sync_flag.store(true, Ordering::Relaxed);
729 let _guard = ClearOnDrop(sync_flag);
730 let sync_db = match Database::open(&sync_db_path) {
731 Ok(d) => d,
732 Err(e) => {
733 tracing::error!(error = %e, "background_sync: failed to open DB");
734 return;
735 }
736 };
737 background_sync(
738 &sync_root_clone,
739 sync_old_hint.as_deref(),
740 &sync_branch.0,
741 &sync_db,
742 &sync_branch,
743 &sync_scan_config,
744 &sync_detection_config,
745 );
746 });
747 }
748
749 let gc_repo_path = match &auto_scan_project_root {
753 Some(root) => root.clone(),
754 None => crate::db::sync_root_for(&std::env::current_dir().unwrap_or_default()),
755 };
756 if let Ok(deleted) = gc_branch_snapshots(&db, &gc_repo_path) {
757 if !deleted.is_empty() {
758 tracing::info!(
759 deleted_count = deleted.len(),
760 deleted_branches = ?deleted,
761 "Garbage collected orphan branch snapshots on startup"
762 );
763 }
764 }
765
766 let submodule_rows = load_submodule_rows(&db);
768 let submodules = open_submodule_connections(&submodule_rows, &repo_info.name);
769
770 let call_log_path = resolve_call_log_path(call_log, config.server.call_log.as_deref());
772
773 let embedding_provider: Option<Arc<dyn seshat_embedding::EmbeddingProvider>> =
775 config.embedding.as_ref().and_then(|emb_config| {
776 match seshat_embedding::create_provider(emb_config) {
777 Ok(provider) => {
778 tracing::info!("Embedding provider enabled: {emb_config}");
779 Some(Arc::from(provider))
780 }
781 Err(e) => {
782 tracing::warn!("Failed to create embedding provider: {e}");
783 eprintln!(" Warning: embedding provider unavailable: {e}");
784 None
785 }
786 }
787 });
788
789 let server_config = config.server.clone();
791 let _start = Instant::now();
792
793 let runtime = tokio::runtime::Runtime::new().map_err(|e| CliError::CommandFailed {
794 command: "serve".to_owned(),
795 reason: format!("failed to create tokio runtime: {e}"),
796 })?;
797
798 let root = ProjectConnection::new(
799 db.connection().clone(),
800 repo_info.name.clone(),
801 detected_branch.clone(),
802 );
803
804 let project_root = match &auto_scan_project_root {
810 Some(root) => root.clone(),
811 None => std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
812 };
813
814 let watcher_enabled = config.watcher.enabled;
815 let watcher_params = WatcherParams {
816 enabled: watcher_enabled,
817 debounce_ms: config.watcher.debounce_ms,
818 ignore_patterns: config.watcher.ignore_patterns.clone(),
819 warm_tier_interval_seconds: config.watcher.warm_tier_interval_seconds,
820 bulk_change_threshold: config.watcher.bulk_change_threshold,
821 };
822 let watcher_scan_config = config.scan.clone();
823 let watcher_detection_config = config.detection.clone();
824
825 let has_auto_scan = auto_scan_project_root.is_some();
826 let auto_scan_root = auto_scan_project_root.clone();
827
828 runtime
829 .block_on(async {
830 let scan_state_clone = scan_state.clone();
831
832 if let Some(scan_root) = auto_scan_root.clone() {
834 let scan_config = config.scan.clone();
835 let scan_db = db.clone();
836 let scan_branch = detected_branch.clone();
837 tokio::spawn(async move {
838 let branch = seshat_core::BranchId::from(scan_branch);
839 let result = tokio::task::spawn_blocking(move || {
840 scan_project(&scan_root, &scan_config, &scan_db, branch)
841 })
842 .await;
843 match result {
844 Ok(Ok(_scan_result)) => {
845 tracing::info!("Auto-scan completed successfully");
846 scan_state_clone.mark_complete();
847 }
848 Ok(Err(scan_err)) => {
849 tracing::error!("Auto-scan failed: {scan_err}");
850 scan_state_clone.mark_failed(scan_err.to_string());
851 }
852 Err(join_err) => {
853 tracing::error!("Auto-scan task panicked: {join_err}");
854 scan_state_clone.mark_failed(join_err.to_string());
855 }
856 }
857 });
858 }
859
860 let gc_db = db.clone();
862 let gc_repo_path = gc_repo_path.clone();
863 let (gc_shutdown_tx, mut gc_shutdown_rx) = oneshot::channel();
864 let gc_task = tokio::spawn(async move {
865 let mut interval = tokio::time::interval(std::time::Duration::from_secs(3600));
866 loop {
867 tokio::select! {
868 _ = interval.tick() => {
869 let db_clone = gc_db.clone();
870 let path_clone = gc_repo_path.clone();
871 match tokio::task::spawn_blocking(move || {
872 gc_branch_snapshots(&db_clone, &path_clone)
873 })
874 .await
875 {
876 Ok(Ok(deleted_list)) => {
877 if !deleted_list.is_empty() {
878 tracing::info!(
879 deleted_count = deleted_list.len(),
880 deleted_branches = ?deleted_list,
881 "Periodic branch snapshot garbage collection"
882 );
883 }
884 }
885 Ok(Err(e)) => {
886 tracing::error!(error = %e, "Periodic GC failed");
887 }
888 Err(join_err) => {
889 tracing::error!(error = %join_err, "Periodic GC task panicked");
890 }
891 }
892 }
893 _ = &mut gc_shutdown_rx => {
894 tracing::debug!("GC background task shutting down");
895 break;
896 }
897 }
898 }
899 });
900 let gc_handle = GcHandle {
901 shutdown_tx: gc_shutdown_tx,
902 task: gc_task,
903 };
904
905 let watcher_rx = if watcher_should_start(watcher_enabled, &scan_state) {
914 let (watcher_tx, watcher_rx) = tokio::sync::oneshot::channel();
915 let params = watcher_params;
916 let root = project_root.clone();
917 let db_p = db_path.clone();
918 let conn = db.connection().clone();
919 let branch = BranchId::from(detected_branch.as_str());
920 let wait_scan = scan_state.clone();
921
922 let on_branch_switch: Arc<dyn Fn() + Send + Sync + 'static> = {
923 let root_clone = project_root.clone();
924 let db_path_clone = db_path.clone();
925 let scan_cfg_clone = watcher_scan_config.clone();
926 let detect_cfg_clone = watcher_detection_config.clone();
927 let sync_flag = sync_in_progress.clone();
928 let switch_guard = switch_in_progress.clone();
929 Arc::new(move || {
930 if switch_guard
932 .compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed)
933 .is_err()
934 {
935 tracing::debug!("Branch switch already in progress — skipping duplicate event");
936 return;
937 }
938 let root = root_clone.clone();
939 let db_path = db_path_clone.clone();
940 let scan_cfg = scan_cfg_clone.clone();
941 let detect_cfg = detect_cfg_clone.clone();
942 let sync_flag = sync_flag.clone();
943 let switch_guard = switch_guard.clone();
944 std::thread::spawn(move || {
945 struct ClearOnDrop(Arc<AtomicBool>);
946 impl Drop for ClearOnDrop {
947 fn drop(&mut self) {
948 self.0.store(false, Ordering::Relaxed);
949 }
950 }
951 let _guard = ClearOnDrop(switch_guard);
952 sync_flag.store(true, Ordering::Relaxed);
953 let _flag_guard = ClearOnDrop(sync_flag);
954 let start = Instant::now();
955 let new_branch = detect_branch(&root);
956 let db = match Database::open(&db_path) {
957 Ok(d) => d,
958 Err(e) => {
959 tracing::error!(error = %e, "Failed to open DB for branch switch");
960 return;
961 }
962 };
963 let branch_repo = SqliteBranchRepository::new(db.connection().clone());
964 let current_branch = branch_repo
965 .get_current_branch()
966 .map(|b| b.0.clone())
967 .unwrap_or_else(|e| {
968 tracing::debug!(error = %e, "Could not read current branch from DB, defaulting to 'main'");
969 "main".to_string()
970 });
971
972 tracing::info!(
973 old_branch = %current_branch,
974 new_branch = %new_branch,
975 "Branch switch detected by watcher"
976 );
977 if new_branch == current_branch {
978 tracing::debug!("Branch unchanged, no switch needed");
979 return;
980 }
981 let new_id = BranchId::from(new_branch.as_str());
982 let old_id = BranchId::from(current_branch.as_str());
983
984 let branches = match branch_repo.list_branches() {
985 Ok(b) => b,
986 Err(e) => {
987 tracing::error!(error = %e, "Failed to list branches for switch");
988 return;
989 }
990 };
991 let snapshot_exists = branches.iter().any(|b| b.0 == new_branch);
992 if snapshot_exists {
993 match branch_repo.switch_branch(&new_id) {
994 Ok(()) => {
995 let elapsed = start.elapsed();
996 tracing::info!(
997 to = %new_branch,
998 elapsed_ms = elapsed.as_millis(),
999 "Branch switch completed (instant, snapshot existed)"
1000 );
1001 }
1002 Err(e) => {
1003 tracing::error!(error = %e, "Failed to switch branch");
1004 return;
1005 }
1006 }
1007 } else {
1008 tracing::info!(
1009 source = %current_branch,
1010 target = %new_branch,
1011 "No snapshot for target — creating"
1012 );
1013 match branch_repo.create_snapshot(&old_id, &new_id) {
1014 Ok(()) => {
1015 match branch_repo.switch_branch(&new_id) {
1016 Ok(()) => {
1017 let elapsed = start.elapsed();
1018 tracing::info!(
1019 to = %new_branch,
1020 elapsed_ms = elapsed.as_millis(),
1021 "Branch switch completed (snapshot created)"
1022 );
1023 }
1024 Err(e) => {
1025 tracing::error!(error = %e, "Failed to switch after snapshot");
1026 return;
1027 }
1028 }
1029 }
1030 Err(e) => {
1031 tracing::error!(error = %e, "Failed to create snapshot");
1032 return;
1033 }
1034 }
1035 }
1036
1037 let old_b = current_branch;
1038 background_sync(
1039 &root,
1040 Some(&old_b),
1041 &new_branch,
1042 &db,
1043 &new_id,
1044 &scan_cfg,
1045 &detect_cfg,
1046 );
1047 });
1048 })
1049 };
1050
1051 tokio::spawn(async move {
1052 wait_scan.wait_for_scan();
1055
1056 if let Some(msg) = wait_scan.error_message() {
1061 tracing::info!(
1062 error_message = %msg,
1063 "Auto-scan failed during watcher wait; not starting file watcher",
1064 );
1065 let _ = watcher_tx.send(Err(WatcherError::ScanFailed(msg)));
1066 return;
1067 }
1068
1069 let result = start_watcher(
1070 params,
1071 root,
1072 db_p,
1073 conn,
1074 branch,
1075 watcher_scan_config,
1076 watcher_detection_config,
1077 on_branch_switch,
1078 )
1079 .await;
1080 if let Err(ref e) = result {
1081 tracing::warn!(
1082 "File watcher failed to start: {e}. \
1083 Serving without incremental updates."
1084 );
1085 }
1086 let _ = watcher_tx.send(result);
1087 });
1088 Some(watcher_rx)
1089 } else {
1090 None
1091 };
1092
1093 let watcher_status: std::borrow::Cow<'_, str> = if !watcher_enabled {
1111 std::borrow::Cow::Borrowed("disabled")
1112 } else if let Some(msg) = scan_state.error_message() {
1113 debug_assert!(
1114 !has_auto_scan,
1115 "scan_state.error_message().is_some() should imply has_auto_scan=false \
1116 (the AutoScan failure branch sets auto_scan_project_root=None)"
1117 );
1118 std::borrow::Cow::Owned(format!("disabled (auto-scan failed: {msg})"))
1119 } else if has_auto_scan && !scan_state.auto_scanned() {
1120 std::borrow::Cow::Borrowed("starting (after scan)")
1121 } else {
1122 std::borrow::Cow::Borrowed("starting")
1123 };
1124 print_startup(
1125 &repo_info,
1126 &submodules,
1127 &config,
1128 call_log_path.as_deref(),
1129 &watcher_status,
1130 is_auto_scan,
1131 &detected_branch,
1132 );
1133
1134 let detached_head = final_branch.0.len() >= 7
1136 && final_branch.0.chars().all(|c| c.is_ascii_hexdigit());
1137
1138 let shutdown = async {
1139 tokio::signal::ctrl_c()
1140 .await
1141 .expect("failed to listen for Ctrl+C");
1142 eprintln!();
1143 eprintln!("Shutting down...");
1144 };
1145
1146 let result = seshat_mcp::start_stdio_with_shutdown(
1147 server_config,
1148 root,
1149 submodules,
1150 call_log_path,
1151 embedding_provider,
1152 scan_state,
1153 sync_in_progress.clone(),
1154 true,
1155 detached_head,
1156 project_root.clone(),
1157 shutdown,
1158 std::time::Duration::from_secs(5),
1159 )
1160 .await;
1161
1162 drop(gc_handle);
1164
1165 if let Some(mut rx) = watcher_rx {
1167 if let Ok(Ok(handle)) = rx.try_recv() {
1168 handle.shutdown().await;
1169 }
1170 }
1171
1172 result
1173 })
1174 .map_err(|e| CliError::CommandFailed {
1175 command: "serve".to_owned(),
1176 reason: format!("MCP server error: {e}"),
1177 })
1178}
1179
1180fn load_repo_info(db: &Database, db_path: &Path) -> Result<RepoInfo, CliError> {
1182 let name = db_path
1183 .file_stem()
1184 .map(|s| s.to_string_lossy().to_string())
1185 .unwrap_or_else(|| "unknown".to_owned());
1186
1187 let info = crate::db::load_project_info(db);
1188
1189 Ok(RepoInfo {
1190 name,
1191 db_path: db_path.to_path_buf(),
1192 branch: info.branch,
1193 file_count: info.file_count,
1194 convention_count: info.convention_count,
1195 })
1196}
1197
1198fn load_submodule_rows(db: &Database) -> Vec<SubmoduleRow> {
1203 let sub_repo = SqliteSubmoduleRepository::new(db.connection().clone());
1204 match sub_repo.list() {
1205 Ok(rows) => rows,
1206 Err(e) => {
1207 eprintln!(
1208 " Warning: could not read submodules table: {e}. Continuing without submodules."
1209 );
1210 Vec::new()
1211 }
1212 }
1213}
1214
1215fn open_submodule_connections(
1221 rows: &[SubmoduleRow],
1222 root_project_name: &str,
1223) -> HashMap<String, ProjectConnection> {
1224 let mut submodules = HashMap::new();
1225
1226 for row in rows {
1227 let db_path =
1228 match crate::db::resolve_submodule_db_path(root_project_name, &row.relative_path) {
1229 Ok(p) => p,
1230 Err(e) => {
1231 eprintln!(
1232 " Warning: could not resolve DB path for submodule '{}': {e}. Skipping.",
1233 row.relative_path
1234 );
1235 continue;
1236 }
1237 };
1238
1239 if !db_path.exists() {
1240 eprintln!(
1241 " Warning: submodule DB not found at '{}'. Skipping '{}'.",
1242 db_path.display(),
1243 row.relative_path
1244 );
1245 continue;
1246 }
1247
1248 let db = match Database::open(&db_path) {
1249 Ok(d) => d,
1250 Err(e) => {
1251 eprintln!(
1252 " Warning: failed to open submodule DB '{}': {e}. Skipping '{}'.",
1253 db_path.display(),
1254 row.relative_path
1255 );
1256 continue;
1257 }
1258 };
1259
1260 let branch_repo = SqliteBranchRepository::new(db.connection().clone());
1262 let branch = branch_repo.get_current_branch().unwrap_or_else(|_| {
1263 tracing::debug!("Could not detect submodule branch from DB, defaulting to 'main'");
1264 BranchId::from("main")
1265 });
1266
1267 let pc = ProjectConnection::new(
1268 db.connection().clone(),
1269 row.relative_path.clone(),
1270 branch.to_string(),
1271 );
1272
1273 submodules.insert(row.relative_path.clone(), pc);
1274 }
1275
1276 submodules
1277}
1278
1279fn print_startup(
1281 info: &RepoInfo,
1282 submodules: &HashMap<String, ProjectConnection>,
1283 config: &AppConfig,
1284 call_log_path: Option<&Path>,
1285 watcher_status: &str,
1286 auto_scanning: bool,
1287 detected_branch: &str,
1288) {
1289 eprintln!("seshat v{}", env!("CARGO_PKG_VERSION"));
1290 eprintln!();
1291 eprintln!(" Repo: {}", info.name);
1292 eprintln!(" Branch: {}", detected_branch);
1293 if auto_scanning {
1294 eprintln!(" Files: 0 (auto-scanning...)");
1295 } else {
1296 eprintln!(" Files: {}", info.file_count);
1297 }
1298 eprintln!(" Conventions: {}", info.convention_count);
1299 eprintln!(" Database: {}", info.db_path.display());
1300 eprintln!(" Watcher: {watcher_status}");
1301
1302 if submodules.is_empty() {
1303 eprintln!(" Submodules: none");
1304 } else {
1305 eprintln!(" Submodules: {}", submodules.len());
1306 let mut names: Vec<&String> = submodules.keys().collect();
1307 names.sort();
1308 for name in names {
1309 eprintln!(" - {name}");
1310 }
1311 }
1312
1313 if let Some(path) = call_log_path {
1314 eprintln!(" Call log: {}", path.display());
1315 }
1316
1317 eprintln!();
1318 eprintln!(
1319 " Transport: stdio ({}:{})",
1320 config.server.host, config.server.port
1321 );
1322 eprintln!();
1323 eprintln!("Ready. Waiting for MCP client connection...");
1324}
1325
1326#[cfg(test)]
1327mod tests {
1328 use super::*;
1329 use seshat_core::DetectionConfig;
1330 use std::collections::HashMap;
1331
1332 #[test]
1333 fn load_repo_info_empty_db() {
1334 let db = Database::open(":memory:").expect("in-memory db");
1336 let path = PathBuf::from("/tmp/test-seshat-project.db");
1337 let info = load_repo_info(&db, &path).expect("should succeed with empty db");
1338 assert_eq!(info.name, "test-seshat-project");
1339 assert_eq!(info.file_count, 0);
1340 assert_eq!(info.convention_count, 0);
1341 assert_eq!(info.branch, BranchId::from("main"));
1342 }
1343
1344 #[test]
1345 fn load_submodule_rows_empty_db() {
1346 let db = Database::open(":memory:").expect("in-memory db");
1347 let rows = load_submodule_rows(&db);
1348 assert!(rows.is_empty());
1349 }
1350
1351 #[test]
1352 fn load_submodule_rows_with_data() {
1353 use seshat_storage::{SqliteSubmoduleRepository, SubmoduleInput, SubmoduleRepository};
1354
1355 let db = Database::open(":memory:").expect("in-memory db");
1356 let sub_repo = SqliteSubmoduleRepository::new(db.connection().clone());
1357 sub_repo
1358 .insert(&SubmoduleInput {
1359 relative_path: "vendor/libfoo".to_string(),
1360 name: "libfoo".to_string(),
1361 db_path: "/data/seshat/repos/proj/vendor/libfoo.db".to_string(),
1362 commit_hash: Some("abc123".to_string()),
1363 })
1364 .expect("insert");
1365 sub_repo
1366 .insert(&SubmoduleInput {
1367 relative_path: "libs/core".to_string(),
1368 name: "core".to_string(),
1369 db_path: "/data/seshat/repos/proj/libs/core.db".to_string(),
1370 commit_hash: Some("def456".to_string()),
1371 })
1372 .expect("insert");
1373
1374 let rows = load_submodule_rows(&db);
1375 assert_eq!(rows.len(), 2);
1376 assert_eq!(rows[0].relative_path, "libs/core");
1378 assert_eq!(rows[1].relative_path, "vendor/libfoo");
1379 }
1380
1381 #[test]
1382 fn open_submodule_connections_empty_rows() {
1383 let submodules = open_submodule_connections(&[], "test-project");
1384 assert!(submodules.is_empty());
1385 }
1386
1387 #[test]
1388 fn open_submodule_connections_missing_db_skipped() {
1389 let project_name = "serve-test-missing-db";
1390
1391 let row = SubmoduleRow {
1392 id: 1,
1393 relative_path: "vendor/nonexistent".to_string(),
1394 name: "nonexistent".to_string(),
1395 db_path: "/no/such/path.db".to_string(),
1396 commit_hash: Some("abc123".to_string()),
1397 created_at: "2026-04-03T00:00:00".to_string(),
1398 updated_at: "2026-04-03T00:00:00".to_string(),
1399 };
1400
1401 let submodules = open_submodule_connections(&[row], project_name);
1402 assert!(submodules.is_empty());
1404
1405 if let Ok(repos) = crate::db::xdg_repos_dir() {
1407 let _ = std::fs::remove_dir_all(repos.join(project_name));
1408 }
1409 }
1410
1411 #[test]
1412 fn resolve_call_log_bare_flag_uses_default_path() {
1413 let result = resolve_call_log_path(Some(PathBuf::from("")), None);
1415 let path = result.expect("should resolve to default path");
1416 let normalized = path.to_string_lossy().replace('\\', "/");
1419 assert!(
1420 normalized.ends_with("seshat/call-log.jsonl"),
1421 "expected default path to end with seshat/call-log.jsonl, got {normalized}"
1422 );
1423 }
1424
1425 #[test]
1426 fn resolve_call_log_explicit_path() {
1427 let result = resolve_call_log_path(Some(PathBuf::from("/tmp/my-log.jsonl")), None);
1428 assert_eq!(result, Some(PathBuf::from("/tmp/my-log.jsonl")));
1429 }
1430
1431 #[test]
1432 fn resolve_call_log_from_config() {
1433 let result = resolve_call_log_path(None, Some("/config/path.jsonl"));
1434 assert_eq!(result, Some(PathBuf::from("/config/path.jsonl")));
1435 }
1436
1437 #[test]
1438 fn resolve_call_log_cli_overrides_config() {
1439 let result = resolve_call_log_path(
1440 Some(PathBuf::from("/cli/path.jsonl")),
1441 Some("/config/path.jsonl"),
1442 );
1443 assert_eq!(result, Some(PathBuf::from("/cli/path.jsonl")));
1444 }
1445
1446 #[test]
1447 fn resolve_call_log_disabled_when_no_flag_and_no_config() {
1448 let result = resolve_call_log_path(None, None);
1449 assert!(result.is_none());
1450 }
1451
1452 #[test]
1453 fn open_submodule_connections_with_real_dbs() {
1454 use std::fs;
1455
1456 let project_name = "serve-test-submod";
1457 let mount_path = "vendor/testlib";
1458
1459 let db_path =
1462 crate::db::resolve_submodule_db_path(project_name, mount_path).expect("resolve path");
1463
1464 struct Cleanup(PathBuf);
1466 impl Drop for Cleanup {
1467 fn drop(&mut self) {
1468 let _ = fs::remove_dir_all(&self.0);
1469 }
1470 }
1471 let repos_dir = crate::db::xdg_repos_dir().expect("xdg repos dir");
1472 let _guard = Cleanup(repos_dir.join(project_name));
1473
1474 let db = Database::open(&db_path).expect("create submodule DB");
1475 drop(db);
1476
1477 let row = SubmoduleRow {
1478 id: 1,
1479 relative_path: mount_path.to_string(),
1480 name: "testlib".to_string(),
1481 db_path: db_path.to_string_lossy().to_string(),
1482 commit_hash: Some("abc123".to_string()),
1483 created_at: "2026-04-03T00:00:00".to_string(),
1484 updated_at: "2026-04-03T00:00:00".to_string(),
1485 };
1486
1487 let submodules = open_submodule_connections(&[row], project_name);
1488 assert_eq!(submodules.len(), 1);
1489 assert!(submodules.contains_key(mount_path));
1490
1491 let pc = &submodules[mount_path];
1492 assert_eq!(pc.name, mount_path);
1493 assert_eq!(pc.branch, "main"); }
1496
1497 #[test]
1500 fn handle_auto_scan_snapshot_main_branch_no_op() {
1501 let db = Database::open(":memory:").expect("in-memory db");
1502 let result = handle_auto_scan_snapshot(&db, "main").expect("should succeed");
1503 assert_eq!(result, BranchId::from("main"));
1504 }
1505
1506 #[test]
1509 fn print_startup_does_not_panic() {
1510 let repos_dir = crate::db::xdg_repos_dir().expect("xdg repos dir");
1511 let _ = std::fs::create_dir_all(&repos_dir);
1512 let info = RepoInfo {
1513 name: "test-project".to_string(),
1514 db_path: PathBuf::from("/tmp/test.db"),
1515 file_count: 5,
1516 convention_count: 42,
1517 branch: BranchId::from("main"),
1518 };
1519 let config = AppConfig::load().unwrap_or_default();
1520 print_startup(
1521 &info,
1522 &HashMap::new(),
1523 &config,
1524 None,
1525 "running",
1526 false,
1527 "main",
1528 );
1529 }
1530
1531 #[test]
1534 fn repo_info_default_name_extraction() {
1535 let info = RepoInfo {
1536 name: "my-awesome-project".to_string(),
1537 db_path: PathBuf::from("/tmp/test.db"),
1538 file_count: 10,
1539 convention_count: 20,
1540 branch: BranchId::from("feat/bar"),
1541 };
1542 assert_eq!(info.name, "my-awesome-project");
1543 assert_eq!(info.file_count, 10);
1544 assert_eq!(info.convention_count, 20);
1545 assert_eq!(info.branch, BranchId::from("feat/bar"));
1546 }
1547
1548 #[test]
1551 fn fallback_rescan_empty_dir_handles_gracefully() {
1552 use tempfile::tempdir;
1553 let dir = tempdir().expect("tempdir");
1554 let db = Database::open(":memory:").expect("in-memory db");
1555 let branch = BranchId::from("main");
1556 fallback_rescan(
1558 dir.path(),
1559 &db,
1560 &branch,
1561 &ScanConfig::default(),
1562 &DetectionConfig::default(),
1563 );
1564 }
1565
1566 #[test]
1569 fn resolve_branch_tree_paths_not_a_git_repo_returns_none() {
1570 use tempfile::tempdir;
1571 let dir = tempdir().expect("tempdir");
1572 let result = resolve_branch_tree_paths(dir.path(), "main");
1573 assert!(result.is_none());
1574 }
1575
1576 fn seed_branch(db: &Database, branch_name: &str) -> BranchId {
1579 let branch = BranchId::from(branch_name);
1580 let br = SqliteBranchRepository::new(db.connection().clone());
1581 br.switch_branch(&branch).unwrap();
1582 let c = db.connection().lock().unwrap();
1584 c.execute(
1585 "INSERT INTO nodes (branch_id, nature, weight, confidence, adoption_count, total_count, description, ext_data)
1586 VALUES (?1, 'convention', 'strong', 0.9, 5, 10, 'test', '{\"source\":\"auto_detected\"}')",
1587 rusqlite::params![branch_name],
1588 ).unwrap();
1589 branch
1590 }
1591
1592 #[test]
1593 fn handle_branch_switch_same_branch_returns_current() {
1594 let db = Database::open(":memory:").expect("in-memory db");
1595 let current = BranchId::from("main");
1596 let result = handle_branch_switch(&db, "main", ¤t, false).unwrap();
1597 assert_eq!(result, current);
1598 }
1599
1600 #[test]
1601 fn handle_branch_switch_target_has_data_no_snapshot() {
1602 let db = Database::open(":memory:").expect("in-memory db");
1603 let current = BranchId::from("main");
1604 seed_branch(&db, "feat/test");
1605 let result = handle_branch_switch(&db, "feat/test", ¤t, false).unwrap();
1606 assert_eq!(result, BranchId::from("feat/test"));
1607 }
1608
1609 #[test]
1610 fn handle_branch_switch_source_no_data_still_switches() {
1611 let db = Database::open(":memory:").expect("in-memory db");
1612 let current = BranchId::from("main");
1613 let result = handle_branch_switch(&db, "feat/empty", ¤t, false).unwrap();
1614 assert_eq!(result, BranchId::from("feat/empty"));
1615 }
1616
1617 #[test]
1618 fn handle_branch_switch_source_has_data_creates_snapshot() {
1619 let db = Database::open(":memory:").expect("in-memory db");
1620 let current = BranchId::from("main");
1621 seed_branch(&db, "main");
1622 let result = handle_branch_switch(&db, "feat/snap", ¤t, false).unwrap();
1623 assert_eq!(result, BranchId::from("feat/snap"));
1624 let br = SqliteBranchRepository::new(db.connection().clone());
1626 let branches = br.list_branches().unwrap();
1627 assert!(branches.iter().any(|b| b.0 == "feat/snap"));
1628 }
1629
1630 #[test]
1633 fn auto_scan_snapshot_non_main_no_main_data_still_switches() {
1634 let db = Database::open(":memory:").expect("in-memory db");
1635 let result = handle_auto_scan_snapshot(&db, "feat/bar").unwrap();
1636 assert_eq!(result, BranchId::from("feat/bar"));
1637 }
1638
1639 #[test]
1640 fn auto_scan_snapshot_non_main_with_main_data_creates_snapshot() {
1641 let db = Database::open(":memory:").expect("in-memory db");
1642 seed_branch(&db, "main");
1643 let result = handle_auto_scan_snapshot(&db, "feat/baz").unwrap();
1644 assert_eq!(result, BranchId::from("feat/baz"));
1645 let br = SqliteBranchRepository::new(db.connection().clone());
1646 let branches = br.list_branches().unwrap();
1647 assert!(branches.iter().any(|b| b.0 == "feat/baz"));
1648 }
1649
1650 #[test]
1653 fn watcher_should_start_disabled_returns_false_regardless_of_scan_state() {
1654 let state_ok = ScanState::not_needed();
1656 assert!(!watcher_should_start(false, &state_ok));
1657
1658 let state_complete = ScanState::in_progress();
1659 state_complete.mark_complete();
1660 assert!(!watcher_should_start(false, &state_complete));
1661 }
1662
1663 #[test]
1664 fn watcher_should_start_enabled_with_no_scan_returns_true() {
1665 let state = ScanState::not_needed();
1668 assert!(watcher_should_start(true, &state));
1669 }
1670
1671 #[test]
1672 fn watcher_should_start_enabled_with_completed_scan_returns_true() {
1673 let state = ScanState::in_progress();
1675 state.mark_complete();
1676 assert!(watcher_should_start(true, &state));
1677 }
1678
1679 #[test]
1680 fn watcher_should_start_enabled_with_in_progress_scan_returns_true() {
1681 let state = ScanState::in_progress();
1685 assert!(watcher_should_start(true, &state));
1686 }
1687
1688 #[test]
1689 fn watcher_should_start_enabled_with_failed_scan_returns_false() {
1690 let state = ScanState::in_progress();
1693 state.mark_failed("project too large".to_owned());
1694 assert!(!watcher_should_start(true, &state));
1695 }
1696
1697 #[test]
1698 fn watcher_should_start_disabled_with_failed_scan_returns_false() {
1699 let state = ScanState::in_progress();
1701 state.mark_failed("scan timeout".to_owned());
1702 assert!(!watcher_should_start(false, &state));
1703 }
1704
1705 #[test]
1718 fn race_guard_pattern_detects_pre_wait_failure() {
1719 let state = ScanState::in_progress();
1722 state.mark_failed("simulated pre-wait failure".to_owned());
1723 state.wait_for_scan(); assert_eq!(
1725 state.error_message(),
1726 Some("simulated pre-wait failure".to_owned())
1727 );
1728 }
1729
1730 #[test]
1731 fn race_guard_pattern_returns_none_for_normal_completion() {
1732 let state = ScanState::in_progress();
1733 state.mark_complete();
1734 state.wait_for_scan();
1735 assert_eq!(state.error_message(), None);
1736 }
1737
1738 #[test]
1739 fn race_guard_pattern_observes_failure_set_during_wait() {
1740 use std::sync::Arc;
1744 use std::thread;
1745 use std::time::Duration;
1746
1747 let state = ScanState::in_progress();
1748 let waiter_state = state.clone();
1749 let observed: Arc<std::sync::Mutex<Option<String>>> = Arc::new(std::sync::Mutex::new(None));
1750 let observed_for_thread = Arc::clone(&observed);
1751 let waiter = thread::spawn(move || {
1752 waiter_state.wait_for_scan();
1753 *observed_for_thread.lock().expect("lock") = waiter_state.error_message();
1754 });
1755
1756 thread::sleep(Duration::from_millis(50));
1760 state.mark_failed("simulated late failure".to_owned());
1761
1762 waiter.join().expect("waiter thread join");
1763 let captured = observed.lock().expect("lock").clone();
1764 assert_eq!(captured, Some("simulated late failure".to_owned()));
1765 }
1766}