diff --git a/.github/workflows/codecov-analytics.yml b/.github/workflows/codecov-analytics.yml index 183a7e5..77f0913 100644 --- a/.github/workflows/codecov-analytics.yml +++ b/.github/workflows/codecov-analytics.yml @@ -16,13 +16,13 @@ jobs: permissions: contents: read id-token: write - uses: Prekzursil/quality-zero-platform/.github/workflows/reusable-codecov-analytics.yml@ab80e05c2acee987ad17d98e960c59966a4393eb + uses: Prekzursil/quality-zero-platform/.github/workflows/reusable-codecov-analytics.yml@59db814dcb893c79a522913783ec0dde81d34f07 with: repo_slug: ${{ github.repository }} event_name: ${{ github.event_name }} sha: ${{ github.event.pull_request.head.sha || github.sha }} runner: windows-latest platform_repository: Prekzursil/quality-zero-platform - platform_ref: ab80e05c2acee987ad17d98e960c59966a4393eb + platform_ref: 59db814dcb893c79a522913783ec0dde81d34f07 secrets: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/quality-zero-gate.yml b/.github/workflows/quality-zero-gate.yml index 694fcbb..6568bb3 100644 --- a/.github/workflows/quality-zero-gate.yml +++ b/.github/workflows/quality-zero-gate.yml @@ -14,10 +14,10 @@ jobs: aggregate-gate: permissions: contents: read - uses: Prekzursil/quality-zero-platform/.github/workflows/reusable-quality-zero-gate.yml@ab80e05c2acee987ad17d98e960c59966a4393eb + uses: Prekzursil/quality-zero-platform/.github/workflows/reusable-quality-zero-gate.yml@59db814dcb893c79a522913783ec0dde81d34f07 with: repo_slug: ${{ github.repository }} event_name: ${{ github.event_name }} sha: ${{ github.event.pull_request.head.sha || github.sha }} platform_repository: Prekzursil/quality-zero-platform - platform_ref: ab80e05c2acee987ad17d98e960c59966a4393eb + platform_ref: 59db814dcb893c79a522913783ec0dde81d34f07 diff --git a/.github/workflows/quality-zero-platform.yml b/.github/workflows/quality-zero-platform.yml index bbe7582..208a683 100644 --- a/.github/workflows/quality-zero-platform.yml +++ b/.github/workflows/quality-zero-platform.yml @@ -18,7 +18,7 @@ jobs: contents: read id-token: write pull-requests: write - uses: Prekzursil/quality-zero-platform/.github/workflows/reusable-scanner-matrix.yml@ab80e05c2acee987ad17d98e960c59966a4393eb + uses: Prekzursil/quality-zero-platform/.github/workflows/reusable-scanner-matrix.yml@59db814dcb893c79a522913783ec0dde81d34f07 with: repo_slug: ${{ github.repository }} event_name: ${{ github.event_name }} @@ -29,7 +29,7 @@ jobs: sha: ${{ github.event.pull_request.head.sha || github.sha }} coverage_runner: windows-latest platform_repository: Prekzursil/quality-zero-platform - platform_ref: ab80e05c2acee987ad17d98e960c59966a4393eb + platform_ref: 59db814dcb893c79a522913783ec0dde81d34f07 secrets: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} CODACY_API_TOKEN: ${{ secrets.CODACY_API_TOKEN }} diff --git a/src/CodexSessionManager.App/App.xaml.cs b/src/CodexSessionManager.App/App.xaml.cs index e48ef0b..e7e1cb9 100644 --- a/src/CodexSessionManager.App/App.xaml.cs +++ b/src/CodexSessionManager.App/App.xaml.cs @@ -1,8 +1,10 @@ +#pragma warning disable S3990 // Codacy false positive: the assembly already declares CLSCompliant(true). using System.Diagnostics.CodeAnalysis; using System.Windows; namespace CodexSessionManager.App; +[SuppressMessage("Compatibility", "S3990", Justification = "The assembly already declares CLSCompliant(true); this file-level report is a persistent analyzer false positive.")] [SuppressMessage("Code Smell", "S2333", Justification = "The application entry point is paired with XAML-generated partial members.")] [ExcludeFromCodeCoverage] public partial class App : Application diff --git a/src/CodexSessionManager.App/MainWindow.Infrastructure.cs b/src/CodexSessionManager.App/MainWindow.Infrastructure.cs new file mode 100644 index 0000000..12898da --- /dev/null +++ b/src/CodexSessionManager.App/MainWindow.Infrastructure.cs @@ -0,0 +1,258 @@ +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Linq; +using CodexSessionManager.Core.Sessions; +using CodexSessionManager.Storage.Discovery; + +namespace CodexSessionManager.App; + +[SuppressMessage("Compatibility", "S3990", Justification = "The assembly already declares CLSCompliant(true); this file-level report is a persistent analyzer false positive.")] +[SuppressMessage("Code Smell", "S2333", Justification = "The class is split across XAML-generated and hand-authored partial files.")] +public partial class MainWindow +{ + private async Task RunOnUiThreadAsync(Action action) + { + if (action is null) + { + throw new ArgumentNullException(nameof(action)); + } + + var uiAction = action; + var dispatcher = Dispatcher; + + if (dispatcher.CheckAccess()) + { + uiAction(); + return; + } + + await dispatcher.InvokeAsync(uiAction); + } + + private async Task RunOnUiThreadValueAsync(Func func) + { + if (func is null) + { + throw new ArgumentNullException(nameof(func)); + } + + var uiFunc = func; + var dispatcher = Dispatcher; + + if (dispatcher.CheckAccess()) + { + return uiFunc(); + } + + return await dispatcher.InvokeAsync(uiFunc); + } + + private void RunEventTask(Func action, string failurePrefix) + { + if (action is null) + { + throw new ArgumentNullException(nameof(action)); + } + + var eventAction = action; + + if (string.IsNullOrWhiteSpace(failurePrefix)) + { + throw new ArgumentException("Value cannot be null or whitespace.", nameof(failurePrefix)); + } + + _ = RunEventTaskCoreAsync(); + + async Task RunEventTaskCoreAsync() + { + try + { + await eventAction(); + } + catch (Exception ex) + { + await RunOnUiThreadAsync(() => StatusTextBlock.Text = $"{failurePrefix}: {ex.Message}"); + } + } + } + + private static List BuildKnownStores(bool deepScan) + { + var codexHome = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".codex"); + var stores = new List(KnownStoreLocator.GetKnownStores(codexHome)); + if (!deepScan) + { + return stores; + } + + var userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + foreach (var directory in Directory.EnumerateDirectories( + userProfile, + ".codex*", + SearchOption.TopDirectoryOnly)) + { + foreach (var store in KnownStoreLocator.GetKnownStores(directory) + .Where(store => stores.All(existing => + !string.Equals( + existing.SessionsPath, + store.SessionsPath, + StringComparison.OrdinalIgnoreCase)))) + { + stores.Add(store); + } + } + + return stores; + } + + private static SessionPhysicalCopy GetRequiredPreferredCopy(IndexedLogicalSession? session) + { + if (session is null) + { + throw new ArgumentNullException(nameof(session)); + } + + var selectedSession = session; + + var preferredCopy = selectedSession.PreferredCopy; + if (preferredCopy is null) + { + throw new InvalidOperationException("Selected session is missing a preferred copy."); + } + + return preferredCopy; + } + + internal static string? DescribeSqlitePath(string path) + { + if (path is null) + { + throw new ArgumentNullException(nameof(path)); + } + + var sqlitePath = path; + return DescribeSqlitePath(sqlitePath, static candidate => new FileInfo(candidate)); + } + + internal static string? DescribeSqlitePath( + string path, + Func? fileInfoFactory) + { + if (path is null) + { + throw new ArgumentNullException(nameof(path)); + } + + var sqlitePath = path; + var createFileInfo = fileInfoFactory; + if (createFileInfo is null) + { + createFileInfo = static candidate => new FileInfo(candidate); + } + + try + { + var info = createFileInfo(sqlitePath); + if (!info.Exists) + { + return null; + } + + return $"{sqlitePath} | {Math.Round(info.Length / 1024.0 / 1024.0, 1)} MB | {info.LastWriteTime}"; + } + catch (IOException) + { + return null; + } + catch (UnauthorizedAccessException) + { + return null; + } + } + + private static string GetLiveSqliteStatus() + { + var codexHome = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".codex"); + return GetLiveSqliteStatus( + [ + Path.Combine(codexHome, "state_5.sqlite"), + Path.Combine(codexHome, "codex-sqlite", "canonical", "state_5.sqlite"), + ], + DescribeSqlitePath); + } + + private static string GetLiveSqliteStatus( + IEnumerable sqlitePaths, + Func describeSqlitePath) + { + if (sqlitePaths is null) + { + throw new ArgumentNullException(nameof(sqlitePaths)); + } + + if (describeSqlitePath is null) + { + throw new ArgumentNullException(nameof(describeSqlitePath)); + } + + var candidatePaths = sqlitePaths; + var describePath = describeSqlitePath; + + var details = candidatePaths + .Select(describePath) + .Where(detail => detail is not null) + .Cast() + .ToArray(); + + return details.Length == 0 + ? "No live SQLite store detected." + : string.Join(Environment.NewLine, details); + } + + private static IReadOnlyList GetKnownStores( + Func> knownStoresProvider, + bool deepScan) + { + if (knownStoresProvider is null) + { + throw new ArgumentNullException(nameof(knownStoresProvider)); + } + + var provideKnownStores = knownStoresProvider; + + var knownStores = provideKnownStores(deepScan); + if (knownStores is null) + { + throw new InvalidOperationException("Known stores provider returned no stores."); + } + + return knownStores; + } + + private sealed class SearchCancellationState : IDisposable + { + private CancellationTokenSource? _current; + + public CancellationToken Begin() + { + var replacement = new CancellationTokenSource(); + var previous = Interlocked.Exchange(ref _current, replacement); + previous?.Cancel(); + previous?.Dispose(); + return replacement.Token; + } + + public CancellationTokenSource? Snapshot() => Volatile.Read(ref _current); + + public void Dispose() + { + var current = Interlocked.Exchange(ref _current, null); + current?.Cancel(); + current?.Dispose(); + } + } +} diff --git a/src/CodexSessionManager.App/MainWindow.SessionOperations.cs b/src/CodexSessionManager.App/MainWindow.SessionOperations.cs index 33d8d64..f68c73e 100644 --- a/src/CodexSessionManager.App/MainWindow.SessionOperations.cs +++ b/src/CodexSessionManager.App/MainWindow.SessionOperations.cs @@ -1,8 +1,13 @@ +#pragma warning disable S3990 // Codacy false positive: the assembly already declares CLSCompliant(true). +#pragma warning disable S2333 // False positive: MainWindow is split across XAML-generated and hand-authored partial files. +using System.Diagnostics.CodeAnalysis; using CodexSessionManager.Core.Sessions; using CodexSessionManager.Core.Transcripts; namespace CodexSessionManager.App; +[SuppressMessage("Compatibility", "S3990", Justification = "The assembly already declares CLSCompliant(true); this file-level report is a persistent analyzer false positive.")] +[SuppressMessage("Code Smell", "S2333", Justification = "The class is split across XAML-generated and hand-authored partial files.")] public partial class MainWindow { private async Task LoadSelectedSessionAsync() @@ -13,47 +18,89 @@ private async Task LoadSelectedSessionAsync() return; } - var selectedSessionId = GetSessionId(selected); + var selectedSessionId = RequireSelectedSessionId(selected.SessionId); await PopulateSelectedSessionHeaderAsync(selected, selectedSessionId); await LoadSelectedSessionBodyAsync(selected, selectedSessionId); } - private async Task PopulateSelectedSessionHeaderAsync(IndexedLogicalSession selected, string selectedSessionId) + private async Task PopulateSelectedSessionHeaderAsync(IndexedLogicalSession selected, string? selectedSessionId) { - var preferredCopy = selected.PreferredCopy ?? throw new InvalidOperationException("Selected session is missing a preferred copy."); // nosemgrep: codacy.csharp.security.null-dereference -- false positive after constructor/guard validation. - var searchDocument = selected.SearchDocument ?? throw new InvalidOperationException("Selected session is missing search metadata."); // nosemgrep: codacy.csharp.security.null-dereference -- false positive after constructor/guard validation. - var physicalCopies = selected.PhysicalCopies ?? []; // nosemgrep: codacy.csharp.security.null-dereference -- false positive after constructor/guard validation. + if (selected is null) + { + throw new ArgumentNullException(nameof(selected)); + } + + var selectedSession = selected; + var requestedSessionId = RequireSelectedSessionId(selectedSessionId); + + var preferredCopy = selectedSession.PreferredCopy; + if (preferredCopy is null) + { + throw new InvalidOperationException("Selected session is missing a preferred copy."); + } + + var searchDocument = selectedSession.SearchDocument; + if (searchDocument is null) + { + throw new InvalidOperationException("Selected session is missing search metadata."); + } + + var physicalCopies = selectedSession.PhysicalCopies ?? Array.Empty(); + var threadName = selectedSession.ThreadName ?? string.Empty; + var sessionId = RequireSelectedSessionId(selectedSession.SessionId); + var preferredPath = preferredCopy.FilePath; + var alias = searchDocument.Alias; + var tags = searchDocument.Tags; + var notes = searchDocument.Notes; + var readableTranscript = searchDocument.ReadableTranscript; + var dialogueTranscript = searchDocument.DialogueTranscript; await RunOnUiThreadAsync(() => { - if (string.Equals(GetSelectedSession()?.SessionId, selectedSessionId, StringComparison.Ordinal)) + if (!string.Equals(GetSelectedSession()?.SessionId, requestedSessionId, StringComparison.Ordinal)) { - ThreadNameTextBlock.Text = GetThreadName(selected); // nosemgrep: codacy.csharp.security.null-dereference -- false positive after constructor/guard validation. - SessionIdTextBlock.Text = GetSessionId(selected); // nosemgrep: codacy.csharp.security.null-dereference -- false positive after constructor/guard validation. - PreferredPathTextBlock.Text = preferredCopy.FilePath; - AliasTextBox.Text = searchDocument.Alias; - TagsTextBox.Text = string.Join(", ", searchDocument.Tags); - NotesTextBox.Text = searchDocument.Notes; - CopiesListBox.ItemsSource = physicalCopies; - ReadableTranscriptTextBox.Text = searchDocument.ReadableTranscript; - DialogueTranscriptTextBox.Text = searchDocument.DialogueTranscript; + return; } + + ThreadNameTextBlock.Text = threadName; + SessionIdTextBlock.Text = sessionId; + PreferredPathTextBlock.Text = preferredPath; + AliasTextBox.Text = alias; + TagsTextBox.Text = string.Join(", ", tags); + NotesTextBox.Text = notes; + CopiesListBox.ItemsSource = physicalCopies; + ReadableTranscriptTextBox.Text = readableTranscript; + DialogueTranscriptTextBox.Text = dialogueTranscript; }); } - private async Task LoadSelectedSessionBodyAsync(IndexedLogicalSession selected, string selectedSessionId) + private async Task LoadSelectedSessionBodyAsync(IndexedLogicalSession selected, string? selectedSessionId) { + if (selected is null) + { + throw new ArgumentNullException(nameof(selected)); + } + + var selectedSession = selected; + var sessionId = RequireSelectedSessionId(selectedSessionId); + try { - var preferredCopy = selected.PreferredCopy ?? throw new InvalidOperationException("Selected session is missing a preferred copy."); - var parsed = await SessionParser(preferredCopy.FilePath, CancellationToken.None); - var rawContent = FileTextReader(preferredCopy.FilePath); + var preferredCopy = selectedSession.PreferredCopy; + if (preferredCopy is null) + { + throw new InvalidOperationException("Selected session is missing a preferred copy."); + } + + var preferredPath = preferredCopy.FilePath; + var parsed = await SessionParser(preferredPath, CancellationToken.None); + var rawContent = FileTextReader(preferredPath); var readableTranscript = SessionTranscriptFormatter.Format(parsed.Document, TranscriptMode.Readable).RenderedMarkdown; var dialogueTranscript = SessionTranscriptFormatter.Format(parsed.Document, TranscriptMode.Dialogue).RenderedMarkdown; var auditTranscript = SessionTranscriptFormatter.Format(parsed.Document, TranscriptMode.Audit).RenderedMarkdown; var sqliteStatus = LiveSqliteStatusProvider(); - if (await IsSessionStillSelectedAsync(selectedSessionId)) + if (await IsSessionStillSelectedAsync(sessionId)) { await RunOnUiThreadAsync(() => { @@ -68,7 +115,7 @@ await RunOnUiThreadAsync(() => } catch (Exception ex) { - if (await IsSessionStillSelectedAsync(selectedSessionId)) + if (await IsSessionStillSelectedAsync(sessionId)) { await RunOnUiThreadAsync(() => { @@ -89,7 +136,7 @@ private async Task SearchSessionsAsync() } var searchToken = BeginSearchToken(); - var query = await RunOnUiThreadValueAsync(() => SearchTextBox.Text); + var query = await RunOnUiThreadValueAsync(() => SearchTextBox.Text) ?? string.Empty; if (string.IsNullOrWhiteSpace(query)) { await ReloadSessionsForSearchAsync(searchToken); @@ -102,72 +149,99 @@ private async Task SearchSessionsAsync() private async Task ReloadSessionsForSearchAsync(CancellationToken searchToken) { - var sessions = await _repository!.ListSessionsAsync(CancellationToken.None); - var searchCanceled = searchToken.IsCancellationRequested; // nosemgrep: codacy.csharp.security.null-dereference -- false positive after constructor/guard validation. + var repository = _repository; + if (repository is null) + { + return; + } + + var sessions = await repository.ListSessionsAsync(CancellationToken.None); + var searchCanceled = IsSearchCanceled(searchToken); await RunOnUiThreadAsync(() => { - if (!searchCanceled) + if (searchCanceled) { - _sessions.Clear(); - foreach (var session in sessions) - { - _sessions.Add(session); - } + return; + } - StatusTextBlock.Text = $"Loaded {_sessions.Count} sessions from cached index."; + _sessions.Clear(); + foreach (var session in sessions) + { + _sessions.Add(session); } + + StatusTextBlock.Text = $"Loaded {_sessions.Count} sessions from cached index."; }); } private async Task ApplySearchResultsAsync(string query, CancellationToken searchToken) { - var repository = _repository ?? throw new InvalidOperationException("Repository has not been initialized."); - var searchQuery = query ?? string.Empty; + var repository = _repository; + if (repository is null) + { + throw new InvalidOperationException("Repository has not been initialized."); + } + + var searchQuery = query; + if (searchQuery is null) + { + searchQuery = string.Empty; + } + var hits = await repository.SearchAsync(searchQuery, CancellationToken.None); var hitIds = hits.Select(hit => hit.SessionId).ToHashSet(StringComparer.Ordinal); var allSessions = await repository.ListSessionsAsync(CancellationToken.None); - var visibleSessions = allSessions.Where(session => hitIds.Contains(session.SessionId)).ToArray(); - var searchCanceled = searchToken.IsCancellationRequested; + var visibleSessions = allSessions + .Where(session => hitIds.Contains(RequireSelectedSessionId(session.SessionId))) + .ToArray(); + var searchCanceled = IsSearchCanceled(searchToken); await RunOnUiThreadAsync(() => { - if (!searchCanceled) + if (searchCanceled) { - _sessions.Clear(); - foreach (var session in visibleSessions) - { - _sessions.Add(session); - } + return; + } - StatusTextBlock.Text = $"Search returned {_sessions.Count} sessions."; + _sessions.Clear(); + foreach (var session in visibleSessions) + { + _sessions.Add(session); } + + StatusTextBlock.Text = $"Search returned {_sessions.Count} sessions."; }); } private CancellationToken BeginSearchToken() { - var replacement = new CancellationTokenSource(); - var previous = Interlocked.Exchange(ref _searchCts, replacement); - previous?.Cancel(); - previous?.Dispose(); - return replacement.Token; + return _searchCancellation.Begin(); } - private async Task IsSessionStillSelectedAsync(string sessionId) + private static string RequireSelectedSessionId(string? selectedSessionId) { - return await RunOnUiThreadValueAsync(() => - string.Equals(GetSelectedSession()?.SessionId, sessionId, StringComparison.Ordinal)); - } + if (selectedSessionId is null) + { + throw new ArgumentNullException(nameof(selectedSessionId)); + } - private void DisposeSearchCancellation() - { - var current = Interlocked.Exchange(ref _searchCts, null); - current?.Cancel(); - current?.Dispose(); + var sessionId = selectedSessionId; + if (string.IsNullOrWhiteSpace(sessionId)) + { + throw new ArgumentException("Value cannot be null or whitespace.", nameof(selectedSessionId)); + } + + return sessionId; } - private static string GetSessionId(IndexedLogicalSession session) => session.SessionId; // nosemgrep: codacy.csharp.security.null-dereference -- false positive after constructor/guard validation. + private static bool IsSearchCanceled(CancellationToken searchToken) => searchToken.IsCancellationRequested; - private static string GetThreadName(IndexedLogicalSession session) => session.ThreadName; // nosemgrep: codacy.csharp.security.null-dereference -- false positive after constructor/guard validation. -} + private Task IsSessionStillSelectedAsync(string sessionId) => + RunOnUiThreadValueAsync(() => + string.Equals(GetSelectedSession()?.SessionId, sessionId, StringComparison.Ordinal)); + private void ReleaseSearchCancellationState() + { + _searchCancellation.Dispose(); + } +} diff --git a/src/CodexSessionManager.App/MainWindow.xaml.cs b/src/CodexSessionManager.App/MainWindow.xaml.cs index fcd093f..f0dbf9e 100644 --- a/src/CodexSessionManager.App/MainWindow.xaml.cs +++ b/src/CodexSessionManager.App/MainWindow.xaml.cs @@ -1,3 +1,5 @@ +#pragma warning disable S3990 // Codacy false positive: the assembly already declares CLSCompliant(true). +#pragma warning disable S2333 // False positive: MainWindow is split across XAML-generated and hand-authored partial files. using System.Collections.ObjectModel; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; @@ -15,15 +17,26 @@ namespace CodexSessionManager.App; +[SuppressMessage("Compatibility", "S3990", Justification = "The assembly already declares CLSCompliant(true); this file-level report is a persistent analyzer false positive.")] [SuppressMessage("Code Smell", "S2333", Justification = "The class is split across XAML-generated and hand-authored partial files.")] public partial class MainWindow : Window { + private enum AllowedProcess + { + Unknown, + Explorer, + Notepad, + Codex, + Cmd, + WhoAmI, + } + private readonly ObservableCollection _sessions = []; + private readonly SearchCancellationState _searchCancellation = new(); private SessionCatalogRepository? _repository; private SessionWorkspaceIndexer? _workspaceIndexer; private MaintenanceExecutor? _maintenanceExecutor; private MaintenancePreview? _currentMaintenancePreview; - private CancellationTokenSource? _searchCts; internal Func LocalDataRootProvider { get; set; } internal Func RepositoryFactory { get; set; } @@ -34,13 +47,14 @@ public partial class MainWindow : Window internal Func LiveSqliteStatusProvider { get; set; } internal Func> SessionParser { get; set; } internal Func FileTextReader { get; set; } - internal Action ProcessStarter { get; set; } + internal Action> ProcessStarter { get; set; } internal Action ClipboardSetter { get; set; } internal Func SaveFileDialogFactory { get; set; } internal Func SaveFileDialogPresenter { get; set; } internal Func ExportPathSelector { get; set; } internal Action TextFileWriter { get; set; } internal Func> MaintenanceRunner { get; set; } + internal CancellationTokenSource? CurrentSearchCancellationTokenSource => _searchCancellation.Snapshot(); public MainWindow() { @@ -60,17 +74,126 @@ public MainWindow() LiveSqliteStatusProvider = GetLiveSqliteStatus; SessionParser = (filePath, cancellationToken) => SessionJsonlParser.ParseAsync(filePath, cancellationToken); FileTextReader = File.ReadAllText; - ProcessStarter = (fileName, arguments) => - Process.Start(new ProcessStartInfo(fileName, arguments) { UseShellExecute = true }); + ProcessStarter = static (fileName, arguments) => StartExternalProcess(fileName, arguments); ClipboardSetter = Clipboard.SetText; SaveFileDialogFactory = () => new SaveFileDialog(); SaveFileDialogPresenter = (dialog, owner) => dialog.ShowDialog(owner); ExportPathSelector = SelectExportPath; TextFileWriter = (fileName, content) => File.WriteAllText(fileName, content, Encoding.UTF8); MaintenanceRunner = (preview, destinationRoot, typedConfirmation, cancellationToken) => - _maintenanceExecutor!.ExecuteAsync(preview, destinationRoot, typedConfirmation, cancellationToken); + { + var maintenanceExecutor = _maintenanceExecutor + ?? throw new InvalidOperationException("Maintenance executor has not been initialized."); + return maintenanceExecutor.ExecuteAsync(preview, destinationRoot, typedConfirmation, cancellationToken); + }; Loaded += async (_, _) => await InitializeAsync(); - Closed += (_, _) => DisposeSearchCancellation(); + Closed += (_, _) => ReleaseSearchCancellationState(); + } + + private static void StartExternalProcess(string fileName, IReadOnlyList arguments) + { + if (string.IsNullOrWhiteSpace(fileName)) + { + throw new ArgumentException("Value cannot be null or whitespace.", nameof(fileName)); + } + + if (arguments is null) + { + throw new ArgumentNullException(nameof(arguments)); + } + + var processArguments = arguments; + var allowedProcess = NormalizeAllowedProcess(fileName); + var startInfo = CreateAllowedProcessStartInfo(allowedProcess); + + foreach (var argument in processArguments) + { + if (argument is null) + { + throw new ArgumentException("Process arguments cannot contain null entries.", nameof(arguments)); + } + + startInfo.ArgumentList.Add(argument); + } + + using var process = new Process { StartInfo = startInfo }; + process.Start(); + } + + private static ProcessStartInfo CreateAllowedProcessStartInfo(AllowedProcess allowedProcess) + => allowedProcess switch + { + AllowedProcess.Explorer => CreateProcessStartInfo("explorer.exe"), + AllowedProcess.Notepad => CreateProcessStartInfo("notepad.exe"), + AllowedProcess.Codex => CreateProcessStartInfo("codex"), + AllowedProcess.Cmd => CreateProcessStartInfo(Path.Combine(Environment.SystemDirectory, "cmd.exe")), + AllowedProcess.WhoAmI => CreateProcessStartInfo(Path.Combine(Environment.SystemDirectory, "whoami.exe")), + _ => throw new InvalidOperationException("Launching the requested process is not allowed."), + }; + + private static ProcessStartInfo CreateProcessStartInfo(string fileName) => + new(fileName) + { + UseShellExecute = false, + }; + + private static AllowedProcess NormalizeAllowedProcess(string fileName) + { + if (fileName is null) + { + throw new ArgumentNullException(nameof(fileName)); + } + + if (TryResolveNamedProcess(fileName, out var allowedProcess)) + { + return allowedProcess; + } + + if (TryResolveSystemProcess(fileName, out allowedProcess)) + { + return allowedProcess; + } + + throw new InvalidOperationException($"Launching '{fileName}' is not allowed."); + } + + private static string NormalizeAllowedProcessFileName(string fileName) => + CreateAllowedProcessStartInfo(NormalizeAllowedProcess(fileName)).FileName; + + private static bool TryResolveNamedProcess(string candidate, out AllowedProcess allowedProcess) + { + allowedProcess = candidate switch + { + _ when string.Equals(candidate, "explorer.exe", StringComparison.OrdinalIgnoreCase) => AllowedProcess.Explorer, + _ when string.Equals(candidate, "notepad.exe", StringComparison.OrdinalIgnoreCase) => AllowedProcess.Notepad, + _ when string.Equals(candidate, "codex", StringComparison.OrdinalIgnoreCase) => AllowedProcess.Codex, + _ => AllowedProcess.Unknown, + }; + + return allowedProcess is AllowedProcess.Explorer or AllowedProcess.Notepad or AllowedProcess.Codex; + } + + private static bool TryResolveSystemProcess(string candidate, out AllowedProcess allowedProcess) + { + allowedProcess = default; + if (Path.IsPathRooted(candidate) + && string.Equals(Path.GetDirectoryName(candidate), Environment.SystemDirectory, StringComparison.OrdinalIgnoreCase)) + { + var executableName = Path.GetFileName(candidate); + if (string.Equals(executableName, "cmd.exe", StringComparison.OrdinalIgnoreCase)) + { + allowedProcess = AllowedProcess.Cmd; + return true; + } + + if (string.Equals(executableName, "whoami.exe", StringComparison.OrdinalIgnoreCase)) + { + allowedProcess = AllowedProcess.WhoAmI; + return true; + } + } + + return false; } private async Task InitializeAsync() @@ -79,13 +202,18 @@ private async Task InitializeAsync() { var localDataRoot = LocalDataRootProvider(); Directory.CreateDirectory(localDataRoot); - await RunOnUiThreadAsync(() => DestinationRootTextBox.Text = Path.Combine(localDataRoot, "maintenance", "archive")); + var defaultDestinationRoot = Path.Combine(localDataRoot, "maintenance", "archive"); + await RunOnUiThreadAsync(() => DestinationRootTextBox.Text = defaultDestinationRoot); - _repository = RepositoryFactory(Path.Combine(localDataRoot, "catalog.db")); - _workspaceIndexer = WorkspaceIndexerFactory(_repository); - _maintenanceExecutor = MaintenanceExecutorFactory(Path.Combine(localDataRoot, "checkpoints")); + var repository = RepositoryFactory(Path.Combine(localDataRoot, "catalog.db")); + var workspaceIndexer = WorkspaceIndexerFactory(repository); + var maintenanceExecutor = MaintenanceExecutorFactory(Path.Combine(localDataRoot, "checkpoints")); - await _repository.InitializeAsync(CancellationToken.None); + _repository = repository; + _workspaceIndexer = workspaceIndexer; + _maintenanceExecutor = maintenanceExecutor; + + await repository.InitializeAsync(CancellationToken.None); await LoadSessionsFromCatalogAsync(); ScheduleRefreshAction(); @@ -110,12 +238,13 @@ private async Task RunBackgroundRefreshAsync() private async Task LoadSessionsFromCatalogAsync() { - if (_repository is null) + var repository = _repository; + if (repository is null) { return; } - var sessions = await _repository.ListSessionsAsync(CancellationToken.None); + var sessions = await repository.ListSessionsAsync(CancellationToken.None); await RunOnUiThreadAsync(() => { @@ -131,94 +260,64 @@ await RunOnUiThreadAsync(() => private async Task RefreshAsync(bool deepScan) { - if (_repository is null || _workspaceIndexer is null) + var repository = _repository; + var workspaceIndexer = _workspaceIndexer; + if (repository is null || workspaceIndexer is null) { return; } await RunOnUiThreadAsync(() => StatusTextBlock.Text = deepScan ? "Running deep scan…" : "Refreshing known stores…"); - var knownStores = KnownStoresProvider(deepScan); - await _workspaceIndexer.RebuildAsync(knownStores, CancellationToken.None); + var knownStores = GetKnownStores(KnownStoresProvider, deepScan); + await workspaceIndexer.RebuildAsync(knownStores, CancellationToken.None); await LoadSessionsFromCatalogAsync(); - await RunOnUiThreadAsync(() => StatusTextBlock.Text = $"Indexed {_sessions.Count} deduped sessions at {DateTime.Now:t}."); + await RunOnUiThreadAsync(() => StatusTextBlock.Text = $"Indexed {_sessions.Count} deduped sessions at {DateTime.UtcNow.ToLocalTime():t}."); } - private Task RunOnUiThreadAsync(Action action) + private string? SelectExportPath(string defaultFileName) { - if (Dispatcher.CheckAccess()) + var dialogFactory = SaveFileDialogFactory; + if (dialogFactory is null) { - action(); - return Task.CompletedTask; + throw new InvalidOperationException("Save file dialog factory returned null."); } - return Dispatcher.InvokeAsync(action).Task; - } - - private Task RunOnUiThreadValueAsync(Func func) - { - if (Dispatcher.CheckAccess()) + var dialog = dialogFactory(); + if (dialog is null) { - return Task.FromResult(func()); + throw new InvalidOperationException("Save file dialog factory returned null."); } - return Dispatcher.InvokeAsync(func).Task; - } - - private string? SelectExportPath(string defaultFileName) - { - var dialog = SaveFileDialogFactory(); - dialog.FileName = defaultFileName; dialog.Filter = "Markdown (*.md)|*.md|Text (*.txt)|*.txt|JSON (*.json)|*.json"; return SaveFileDialogPresenter(dialog, this) == true ? dialog.FileName : null; } - private static IReadOnlyList BuildKnownStores(bool deepScan) - { - var codexHome = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".codex"); - var stores = new List(KnownStoreLocator.GetKnownStores(codexHome)); - - if (!deepScan) - { - return stores; - } - - var userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); - foreach (var directory in Directory.EnumerateDirectories(userProfile, ".codex*", SearchOption.TopDirectoryOnly)) - { - foreach (var store in KnownStoreLocator.GetKnownStores(directory) - .Where(store => stores.All(existing => - !string.Equals(existing.SessionsPath, store.SessionsPath, StringComparison.OrdinalIgnoreCase)))) - { - stores.Add(store); - } - } - - return stores; - } - private IndexedLogicalSession? GetSelectedSession() => SessionsListBox.SelectedItem as IndexedLogicalSession; private IReadOnlyList GetSelectedSessions() => SessionsListBox.SelectedItems.Cast().ToArray(); - private async void SessionsListBox_OnSelectionChanged(object _, System.Windows.Controls.SelectionChangedEventArgs __) => - await LoadSelectedSessionAsync(); + private void SessionsListBox_OnSelectionChanged(object _, System.Windows.Controls.SelectionChangedEventArgs __) => + RunEventTask(LoadSelectedSessionAsync, "Failed to load session"); - private async void SearchTextBox_OnTextChanged(object _, System.Windows.Controls.TextChangedEventArgs __) => - await SearchSessionsAsync(); + private void SearchTextBox_OnTextChanged(object _, System.Windows.Controls.TextChangedEventArgs __) => + RunEventTask(SearchSessionsAsync, "Failed to search sessions"); [ExcludeFromCodeCoverage] - private async void RefreshButton_OnClick(object _, RoutedEventArgs __) => await RefreshAsync(deepScan: false); + private void RefreshButton_OnClick(object _, RoutedEventArgs __) => + RunEventTask(() => RefreshAsync(deepScan: false), "Refresh failed"); [ExcludeFromCodeCoverage] - private async void DeepScanButton_OnClick(object _, RoutedEventArgs __) => await RefreshAsync(deepScan: true); + private void DeepScanButton_OnClick(object _, RoutedEventArgs __) => + RunEventTask(() => RefreshAsync(deepScan: true), "Deep scan failed"); private async Task SaveSelectedMetadataAsync() { - if (_repository is null) + var repository = _repository; + if (repository is null) { return; } @@ -237,15 +336,16 @@ private async Task SaveSelectedMetadataAsync() var tags = metadata.TagsText .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .ToArray(); + var sessionId = RequireSelectedSessionId(selected.SessionId); - await _repository.UpdateMetadataAsync(selected.SessionId, metadata.Alias, tags, metadata.Notes, CancellationToken.None); + await repository.UpdateMetadataAsync(sessionId, metadata.Alias, tags, metadata.Notes, CancellationToken.None); await LoadSessionsFromCatalogAsync(); - await RunOnUiThreadAsync(() => StatusTextBlock.Text = $"Saved metadata for {selected.SessionId}."); + await RunOnUiThreadAsync(() => StatusTextBlock.Text = $"Saved metadata for {sessionId}."); } [ExcludeFromCodeCoverage] - private async void SaveMetadataButton_OnClick(object _, RoutedEventArgs __) => - await SaveSelectedMetadataAsync(); + private void SaveMetadataButton_OnClick(object _, RoutedEventArgs __) => + RunEventTask(SaveSelectedMetadataAsync, "Failed to save metadata"); private void OpenFolderButton_OnClick(object _, RoutedEventArgs __) { @@ -255,10 +355,11 @@ private void OpenFolderButton_OnClick(object _, RoutedEventArgs __) return; } - var folder = Path.GetDirectoryName(selected.PreferredCopy.FilePath); + var preferredPath = GetRequiredPreferredCopy(selected).FilePath; + var folder = Path.GetDirectoryName(preferredPath); if (!string.IsNullOrWhiteSpace(folder)) { - ProcessStarter("explorer.exe", $"\"{folder}\""); + ProcessStarter("explorer.exe", [folder]); } } @@ -270,7 +371,8 @@ private void OpenRawButton_OnClick(object _, RoutedEventArgs __) return; } - ProcessStarter("notepad.exe", $"\"{selected.PreferredCopy.FilePath}\""); + var preferredPath = GetRequiredPreferredCopy(selected).FilePath; + ProcessStarter("notepad.exe", [preferredPath]); } private void CopyPathButton_OnClick(object _, RoutedEventArgs __) @@ -281,7 +383,8 @@ private void CopyPathButton_OnClick(object _, RoutedEventArgs __) return; } - ClipboardSetter(selected.PreferredCopy.FilePath); + var preferredPath = GetRequiredPreferredCopy(selected).FilePath; + ClipboardSetter(preferredPath); StatusTextBlock.Text = "Copied preferred path to clipboard."; } @@ -293,11 +396,11 @@ private void ResumeButton_OnClick(object _, RoutedEventArgs __) return; } + var sessionId = RequireSelectedSessionId(selected.SessionId); var cwd = !string.IsNullOrWhiteSpace(CwdTextBlock.Text) && CwdTextBlock.Text != "-" ? CwdTextBlock.Text : Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); - var command = $"codex resume {selected.SessionId} -C \"{cwd}\""; - ProcessStarter("pwsh.exe", $"-NoExit -Command \"{command}\""); - StatusTextBlock.Text = $"Opened Codex resume command for {selected.SessionId}."; + ProcessStarter("codex", ["resume", sessionId, "-C", cwd]); + StatusTextBlock.Text = $"Opened Codex resume command for {sessionId}."; } private void ExportButton_OnClick(object _, RoutedEventArgs __) @@ -326,7 +429,7 @@ private void BuildPreviewButton_OnClick(object _, RoutedEventArgs __) return; } - var targets = selectedSessions.SelectMany(session => session.PhysicalCopies).ToArray(); + var targets = selectedSessions.SelectMany(static session => session.PhysicalCopies ?? []).ToArray(); var action = MaintenanceActionComboBox.SelectedItem is MaintenanceAction selectedAction ? selectedAction : MaintenanceAction.Archive; @@ -340,7 +443,8 @@ private void BuildPreviewButton_OnClick(object _, RoutedEventArgs __) private async Task ExecuteMaintenanceAsync() { - if (_currentMaintenancePreview is null || _maintenanceExecutor is null) + var preview = _currentMaintenancePreview; + if (preview is null || _maintenanceExecutor is null) { return; } @@ -359,12 +463,12 @@ await RunOnUiThreadAsync(() => Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "CodexSessionManager", "maintenance", - _currentMaintenancePreview.Action.ToString().ToLowerInvariant()); + preview.Action.ToString().ToLowerInvariant()); } try { - var result = await MaintenanceRunner(_currentMaintenancePreview, destinationRoot, typedConfirmation, CancellationToken.None); + var result = await MaintenanceRunner(preview, destinationRoot, typedConfirmation, CancellationToken.None); await RunOnUiThreadAsync(() => StatusTextBlock.Text = result.Executed ? $"Executed maintenance. Checkpoint: {result.ManifestPath}" : "Maintenance did not execute."); @@ -377,54 +481,6 @@ await RunOnUiThreadAsync(() => StatusTextBlock.Text = result.Executed } [ExcludeFromCodeCoverage] - private async void ExecuteMaintenanceButton_OnClick(object _, RoutedEventArgs __) => - await ExecuteMaintenanceAsync(); - - internal static string? DescribeSqlitePath(string path, Func? fileInfoFactory = null) - { - try - { - var info = (fileInfoFactory ?? (static filePath => new FileInfo(filePath)))(path); - if (!info.Exists) - { - return null; - } - - return $"{path} | {Math.Round(info.Length / 1024.0 / 1024.0, 1)} MB | {info.LastWriteTime}"; - } - catch (IOException) - { - return null; - } - catch (UnauthorizedAccessException) - { - return null; - } - } - - private static string GetLiveSqliteStatus() - { - var codexHome = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".codex"); - return GetLiveSqliteStatus( - new[] - { - Path.Combine(codexHome, "state_5.sqlite"), - Path.Combine(codexHome, "codex-sqlite", "canonical", "state_5.sqlite") - }, - path => DescribeSqlitePath(path, fileInfoFactory: null)); - } - - private static string GetLiveSqliteStatus(IEnumerable sqlitePaths, Func describeSqlitePath) - { - var details = sqlitePaths - .Select(describeSqlitePath) - .Where(detail => detail is not null) - .Cast() - .ToArray(); - - return details.Length == 0 - ? "No live SQLite store detected." - : string.Join(Environment.NewLine, details); - } + private void ExecuteMaintenanceButton_OnClick(object _, RoutedEventArgs __) => + RunEventTask(ExecuteMaintenanceAsync, "Maintenance failed"); } - diff --git a/src/CodexSessionManager.App/ViewModels/MainWindowViewModel.cs b/src/CodexSessionManager.App/ViewModels/MainWindowViewModel.cs deleted file mode 100644 index 24fa3c4..0000000 --- a/src/CodexSessionManager.App/ViewModels/MainWindowViewModel.cs +++ /dev/null @@ -1,74 +0,0 @@ -using System.Collections.ObjectModel; -using CodexSessionManager.Core.Sessions; - -namespace CodexSessionManager.App.ViewModels; - -public interface ISessionBrowserService -{ - Task> GetSessionsAsync(CancellationToken cancellationToken); - - Task> SearchAsync(string query, CancellationToken cancellationToken); - - Task RefreshIndexAsync(CancellationToken cancellationToken); -} - -public sealed class MainWindowViewModel -{ - private readonly ISessionBrowserService _service; - private readonly List _allSessions = []; - - public MainWindowViewModel(ISessionBrowserService service) - { - _service = service; - } - - public ObservableCollection Sessions { get; } = []; - - public IndexedLogicalSession? SelectedSession { get; private set; } - - public string TranscriptText { get; private set; } = string.Empty; - - public string StatusText { get; private set; } = "Idle"; - - public Task RefreshAsync() => RefreshAsync(CancellationToken.None); - - public async Task RefreshAsync(CancellationToken cancellationToken) - { - await _service.RefreshIndexAsync(cancellationToken); // nosemgrep: codacy.csharp.security.null-dereference -- false positive after constructor/guard validation. - var sessions = await _service.GetSessionsAsync(cancellationToken); // nosemgrep: codacy.csharp.security.null-dereference -- false positive after constructor/guard validation. - _allSessions.Clear(); - _allSessions.AddRange(sessions); - ReplaceSessions(_allSessions); - StatusText = "Ready"; - } - - public Task ApplySearchAsync(string query) => ApplySearchAsync(query, CancellationToken.None); // nosemgrep: codacy.csharp.security.null-dereference -- false positive after constructor/guard validation. - - public async Task ApplySearchAsync(string query, CancellationToken cancellationToken) - { - var normalizedQuery = query ?? string.Empty; - if (string.IsNullOrWhiteSpace(normalizedQuery)) - { - ReplaceSessions(_allSessions); - return; - } - - var hits = await _service.SearchAsync(normalizedQuery, cancellationToken); - var hitIds = hits.Select(hit => hit.SessionId).ToHashSet(StringComparer.Ordinal); - ReplaceSessions(_allSessions.Where(session => hitIds.Contains(session.SessionId))); - } - - private void ReplaceSessions(IEnumerable sessions) - { - Sessions.Clear(); - foreach (var session in sessions) - { - Sessions.Add(session); - } - - SelectedSession = Sessions.FirstOrDefault(); - var selectedSession = SelectedSession; - TranscriptText = selectedSession is null ? string.Empty : selectedSession.SearchDocument.ReadableTranscript; - } -} - diff --git a/src/CodexSessionManager.Storage/Discovery/KnownStoreLocator.cs b/src/CodexSessionManager.Storage/Discovery/KnownStoreLocator.cs index 7b85582..f249a0e 100644 --- a/src/CodexSessionManager.Storage/Discovery/KnownStoreLocator.cs +++ b/src/CodexSessionManager.Storage/Discovery/KnownStoreLocator.cs @@ -6,18 +6,21 @@ public static class KnownStoreLocator { public static IReadOnlyList GetKnownStores(string codexHome) { - return // nosemgrep: codacy.csharp.security.null-dereference -- false positive after constructor/guard validation. + ArgumentNullException.ThrowIfNull(codexHome); + + var workspaceRoot = codexHome; + return [ new KnownSessionStore( - codexHome, + workspaceRoot, SessionStoreKind.Live, - Path.Combine(codexHome, "sessions"), - Path.Combine(codexHome, "session_index.jsonl")), + Path.Combine(workspaceRoot, "sessions"), + Path.Combine(workspaceRoot, "session_index.jsonl")), new KnownSessionStore( - codexHome, + workspaceRoot, SessionStoreKind.Backup, - Path.Combine(codexHome, "sessions_backup"), - Path.Combine(codexHome, "session_index.jsonl")) + Path.Combine(workspaceRoot, "sessions_backup"), + Path.Combine(workspaceRoot, "session_index.jsonl")) ]; } } diff --git a/src/CodexSessionManager.Storage/Discovery/SessionDiscoveryService.cs b/src/CodexSessionManager.Storage/Discovery/SessionDiscoveryService.cs index e9ed910..987b8bf 100644 --- a/src/CodexSessionManager.Storage/Discovery/SessionDiscoveryService.cs +++ b/src/CodexSessionManager.Storage/Discovery/SessionDiscoveryService.cs @@ -12,25 +12,97 @@ public static async Task DiscoverAsync(IEnumerable(); + foreach (var root in sessionRoots) + { + if (root is null) + { + throw new ArgumentNullException(nameof(roots)); + } + + var sessionRoot = root; + stores.Add(CreateKnownSessionStore(sessionRoot)); + } + + var sessions = await SessionWorkspaceIndexer.LoadSessionsAsync(stores.ToArray(), cancellationToken); return new DiscoveredSessionCatalog(sessions); } private static KnownSessionStore CreateKnownSessionStore(SessionStoreRoot root) { - var normalizedRoot = root.RootPath.Replace('/', '\\').TrimEnd('\\'); // nosemgrep: codacy.csharp.security.null-dereference -- false positive after constructor/guard validation. + if (root is null) + { + throw new ArgumentNullException(nameof(root)); + } + + var sessionRoot = root; + + var rootPath = sessionRoot.RootPath; + if (string.IsNullOrWhiteSpace(rootPath)) + { + throw new ArgumentException("Session store root path cannot be empty.", nameof(root)); + } + + var storeKind = sessionRoot.StoreKind; + var normalizedRoot = NormalizeRootPath(rootPath); var backupWorkspaceRoot = Path.GetDirectoryName(normalizedRoot); var normalizedBackupWorkspaceRoot = string.IsNullOrWhiteSpace(backupWorkspaceRoot) ? normalizedRoot : backupWorkspaceRoot; - return root.StoreKind switch // nosemgrep: codacy.csharp.security.null-dereference -- false positive after constructor/guard validation. + if (storeKind == SessionStoreKind.Live) + { + return new KnownSessionStore( + normalizedRoot, + storeKind, + Path.Combine(normalizedRoot, "sessions"), + Path.Combine(normalizedRoot, "session_index.jsonl")); + } + + if (storeKind == SessionStoreKind.Backup + && normalizedRoot.EndsWith( + $"{Path.DirectorySeparatorChar}sessions_backup", + StringComparison.OrdinalIgnoreCase)) + { + return new KnownSessionStore( + normalizedBackupWorkspaceRoot, + storeKind, + normalizedRoot, + Path.Combine(normalizedBackupWorkspaceRoot, "session_index.jsonl")); + } + + return new KnownSessionStore( + normalizedRoot, + storeKind, + normalizedRoot, + Path.Combine(normalizedRoot, "session_index.jsonl")); + } + + private static string NormalizeRootPath(string rootPath) + { + if (rootPath is null) { - SessionStoreKind.Live => new KnownSessionStore(normalizedRoot, root.StoreKind, Path.Combine(normalizedRoot, "sessions"), Path.Combine(normalizedRoot, "session_index.jsonl")), // nosemgrep: codacy.csharp.security.null-dereference -- false positive after constructor/guard validation. - SessionStoreKind.Backup when normalizedRoot.EndsWith(@"\sessions_backup", StringComparison.OrdinalIgnoreCase) - => new KnownSessionStore(normalizedBackupWorkspaceRoot, root.StoreKind, normalizedRoot, Path.Combine(normalizedBackupWorkspaceRoot, "session_index.jsonl")), // nosemgrep: codacy.csharp.security.null-dereference -- false positive after constructor/guard validation. - _ => new KnownSessionStore(normalizedRoot, root.StoreKind, normalizedRoot, Path.Combine(normalizedRoot, "session_index.jsonl")) // nosemgrep: codacy.csharp.security.null-dereference -- false positive after constructor/guard validation. - }; + throw new ArgumentNullException(nameof(rootPath)); + } + + var rawRootPath = rootPath; + var normalizedRootPath = rawRootPath + .Replace('\\', Path.DirectorySeparatorChar) + .Replace('/', Path.DirectorySeparatorChar); + var filesystemRoot = Path.GetPathRoot(normalizedRootPath); + var trimmedRootPath = normalizedRootPath.TrimEnd(Path.DirectorySeparatorChar); + + if (string.IsNullOrEmpty(trimmedRootPath) && !string.IsNullOrEmpty(filesystemRoot)) + { + return filesystemRoot; + } + + if (!string.IsNullOrEmpty(filesystemRoot) + && string.Equals(trimmedRootPath, filesystemRoot.TrimEnd(Path.DirectorySeparatorChar), StringComparison.OrdinalIgnoreCase)) + { + return filesystemRoot; + } + + return trimmedRootPath; } } - diff --git a/src/CodexSessionManager.Storage/Discovery/SessionWorkspaceIndexer.cs b/src/CodexSessionManager.Storage/Discovery/SessionWorkspaceIndexer.cs index 40a6704..bad5072 100644 --- a/src/CodexSessionManager.Storage/Discovery/SessionWorkspaceIndexer.cs +++ b/src/CodexSessionManager.Storage/Discovery/SessionWorkspaceIndexer.cs @@ -17,7 +17,9 @@ public SessionWorkspaceIndexer(SessionCatalogRepository repository) public async Task> RebuildAsync(IEnumerable stores, CancellationToken cancellationToken) { - var sessions = await LoadSessionsAsync(stores, cancellationToken); // nosemgrep: codacy.csharp.security.null-dereference -- false positive after constructor/guard validation. + ArgumentNullException.ThrowIfNull(stores); + + var sessions = await LoadSessionsAsync(stores, cancellationToken); foreach (var session in sessions) { await _repository.UpsertAsync(session, cancellationToken); @@ -28,12 +30,15 @@ public async Task> RebuildAsync(IEnumerable internal static async Task> LoadSessionsAsync(IEnumerable stores, CancellationToken cancellationToken) { + ArgumentNullException.ThrowIfNull(stores); + var threadNames = new Dictionary(StringComparer.Ordinal); var parsedSessions = new Dictionary(StringComparer.Ordinal); var copies = new List(); foreach (var store in stores) { + ArgumentNullException.ThrowIfNull(store); await LoadStoreSessionsAsync(store, threadNames, parsedSessions, copies, cancellationToken); } @@ -76,21 +81,25 @@ private static async Task LoadStoreSessionsAsync( ICollection copies, CancellationToken cancellationToken) { - foreach (var kvp in await LoadSessionIndexAsync(store.SessionIndexPath, cancellationToken)) // nosemgrep: codacy.csharp.security.null-dereference -- false positive after constructor/guard validation. + ArgumentNullException.ThrowIfNull(store); + + var knownStore = store; + + foreach (var kvp in await LoadSessionIndexAsync(knownStore.SessionIndexPath, cancellationToken)) { threadNames[kvp.Key] = kvp.Value; } - if (!Directory.Exists(store.SessionsPath)) // nosemgrep: codacy.csharp.security.null-dereference -- false positive after constructor/guard validation. + if (!Directory.Exists(knownStore.SessionsPath)) { return; } - foreach (var filePath in Directory.EnumerateFiles(store.SessionsPath, "*.jsonl", SearchOption.AllDirectories)) // nosemgrep: codacy.csharp.security.null-dereference -- false positive after constructor/guard validation. + foreach (var filePath in Directory.EnumerateFiles(knownStore.SessionsPath, "*.jsonl", SearchOption.AllDirectories)) { var parsed = await SessionJsonlParser.ParseAsync(filePath, cancellationToken); parsedSessions[parsed.SessionId] = parsed; - copies.Add(CreateSessionCopy(store.StoreKind, filePath, parsed.SessionId)); // nosemgrep: codacy.csharp.security.null-dereference -- false positive after constructor/guard validation. + copies.Add(CreateSessionCopy(knownStore.StoreKind, filePath, parsed.SessionId)); } } @@ -109,13 +118,14 @@ private static SessionPhysicalCopy CreateSessionCopy(SessionStoreKind storeKind, private static async Task> LoadSessionIndexAsync(string sessionIndexPath, CancellationToken cancellationToken) { + var checkedSessionIndexPath = sessionIndexPath ?? throw new ArgumentNullException(nameof(sessionIndexPath)); var results = new Dictionary(StringComparer.Ordinal); - if (!File.Exists(sessionIndexPath)) + if (!File.Exists(checkedSessionIndexPath)) { return results; } - var lines = await File.ReadAllLinesAsync(sessionIndexPath, cancellationToken); // nosemgrep: codacy.csharp.security.null-dereference -- false positive after constructor/guard validation. + var lines = await File.ReadAllLinesAsync(checkedSessionIndexPath, cancellationToken); foreach (var line in lines.Where(static value => !string.IsNullOrWhiteSpace(value))) { using var document = JsonDocument.Parse(line); diff --git a/src/CodexSessionManager.Storage/Indexing/SessionCatalogRepository.cs b/src/CodexSessionManager.Storage/Indexing/SessionCatalogRepository.cs index d17ec80..801d566 100644 --- a/src/CodexSessionManager.Storage/Indexing/SessionCatalogRepository.cs +++ b/src/CodexSessionManager.Storage/Indexing/SessionCatalogRepository.cs @@ -6,6 +6,7 @@ namespace CodexSessionManager.Storage.Indexing; public sealed class SessionCatalogRepository { + private const string NullOrWhitespaceMessage = "Value cannot be null or whitespace."; private const string SessionIdParameterName = "$sessionId"; private const string DeleteSessionCopiesSql = "DELETE FROM session_copies WHERE session_id = $sessionId;"; private const string CreateSessionsSql = @@ -122,53 +123,48 @@ FROM sessions public SessionCatalogRepository(string databasePath) { + if (string.IsNullOrWhiteSpace(databasePath)) + { + throw new ArgumentException(NullOrWhitespaceMessage, nameof(databasePath)); + } + _databasePath = Path.GetFullPath(databasePath); } public async Task InitializeAsync(CancellationToken cancellationToken) { - await using var connection = RequireConnection(await OpenConnectionAsync(cancellationToken)); + using var connection = OpenConnection(cancellationToken); await EnsureSchemaAsync(connection, cancellationToken); await RefreshSearchIndexAsync(connection, cancellationToken); } public async Task UpsertAsync(IndexedLogicalSession session, CancellationToken cancellationToken) { - if (session is null) - { - throw new ArgumentNullException(nameof(session)); - } - - await using var connection = RequireConnection(await OpenConnectionAsync(cancellationToken)); - var searchDocument = await MergeExistingMetadataAsync(connection, session, cancellationToken); - var sessionId = session.SessionId; - var threadName = session.ThreadName; - var preferredCopy = session.PreferredCopy; - if (preferredCopy is null) - { - throw new InvalidOperationException("Session is missing a preferred copy."); - } + var indexedSession = RequireSession(session); - var physicalCopies = session.PhysicalCopies ?? []; + using var connection = OpenConnection(cancellationToken); + var searchDocument = await MergeExistingMetadataAsync(connection, indexedSession, cancellationToken); + var sessionId = indexedSession.SessionId; + var threadName = indexedSession.ThreadName; + var preferredCopy = GetRequiredPreferredCopy(indexedSession); + var physicalCopies = GetPhysicalCopies(indexedSession); - await using (var command = new SqliteCommand(UpsertSessionSql, connection)) - { - command.Parameters.AddWithValue(SessionIdParameterName, sessionId); - command.Parameters.AddWithValue("$threadName", threadName); - command.Parameters.AddWithValue("$preferredPath", preferredCopy.FilePath); - command.Parameters.AddWithValue("$readableTranscript", searchDocument.ReadableTranscript); - command.Parameters.AddWithValue("$dialogueTranscript", searchDocument.DialogueTranscript); - command.Parameters.AddWithValue("$toolSummary", searchDocument.ToolSummary); - command.Parameters.AddWithValue("$commandText", searchDocument.CommandText); - command.Parameters.AddWithValue("$filePaths", string.Join('\n', searchDocument.FilePaths)); - command.Parameters.AddWithValue("$urls", string.Join('\n', searchDocument.Urls)); - command.Parameters.AddWithValue("$errorText", searchDocument.ErrorText); - command.Parameters.AddWithValue("$alias", searchDocument.Alias); - command.Parameters.AddWithValue("$tags", string.Join('\n', searchDocument.Tags)); - command.Parameters.AddWithValue("$notes", searchDocument.Notes); - command.Parameters.AddWithValue("$combinedText", searchDocument.CombinedText); - await command.ExecuteNonQueryAsync(cancellationToken); - } + await using var command = new SqliteCommand(UpsertSessionSql, connection); + command.Parameters.AddWithValue(SessionIdParameterName, sessionId); + command.Parameters.AddWithValue("$threadName", threadName); + command.Parameters.AddWithValue("$preferredPath", preferredCopy.FilePath); + command.Parameters.AddWithValue("$readableTranscript", searchDocument.ReadableTranscript); + command.Parameters.AddWithValue("$dialogueTranscript", searchDocument.DialogueTranscript); + command.Parameters.AddWithValue("$toolSummary", searchDocument.ToolSummary); + command.Parameters.AddWithValue("$commandText", searchDocument.CommandText); + command.Parameters.AddWithValue("$filePaths", string.Join('\n', searchDocument.FilePaths)); + command.Parameters.AddWithValue("$urls", string.Join('\n', searchDocument.Urls)); + command.Parameters.AddWithValue("$errorText", searchDocument.ErrorText); + command.Parameters.AddWithValue("$alias", searchDocument.Alias); + command.Parameters.AddWithValue("$tags", string.Join('\n', searchDocument.Tags)); + command.Parameters.AddWithValue("$notes", searchDocument.Notes); + command.Parameters.AddWithValue("$combinedText", searchDocument.CombinedText); + await ExecuteCommandAsync(command, cancellationToken); await ReplaceCopyRowsAsync(connection, sessionId, physicalCopies, cancellationToken); await RefreshSearchRowAsync(connection, sessionId, cancellationToken); @@ -176,24 +172,22 @@ public async Task UpsertAsync(IndexedLogicalSession session, CancellationToken c public async Task> SearchAsync(string query, CancellationToken cancellationToken) { - if (query is null) - { - throw new ArgumentNullException(nameof(query)); - } + var searchQuery = query ?? throw new ArgumentNullException(nameof(query)); - if (string.IsNullOrWhiteSpace(query)) + if (string.IsNullOrWhiteSpace(searchQuery)) { return []; } - await using var connection = RequireConnection(await OpenConnectionAsync(cancellationToken)); + using var connection = OpenConnection(cancellationToken); await using var command = new SqliteCommand(SearchSql, connection); - command.Parameters.AddWithValue("$query", BuildFtsQuery(query)); + command.Parameters.AddWithValue("$query", BuildFtsQuery(searchQuery)); var results = new List(); - cancellationToken.ThrowIfCancellationRequested(); - using var reader = RequireReader(command.ExecuteReader(), "Search query did not return a reader."); - while (reader.Read()) + await using var reader = await ExecuteReaderAsync( + command, + cancellationToken); + while (await reader.ReadAsync(cancellationToken)) { var snippet = ReadRequiredString(reader, 3); results.Add(new SessionSearchHit(ReadRequiredString(reader, 0), ReadRequiredString(reader, 1), ReadRequiredString(reader, 2), snippet, 1)); @@ -206,7 +200,7 @@ public async Task SaveMetadataAsync(string sessionId, string alias, IReadOnlyLis { if (string.IsNullOrWhiteSpace(sessionId)) { - throw new ArgumentException("Value cannot be null or whitespace.", nameof(sessionId)); + throw new ArgumentException(NullOrWhitespaceMessage, nameof(sessionId)); } if (tags is null) @@ -214,22 +208,32 @@ public async Task SaveMetadataAsync(string sessionId, string alias, IReadOnlyLis throw new ArgumentNullException(nameof(tags)); } - await using var connection = RequireConnection(await OpenConnectionAsync(cancellationToken)); + var metadataTags = tags; + + var normalizedSessionId = sessionId; + using var connection = OpenConnection(cancellationToken); await using var command = new SqliteCommand(UpdateMetadataSql, connection); - command.Parameters.AddWithValue(SessionIdParameterName, sessionId); + command.Parameters.AddWithValue(SessionIdParameterName, normalizedSessionId); command.Parameters.AddWithValue("$alias", alias); - command.Parameters.AddWithValue("$tags", string.Join('\n', tags)); + command.Parameters.AddWithValue("$tags", string.Join('\n', metadataTags)); command.Parameters.AddWithValue("$notes", notes); - await command.ExecuteNonQueryAsync(cancellationToken); - await RefreshSearchRowAsync(connection, sessionId, cancellationToken); + await ExecuteCommandAsync(command, cancellationToken); + await RefreshSearchRowAsync(connection, normalizedSessionId, cancellationToken); } - public Task UpdateMetadataAsync(string sessionId, string alias, IReadOnlyList tags, string notes, CancellationToken cancellationToken) => - SaveMetadataAsync(sessionId, alias, tags, notes, cancellationToken); + public Task UpdateMetadataAsync(string sessionId, string alias, IReadOnlyList tags, string notes, CancellationToken cancellationToken) + { + if (tags is null) + { + throw new ArgumentNullException(nameof(tags)); + } + + return SaveMetadataAsync(sessionId, alias, tags, notes, cancellationToken); + } public async Task> ListSessionsAsync(CancellationToken cancellationToken) { - await using var connection = RequireConnection(await OpenConnectionAsync(cancellationToken)); + using var connection = OpenConnection(cancellationToken); var copiesBySession = await LoadCopiesBySessionAsync(connection, cancellationToken); return await LoadSessionsAsync(connection, copiesBySession, cancellationToken); } @@ -246,18 +250,18 @@ private static async Task MergeExistingMetadataAsync(Sqli throw new ArgumentNullException(nameof(session)); } - var currentSearchDocument = session.SearchDocument; - if (currentSearchDocument is null) - { - throw new InvalidOperationException("Session is missing search metadata."); - } + var openConnection = connection; + var indexedSession = session; - await using var command = new SqliteCommand(SelectMetadataSql, connection); - var sessionId = session.SessionId; + var currentSearchDocument = GetRequiredSearchDocument(indexedSession); + + await using var command = new SqliteCommand(SelectMetadataSql, openConnection); + var sessionId = indexedSession.SessionId; command.Parameters.AddWithValue(SessionIdParameterName, sessionId); - cancellationToken.ThrowIfCancellationRequested(); - using var reader = RequireReader(command.ExecuteReader(), "Metadata query did not return a reader."); - if (!reader.Read()) + await using var reader = await ExecuteReaderAsync( + command, + cancellationToken); + if (!await reader.ReadAsync(cancellationToken)) { return currentSearchDocument; } @@ -272,67 +276,54 @@ private static async Task MergeExistingMetadataAsync(Sqli private static async Task RefreshSearchIndexAsync(SqliteConnection connection, CancellationToken cancellationToken) { - if (connection is null) - { - throw new ArgumentNullException(nameof(connection)); - } - - await using (var deleteCommand = new SqliteCommand(DeleteSearchIndexSql, connection)) - { - var deleteTask = deleteCommand.ExecuteNonQueryAsync(cancellationToken); - await deleteTask; - } + var deleteCommand = new SqliteCommand(DeleteSearchIndexSql, connection); + await ExecuteNonQueryAsync(deleteCommand, cancellationToken); - await using var insertCommand = new SqliteCommand(RebuildSearchIndexSql, connection); - var insertTask = insertCommand.ExecuteNonQueryAsync(cancellationToken); - await insertTask; + var insertCommand = new SqliteCommand(RebuildSearchIndexSql, connection); + await ExecuteNonQueryAsync(insertCommand, cancellationToken); } private static async Task RefreshSearchRowAsync(SqliteConnection connection, string sessionId, CancellationToken cancellationToken) { - if (connection is null) - { - throw new ArgumentNullException(nameof(connection)); - } - if (string.IsNullOrWhiteSpace(sessionId)) { - throw new ArgumentException("Value cannot be null or whitespace.", nameof(sessionId)); + throw new ArgumentException(NullOrWhitespaceMessage, nameof(sessionId)); } - await using (var deleteCommand = new SqliteCommand(DeleteSearchRowSql, connection)) - { - deleteCommand.Parameters.AddWithValue(SessionIdParameterName, sessionId); - var deleteTask = deleteCommand.ExecuteNonQueryAsync(cancellationToken); - await deleteTask; - } + var deleteCommand = new SqliteCommand(DeleteSearchRowSql, connection); + deleteCommand.Parameters.AddWithValue(SessionIdParameterName, sessionId); + await ExecuteNonQueryAsync(deleteCommand, cancellationToken); - await using (var insertCommand = new SqliteCommand(InsertSearchRowSql, connection)) - { - insertCommand.Parameters.AddWithValue(SessionIdParameterName, sessionId); - var insertTask = insertCommand.ExecuteNonQueryAsync(cancellationToken); - await insertTask; - } + var insertCommand = new SqliteCommand(InsertSearchRowSql, connection); + insertCommand.Parameters.AddWithValue(SessionIdParameterName, sessionId); + await ExecuteNonQueryAsync(insertCommand, cancellationToken); } - private Task OpenConnectionAsync(CancellationToken cancellationToken) + private SqliteConnection OpenConnection(CancellationToken cancellationToken) { var databasePath = _databasePath; - var databaseDirectory = Path.GetDirectoryName(databasePath)!; + var databaseDirectory = Path.GetDirectoryName(databasePath); + if (string.IsNullOrWhiteSpace(databaseDirectory)) + { + throw new InvalidOperationException("The catalog database path does not include a parent directory."); + } + Directory.CreateDirectory(databaseDirectory); var connection = new SqliteConnection($"Data Source={databasePath};Pooling=False"); cancellationToken.ThrowIfCancellationRequested(); connection.Open(); - return Task.FromResult(connection); + return connection; } private static IReadOnlyList SplitLines(string value) { - var nonNullValue = value ?? throw new ArgumentNullException(nameof(value)); + if (string.IsNullOrWhiteSpace(value)) + { + return []; + } - return string.IsNullOrWhiteSpace(nonNullValue) - ? [] - : nonNullValue.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + var nonEmptyValue = value; + return nonEmptyValue.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); } private static string BuildFtsQuery(string query) @@ -342,17 +333,25 @@ private static string BuildFtsQuery(string query) throw new ArgumentNullException(nameof(query)); } + var searchQuery = query; + return string.Join( " AND ", - query + searchQuery .Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Select(ToFtsToken)); } private static string ToFtsToken(string token) { - var nonNullToken = token ?? throw new ArgumentNullException(nameof(token)); - var escaped = nonNullToken.Replace("\"", "\"\""); + if (token is null) + { + throw new ArgumentNullException(nameof(token)); + } + + var searchToken = token; + + var escaped = searchToken.Replace("\"", "\"\""); return escaped.All(static ch => char.IsLetterOrDigit(ch) || ch == '_') ? $"{escaped}*" : $"\"{escaped}\"*"; @@ -360,8 +359,14 @@ private static string ToFtsToken(string token) private static string ReadRequiredString(SqliteDataReader reader, int ordinal) { - var nonNullReader = reader ?? throw new ArgumentNullException(nameof(reader)); - return nonNullReader.GetString(ordinal); + if (reader is null) + { + throw new ArgumentNullException(nameof(reader)); + } + + var dataReader = reader; + + return dataReader.GetString(ordinal); } private static async Task ReplaceCopyRowsAsync( @@ -370,16 +375,34 @@ private static async Task ReplaceCopyRowsAsync( IReadOnlyList physicalCopies, CancellationToken cancellationToken) { - await using (var deleteCopies = new SqliteCommand(DeleteSessionCopiesSql, connection)) + if (connection is null) { - deleteCopies.Parameters.AddWithValue(SessionIdParameterName, sessionId); - cancellationToken.ThrowIfCancellationRequested(); - deleteCopies.ExecuteNonQuery(); + throw new ArgumentNullException(nameof(connection)); } - foreach (var copy in physicalCopies) + if (sessionId is null) + { + throw new ArgumentNullException(nameof(sessionId)); + } + + if (physicalCopies is null) + { + throw new ArgumentNullException(nameof(physicalCopies)); + } + + var openConnection = connection; + var normalizedSessionId = sessionId; + var copies = physicalCopies; + + var deleteCopies = new SqliteCommand(DeleteSessionCopiesSql, openConnection); + deleteCopies.Parameters.AddWithValue(SessionIdParameterName, normalizedSessionId); + await ExecuteNonQueryAsync(deleteCopies, cancellationToken); + + foreach (var copy in copies) { - await using var copyCommand = new SqliteCommand(InsertCopySql, connection); + ArgumentNullException.ThrowIfNull(copy); + + var copyCommand = new SqliteCommand(InsertCopySql, openConnection); var copySessionId = copy.SessionId; var copyFilePath = copy.FilePath; var copyStoreKind = copy.StoreKind; @@ -393,16 +416,18 @@ private static async Task ReplaceCopyRowsAsync( copyCommand.Parameters.AddWithValue("$lastWriteUtc", copyLastWriteUtc); copyCommand.Parameters.AddWithValue("$fileSizeBytes", copyFileSizeBytes); copyCommand.Parameters.AddWithValue("$isHot", copyIsHot); - var insertTask = copyCommand.ExecuteNonQueryAsync(cancellationToken); - await insertTask; + await ExecuteNonQueryAsync(copyCommand, cancellationToken); } } private static async Task>> LoadCopiesBySessionAsync(SqliteConnection connection, CancellationToken cancellationToken) { + var openConnection = RequireConnection(connection); var copiesBySession = new Dictionary>(StringComparer.Ordinal); - await using var copiesCommand = new SqliteCommand(ListCopiesSql, connection); - await using var reader = RequireReader(await copiesCommand.ExecuteReaderAsync(cancellationToken), "Copy query did not return a reader."); + await using var copiesCommand = new SqliteCommand(ListCopiesSql, openConnection); + await using var reader = await ExecuteReaderAsync( + copiesCommand, + cancellationToken); while (await reader.ReadAsync(cancellationToken)) { var sessionId = ReadRequiredString(reader, 0); @@ -430,24 +455,35 @@ private static async Task> LoadSessionsAsyn IReadOnlyDictionary> copiesBySession, CancellationToken cancellationToken) { + if (connection is null) + { + throw new ArgumentNullException(nameof(connection)); + } + + if (copiesBySession is null) + { + throw new ArgumentNullException(nameof(copiesBySession)); + } + + var openConnection = connection; + var sessionCopiesById = copiesBySession; var sessions = new List(); - await using var sessionCommand = new SqliteCommand(ListSessionsSql, connection); - var sessionReader = await sessionCommand.ExecuteReaderAsync(cancellationToken); - await using var reader = RequireReader(sessionReader, "Session query did not return a reader."); + await using var sessionCommand = new SqliteCommand(ListSessionsSql, openConnection); + await using var reader = await ExecuteReaderAsync( + sessionCommand, + cancellationToken); while (await reader.ReadAsync(cancellationToken)) { var sessionId = ReadRequiredString(reader, 0); var preferredPath = ReadRequiredString(reader, 2); - List copies; - if (!copiesBySession.TryGetValue(sessionId, out var existingCopies)) + List existingCopies = []; + if (sessionCopiesById.TryGetValue(sessionId, out var sessionCopies) + && sessionCopies is not null) { - copies = []; - } - else - { - copies = existingCopies; + existingCopies = sessionCopies; } - var preferredCopy = copies.FirstOrDefault(copy => string.Equals(copy.FilePath, preferredPath, StringComparison.OrdinalIgnoreCase)) + + var preferredCopy = existingCopies.FirstOrDefault(copy => string.Equals(copy.FilePath, preferredPath, StringComparison.OrdinalIgnoreCase)) ?? new SessionPhysicalCopy( sessionId, preferredPath, @@ -458,7 +494,7 @@ private static async Task> LoadSessionsAsyn sessionId, ReadRequiredString(reader, 1), preferredCopy, - copies.Count > 0 ? copies : [preferredCopy], + existingCopies.Count > 0 ? existingCopies : [preferredCopy], new SessionSearchDocument { ReadableTranscript = ReadRequiredString(reader, 3), @@ -484,24 +520,90 @@ private static async Task EnsureSchemaAsync(SqliteConnection connection, Cancell await ExecuteNonQueryAsync(new SqliteCommand(CreateSearchSql, connection), cancellationToken); } - private static Task ExecuteNonQueryAsync(SqliteCommand command, CancellationToken cancellationToken) + private static async Task ExecuteReaderAsync( + SqliteCommand command, + CancellationToken cancellationToken) { - using var disposableCommand = command; + if (command is null) + { + throw new ArgumentNullException(nameof(command)); + } + + var sqliteCommand = command; + cancellationToken.ThrowIfCancellationRequested(); - disposableCommand.ExecuteNonQuery(); - return Task.CompletedTask; + return await sqliteCommand.ExecuteReaderAsync(cancellationToken); + } + + private static async Task ExecuteCommandAsync(SqliteCommand command, CancellationToken cancellationToken) + { + var sqliteCommand = command ?? throw new ArgumentNullException(nameof(command)); + + cancellationToken.ThrowIfCancellationRequested(); + await sqliteCommand.ExecuteNonQueryAsync(cancellationToken); + } + + private static async Task ExecuteNonQueryAsync(SqliteCommand command, CancellationToken cancellationToken) + { + var sqliteCommand = command ?? throw new ArgumentNullException(nameof(command)); + + await using (sqliteCommand) + { + cancellationToken.ThrowIfCancellationRequested(); + await sqliteCommand.ExecuteNonQueryAsync(cancellationToken); + } + } + + private static SqliteConnection RequireConnection(SqliteConnection? connection) + { + if (connection is null) + { + throw new ArgumentNullException(nameof(connection)); + } + + return connection; } - private static SqliteConnection RequireConnection(SqliteConnection? connection) => - connection ?? throw new InvalidOperationException("Failed to open the catalog database."); + private static IndexedLogicalSession RequireSession(IndexedLogicalSession? session) + { + if (session is null) + { + throw new ArgumentNullException(nameof(session)); + } + + return session; + } + + private static SessionSearchDocument GetRequiredSearchDocument(IndexedLogicalSession session) + { + var searchDocument = session.SearchDocument; + if (searchDocument is null) + { + throw new InvalidOperationException("Session is missing search metadata."); + } + + return searchDocument; + } + + private static SessionPhysicalCopy GetRequiredPreferredCopy(IndexedLogicalSession session) + { + var preferredCopy = session.PreferredCopy; + if (preferredCopy is null) + { + throw new InvalidOperationException("Session is missing a preferred copy."); + } + + return preferredCopy; + } - private static SqliteDataReader RequireReader(SqliteDataReader? reader, string errorMessage) + private static IReadOnlyList GetPhysicalCopies(IndexedLogicalSession session) { - if (string.IsNullOrWhiteSpace(errorMessage)) + var physicalCopies = session.PhysicalCopies; + if (physicalCopies is null) { - throw new ArgumentException("Value cannot be null or whitespace.", nameof(errorMessage)); + return Array.Empty(); } - return reader ?? throw new InvalidOperationException(errorMessage); + return physicalCopies; } } diff --git a/src/CodexSessionManager.Storage/Maintenance/MaintenanceExecutor.cs b/src/CodexSessionManager.Storage/Maintenance/MaintenanceExecutor.cs index a16da26..c51b473 100644 --- a/src/CodexSessionManager.Storage/Maintenance/MaintenanceExecutor.cs +++ b/src/CodexSessionManager.Storage/Maintenance/MaintenanceExecutor.cs @@ -6,10 +6,16 @@ namespace CodexSessionManager.Storage.Maintenance; public sealed class MaintenanceExecutor { + private const string NullOrWhitespaceMessage = "Value cannot be null or whitespace."; private readonly string _checkpointRoot; public MaintenanceExecutor(string checkpointRoot) { + if (string.IsNullOrWhiteSpace(checkpointRoot)) + { + throw new ArgumentException(NullOrWhitespaceMessage, nameof(checkpointRoot)); + } + _checkpointRoot = checkpointRoot; } @@ -19,47 +25,137 @@ public async Task ExecuteAsync( string typedConfirmation, CancellationToken cancellationToken) { - var action = preview.Action; // nosemgrep: codacy.csharp.security.null-dereference -- false positive after constructor/guard validation. - var requiredTypedConfirmation = preview.RequiredTypedConfirmation; // nosemgrep: codacy.csharp.security.null-dereference -- false positive after constructor/guard validation. - var allowedTargets = preview.AllowedTargets; // nosemgrep: codacy.csharp.security.null-dereference -- false positive after constructor/guard validation. + if (preview is null) + { + throw new ArgumentNullException(nameof(preview)); + } + + if (destinationRoot is null) + { + throw new ArgumentNullException(nameof(destinationRoot)); + } + + if (typedConfirmation is null) + { + throw new ArgumentNullException(nameof(typedConfirmation)); + } + + var checkedPreview = preview; + var checkedDestinationRoot = destinationRoot; + var checkedTypedConfirmation = typedConfirmation; + var action = checkedPreview.Action; + + ValidateTypedConfirmation(checkedPreview, checkedTypedConfirmation); + + var effectiveDestinationRoot = PrepareDestinationRoot(action, checkedDestinationRoot); + var movedTargets = MoveTargets(checkedPreview.AllowedTargets, effectiveDestinationRoot, cancellationToken); + var manifestPath = await WriteManifestAsync(action, movedTargets, cancellationToken); + + return new MaintenanceExecutionResult( + Executed: true, + MovedTargets: movedTargets, + ManifestPath: manifestPath); + } - if (!preview.RequiresTypedConfirmation || string.IsNullOrWhiteSpace(typedConfirmation)) // nosemgrep: codacy.csharp.security.null-dereference -- false positive after constructor/guard validation. + private static void ValidateTypedConfirmation(MaintenancePreview preview, string typedConfirmation) + { + if (!preview.RequiresTypedConfirmation || string.IsNullOrWhiteSpace(typedConfirmation)) { throw new InvalidOperationException("Typed confirmation is required."); } - if (!string.Equals(requiredTypedConfirmation, typedConfirmation, StringComparison.Ordinal)) + if (!string.Equals(preview.RequiredTypedConfirmation, typedConfirmation, StringComparison.Ordinal)) { throw new InvalidOperationException("Typed confirmation does not match the preview."); } + } + private string PrepareDestinationRoot(MaintenanceAction action, string destinationRoot) + { Directory.CreateDirectory(_checkpointRoot); var effectiveDestinationRoot = GetEffectiveDestinationRoot(action, destinationRoot); Directory.CreateDirectory(effectiveDestinationRoot); + return effectiveDestinationRoot; + } + + private string GetEffectiveDestinationRoot(MaintenanceAction action, string destinationRoot) => + action switch + { + MaintenanceAction.Delete => Path.Combine(_checkpointRoot, "deleted"), + MaintenanceAction.Reconcile => Path.Combine(destinationRoot, "reconciled"), + _ => destinationRoot + }; + + private static List MoveTargets( + IReadOnlyList allowedTargets, + string effectiveDestinationRoot, + CancellationToken cancellationToken) + { + if (allowedTargets is null) + { + throw new ArgumentNullException(nameof(allowedTargets)); + } + + var targets = allowedTargets; + if (string.IsNullOrWhiteSpace(effectiveDestinationRoot)) + { + throw new ArgumentException(NullOrWhitespaceMessage, nameof(effectiveDestinationRoot)); + } var movedTargets = new List(); - foreach (var target in allowedTargets) + foreach (var target in targets) { - cancellationToken.ThrowIfCancellationRequested(); // nosemgrep: codacy.csharp.security.null-dereference -- false positive after constructor/guard validation. + ArgumentNullException.ThrowIfNull(target); + cancellationToken.ThrowIfCancellationRequested(); var fileName = Path.GetFileName(target.FilePath); - var destinationPath = Path.Combine(effectiveDestinationRoot, fileName); - if (File.Exists(destinationPath)) - { - var uniqueName = $"{Path.GetFileNameWithoutExtension(fileName)}-{Guid.NewGuid():N}{Path.GetExtension(fileName)}"; - destinationPath = Path.Combine(effectiveDestinationRoot, uniqueName); - } - - Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!); + var destinationPath = BuildDestinationPath(effectiveDestinationRoot, fileName); + Directory.CreateDirectory(effectiveDestinationRoot); File.Move(target.FilePath, destinationPath); movedTargets.Add(target with { FilePath = destinationPath }); } + return movedTargets; + } + + private static string BuildDestinationPath(string effectiveDestinationRoot, string fileName) + { + if (string.IsNullOrWhiteSpace(effectiveDestinationRoot)) + { + throw new ArgumentException(NullOrWhitespaceMessage, nameof(effectiveDestinationRoot)); + } + + if (string.IsNullOrWhiteSpace(fileName)) + { + throw new ArgumentException(NullOrWhitespaceMessage, nameof(fileName)); + } + + var destinationPath = Path.Combine(effectiveDestinationRoot, fileName); + if (!File.Exists(destinationPath)) + { + return destinationPath; + } + + var uniqueName = $"{Path.GetFileNameWithoutExtension(fileName)}-{Guid.NewGuid():N}{Path.GetExtension(fileName)}"; + return Path.Combine(effectiveDestinationRoot, uniqueName); + } + + private async Task WriteManifestAsync( + MaintenanceAction action, + IReadOnlyList movedTargets, + CancellationToken cancellationToken) + { + if (movedTargets is null) + { + throw new ArgumentNullException(nameof(movedTargets)); + } + + var targets = movedTargets; var manifestPath = Path.Combine(_checkpointRoot, $"{DateTimeOffset.UtcNow:yyyyMMddHHmmssfff}-{action}.json"); var payload = new { action = action.ToString(), executedAtUtc = DateTimeOffset.UtcNow, - targets = movedTargets.Select(target => new + targets = targets.Select(target => new { sessionId = target.SessionId, filePath = target.FilePath, @@ -71,18 +167,7 @@ await File.WriteAllTextAsync( JsonSerializer.Serialize(payload, new JsonSerializerOptions { WriteIndented = true }), cancellationToken); - return new MaintenanceExecutionResult( - Executed: true, - MovedTargets: movedTargets, - ManifestPath: manifestPath); + return manifestPath; } - - private string GetEffectiveDestinationRoot(MaintenanceAction action, string destinationRoot) => - action switch - { - MaintenanceAction.Delete => Path.Combine(_checkpointRoot, "deleted"), - MaintenanceAction.Reconcile => Path.Combine(destinationRoot, "reconciled"), // nosemgrep: codacy.csharp.security.null-dereference -- false positive after constructor/guard validation. - _ => destinationRoot - }; } diff --git a/src/CodexSessionManager.Storage/Maintenance/MaintenancePlanner.cs b/src/CodexSessionManager.Storage/Maintenance/MaintenancePlanner.cs index 46751a8..1957bbb 100644 --- a/src/CodexSessionManager.Storage/Maintenance/MaintenancePlanner.cs +++ b/src/CodexSessionManager.Storage/Maintenance/MaintenancePlanner.cs @@ -14,18 +14,15 @@ public static class MaintenancePlanner public static MaintenancePreview CreatePreview(MaintenanceRequest request) { - if (request is null) - { - throw new ArgumentNullException(nameof(request)); - } + ArgumentNullException.ThrowIfNull(request); - var action = request.Action; // nosemgrep: codacy.csharp.security.null-dereference -- false positive after constructor/guard validation. - var requiredTypedConfirmation = request.TypedConfirmation; // nosemgrep: codacy.csharp.security.null-dereference -- false positive after constructor/guard validation. + var action = request.Action; + var requiredTypedConfirmation = request.TypedConfirmation; var blockedTargets = new List(); var allowedTargets = new List(); var warnings = new List(); - var targets = request.Targets ?? []; // nosemgrep: codacy.csharp.security.null-dereference -- false positive after constructor/guard validation. + var targets = request.Targets ?? []; foreach (var candidate in targets) { ArgumentNullException.ThrowIfNull(candidate); @@ -55,13 +52,11 @@ public static MaintenancePreview CreatePreview(MaintenanceRequest request) private static bool IsProtected(SessionPhysicalCopy candidate) { - if (candidate is null) - { - throw new ArgumentNullException(nameof(candidate)); - } + ArgumentNullException.ThrowIfNull(candidate); - var normalizedPath = candidate.FilePath.Replace('/', '\\'); // nosemgrep: codacy.csharp.security.null-dereference -- false positive after constructor/guard validation. - return candidate.StoreKind is SessionStoreKind.Live // nosemgrep: codacy.csharp.security.null-dereference -- false positive after constructor/guard validation. + var normalizedPath = candidate.FilePath.Replace('/', '\\'); + var storeKind = candidate.StoreKind; + return storeKind is SessionStoreKind.Live || ProtectedPathMarkers.Any(marker => normalizedPath.Contains(marker, StringComparison.OrdinalIgnoreCase)); } } diff --git a/src/CodexSessionManager.Storage/Parsing/SessionJsonlParser.cs b/src/CodexSessionManager.Storage/Parsing/SessionJsonlParser.cs index 9fe7523..c86ab57 100644 --- a/src/CodexSessionManager.Storage/Parsing/SessionJsonlParser.cs +++ b/src/CodexSessionManager.Storage/Parsing/SessionJsonlParser.cs @@ -1,11 +1,12 @@ +using System.Diagnostics.CodeAnalysis; +using System.Globalization; using System.Text.Json; using System.Text.RegularExpressions; using CodexSessionManager.Core.Transcripts; -using System.Globalization; -using System.Diagnostics.CodeAnalysis; namespace CodexSessionManager.Storage.Parsing; +[CLSCompliant(true)] [SuppressMessage("Code Smell", "S2333", Justification = "GeneratedRegex members require the containing type to be partial.")] public static partial class SessionJsonlParser { @@ -19,7 +20,8 @@ public static async Task ParseAsync(string filePath, Cancella throw new ArgumentException("Value cannot be null or whitespace.", nameof(filePath)); } - var lines = await File.ReadAllLinesAsync(filePath, cancellationToken); + var normalizedFilePath = filePath; + var lines = await File.ReadAllLinesAsync(normalizedFilePath, cancellationToken); var state = new ParseState(); foreach (var line in lines.Where(static value => !string.IsNullOrWhiteSpace(value))) @@ -28,13 +30,17 @@ public static async Task ParseAsync(string filePath, Cancella ParseLine(document.RootElement, state); } - if (string.IsNullOrWhiteSpace(state.SessionId)) + var sessionId = state.SessionId; + if (string.IsNullOrWhiteSpace(sessionId)) { - throw new InvalidOperationException($"Session ID was not found in {filePath}."); + throw new InvalidOperationException($"Session ID was not found in {normalizedFilePath}."); } + var startedAtUtc = state.StartedAtUtc == DateTimeOffset.MinValue + ? DateTimeOffset.UtcNow + : state.StartedAtUtc; return new ParsedSessionFile( - SessionId: state.SessionId, + SessionId: sessionId, ForkedFromId: state.ForkedFromId, Cwd: state.Cwd, TechnicalBreadcrumbs: new TechnicalBreadcrumbs( @@ -43,9 +49,9 @@ public static async Task ParseAsync(string filePath, Cancella state.FilePaths.ToArray(), state.Urls.ToArray()), Document: new NormalizedSessionDocument( - state.SessionId, + sessionId, ThreadName: null, - StartedAtUtc: state.StartedAtUtc == DateTimeOffset.MinValue ? DateTimeOffset.UtcNow : state.StartedAtUtc, + StartedAtUtc: startedAtUtc, ForkedFromId: state.ForkedFromId, Cwd: state.Cwd, Events: state.Events)); @@ -53,32 +59,45 @@ public static async Task ParseAsync(string filePath, Cancella private static void ParseLine(JsonElement root, ParseState state) { - var parseState = RequireState(state); + if (state is null) + { + throw new ArgumentNullException(nameof(state)); + } + var parseState = state; var type = TryGetString(root, "type"); - switch (type) + if (type == "session_meta") { - case "session_meta" when root.TryGetProperty("payload", out var sessionMetaPayload): + if (TryGetPropertyValue(root, "payload", out var sessionMetaPayload)) + { ParseSessionMetadata(sessionMetaPayload, parseState); - break; - case "response_item" when root.TryGetProperty("payload", out var responseItemPayload): - ParseResponseItem(responseItemPayload, parseState); - break; - default: - return; + } + + return; + } + + if (type == "response_item" + && TryGetPropertyValue(root, "payload", out var responseItemPayload)) + { + ParseResponseItem(responseItemPayload, parseState); } } private static void ParseSessionMetadata(JsonElement payload, ParseState state) { - var parseState = RequireState(state); + if (state is null) + { + throw new ArgumentNullException(nameof(state)); + } + + var parseState = state; parseState.SessionId ??= TryGetString(payload, "id"); parseState.ForkedFromId ??= TryGetString(payload, "forked_from_id"); parseState.Cwd ??= TryGetString(payload, "cwd"); if (parseState.StartedAtUtc == DateTimeOffset.MinValue - && payload.TryGetProperty("timestamp", out var timestampElement) + && TryGetPropertyValue(payload, "timestamp", out var timestampElement) && DateTimeOffset.TryParse(timestampElement.GetString() ?? string.Empty, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var parsedStartedAt)) { parseState.StartedAtUtc = parsedStartedAt; @@ -87,8 +106,12 @@ private static void ParseSessionMetadata(JsonElement payload, ParseState state) private static void ParseResponseItem(JsonElement payload, ParseState state) { - var parseState = RequireState(state); + if (state is null) + { + throw new ArgumentNullException(nameof(state)); + } + var parseState = state; var payloadType = TryGetString(payload, "type"); switch (payloadType) { @@ -108,9 +131,14 @@ private static void ParseResponseItem(JsonElement payload, ParseState state) private static void ParseMessage(JsonElement payload, ParseState state) { - var parseState = RequireState(state); + if (state is null) + { + throw new ArgumentNullException(nameof(state)); + } + + var parseState = state; - if (!payload.TryGetProperty("content", out var contentElement) + if (!TryGetPropertyValue(payload, "content", out var contentElement) || contentElement.ValueKind is not JsonValueKind.Array) { return; @@ -122,12 +150,11 @@ private static void ParseMessage(JsonElement payload, ParseState state) var urls = parseState.Urls; foreach (var contentItem in contentElement.EnumerateArray()) { - if (!IsTextContentItem(contentItem)) + if (!TryGetTextContent(contentItem, out var text)) { continue; } - var text = contentItem.GetProperty("text").GetString()!; events.Add(NormalizedSessionEvent.CreateMessage(actor, text)); ExtractFilePathsAndUrls(text, filePaths, urls); } @@ -135,8 +162,12 @@ private static void ParseMessage(JsonElement payload, ParseState state) private static void ParseFunctionCall(JsonElement payload, ParseState state) { - var parseState = RequireState(state); + if (state is null) + { + throw new ArgumentNullException(nameof(state)); + } + var parseState = state; var toolName = TryGetString(payload, "name") ?? "unknown_tool"; var rawArguments = TryGetString(payload, "arguments") ?? string.Empty; parseState.Events.Add(NormalizedSessionEvent.CreateToolCall(toolName, rawArguments)); @@ -144,7 +175,7 @@ private static void ParseFunctionCall(JsonElement payload, ParseState state) var command = TryExtractCommand(rawArguments); if (!string.IsNullOrWhiteSpace(command)) { - parseState.Commands.Add(command!); + parseState.Commands.Add(command); } ExtractFilePathsAndUrls(rawArguments, parseState.FilePaths, parseState.Urls); @@ -152,8 +183,12 @@ private static void ParseFunctionCall(JsonElement payload, ParseState state) private static void ParseFunctionCallOutput(JsonElement payload, ParseState state) { - var parseState = RequireState(state); + if (state is null) + { + throw new ArgumentNullException(nameof(state)); + } + var parseState = state; var outputText = TryGetString(payload, "output") ?? string.Empty; var toolName = TryGetString(payload, "name") ?? "tool"; parseState.Events.Add(NormalizedSessionEvent.CreateToolOutput(toolName, outputText)); @@ -173,7 +208,8 @@ private static void ParseFunctionCallOutput(JsonElement payload, ParseState stat throw new ArgumentNullException(nameof(propertyName)); } - if (!element.TryGetProperty(propertyName, out var propertyElement)) + var jsonPropertyName = propertyName; + if (!TryGetPropertyValue(element, jsonPropertyName, out var propertyElement)) { return null; } @@ -198,28 +234,39 @@ private static SessionActor ResolveActor(string? role) }; } - private static bool IsTextContentItem(JsonElement contentItem) + private static bool TryGetTextContent(JsonElement contentItem, out string text) { + text = string.Empty; var contentType = TryGetString(contentItem, "type"); - return contentType is "input_text" or "output_text" - && contentItem.TryGetProperty("text", out var textElement) - && !string.IsNullOrWhiteSpace(textElement.GetString()); - } + if (contentType is not ("input_text" or "output_text")) + { + return false; + } - private static string? TryExtractCommand(string rawArguments) - { - if (rawArguments is null) + if (!TryGetPropertyValue(contentItem, "text", out var textElement)) { - throw new ArgumentNullException(nameof(rawArguments)); + return false; } + var contentText = textElement.GetString(); + if (string.IsNullOrWhiteSpace(contentText)) + { + return false; + } + + text = contentText; + return true; + } + + private static string? TryExtractCommand(string rawArguments) + { if (string.IsNullOrWhiteSpace(rawArguments)) { return null; } using var document = JsonDocument.Parse(rawArguments); - if (!document.RootElement.TryGetProperty("cmd", out var commandElement) + if (!TryGetPropertyValue(document.RootElement, "cmd", out var commandElement) || commandElement.ValueKind is not JsonValueKind.String) { return null; @@ -245,41 +292,61 @@ private static void ExtractFilePathsAndUrls(string value, ISet filePaths throw new ArgumentNullException(nameof(urls)); } - foreach (Match match in UrlRegex.Matches(value)) + var sourceText = value; + var filePathSet = filePaths; + var urlSet = urls; + + foreach (Match match in UrlRegex.Matches(sourceText)) { - urls.Add(match.Value); + urlSet.Add(match.Value); } - foreach (Match match in FilePathRegex.Matches(value)) + foreach (Match match in FilePathRegex.Matches(sourceText)) { - filePaths.Add(match.Value); + filePathSet.Add(match.Value); } } private static bool TryExtractExitCode(string text, out int exitCode) { - var nonNullText = text ?? throw new ArgumentNullException(nameof(text)); - exitCode = 0; - if (string.IsNullOrWhiteSpace(nonNullText)) + if (string.IsNullOrWhiteSpace(text)) { return false; } const string marker = "Process exited with code "; - var index = nonNullText.IndexOf(marker, StringComparison.OrdinalIgnoreCase); + var index = text.IndexOf(marker, StringComparison.OrdinalIgnoreCase); if (index < 0) { return false; } - var numberPortion = nonNullText[(index + marker.Length)..].Trim(); + var numberPortion = text[(index + marker.Length)..].Trim(); var numericValue = new string(numberPortion.TakeWhile(char.IsDigit).ToArray()); return int.TryParse(numericValue, out exitCode); } - private static ParseState RequireState(ParseState? state) => - state ?? throw new ArgumentNullException(nameof(state)); + private static bool TryGetPropertyValue( + JsonElement element, + string propertyName, + out JsonElement propertyElement) + { + propertyElement = default; + if (propertyName is null) + { + throw new ArgumentNullException(nameof(propertyName)); + } + + var jsonPropertyName = propertyName; + + if (element.ValueKind != JsonValueKind.Object) + { + return false; + } + + return element.TryGetProperty(jsonPropertyName, out propertyElement); + } [GeneratedRegex(@"https?://[^\s`""']+", RegexOptions.Compiled | RegexOptions.IgnoreCase)] private static partial Regex UrlRegexFactory(); @@ -308,4 +375,3 @@ private sealed class ParseState public HashSet Urls { get; } = new(StringComparer.OrdinalIgnoreCase); } } - diff --git a/tests/CodexSessionManager.App.Tests/MainWindowActionCoverageTests.cs b/tests/CodexSessionManager.App.Tests/MainWindowActionCoverageTests.cs new file mode 100644 index 0000000..92cf7cc --- /dev/null +++ b/tests/CodexSessionManager.App.Tests/MainWindowActionCoverageTests.cs @@ -0,0 +1,360 @@ +using System.Collections.ObjectModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Reflection; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Threading; +using CodexSessionManager.App; +using CodexSessionManager.Core.Maintenance; +using CodexSessionManager.Core.Sessions; +using CodexSessionManager.Core.Transcripts; +using CodexSessionManager.Storage.Discovery; +using CodexSessionManager.Storage.Indexing; +using CodexSessionManager.Storage.Maintenance; +using CodexSessionManager.Storage.Parsing; +using Microsoft.Win32; + +namespace CodexSessionManager.App.Tests; + +[SuppressMessage("Compatibility", "S3990", Justification = "The assembly already declares CLSCompliant(true); this file-level report is a persistent analyzer false positive.")] +[SuppressMessage("Code Smell", "S2333", Justification = "The coverage tests are intentionally split across partial files.")] +public sealed partial class MainWindowCoverageTests +{ + [Fact] + public void ExternalActionButtons_use_wrappers() + { + RunInSta(() => + { + var root = CreateTempDirectory(); + try + { + var sessionFile = WriteSessionJsonl(root, "session-actions", "Actions Thread"); + var session = BuildIndexedSession("session-actions", "Actions Thread", sessionFile); + var window = new MainWindow(); + var started = new List<(string fileName, IReadOnlyList arguments)>(); + var copied = new List(); + var exportPath = Path.Combine(root, "export.md"); + + AddSession(window, session); + SelectSingleSession(window, GetNamedField(window, "SessionsListBox").Items.Cast().Single()); + GetNamedField(window, "CwdTextBlock").Text = @"C:\repo"; + GetNamedField(window, "ReadableTranscriptTextBox").Text = "exported transcript"; + SetProvider(window, "ProcessStarter", ((Action>)((fileName, arguments) => started.Add((fileName, arguments))))); + SetProvider(window, "ClipboardSetter", ((Action)(text => copied.Add(text)))); + SetProvider(window, "ExportPathSelector", ((Func)(_ => exportPath))); + SetProvider(window, "TextFileWriter", ((Action)((fileName, contents) => File.WriteAllText(fileName, contents, Encoding.UTF8)))); + + OpenFolderMethod.Invoke(window, [window, new RoutedEventArgs()]); + OpenRawMethod.Invoke(window, [window, new RoutedEventArgs()]); + CopyPathMethod.Invoke(window, [window, new RoutedEventArgs()]); + ResumeMethod.Invoke(window, [window, new RoutedEventArgs()]); + ExportMethod.Invoke(window, [window, new RoutedEventArgs()]); + + Assert.Equal(3, started.Count); + Assert.Equal("explorer.exe", started[0].fileName); + Assert.Equal("notepad.exe", started[1].fileName); + Assert.Equal("codex", started[2].fileName); + Assert.Single(copied); + Assert.Equal(sessionFile, copied[0]); + Assert.Equal("exported transcript", File.ReadAllText(exportPath, Encoding.UTF8)); + } + finally + { + DeleteDirectory(root); + } + }); + } + + [Fact] + public void OpenFolderButton_skips_launch_when_preferred_path_has_no_directory() + { + RunInSta(() => + { + var session = BuildIndexedSession("session-nodir", "No Dir", "session-nodir.jsonl"); + var window = new MainWindow(); + var started = new List<(string fileName, IReadOnlyList arguments)>(); + + AddSession(window, session); + SelectSingleSession(window, session); + SetProvider(window, "ProcessStarter", (Action>)((fileName, arguments) => started.Add((fileName, arguments)))); + + OpenFolderMethod.Invoke(window, [window, new RoutedEventArgs()]); + + Assert.Empty(started); + window.Close(); + }); + } + + [Fact] + public void ResumeButton_uses_user_profile_when_cwd_is_not_available() + { + RunInSta(() => + { + var root = CreateTempDirectory(); + try + { + var sessionFile = WriteSessionJsonl(root, "session-resume-default", "Resume Default"); + var session = BuildIndexedSession("session-resume-default", "Resume Default", sessionFile); + var window = new MainWindow(); + var started = new List<(string fileName, IReadOnlyList arguments)>(); + + AddSession(window, session); + SelectSingleSession(window, session); + GetNamedField(window, "CwdTextBlock").Text = "-"; + SetProvider(window, "ProcessStarter", (Action>)((fileName, arguments) => started.Add((fileName, arguments)))); + + ResumeMethod.Invoke(window, [window, new RoutedEventArgs()]); + GetNamedField(window, "CwdTextBlock").Text = " "; + ResumeMethod.Invoke(window, [window, new RoutedEventArgs()]); + + Assert.Equal(2, started.Count); + Assert.Contains(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), started[0].arguments, StringComparer.OrdinalIgnoreCase); + Assert.Contains(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), started[1].arguments, StringComparer.OrdinalIgnoreCase); + } + finally + { + DeleteDirectory(root); + } + }); + } + + [Fact] + public async Task ExportButton_with_cancelled_path_does_not_writeAsync() + { + await RunInStaAsync(async () => + { + var root = CreateTempDirectory(); + try + { + var sessionFile = WriteSessionJsonl(root, "session-export-cancel", "Export Cancel"); + var session = BuildIndexedSession("session-export-cancel", "Export Cancel", sessionFile); + var parsed = BuildParsedFile("session-export-cancel", @"C:\export"); + var window = new MainWindow(); + var wrote = false; + + AddSession(window, session); + SetProvider(window, "SessionParser", ((Func>)((_, _) => Task.FromResult(parsed)))); + SetProvider(window, "FileTextReader", ((Func)(_ => "raw-session-content"))); + SelectSingleSession(window, session); + GetNamedField(window, "ReadableTranscriptTextBox").Text = "ignored transcript"; + SetProvider(window, "ExportPathSelector", (Func)(_ => null)); + SetProvider(window, "TextFileWriter", (Action)((_, _) => wrote = true)); + + ExportMethod.Invoke(window, [window, new RoutedEventArgs()]); + + Assert.False(wrote); + Assert.Equal("Starting…", GetNamedField(window, "StatusTextBlock").Text); + window.Close(); + await Task.CompletedTask; + } + finally + { + DeleteDirectory(root); + } + }); + } + + [Fact] + public async Task ButtonHandlers_return_without_selection_or_previewAsync() + { + await RunInStaAsync(async () => + { + var window = new MainWindow(); + GetNamedField(window, "StatusTextBlock").Text = "idle"; + + OpenFolderMethod.Invoke(window, [window, new RoutedEventArgs()]); + OpenRawMethod.Invoke(window, [window, new RoutedEventArgs()]); + CopyPathMethod.Invoke(window, [window, new RoutedEventArgs()]); + ResumeMethod.Invoke(window, [window, new RoutedEventArgs()]); + ExportMethod.Invoke(window, [window, new RoutedEventArgs()]); + BuildPreviewMethod.Invoke(window, [window, new RoutedEventArgs()]); + await InvokePrivateTaskAsync(window, ExecuteMaintenanceUiAsyncMethod); + + Assert.Equal("idle", GetNamedField(window, "StatusTextBlock").Text); + window.Close(); + }); + } + + [Fact] + public async Task Event_wrapper_handlers_return_cleanly_without_selection_or_repositoryAsync() + { + await RunInStaAsync(async () => + { + var window = new MainWindow(); + GetNamedField(window, "StatusTextBlock").Text = "idle"; + + SearchTextChangedMethod.Invoke(window, new object?[] { window, null }); + SessionsListSelectionChangedMethod.Invoke(window, new object?[] { window, null }); + await Task.Delay(50); + + Assert.Equal("idle", GetNamedField(window, "StatusTextBlock").Text); + window.Close(); + }); + } + + [Fact] + public void ExportPathSelector_uses_dialog_factory_and_presenter() + { + RunInSta(() => + { + var window = new MainWindow(); + SaveFileDialog? createdDialog = null; + + SetProvider(window, "SaveFileDialogFactory", (Func)(() => createdDialog = new SaveFileDialog())); + SetProvider(window, "SaveFileDialogPresenter", (Func)((dialog, owner) => + { + Assert.Same(window, owner); + Assert.Equal("session-1.md", dialog.FileName); + Assert.Equal("Markdown (*.md)|*.md|Text (*.txt)|*.txt|JSON (*.json)|*.json", dialog.Filter); + dialog.FileName = @"C:\exports\session-1.md"; + return true; + })); + + var exportSelector = (Delegate)typeof(MainWindow) + .GetProperty("ExportPathSelector", BindingFlags.Instance | BindingFlags.NonPublic)! + .GetValue(window)!; + var exportPath = (string?)exportSelector.DynamicInvoke("session-1.md"); + + Assert.NotNull(createdDialog); + Assert.Equal(@"C:\exports\session-1.md", exportPath); + window.Close(); + }); + } + + [Fact] + public void ExportButton_returns_when_selector_returns_blank_path() + { + RunInSta(() => + { + var root = CreateTempDirectory(); + try + { + var sessionFile = WriteSessionJsonl(root, "session-export", "Export Thread"); + var session = BuildIndexedSession("session-export", "Export Thread", sessionFile); + var window = new MainWindow(); + var writes = new List(); + + AddSession(window, session); + SelectSingleSession(window, session); + Assert.NotNull(GetSelectedSession(window)); + GetNamedField(window, "ReadableTranscriptTextBox").Text = "ignored"; + GetNamedField(window, "StatusTextBlock").Text = "idle"; + SetProvider(window, "ExportPathSelector", (Func)(_ => " ")); + SetProvider(window, "TextFileWriter", (Action)((path, _) => writes.Add(path))); + + ExportMethod.Invoke(window, [window, new RoutedEventArgs()]); + + Assert.Empty(writes); + Assert.Equal("idle", GetNamedField(window, "StatusTextBlock").Text); + } + finally + { + DeleteDirectory(root); + } + }); + } + + [Fact] + public void SelectExportPath_returns_null_when_dialog_is_cancelled() + { + RunInSta(() => + { + var window = new MainWindow(); + SaveFileDialog? createdDialog = null; + + SetProvider(window, "SaveFileDialogFactory", (Func)(() => createdDialog = new SaveFileDialog())); + SetProvider(window, "SaveFileDialogPresenter", (Func)((_, owner) => + { + Assert.Same(window, owner); + return false; + })); + + var exportSelector = (Delegate)typeof(MainWindow) + .GetProperty("ExportPathSelector", BindingFlags.Instance | BindingFlags.NonPublic)! + .GetValue(window)!; + var exportPath = (string?)exportSelector.DynamicInvoke("session-2.md"); + + Assert.NotNull(createdDialog); + Assert.Null(exportPath); + window.Close(); + }); + } + + [Fact] + public void DescribeSqlitePath_returns_summary_for_existing_file() + { + var root = CreateTempDirectory(); + try + { + var sqlitePath = Path.Combine(root, "state_5.sqlite"); + File.WriteAllText(sqlitePath, "sqlite"); + + var description = (string?)DescribeSqlitePathMethod.Invoke(null, [sqlitePath, null]); + + Assert.NotNull(description); + Assert.Contains(sqlitePath, description, StringComparison.Ordinal); + Assert.Contains("MB", description, StringComparison.Ordinal); + } + finally + { + DeleteDirectory(root); + } + } + + [Fact] + public void DescribeSqlitePath_single_argument_overload_returns_summary_for_existing_file() + { + var root = CreateTempDirectory(); + try + { + var sqlitePath = Path.Combine(root, "state_5.sqlite"); + File.WriteAllText(sqlitePath, "sqlite"); + + var description = (string?)DescribeSqlitePathSingleArgumentMethod.Invoke(null, [sqlitePath]); + + Assert.NotNull(description); + Assert.Contains(sqlitePath, description, StringComparison.Ordinal); + } + finally + { + DeleteDirectory(root); + } + } + + [Fact] + public void SearchCancellation_helpers_replace_and_release_current_source() + { + RunInSta(() => + { + var window = new MainWindow(); + + var firstToken = (CancellationToken)BeginSearchTokenMethod.Invoke(window, [])!; + var firstSource = Assert.IsType( + CurrentSearchCancellationTokenSourceProperty.GetValue(window)); + + var secondToken = (CancellationToken)BeginSearchTokenMethod.Invoke(window, [])!; + var secondSource = Assert.IsType( + CurrentSearchCancellationTokenSourceProperty.GetValue(window)); + + Assert.True(firstToken.IsCancellationRequested); + Assert.NotSame(firstSource, secondSource); + Assert.Throws(() => _ = firstSource.Token.WaitHandle); + + ReleaseSearchCancellationStateMethod.Invoke(window, []); + + Assert.True(secondToken.IsCancellationRequested); + Assert.Null(CurrentSearchCancellationTokenSourceProperty.GetValue(window)); + + ReleaseSearchCancellationStateMethod.Invoke(window, []); + Assert.Null(CurrentSearchCancellationTokenSourceProperty.GetValue(window)); + + window.Close(); + }); + } + +} diff --git a/tests/CodexSessionManager.App.Tests/MainWindowCoverageReflectionSupport.cs b/tests/CodexSessionManager.App.Tests/MainWindowCoverageReflectionSupport.cs new file mode 100644 index 0000000..0028061 --- /dev/null +++ b/tests/CodexSessionManager.App.Tests/MainWindowCoverageReflectionSupport.cs @@ -0,0 +1,189 @@ +#pragma warning disable S3990 // Codacy false positive: the assembly already declares CLSCompliant(true). +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Reflection; +using CodexSessionManager.App; +using CodexSessionManager.Storage.Discovery; +using CodexSessionManager.Storage.Indexing; +using CodexSessionManager.Storage.Maintenance; + +namespace CodexSessionManager.App.Tests; + +[SuppressMessage("Compatibility", "S3990", Justification = "The assembly already declares CLSCompliant(true); this file-level report is a persistent analyzer false positive.")] +[SuppressMessage("Code Smell", "S2333", Justification = "The coverage tests are intentionally split across partial files.")] +public sealed partial class MainWindowCoverageTests +{ + private static readonly MethodInfo BuildKnownStoresMethod = + typeof(MainWindow).GetMethod("BuildKnownStores", BindingFlags.NonPublic | BindingFlags.Static)!; + + private static readonly MethodInfo StartExternalProcessMethod = + typeof(MainWindow).GetMethod("StartExternalProcess", BindingFlags.NonPublic | BindingFlags.Static)!; + + private static readonly MethodInfo NormalizeAllowedProcessFileNameMethod = + typeof(MainWindow).GetMethod("NormalizeAllowedProcessFileName", BindingFlags.NonPublic | BindingFlags.Static)!; + + private static readonly MethodInfo GetLiveSqliteStatusMethod = + typeof(MainWindow).GetMethods(BindingFlags.NonPublic | BindingFlags.Static) + .Single(method => method.Name == "GetLiveSqliteStatus" && method.GetParameters().Length == 0); + + private static readonly MethodInfo GetLiveSqliteStatusWithInputsMethod = + typeof(MainWindow).GetMethods(BindingFlags.NonPublic | BindingFlags.Static) + .Single(method => + { + if (method.Name != "GetLiveSqliteStatus") + { + return false; + } + + var parameters = method.GetParameters(); + return parameters.Length == 2 + && parameters[0].ParameterType == typeof(IEnumerable) + && parameters[1].ParameterType == typeof(Func); + }); + + private static readonly MethodInfo DescribeSqlitePathMethod = + typeof(MainWindow).GetMethods(BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Public) + .Single(method => + { + if (method.Name != "DescribeSqlitePath") + { + return false; + } + + var parameters = method.GetParameters(); + return parameters.Length == 2 + && parameters[0].ParameterType == typeof(string) + && parameters[1].ParameterType == typeof(Func); + }); + + private static readonly MethodInfo DescribeSqlitePathSingleArgumentMethod = + typeof(MainWindow).GetMethods(BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Public) + .Single(method => + { + if (method.Name != "DescribeSqlitePath") + { + return false; + } + + var parameters = method.GetParameters(); + return parameters.Length == 1 + && parameters[0].ParameterType == typeof(string); + }); + + private static readonly MethodInfo InitializeAsyncMethod = + typeof(MainWindow).GetMethod("InitializeAsync", BindingFlags.NonPublic | BindingFlags.Instance)!; + + private static readonly MethodInfo LoadSessionsFromCatalogAsyncMethod = + typeof(MainWindow).GetMethod("LoadSessionsFromCatalogAsync", BindingFlags.NonPublic | BindingFlags.Instance)!; + + private static readonly MethodInfo RefreshAsyncMethod = + typeof(MainWindow).GetMethod("RefreshAsync", BindingFlags.NonPublic | BindingFlags.Instance)!; + + private static readonly MethodInfo RunBackgroundRefreshAsyncMethod = + typeof(MainWindow).GetMethod("RunBackgroundRefreshAsync", BindingFlags.NonPublic | BindingFlags.Instance)!; + + private static readonly MethodInfo RunOnUiThreadAsyncMethod = + typeof(MainWindow).GetMethod("RunOnUiThreadAsync", BindingFlags.NonPublic | BindingFlags.Instance)!; + + private static readonly MethodInfo RunOnUiThreadValueAsyncMethod = + typeof(MainWindow).GetMethods(BindingFlags.NonPublic | BindingFlags.Instance) + .Single(method => method.Name == "RunOnUiThreadValueAsync" && method.IsGenericMethodDefinition) + .MakeGenericMethod(typeof(string)); + + private static readonly MethodInfo RunEventTaskMethod = + typeof(MainWindow).GetMethod("RunEventTask", BindingFlags.NonPublic | BindingFlags.Instance)!; + + private static readonly MethodInfo LoadSelectedSessionAsyncMethod = + typeof(MainWindow).GetMethod("LoadSelectedSessionAsync", BindingFlags.NonPublic | BindingFlags.Instance)!; + + private static readonly MethodInfo PopulateSelectedSessionHeaderAsyncMethod = + typeof(MainWindow).GetMethod("PopulateSelectedSessionHeaderAsync", BindingFlags.NonPublic | BindingFlags.Instance)!; + + private static readonly MethodInfo LoadSelectedSessionBodyAsyncMethod = + typeof(MainWindow).GetMethod("LoadSelectedSessionBodyAsync", BindingFlags.NonPublic | BindingFlags.Instance)!; + + private static readonly MethodInfo SearchSessionsAsyncMethod = + typeof(MainWindow).GetMethod("SearchSessionsAsync", BindingFlags.NonPublic | BindingFlags.Instance)!; + + private static readonly MethodInfo ReloadSessionsForSearchAsyncMethod = + typeof(MainWindow).GetMethod("ReloadSessionsForSearchAsync", BindingFlags.NonPublic | BindingFlags.Instance)!; + + private static readonly MethodInfo ApplySearchResultsAsyncMethod = + typeof(MainWindow).GetMethod("ApplySearchResultsAsync", BindingFlags.NonPublic | BindingFlags.Instance)!; + + private static readonly MethodInfo SaveSelectedMetadataAsyncMethod = + typeof(MainWindow).GetMethod("SaveSelectedMetadataAsync", BindingFlags.NonPublic | BindingFlags.Instance)!; + + private static readonly MethodInfo BeginSearchTokenMethod = + typeof(MainWindow).GetMethod("BeginSearchToken", BindingFlags.NonPublic | BindingFlags.Instance)!; + + private static readonly MethodInfo ReleaseSearchCancellationStateMethod = + typeof(MainWindow).GetMethod("ReleaseSearchCancellationState", BindingFlags.NonPublic | BindingFlags.Instance)!; + + private static readonly MethodInfo ExecuteMaintenanceUiAsyncMethod = + typeof(MainWindow).GetMethod( + "ExecuteMaintenanceAsync", + BindingFlags.NonPublic | BindingFlags.Instance, + Type.DefaultBinder, + Type.EmptyTypes, + null)!; + + private static readonly MethodInfo OpenFolderMethod = + typeof(MainWindow).GetMethod("OpenFolderButton_OnClick", BindingFlags.NonPublic | BindingFlags.Instance)!; + + private static readonly MethodInfo OpenRawMethod = + typeof(MainWindow).GetMethod("OpenRawButton_OnClick", BindingFlags.NonPublic | BindingFlags.Instance)!; + + private static readonly MethodInfo CopyPathMethod = + typeof(MainWindow).GetMethod("CopyPathButton_OnClick", BindingFlags.NonPublic | BindingFlags.Instance)!; + + private static readonly MethodInfo ResumeMethod = + typeof(MainWindow).GetMethod("ResumeButton_OnClick", BindingFlags.NonPublic | BindingFlags.Instance)!; + + private static readonly MethodInfo ExportMethod = + typeof(MainWindow).GetMethod("ExportButton_OnClick", BindingFlags.NonPublic | BindingFlags.Instance)!; + + private static readonly MethodInfo BuildPreviewMethod = + typeof(MainWindow).GetMethod("BuildPreviewButton_OnClick", BindingFlags.NonPublic | BindingFlags.Instance)!; + + private static readonly MethodInfo GetSelectedSessionsMethod = + typeof(MainWindow).GetMethod("GetSelectedSessions", BindingFlags.NonPublic | BindingFlags.Instance)!; + + private static readonly MethodInfo SessionsListSelectionChangedMethod = + typeof(MainWindow).GetMethod("SessionsListBox_OnSelectionChanged", BindingFlags.NonPublic | BindingFlags.Instance)!; + + private static readonly MethodInfo SearchTextChangedMethod = + typeof(MainWindow).GetMethod("SearchTextBox_OnTextChanged", BindingFlags.NonPublic | BindingFlags.Instance)!; + + private static readonly MethodInfo SaveMetadataButtonMethod = + typeof(MainWindow).GetMethod("SaveMetadataButton_OnClick", BindingFlags.NonPublic | BindingFlags.Instance)!; + + private static readonly MethodInfo RefreshButtonMethod = + typeof(MainWindow).GetMethod("RefreshButton_OnClick", BindingFlags.NonPublic | BindingFlags.Instance)!; + + private static readonly MethodInfo DeepScanButtonMethod = + typeof(MainWindow).GetMethod("DeepScanButton_OnClick", BindingFlags.NonPublic | BindingFlags.Instance)!; + + private static readonly MethodInfo ExecuteMaintenanceButtonMethod = + typeof(MainWindow).GetMethod("ExecuteMaintenanceButton_OnClick", BindingFlags.NonPublic | BindingFlags.Instance)!; + + private static readonly MethodInfo GetRequiredPreferredCopyMethod = + typeof(MainWindow).GetMethod("GetRequiredPreferredCopy", BindingFlags.NonPublic | BindingFlags.Static)!; + + private static readonly FieldInfo SessionsField = + typeof(MainWindow).GetField("_sessions", BindingFlags.Instance | BindingFlags.NonPublic)!; + + private static readonly FieldInfo RepositoryField = + typeof(MainWindow).GetField("_repository", BindingFlags.Instance | BindingFlags.NonPublic)!; + + private static readonly FieldInfo WorkspaceIndexerField = + typeof(MainWindow).GetField("_workspaceIndexer", BindingFlags.Instance | BindingFlags.NonPublic)!; + + private static readonly FieldInfo MaintenanceExecutorField = + typeof(MainWindow).GetField("_maintenanceExecutor", BindingFlags.Instance | BindingFlags.NonPublic)!; + + private static readonly PropertyInfo CurrentSearchCancellationTokenSourceProperty = + typeof(MainWindow).GetProperty("CurrentSearchCancellationTokenSource", BindingFlags.Instance | BindingFlags.NonPublic)!; + + private static readonly string[] SqliteStatusPaths = ["first", "second"]; +} diff --git a/tests/CodexSessionManager.App.Tests/MainWindowCoverageTestSupport.cs b/tests/CodexSessionManager.App.Tests/MainWindowCoverageTestSupport.cs new file mode 100644 index 0000000..1408999 --- /dev/null +++ b/tests/CodexSessionManager.App.Tests/MainWindowCoverageTestSupport.cs @@ -0,0 +1,228 @@ +#pragma warning disable S3990 // Codacy false positive: the assembly already declares CLSCompliant(true). +using System.Collections.ObjectModel; +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Threading; +using CodexSessionManager.App; +using CodexSessionManager.Core.Maintenance; +using CodexSessionManager.Core.Sessions; +using CodexSessionManager.Core.Transcripts; +using CodexSessionManager.Storage.Discovery; +using CodexSessionManager.Storage.Indexing; +using CodexSessionManager.Storage.Parsing; + +namespace CodexSessionManager.App.Tests; + +[SuppressMessage("Compatibility", "S3990", Justification = "The assembly already declares CLSCompliant(true); this file-level report is a persistent analyzer false positive.")] +[SuppressMessage("Code Smell", "S2333", Justification = "The coverage tests are intentionally split across partial files.")] +public sealed partial class MainWindowCoverageTests +{ + private static IReadOnlyList InvokeBuildKnownStores(bool deepScan) => + (IReadOnlyList)BuildKnownStoresMethod.Invoke(null, [deepScan])!; + + private static Task InvokePrivateTaskAsync(object instance, MethodInfo method, params object?[] args) => + (Task)method.Invoke(instance, args)!; + + private static T GetNamedField(MainWindow window, string name) where T : class => + (typeof(MainWindow).GetField(name, BindingFlags.Instance | BindingFlags.NonPublic)?.GetValue(window) as T) + ?? throw new InvalidOperationException($"Field '{name}' was not found."); + + private static IndexedLogicalSession? GetSelectedSession(MainWindow window) => + typeof(MainWindow).GetMethod("GetSelectedSession", BindingFlags.Instance | BindingFlags.NonPublic)?.Invoke(window, []) as IndexedLogicalSession; + + private static void AddSession(MainWindow window, IndexedLogicalSession session) + { + var sessions = (ObservableCollection)SessionsField.GetValue(window)!; + sessions.Add(session); + } + + private static void SelectSingleSession(MainWindow window, IndexedLogicalSession session) + { + var listBox = GetNamedField(window, "SessionsListBox"); + listBox.SelectedItem = session; + listBox.SelectedItems.Clear(); + listBox.SelectedItems.Add(session); + } + + private static SessionCatalogRepository CreateRepository(string root, params IndexedLogicalSession[] sessions) + { + var repository = new SessionCatalogRepository(Path.Combine(root, "catalog.db")); + repository.InitializeAsync(CancellationToken.None).GetAwaiter().GetResult(); + foreach (var session in sessions) + { + repository.UpsertAsync(session, CancellationToken.None).GetAwaiter().GetResult(); + } + + return repository; + } + + private static IndexedLogicalSession BuildIndexedSession(string sessionId, string threadName, string filePath) => + new( + sessionId, + threadName, + new SessionPhysicalCopy(sessionId, filePath, SessionStoreKind.Live, new SessionPhysicalCopyState(DateTimeOffset.UtcNow, 1024, false)), + [new SessionPhysicalCopy(sessionId, filePath, SessionStoreKind.Live, new SessionPhysicalCopyState(DateTimeOffset.UtcNow, 1024, false))], + new SessionSearchDocument + { + ReadableTranscript = $"Readable transcript for {threadName}", + DialogueTranscript = $"Dialogue transcript for {threadName}", + ToolSummary = $"Tool summary for {threadName}", + CommandText = "codex resume", + FilePaths = [filePath], + Urls = ["https://example.com"], + ErrorText = string.Empty, + Alias = string.Empty, + Tags = [], + Notes = string.Empty + }); + + private static ParsedSessionFile BuildParsedFile(string sessionId, string? cwd) => + new( + sessionId, + null, + cwd, + new TechnicalBreadcrumbs(["codex resume"], [0], [], []), + new NormalizedSessionDocument( + sessionId, + "Thread", + DateTimeOffset.UtcNow, + null, + cwd, + [ + NormalizedSessionEvent.CreateMessage(SessionActor.User, "Hello"), + NormalizedSessionEvent.CreateMessage(SessionActor.Assistant, "World") + ])); + + private static IndexedLogicalSession WithNullIndexedSessionProperty(IndexedLogicalSession session, string propertyName) + { + var clone = session with { }; + typeof(IndexedLogicalSession).GetField($"<{propertyName}>k__BackingField", BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(clone, null); + return clone; + } + + private static string WriteSessionJsonl(string root, string sessionId, string threadName) + { + var sessionsRoot = Path.Combine(root, "sessions"); + Directory.CreateDirectory(sessionsRoot); + var filePath = Path.Combine(sessionsRoot, $"{sessionId}.jsonl"); + var lines = new[] + { + JsonSerializer.Serialize(new + { + type = "session_meta", + payload = new + { + id = sessionId, + cwd = root, + timestamp = "2026-03-26T00:00:00Z" + } + }), + JsonSerializer.Serialize(new + { + type = "response_item", + payload = new + { + type = "message", + role = "user", + content = new[] + { + new + { + type = "input_text", + text = threadName + } + } + } + }) + }; + + File.WriteAllLines(filePath, lines, Encoding.UTF8); + return filePath; + } + + private static string CreateTempDirectory() + { + var path = Path.Combine(Path.GetTempPath(), "codex-session-manager-tests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(path); + return path; + } + + private static void DeleteDirectory(string path) + { + if (!Directory.Exists(path)) + { + return; + } + + for (var attempt = 0; attempt < 5; attempt++) + { + try + { + Directory.Delete(path, recursive: true); + return; + } + catch (IOException) when (attempt < 4) + { + Thread.Sleep(100 * (attempt + 1)); + } + catch (UnauthorizedAccessException) when (attempt < 4) + { + Thread.Sleep(100 * (attempt + 1)); + } + } + } + + private static void SetProvider(MainWindow window, string propertyName, object value) => + typeof(MainWindow).GetProperty(propertyName, BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public)! + .SetValue(window, value); + + private static void RunInSta(Action action) => + RunInStaAsync(() => + { + action(); + return Task.CompletedTask; + }).GetAwaiter().GetResult(); + + private static void RunInSta(Func action) => + RunInStaAsync(action).GetAwaiter().GetResult(); + + private static Task RunInStaAsync(Func action) + { + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var thread = new Thread(() => + { + SynchronizationContext.SetSynchronizationContext(new DispatcherSynchronizationContext(Dispatcher.CurrentDispatcher)); + Dispatcher.CurrentDispatcher.BeginInvoke(new Action(async () => + { + try + { + await action(); + completion.SetResult(); + } + catch (OperationCanceledException) + { + completion.SetCanceled(); + } + catch (Exception ex) + { + completion.SetException(ex); + } + finally + { + Dispatcher.CurrentDispatcher.BeginInvokeShutdown(DispatcherPriority.Background); + } + })); + + Dispatcher.Run(); + }); + + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + return completion.Task; + } +} diff --git a/tests/CodexSessionManager.App.Tests/MainWindowCoverageTests.cs b/tests/CodexSessionManager.App.Tests/MainWindowCoverageTests.cs deleted file mode 100644 index d5ef5e7..0000000 --- a/tests/CodexSessionManager.App.Tests/MainWindowCoverageTests.cs +++ /dev/null @@ -1,1482 +0,0 @@ -using System.Collections.ObjectModel; -using System.Linq; -using System.Reflection; -using System.Text; -using System.Text.Json; -using System.Threading; -using System.Windows; -using System.Windows.Controls; -using System.Windows.Threading; -using CodexSessionManager.App; -using CodexSessionManager.Core.Maintenance; -using CodexSessionManager.Core.Sessions; -using CodexSessionManager.Core.Transcripts; -using CodexSessionManager.Storage.Discovery; -using CodexSessionManager.Storage.Indexing; -using CodexSessionManager.Storage.Maintenance; -using CodexSessionManager.Storage.Parsing; -using Microsoft.Win32; - -namespace CodexSessionManager.App.Tests; - -public sealed class MainWindowCoverageTests -{ - private static readonly MethodInfo BuildKnownStoresMethod = - typeof(MainWindow).GetMethod("BuildKnownStores", BindingFlags.NonPublic | BindingFlags.Static)!; - - private static readonly MethodInfo GetLiveSqliteStatusMethod = - typeof(MainWindow).GetMethods(BindingFlags.NonPublic | BindingFlags.Static) - .Single(method => method.Name == "GetLiveSqliteStatus" && method.GetParameters().Length == 0); - - private static readonly MethodInfo GetLiveSqliteStatusWithInputsMethod = - typeof(MainWindow).GetMethods(BindingFlags.NonPublic | BindingFlags.Static) - .Single(method => - { - if (method.Name != "GetLiveSqliteStatus") - { - return false; - } - - var parameters = method.GetParameters(); - return parameters.Length == 2 - && parameters[0].ParameterType == typeof(IEnumerable) - && parameters[1].ParameterType == typeof(Func); - }); - - private static readonly MethodInfo DescribeSqlitePathMethod = - typeof(MainWindow).GetMethod("DescribeSqlitePath", BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Public)!; - - private static readonly MethodInfo InitializeAsyncMethod = - typeof(MainWindow).GetMethod("InitializeAsync", BindingFlags.NonPublic | BindingFlags.Instance)!; - - private static readonly MethodInfo LoadSessionsFromCatalogAsyncMethod = - typeof(MainWindow).GetMethod("LoadSessionsFromCatalogAsync", BindingFlags.NonPublic | BindingFlags.Instance)!; - - private static readonly MethodInfo RefreshAsyncMethod = - typeof(MainWindow).GetMethod("RefreshAsync", BindingFlags.NonPublic | BindingFlags.Instance)!; - - private static readonly MethodInfo RunBackgroundRefreshAsyncMethod = - typeof(MainWindow).GetMethod("RunBackgroundRefreshAsync", BindingFlags.NonPublic | BindingFlags.Instance)!; - - private static readonly MethodInfo RunOnUiThreadAsyncMethod = - typeof(MainWindow).GetMethod("RunOnUiThreadAsync", BindingFlags.NonPublic | BindingFlags.Instance)!; - - private static readonly MethodInfo RunOnUiThreadValueAsyncMethod = - typeof(MainWindow).GetMethods(BindingFlags.NonPublic | BindingFlags.Instance) - .Single(method => method.Name == "RunOnUiThreadValueAsync" && method.IsGenericMethodDefinition) - .MakeGenericMethod(typeof(string)); - - private static readonly MethodInfo LoadSelectedSessionAsyncMethod = - typeof(MainWindow).GetMethod("LoadSelectedSessionAsync", BindingFlags.NonPublic | BindingFlags.Instance)!; - - private static readonly MethodInfo PopulateSelectedSessionHeaderAsyncMethod = - typeof(MainWindow).GetMethod("PopulateSelectedSessionHeaderAsync", BindingFlags.NonPublic | BindingFlags.Instance)!; - - private static readonly MethodInfo LoadSelectedSessionBodyAsyncMethod = - typeof(MainWindow).GetMethod("LoadSelectedSessionBodyAsync", BindingFlags.NonPublic | BindingFlags.Instance)!; - - private static readonly MethodInfo SearchSessionsAsyncMethod = - typeof(MainWindow).GetMethod("SearchSessionsAsync", BindingFlags.NonPublic | BindingFlags.Instance)!; - - private static readonly MethodInfo SaveSelectedMetadataAsyncMethod = - typeof(MainWindow).GetMethod("SaveSelectedMetadataAsync", BindingFlags.NonPublic | BindingFlags.Instance)!; - - private static readonly MethodInfo ExecuteMaintenanceUiAsyncMethod = - typeof(MainWindow).GetMethod( - "ExecuteMaintenanceAsync", - BindingFlags.NonPublic | BindingFlags.Instance, - Type.DefaultBinder, - Type.EmptyTypes, - null)!; - - private static readonly MethodInfo OpenFolderMethod = - typeof(MainWindow).GetMethod("OpenFolderButton_OnClick", BindingFlags.NonPublic | BindingFlags.Instance)!; - - private static readonly MethodInfo OpenRawMethod = - typeof(MainWindow).GetMethod("OpenRawButton_OnClick", BindingFlags.NonPublic | BindingFlags.Instance)!; - - private static readonly MethodInfo CopyPathMethod = - typeof(MainWindow).GetMethod("CopyPathButton_OnClick", BindingFlags.NonPublic | BindingFlags.Instance)!; - - private static readonly MethodInfo ResumeMethod = - typeof(MainWindow).GetMethod("ResumeButton_OnClick", BindingFlags.NonPublic | BindingFlags.Instance)!; - - private static readonly MethodInfo ExportMethod = - typeof(MainWindow).GetMethod("ExportButton_OnClick", BindingFlags.NonPublic | BindingFlags.Instance)!; - - private static readonly MethodInfo BuildPreviewMethod = - typeof(MainWindow).GetMethod("BuildPreviewButton_OnClick", BindingFlags.NonPublic | BindingFlags.Instance)!; - - private static readonly FieldInfo SessionsField = - typeof(MainWindow).GetField("_sessions", BindingFlags.Instance | BindingFlags.NonPublic)!; - - private static readonly FieldInfo RepositoryField = - typeof(MainWindow).GetField("_repository", BindingFlags.Instance | BindingFlags.NonPublic)!; - - private static readonly FieldInfo WorkspaceIndexerField = - typeof(MainWindow).GetField("_workspaceIndexer", BindingFlags.Instance | BindingFlags.NonPublic)!; - - private static readonly FieldInfo MaintenanceExecutorField = - typeof(MainWindow).GetField("_maintenanceExecutor", BindingFlags.Instance | BindingFlags.NonPublic)!; - - private static readonly FieldInfo SearchCtsField = - typeof(MainWindow).GetField("_searchCts", BindingFlags.Instance | BindingFlags.NonPublic)!; - - private static readonly string[] SqliteStatusPaths = ["first", "second"]; - - [Fact] - public void Constructor_initializes_core_bindings() - { - RunInSta(() => - { - var window = new MainWindow(); - - Assert.NotNull(GetNamedField(window, "SessionsListBox").ItemsSource); - Assert.Equal(MaintenanceAction.Archive, GetNamedField(window, "MaintenanceActionComboBox").SelectedItem); - Assert.Equal("Starting…", GetNamedField(window, "StatusTextBlock").Text); - }); - } - - [Fact] - public void InitializeComponent_rehydrates_named_controls() - { - RunInSta(() => - { - var window = new MainWindow(); - - window.InitializeComponent(); - - Assert.Equal("Codex Session Manager", window.Title); - }); - } - - [Fact] - public void InitializeComponent_can_be_called_twice_without_reloading_content() - { - RunInSta(() => - { - var window = new MainWindow(); - - window.InitializeComponent(); - - Assert.NotNull(GetNamedField