diff --git a/CHANGELOG.md b/CHANGELOG.md index a26cc8b3..a5637695 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,10 @@ finalized in place with a date — no renaming/migration step needed. - **File metadata is persisted once per index batch instead of once per pass.** The vector and FTS stores already commit per batch, but the file-meta cache was only written at the very end of a refresh pass — a serve restart (or a failed FTS commit) mid-pass left the meta empty while chunks existed, and the next open then wiped the whole store and re-embedded everything again. The meta now follows the stores' own commit granularity, and `save()` writes atomically (tmp + rename) so a crash mid-write can never truncate the file. +### Changed + +- **find_impact position queries that resolve to no symbol now return an error with a hint.** Previously they returned an empty references list, which looked like "no callers". + ## [1.5.29] ### Fixed diff --git a/helpers/csharp/ReferenceResolver.cs b/helpers/csharp/ReferenceResolver.cs index a9cfcf96..8927cf38 100644 --- a/helpers/csharp/ReferenceResolver.cs +++ b/helpers/csharp/ReferenceResolver.cs @@ -136,6 +136,36 @@ public async Task BatchFindRefsAsync(Solution solution, IRe return null; } + /// + /// Picks the workspace counterpart of among the + /// similar-symbol candidates: the exact signature match wins (the first + /// candidate may be another overload), a lone candidate is accepted, and + /// anything else returns with + /// set. + /// + internal static ISymbol PickWorkspaceSymbol(ISymbol target, IEnumerable similar, out bool fellBack) + { + var candidates = similar.ToList(); + var signature = target.ToDisplayString(SymbolDisplayFormat.CSharpErrorMessageFormat); + + var exact = candidates.FirstOrDefault(c => + c.ToDisplayString(SymbolDisplayFormat.CSharpErrorMessageFormat) == signature); + if (exact is not null) + { + fellBack = false; + return exact; + } + + if (candidates.Count == 1) + { + fellBack = false; + return candidates[0]; + } + + fellBack = true; + return target; + } + /// /// Resolves references for one symbol. Returns the occurrences plus a /// warning per survived failure — a caught FindReferencesAsync exception @@ -157,20 +187,36 @@ public async Task BatchFindRefsAsync(Solution solution, IRe // while the calls were visible to text search). Re-resolve the // symbol inside the containing project's workspace compilation // first; fall back to the foreign symbol when no counterpart - // exists. + // exists. Multi-target projects share an AssemblyName, so every + // matching project is tried until one yields a counterpart. var workspaceSymbol = targetSymbol; + var resolved = false; var assemblyName = targetSymbol.ContainingAssembly?.Name; - var project = solution.Projects.FirstOrDefault(p => - string.Equals(p.AssemblyName, assemblyName, StringComparison.OrdinalIgnoreCase)); - if (project is not null) + foreach (var project in solution.Projects) { + if (!string.Equals(project.AssemblyName, assemblyName, StringComparison.OrdinalIgnoreCase)) + continue; + var compilation = await project.GetCompilationAsync().ConfigureAwait(false); - if (compilation is not null) - { - var similar = SymbolFinder - .FindSimilarSymbols(targetSymbol, compilation); - workspaceSymbol = similar.FirstOrDefault() ?? targetSymbol; - } + if (compilation is null) + continue; + + var picked = PickWorkspaceSymbol( + targetSymbol, + SymbolFinder.FindSimilarSymbols(targetSymbol, compilation), + out var fellBack); + if (fellBack) + continue; + + workspaceSymbol = picked; + resolved = true; + break; + } + + if (!resolved) + { + Console.Error.WriteLine( + $"[INFO] No workspace counterpart for {targetSymbol.Name}; resolving references with the detached symbol"); } var refResults = await SymbolFinder diff --git a/helpers/csharp/WorkspaceNoise.cs b/helpers/csharp/WorkspaceNoise.cs index f8a8f06b..52177c60 100644 --- a/helpers/csharp/WorkspaceNoise.cs +++ b/helpers/csharp/WorkspaceNoise.cs @@ -89,9 +89,10 @@ public static bool IsBuildHostShutdownChatter(string? message) /// /// True when a restore or load diagnostic is a NuGet source that cannot /// be reached or authenticated ("Unable to load the service index for - /// source ...", "Unable to find package X ... in source(s)"). A host - /// environment problem, not a code defect; the restore fallback already - /// reports the affected projects. Pure and null-tolerant. + /// source ..."). A host environment problem, not a code defect; the + /// restore fallback already reports the affected projects. "Unable to + /// find package" is deliberately not matched: it means a package is + /// really missing and the project loads degraded. Pure and null-tolerant. /// public static bool IsNuGetSourceFailure(string? message) { @@ -100,7 +101,6 @@ public static bool IsNuGetSourceFailure(string? message) return false; } return message.Contains("Unable to load the service index for source", StringComparison.OrdinalIgnoreCase) - || message.Contains("Unable to find package", StringComparison.OrdinalIgnoreCase) || message.Contains("Error occurred while getting package vulnerability data", StringComparison.OrdinalIgnoreCase); } diff --git a/helpers/csharp/tests/ScipCsharp.Tests.csproj b/helpers/csharp/tests/ScipCsharp.Tests.csproj index 4213fbe3..49ac02bc 100644 --- a/helpers/csharp/tests/ScipCsharp.Tests.csproj +++ b/helpers/csharp/tests/ScipCsharp.Tests.csproj @@ -44,6 +44,7 @@ + diff --git a/helpers/csharp/tests/WorkspaceNoiseTests.cs b/helpers/csharp/tests/WorkspaceNoiseTests.cs index 4dd9e856..c74037da 100644 --- a/helpers/csharp/tests/WorkspaceNoiseTests.cs +++ b/helpers/csharp/tests/WorkspaceNoiseTests.cs @@ -21,11 +21,15 @@ public void NuGetAuditAdvisoryIsFiltered() [Theory] [InlineData("Acme.Core.csproj: Unable to load the service index for source https://pkgs.example/index.json.")] - [InlineData("Acme.Core.csproj: Unable to find package Acme.Shared. No packages exist with this id in source(s)")] [InlineData("Acme.Core.csproj: Error occurred while getting package vulnerability data: Unable to load the service index")] public void NuGetSourceFailureIsDemoted(string message) => Assert.True(WorkspaceNoise.IsNuGetSourceFailure(message)); + [Fact] + public void MissingPackageIsNotNuGetSourceFailure() => + Assert.False(WorkspaceNoise.IsNuGetSourceFailure( + "Acme.Core.csproj: Unable to find package Acme.Shared. No packages exist with this id in source(s)")); + [Fact] public void GenuineLoadFailureIsNotNuGetSourceFailure() => Assert.False(WorkspaceNoise.IsNuGetSourceFailure("The type or namespace name 'Catalog' does not exist")); diff --git a/helpers/csharp/tests/WorkspaceSymbolPickTests.cs b/helpers/csharp/tests/WorkspaceSymbolPickTests.cs new file mode 100644 index 00000000..a8435459 --- /dev/null +++ b/helpers/csharp/tests/WorkspaceSymbolPickTests.cs @@ -0,0 +1,99 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.FindSymbols; +using ScipCsharp; +using Xunit; + +namespace ScipCsharp.Tests; + +/// +/// Pins how the detached map symbol is paired with its workspace counterpart: +/// the exact overload must win over whichever similar symbol comes first, and +/// an unresolvable pairing must be reported as a fallback, never passed off +/// as a match. +/// +public class WorkspaceSymbolPickTests +{ + private const string Overloads = + "class C { public int Add(int a, int b) => a + b; public int Add(int a, int b, int c) => a + b + c; }"; + + private const string OtherOverloads = + "class C { public int Add(int a) => a; public int Add(long a) => 0; }"; + + private static CSharpCompilation Compile(string source) => + CSharpCompilation.Create( + "Acme.Core", + [CSharpSyntaxTree.ParseText(source)], + references: [MetadataReference.CreateFromFile(typeof(object).Assembly.Location)], + options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + private static List AddOverloads(Compilation compilation) => + compilation.GetTypeByMetadataName("C")! + .GetMembers("Add") + .OfType() + .OrderBy(m => m.Parameters.Length) + .ToList(); + + [Fact] + public void PicksTheMatchingOverloadFromTheWorkspaceCompilation() + { + var detached = Compile(Overloads); + var workspace = Compile(Overloads); + var target = AddOverloads(detached)[1]; + + var similar = SymbolFinder.FindSimilarSymbols(target, workspace); + var picked = ReferenceResolver.PickWorkspaceSymbol(target, similar, out var fellBack); + + Assert.False(fellBack); + Assert.NotSame(target, picked); + Assert.Same(workspace.Assembly, picked.ContainingAssembly); + Assert.Equal(3, Assert.IsAssignableFrom(picked).Parameters.Length); + } + + [Fact] + public void ExactSignatureWinsOverAnEarlierCandidate() + { + var target = AddOverloads(Compile(Overloads))[1]; + var workspaceOverloads = AddOverloads(Compile(Overloads)); + + var picked = ReferenceResolver.PickWorkspaceSymbol(target, workspaceOverloads, out var fellBack); + + Assert.False(fellBack); + Assert.Same(workspaceOverloads[1], picked); + } + + [Fact] + public void NoCandidatesFallsBackToTheTarget() + { + var target = AddOverloads(Compile(Overloads))[1]; + + var picked = ReferenceResolver.PickWorkspaceSymbol(target, [], out var fellBack); + + Assert.True(fellBack); + Assert.Same(target, picked); + } + + [Fact] + public void LoneNonExactCandidateIsAccepted() + { + var target = AddOverloads(Compile(Overloads))[1]; + var lone = AddOverloads(Compile(OtherOverloads))[0]; + + var picked = ReferenceResolver.PickWorkspaceSymbol(target, [lone], out var fellBack); + + Assert.False(fellBack); + Assert.Same(lone, picked); + } + + [Fact] + public void SeveralNonExactCandidatesFallBackToTheTarget() + { + var target = AddOverloads(Compile(Overloads))[1]; + var others = AddOverloads(Compile(OtherOverloads)); + + var picked = ReferenceResolver.PickWorkspaceSymbol(target, others, out var fellBack); + + Assert.True(fellBack); + Assert.Same(target, picked); + } +} diff --git a/src/cache/file_meta.rs b/src/cache/file_meta.rs index 292b22f4..5ed0283d 100644 --- a/src/cache/file_meta.rs +++ b/src/cache/file_meta.rs @@ -6,7 +6,9 @@ use std::fs; use std::path::{Path, PathBuf}; use std::time::SystemTime; -use crate::constants::FILE_META_DB_NAME; +use crate::constants::{ + FILE_META_DB_NAME, FILE_META_SAVE_RENAME_ATTEMPTS, FILE_META_SAVE_RENAME_DELAY_MS, +}; // ─── CANONICAL PATH POLICY ──────────────────────────────────────────────────── // @@ -355,8 +357,23 @@ impl FileMetaStore { // would trip the empty-meta wipe guard. let tmp_path = db_path.join(format!("{}.tmp", Self::FILENAME)); fs::write(&tmp_path, content)?; - fs::rename(&tmp_path, &meta_path)?; - Ok(()) + // Windows: an indexer can briefly hold the target (os error 5/32). + let mut attempt = 1; + loop { + match fs::rename(&tmp_path, &meta_path) { + Ok(()) => return Ok(()), + Err(e) if attempt >= FILE_META_SAVE_RENAME_ATTEMPTS => { + let _ = fs::remove_file(&tmp_path); + return Err(e.into()); + } + Err(_) => { + attempt += 1; + std::thread::sleep(std::time::Duration::from_millis( + FILE_META_SAVE_RENAME_DELAY_MS, + )); + } + } + } } /// Migrate stored paths to normalized format. diff --git a/src/chunker/semantic.rs b/src/chunker/semantic.rs index 8638429c..b7568ee7 100644 --- a/src/chunker/semantic.rs +++ b/src/chunker/semantic.rs @@ -1230,6 +1230,72 @@ main "$@" ); } + #[test] + fn test_chunk_php_fallback() { + // PHP has a tree-sitter grammar but no extractor, so it falls back + // to fallback_chunk (sliding window) like Shell does. + let mut chunker = SemanticChunker::new(100, 2000, 10); + + let php_source = r#" 0; + } +} + +function formatPrice(float $amount): string +{ + return number_format($amount, 2); +} +"#; + + let path = Path::new("Service.php"); + + // Verify language detection + assert_eq!( + crate::file::Language::from_path(path), + crate::file::Language::Php, + ".php files should be detected as PHP language" + ); + + let chunks = chunker + .chunk_semantic(crate::file::Language::Php, path, php_source) + .unwrap(); + + // Should produce chunks (via fallback) + assert!( + !chunks.is_empty(), + "PHP files should produce chunks via fallback, got 0" + ); + + // All chunks should be Block kind (fallback chunking) + assert!( + chunks.iter().all(|c| c.kind == ChunkKind::Block), + "Fallback chunks should all be Block kind" + ); + + // Chunks should cover the PHP content + let all_content: String = chunks.iter().map(|c| c.content.as_str()).collect(); + assert!( + all_content.contains("createOrder"), + "Chunks should contain createOrder method" + ); + assert!( + all_content.contains("cancelOrder"), + "Chunks should contain cancelOrder method" + ); + } + #[test] fn test_context_breadcrumbs() { let mut chunker = SemanticChunker::new(100, 2000, 10); diff --git a/src/constants.rs b/src/constants.rs index f1fd8420..c0e5437b 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -45,6 +45,12 @@ pub const CONFIG_DIR_NAME: &str = ".codesearch"; /// Name of the file metadata database pub const FILE_META_DB_NAME: &str = "file_meta.json"; +/// Rename attempts when publishing the file metadata (AV/Search indexer can hold the file) +pub const FILE_META_SAVE_RENAME_ATTEMPTS: u32 = 5; + +/// Delay between file metadata rename attempts, in milliseconds +pub const FILE_META_SAVE_RENAME_DELAY_MS: u64 = 100; + /// Subdirectory name for embedding models within the global config dir const MODELS_SUBDIR: &str = "models"; diff --git a/src/fts/tantivy_store.rs b/src/fts/tantivy_store.rs index 76faca9c..4844678c 100644 --- a/src/fts/tantivy_store.rs +++ b/src/fts/tantivy_store.rs @@ -29,6 +29,10 @@ pub struct FtsResult { pub score: f32, } +/// Shared prefix of every commit-failure message `FtsStore::commit` returns; +/// scopes the transient-error classifier to commit failures. +const FTS_COMMIT_FAILED_CONTEXT: &str = "FTS commit failed"; + enum CommitAttempt { Committed, PrepareFailed(String), @@ -426,11 +430,15 @@ impl FtsStore { /// Only a failure of THIS class justifies redoing the whole indexing /// pass — a commit retry on the same writer is never safe (a /// half-joined pipeline must not be recommitted, see `commit_once`). + /// + /// Scoped to `commit()` failures (`FTS_COMMIT_FAILED_CONTEXT`): the same + /// OS errors from a store open, or a killed writer, are not this race. pub fn is_transient_fts_commit_error(error: &str) -> bool { - error.contains("Access is denied") - || error.contains("os error 5") - || error.contains("os error 32") - || error.contains("os error 33") + error.contains(FTS_COMMIT_FAILED_CONTEXT) + && (error.contains("Access is denied") + || error.contains("os error 5") + || error.contains("os error 32") + || error.contains("os error 33")) } /// One commit attempt, split so a failure in `prepare_commit` is told @@ -724,12 +732,14 @@ impl FtsStore { } /// Clear the entire index - #[allow(dead_code)] // Reserved for index reset pub fn clear(&mut self) -> Result<()> { self.ensure_writer()?; let writer = self.writer.as_mut().unwrap(); writer.delete_all_documents()?; - writer.commit()?; + // classifier scope: is_transient_fts_commit_error matches only this prefix + writer + .commit() + .map_err(|e| anyhow!("{FTS_COMMIT_FAILED_CONTEXT}: {e}"))?; self.reader.reload()?; Ok(()) } @@ -756,15 +766,42 @@ mod tests { file for write: 'IoError { io_error: Os { code: 5, kind: PermissionDenied, \ message: \"Access is denied.\" }, filepath: \"55fb.pos\" }'"; assert!(FtsStore::is_transient_fts_commit_error(logged)); - assert!(FtsStore::is_transient_fts_commit_error("os error 5")); - assert!(FtsStore::is_transient_fts_commit_error("os error 32")); - assert!(FtsStore::is_transient_fts_commit_error("os error 33")); + assert!(FtsStore::is_transient_fts_commit_error( + "FTS commit failed: Failed to open file (os error 5)" + )); + assert!(FtsStore::is_transient_fts_commit_error( + "FTS commit failed: Failed to open file (os error 32)" + )); + assert!(FtsStore::is_transient_fts_commit_error( + "FTS commit failed after 5 retries: Failed to open file (os error 33)" + )); assert!(!FtsStore::is_transient_fts_commit_error( "writer was killed" )); + assert!(!FtsStore::is_transient_fts_commit_error( + "Access is denied (os error 5)" + )); + assert!(!FtsStore::is_transient_fts_commit_error( + "FTS writer was killed during commit: Access is denied. os error 5" + )); + assert!(!FtsStore::is_transient_fts_commit_error( + "FTS commit after recovery failed: Access is denied. (os error 5)" + )); assert!(!FtsStore::is_transient_fts_commit_error("schema error")); } + #[test] + fn clear_removes_committed_documents() -> Result<()> { + let mut store = FtsStore::new_in_memory()?; + store.add_chunk(1, "fn hello() {}", "src/main.rs", Some("hello"), "function")?; + store.commit()?; + assert_eq!(store.stats()?.num_documents, 1); + + store.clear()?; + assert_eq!(store.stats()?.num_documents, 0); + Ok(()) + } + #[test] fn test_fts_basic() -> Result<()> { let mut store = FtsStore::new_in_memory()?; diff --git a/src/index/manager.rs b/src/index/manager.rs index 8ce4fcad..4690d3c6 100644 --- a/src/index/manager.rs +++ b/src/index/manager.rs @@ -1536,6 +1536,9 @@ impl IndexManager { /// indicator (`Started`/`Succeeded`/`Failed`). Non-applicable languages /// (no `.sln` / no `tsconfig.json`) or an unavailable helper are skipped /// without touching any status — mirroring the debounce path. + /// + /// Serializes with the other two C# rebuild tasks via the shared `rebuild_guard`. + #[allow(clippy::too_many_arguments)] fn spawn_branch_change_symbol_rebuild( symbol_registry: Arc, repo_path: PathBuf, @@ -1544,8 +1547,12 @@ impl IndexManager { csharp_notifier: Option, indexing_cb: Option, cancel_token: CancellationToken, - ) { + rebuild_guard: Arc>, + ) -> tokio::task::JoinHandle<()> { tokio::task::spawn_blocking(move || { + // Two rebuilds on one LMDB db must not overlap. + let _rebuild_guard = rebuild_guard.lock().unwrap_or_else(|p| p.into_inner()); + // Resolve applicable + available indexers up front so we only toggle // the "Indexing" label when there is real work to do. let csharp = symbol_registry @@ -1614,7 +1621,7 @@ impl IndexManager { if let Some(ref cb) = indexing_cb { cb(false); } - }); + }) } /// Run a `RebuildScope::Full` rebuild for one language's indexer, log the @@ -1772,6 +1779,8 @@ impl IndexManager { // a FULL rebuild, not the per-project incremental (issue #225). let mut restore_inputs_changed = false; let mut restore_input_last_event_time: Option = None; + // Serializes the detached symbol rebuild tasks (incremental, restore-input full, branch-change full). + let symbol_rebuild_guard = Arc::new(std::sync::Mutex::new(())); // Symbol indexer debounce: .ts/.tsx/.mts/.cts files are buffered separately // and flushed after SCIP_TYPESCRIPT_DEBOUNCE_MS of quiet time. Unlike C#'s @@ -1839,7 +1848,7 @@ impl IndexManager { // Trigger a fire-and-forget FULL symbol rebuild for every // language that applies, so `find_impact` reflects the new // branch instead of silently serving stale references. - Self::spawn_branch_change_symbol_rebuild( + drop(Self::spawn_branch_change_symbol_rebuild( symbol_registry.clone(), path.clone(), db_path.clone(), @@ -1847,7 +1856,8 @@ impl IndexManager { csharp_notifier.clone(), indexing_cb.clone(), cancel_token.clone(), - ); + symbol_rebuild_guard.clone(), + )); } } } @@ -2063,7 +2073,11 @@ impl IndexManager { // separate from the text-index callback used for // branch-change refreshes above. let indexing_cb_scip = indexing_cb.clone(); + let guard = symbol_rebuild_guard.clone(); tokio::task::spawn_blocking(move || { + // Two rebuilds on one LMDB db must not overlap. + let _rebuild_guard = + guard.lock().unwrap_or_else(|p| p.into_inner()); if let Some(indexer) = reg.get(LANG_CSHARP) { if !indexer.applies_to(&rp) { info!( @@ -2275,7 +2289,11 @@ impl IndexManager { let repo_label = repo_label.clone(); let notifier = csharp_notifier.clone(); let indexing_cb_scip = indexing_cb.clone(); + let guard = symbol_rebuild_guard.clone(); tokio::task::spawn_blocking(move || { + // Two rebuilds on one LMDB db must not overlap. + let _rebuild_guard = + guard.lock().unwrap_or_else(|p| p.into_inner()); if let Some(indexer) = reg.get(LANG_CSHARP) { if !indexer.applies_to(&rp) { info!( @@ -3516,7 +3534,7 @@ mod tests { let mut attempts = 0u32; let out: Result<()> = IndexManager::retry_on_transient_fts(&ms, || { attempts += 1; - async { Err(anyhow::anyhow!("os error 32")) } + async { Err(anyhow::anyhow!("FTS commit failed: os error 32")) } }) .await; assert!(out.is_err()); @@ -3531,7 +3549,7 @@ mod tests { if done { Ok(7) } else { - Err(anyhow::anyhow!("os error 5")) + Err(anyhow::anyhow!("FTS commit failed: os error 5")) } } }) @@ -4345,4 +4363,41 @@ mod tests { "the untracked duplicate chunk must be swept" ); } + + /// The branch-change full rebuild must queue behind the shared guard so it + /// cannot overlap the incremental or restore-input rebuilds on one LMDB db. + #[tokio::test] + async fn branch_change_symbol_rebuild_waits_for_shared_guard() { + let temp = tempdir().unwrap(); + let guard = Arc::new(std::sync::Mutex::new(())); + let held = guard.lock().unwrap(); + + let handle = IndexManager::spawn_branch_change_symbol_rebuild( + Arc::new(SymbolIndexerRegistry::new()), + temp.path().to_path_buf(), + temp.path().join("db"), + "test".to_string(), + None, + None, + CancellationToken::new(), + guard.clone(), + ); + + std::thread::sleep(std::time::Duration::from_millis(300)); + assert!( + !handle.is_finished(), + "rebuild must block while the shared guard is held" + ); + + drop(held); + tokio::time::timeout(std::time::Duration::from_secs(10), handle) + .await + .expect("rebuild must finish once the guard is released") + .expect("rebuild task must not panic"); + + assert!( + guard.try_lock().is_ok(), + "guard must be released after the rebuild" + ); + } } diff --git a/src/symbols/csharp.rs b/src/symbols/csharp.rs index 78c33cd1..7eca79c8 100644 --- a/src/symbols/csharp.rs +++ b/src/symbols/csharp.rs @@ -607,8 +607,11 @@ impl CSharpSymbolIndexer { } let resolved = self.resolve_helper_path(); - let mut lock = self.helper_path.lock().unwrap(); - *lock = Some(resolved.clone()); // cache both Some and None + { + let mut lock = self.helper_path.lock().unwrap(); + *lock = Some(resolved.clone()); // cache both Some and None + } + // After the guard is released: the version probe spawns a subprocess. if let Some(ref path) = resolved { self.log_helper_version_once(path); } diff --git a/src/symbols/csharp_tests.rs b/src/symbols/csharp_tests.rs index d18e9357..167698fd 100644 --- a/src/symbols/csharp_tests.rs +++ b/src/symbols/csharp_tests.rs @@ -3,7 +3,7 @@ use super::csharp::{ drain_pipe_to_tracing, is_helper_warning_line, is_restore_pending, restore_pending_flag_clear, - restore_pending_flag_set, RestorePendingError, + restore_pending_flag_set, RestorePendingError, HELPER_RESTORE_PENDING_EXIT, }; use std::io::Cursor; use std::path::PathBuf; @@ -525,3 +525,33 @@ fn restore_pending_flag_round_trips_through_the_trait_method() { restore_pending_flag_clear(&db_path); assert!(!indexer.restore_pending(&db_path)); } + +#[test] +fn helper_restore_pending_exit_matches_csharp_exit_codes() { + // A drifted exit code would demote restore-pending to a generic helper failure. + let path = concat!(env!("CARGO_MANIFEST_DIR"), "/helpers/csharp/RestoreGate.cs"); + let source = std::fs::read_to_string(path) + .unwrap_or_else(|e| panic!("cannot read {path} to check HELPER_RESTORE_PENDING_EXIT: {e}")); + let line = source + .lines() + .find(|l| l.contains("const int RestorePending")) + .unwrap_or_else(|| { + panic!("{path} has no `const int RestorePending` line to compare with HELPER_RESTORE_PENDING_EXIT") + }); + let raw = line + .split_once('=') + .and_then(|(_, rhs)| rhs.split_once(';')) + .map(|(value, _)| value.trim()) + .unwrap_or_else(|| { + panic!( + "cannot find `= ;` in {line:?} to compare with HELPER_RESTORE_PENDING_EXIT" + ) + }); + let parsed: i32 = raw.parse().unwrap_or_else(|e| { + panic!("C# RestorePending value {raw:?} is not an integer ({e}); compare with HELPER_RESTORE_PENDING_EXIT") + }); + assert_eq!( + parsed, HELPER_RESTORE_PENDING_EXIT, + "C# ExitCodes.RestorePending ({parsed}) and Rust HELPER_RESTORE_PENDING_EXIT ({HELPER_RESTORE_PENDING_EXIT}) must stay in sync" + ); +}