diff --git a/README.md b/README.md index 138ca77..e4437e4 100644 --- a/README.md +++ b/README.md @@ -90,9 +90,9 @@ These features are **implemented on the main development branch**. They are not | **Scripts menu** | A Scripts menu in the header runs PowerShell scripts from `%LOCALAPPDATA%\Meridian\Scripts`: subfolders become menu groups, rebuilt every time the menu opens. A script runs in a visible console with the active pane's folder as its working folder and receives the panes' folders and the selected items' paths as environment variables (`CURRENT_DIR`, `OPPOSITE_DIR`, `SELECTED_FILES`, plus `OC_SCRIPTS_DIR`, `OC_SETTINGS_DIR`, `OC_USERDATA_DIR`) — the variables OneCommander sets, so OneCommander scripts run unchanged. PowerShell 7 is used when installed, Windows PowerShell otherwise. The folder is seeded on first run with the KK-OneCommanderScripts pack by KPC Integrations (MIT) and is never overwritten afterwards. | | **Event hooks** | Scripts placed in `Scripts\Hooks\` run automatically and hidden after file operations — `AfterCopy`, `AfterMove`, `AfterDelete`, `AfterRename`, `AfterTransferComplete` — receiving the script variables plus `TRIGGER`, `FILE`, `DESTINATION`, and `SCOPE`. A failing hook is only written to the failure log; it can never block or fail the operation. The event folders and a README explaining them are created automatically when missing. | -| **Conservative file actions** | Copy, move, rename, create folders, view basic properties, and request Recycle Bin deletion. | +| **Conservative file actions** | Copy, move, rename — including multi-item batch rename with a before/after preview (F2 or the Rename command, each name checked before anything changes). Renames run through the Transfers queue, so they show progress, can be cancelled, and — like every finished transfer — can be undone from History. Also: create folders, view basic properties, request Recycle Bin deletion, and apply color tags to files and folders. | -| **Batch rename** | With several items selected, Rename (or `F2`) opens a batch rename dialog: edit the names as a text list, one full name per line, or fill the list from a find-and-replace pass and edit further. Generators add regex replacement, case conversion, and padded numbering; a template mode fills tokens from each file ({name}, {date}, {size}, image dimensions {w}/{h}, a counter, EXIF {exifdate}/{camera} for photos, and ID3 {title}/{artist}/{album}/{track} for MP3s); and the name mapping can be exported to and imported from CSV. The plan is checked as you type — invalid names, duplicates, and collisions are listed per line and renaming stays unavailable until the plan is clean. Case-only renames and swaps go through temporary names, nothing is ever overwritten, and one failure does not stop the rest. | +| **Batch rename** | With several items selected, Rename (or `F2`) opens a batch rename dialog: edit the names as a text list, one full name per line, or fill the list from a find-and-replace pass and edit further. Generators add regex replacement, case conversion, and padded numbering; a template mode fills tokens from each file ({name}, {date}, {size}, image dimensions {w}/{h}, a counter, EXIF {exifdate}/{camera} for photos, and ID3 {title}/{artist}/{album}/{track} for MP3s); and the name mapping can be exported to and imported from CSV. The plan is checked as you type — invalid names, duplicates, and collisions are listed per line and renaming stays unavailable until the plan is clean. Renames run through the Transfers queue with progress, cancellation, recovery checks, and undo from History. Existing destinations wait for a keep-both or skip choice; files are never overwritten. | | **Universal Viewer** | Preview text (UTF-8/UTF-16), hex, and images, and play audio/video with Windows media controls and no autoplay. Reads are bounded and read-only; playback behavior is not yet verified on real devices. | | **Archives** | Inspect ZIP/TAR/TGZ contents read-only, create an archive from selected files and folders, or extract into a new no-overwrite folder under entry-policy and size limits, with cancellation. | | **TimeVault (read-only)** | Discover existing Windows shadow copies and list a snapshot's version of the current folder. Discovery only — no recovery operations, and real VSS access is unverified. | diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index a23a25b..f6753e9 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,6 +2,11 @@ ## Unreleased +- Batch rename with preview and undo: select multiple files or folders and rename them all at once from the Rename command or F2. Every new name is checked before anything is renamed, collisions are flagged in the preview, and the whole batch runs as a transfer job in the Transfers queue — progress shows in the strip, ordinary item errors do not stop the others; a persistence or recovery failure stops the batch, and every finished rename can be undone from Transfer History. +- Color tags: mark files and folders with a colored indicator (red, orange, yellow, green, blue, purple, gray or none) from the right-click menu in either pane. Tags persist across sessions and travel with the item row wherever it is shown. + +- Rename intent, identity and tag migration checkpoints must be durable before execution continues. Interrupted settlement recovers the History receipt before removing the queue entry; persistence failure stops the batch. + ## 0.9.6 - A command panel now docks under the panes (View menu or F4): type a command and it runs in the active pane's folder through PowerShell (or the Command Prompt where PowerShell is absent), with its output shown in the panel as it runs, a stop button for the running command, and Up/Down recall of earlier commands. The panel follows the active pane, so its folder is always the folder you are looking at. It runs commands and shows their output; programs that wait for typed answers still belong in a full terminal — "Open terminal here" remains for those. diff --git a/docs/PROJECT_MOCKUP.md b/docs/PROJECT_MOCKUP.md new file mode 100644 index 0000000..fdfba7f --- /dev/null +++ b/docs/PROJECT_MOCKUP.md @@ -0,0 +1,130 @@ +# Meridian — Complete Project Mockup + +> This document is the synthesized mockup/specification for Meridian based on the current roadmap (`ROADMAP.md`, `PRODUCT_VISION.md`, `docs/ARCHITECTURE.md`) and the differentiation decision: **"A project-focused file manager that makes complex file operations fast, visible, and recoverable."** It is aspirational, not a claim that any unchecked item is implemented. Implemented scope is tracked in `STATUS.md`. + +--- + +## Positioning (decision) + +**One-sentence promise:** "A project-focused file manager that makes complex file operations fast, visible, and recoverable." + +**Why not "OneCommander with more options":** OneCommander is a broad "file manager for everyone" with configurable views, color tags, previews, and scripting. Meridian's deliberate differentiation is three pillars: + +1. **Safe/recoverable operations** (signature): every destructive or bulk action is previewed before execution, journaled, cancellable, and — where technically possible — undoable. *Just shipped: queued rename with before/after preview and History undo.* +2. **Project workspaces**: a project is a first-class concept — saved source/destination folder pairs, pinned files, saved searches, notes, and repeatable actions — not just a sidebar bookmark list. +3. **Dependable large-scale work**: per-item progress, partial-failure reporting, pause/resume/retry, and honest recovery limits at any scale. + +--- + +## Complete capability map (roadmap synthesized) + +| Area | Status | What "complete" looks like | Differentiation angle | +|------|--------|---------------------------|----------------------| +| **Dual-pane / columns navigation** | Implemented | One-, two-, four-pane layouts; Miller columns; adjustable splits | Keyboard-first, predictable | +| **Workspace tabs + sessions** | Partial (single session) | Multi-tab, pinning, tab groups, saved workspace profiles | Task-oriented, not just locations | +| **Bookmarks & favorites** | Implemented | Project groups, folder trees, mini trees | Grouped by project, not just recency | +| **Drop Shelf** | Implemented (in-memory) | Persistent shelf, capsule save/load, cross-session | Gather before acting | +| **Copy/Move queue** | Implemented | Reorderable queue, pause/resume, speed limits, selective retry | Visible progress per file + per job | +| **Transfer History + Undo** | Implemented | Operation journal, recovery receipts, undo where reliable | *Signature: nothing is silently destructive* | +| **Rename** | Implemented (queued + undo) | Batch rename with regex, counters, metadata tokens, live preview | Preview before touching anything | +| **Delete** | Implemented (Recycle Bin) | Secure-deletion research with honest SSD/snapshot limitations | Never claim guarantees we can't substantiate | +| **Color tags** | Implemented | Ratings, file-age visualization, NTFS ADS storage option | Visual organization without moving files | +| **Folder DNA** | Implemented (summary) | Read-only hashes, signatures, provenance analysis | Understand before acting | +| **Archives ZIP/TAR/TGZ** | Implemented | Archive-as-folder navigation, 7z/RAR/ISO where licensed | Safe extraction by default | +| **TimeVault (VSS)** | Implemented (read-only) | File History adapters, unified version timeline | Recovery paths are explicit, not assumed | +| **Universal Viewer** | Implemented (build) | Spacebar quick preview, PDF/Office/SVG, dockable viewer | Preview is part of every workflow | +| **Smart Collections** | **Not started** | Metadata-backed cross-folder groups, rules, dynamic refresh | *Key differentiator: organize without relocating* | +| **Search (OmniSearch)** | Not started | Filename + content + metadata, saved searches, Everything integration | First-class search workspace | +| **Duplicate finder** | Not started | Name/size/hash + perceptual media matching, review-before-delete | Safe by default | +| **Directory compare/sync** | Not started | Two-way sync with conflict preview, conservative defaults | Explicit before implicit | +| **Project workspaces** | **Not started** | Saved folder pairs, pinned files, saved searches, notes, repeatable actions | *Key differentiator: project as first-class concept* | +| **Embedded terminal** | Not started | PowerShell/CMD/Git Bash/WSL panel, working-directory sync | Developer workflow | +| **Git integration** | Not started | Branch/status columns, diff, ahead/behind | Developer workflow | +| **Themes/customization** | Partial (Dark/Light/System tokens) | Mica/Acrylic, export/import setup, configurable shortcuts | Calm default, reveal complexity on demand | +| **Shell integration** | Not started | Context-menu actions, compact menu option | Respect Windows policy | + +--- + +## UI mockup — default workspace (text layout) + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ █ MERIDIAN · PROJECT: "Q4 Launch" [● active] ⭒ Settings ✕ │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ Copy ▾ Move ▾ New folder Rename Preview Properties Archive ▾ │ +│ Tools ▾ Delete│ +├──────────┬──────────────────────────────────────────────────────────────────┤ +│ PROJECTS │ LEFT PANE — "source" │ │ +│ ▼ Q4 Launch RIGHT PANE — "destination" │ │ +│ • assets/ 📁 report (2).docx [blue tag] │ │ +│ • drafts/ budget_Q4.xlsx [green tag] │ │ +│ • ▼ images/ notes.md │ │ +│ • hero.png contract.pdf │ │ +│ • ▼ notes/ [3 selected · 2.4 MB] │ │ +│ • meeting.txt ⏳ Renaming 2 of 3 items… │ │ +│ Settings [Cancel] │ │ +├──────────┴──────────────────────────────────────────────────────────────────┤ +│ Status: Renaming 2 items · 45% · report (2).docx → report_Q4.docx ⚠ Undo │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +**Key UI distinctions from OneCommander:** +- The project name is persistent in the header, not buried in a favorites sidebar. +- Tag colors appear on rows as small badges (already implemented). +- The status bar shows per-operation progress with an undo affordance, not just "Ready." +- The transfer strip is always visible below the panes when anything is queued. +- No ribbon, no icon-only mystery buttons, no copied artwork. + +--- + +## Project workspace spec (next flagship feature) + +A **project** is a saved workspace that remembers: + +| Element | Purpose | +|---------|---------| +| Name + color + optional notes | Identity, shown in header | +| Folder pairs (left/right) | The locations the user switches between | +| Pinned files | Quick-open within the project | +| Saved searches | Queries that refresh as files change | +| Layout + view preferences | Split ratio, density, columns per pane | +| Repeatable actions | Named copy/move/rename plans with preview | + +**Workflow:** Open project → both panes restore to their folders → tags/colors show status → run a queued operation → undo from History if needed → close project, state persists. + +**Storage:** `%LOCALAPPDATA%\Meridian\projects\.json`, atomic write, migration-tolerant schema. + +--- + +## Implemented → Roadmap traceability + +| Roadmap item | Implemented | Evidence | +|--------------|-------------|----------| +| Phase 1: Paired workspace tabs + session persistence | Partial | `WorkspaceStore`, single session | +| Phase 1: Central transfer queue | Implemented | `TransferCoordinator`, `TransferExecutor` | +| Phase 1: Rename with validation + no-overwrite | Implemented (queued + undo) | `TransferJobKind.Rename`, undo planner | +| Phase 1: Delete to Recycle Bin | Implemented | `FileOperations`, confirmed dialog | +| Phase 1: Properties dialog | Implemented | Basic metadata | +| Phase 1: Installer + portable artifacts | Implemented | `packaging/Meridian.iss`, CI | +| Phase 2: Color labels/tags | Implemented | `FileTagStore`, `FileTagPalette`, UI badges | +| Phase 2: Folder DNA | Implemented (summary) | `FolderDna`, immediate listing only | +| Phase 2: Color labels → ratings/ADS storage | Planned | Roadmap Phase 2 | +| Phase 3: Batch rename with preview | Implemented (basic) | Preview dialog + queue | +| Phase 3: Directory compare/sync | Planned | Roadmap Phase 3 | +| Phase 4: Search/metadata | Planned | Roadmap Phase 4 | +| Phase 5: Preview/viewer | Partial | `QuickPreviewOverlay`, Universal Viewer | +| Phase 6: Archives | Implemented (read-only + create/extract) | `Archives/*` | +| Phase 7: Developer tools | Planned | Roadmap Phase 7 | +| Phase 8: Customization | Partial | Theme/density/accent settings | + +--- + +## Next implementation priorities (my decision) + +1. **Smart Collections** (Phase 2) — metadata-backed cross-folder groups. Organize without relocating files; dynamic refresh; tags + rules. *This is the clearest OneCommander differentiator.* +2. **Project workspaces** (Phase 2/7) — named workspace snapshots with folder pairs, pinned files, saved searches. +3. **Duplicate finder** (Phase 4) — name/size/hash with review-before-delete. +4. **Directory compare/sync** (Phase 3) — conflict preview before two-way sync. +5. **Spacebar quick preview** (Phase 5) — inline preview without opening a dialog. + +I will implement **Smart Collections** next. diff --git a/src/Meridian.App/App.xaml b/src/Meridian.App/App.xaml index 7d1f369..e691b7e 100644 --- a/src/Meridian.App/App.xaml +++ b/src/Meridian.App/App.xaml @@ -1,4 +1,4 @@ - + @@ -144,6 +144,9 @@ + + + 2 1 Cascadia Mono, Consolas diff --git a/src/Meridian.App/Converters/FileTagToBrushConverter.cs b/src/Meridian.App/Converters/FileTagToBrushConverter.cs new file mode 100644 index 0000000..071264e --- /dev/null +++ b/src/Meridian.App/Converters/FileTagToBrushConverter.cs @@ -0,0 +1,50 @@ +using System; +using System.Globalization; +using Microsoft.UI.Xaml.Data; +using Microsoft.UI.Xaml.Media; +using Meridian.Models; +using Meridian.Services; +using Windows.UI; + +namespace Meridian.Converters; + +/// Converts a to the +/// used to paint a tag indicator, or null when the item is untagged. Colors are +/// stored as hex strings in so Core stays free of +/// WinUI dependencies; this converter parses them into brushes. +public sealed class FileTagToBrushConverter : IValueConverter +{ + public object Convert(object value, Type targetType, object parameter, string language) + { + var tag = value as FileTagColor?; + var hex = FileTagPalette.HexFor(tag); + if (string.IsNullOrEmpty(hex) || string.Equals(hex, "transparent", StringComparison.OrdinalIgnoreCase)) + return null; + try + { + var color = ColorFromHex(hex); + return new SolidColorBrush(color); + } + catch + { + // A malformed hex string must not break rendering; fall back to no tag. + return null; + } + } + + public object ConvertBack(object value, Type targetType, object parameter, string language) + => throw new NotSupportedException(); + + private static Color ColorFromHex(string hex) + { + var h = hex.TrimStart('#'); + byte a = 0xFF, r = 0, g = 0, b = 0; + if (h.Length == 6) + { + r = byte.Parse(h.Substring(0, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture); + g = byte.Parse(h.Substring(2, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture); + b = byte.Parse(h.Substring(4, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture); + } + return Color.FromArgb(a, r, g, b); + } +} diff --git a/src/Meridian.App/Converters/FileTagToLabelConverter.cs b/src/Meridian.App/Converters/FileTagToLabelConverter.cs new file mode 100644 index 0000000..a0038e2 --- /dev/null +++ b/src/Meridian.App/Converters/FileTagToLabelConverter.cs @@ -0,0 +1,19 @@ +using System; +using Microsoft.UI.Xaml.Data; +using Meridian.Models; +using Meridian.Services; + +namespace Meridian.Converters; + +/// Converts a to its plain-text accessibility label. +public sealed class FileTagToLabelConverter : IValueConverter +{ + public object Convert(object value, Type targetType, object parameter, string language) + { + var tag = value as FileTagColor?; + return FileTagPalette.LabelFor(tag); + } + + public object ConvertBack(object value, Type targetType, object parameter, string language) + => throw new NotSupportedException(); +} diff --git a/src/Meridian.App/Converters/FileTagToVisibilityConverter.cs b/src/Meridian.App/Converters/FileTagToVisibilityConverter.cs new file mode 100644 index 0000000..d2d83f9 --- /dev/null +++ b/src/Meridian.App/Converters/FileTagToVisibilityConverter.cs @@ -0,0 +1,21 @@ +using System; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Data; +using Meridian.Models; +using Meridian.Services; + +namespace Meridian.Converters; + +/// Converts a to +/// when the item is tagged, or when untagged. +public sealed class FileTagToVisibilityConverter : IValueConverter +{ + public object Convert(object value, Type targetType, object parameter, string language) + { + var tag = value as FileTagColor?; + return FileTagPalette.IsTagged(tag) ? Visibility.Visible : Visibility.Collapsed; + } + + public object ConvertBack(object value, Type targetType, object parameter, string language) + => throw new NotSupportedException(); +} diff --git a/src/Meridian.App/MainWindow.BatchRename.cs b/src/Meridian.App/MainWindow.BatchRename.cs index fac94bf..85eea45 100644 --- a/src/Meridian.App/MainWindow.BatchRename.cs +++ b/src/Meridian.App/MainWindow.BatchRename.cs @@ -1,3 +1,4 @@ +using Meridian.Core.Transfers; using Meridian.Models; using Meridian.Services; using Microsoft.UI.Xaml; @@ -23,6 +24,102 @@ namespace Meridian; /// public sealed partial class MainWindow { + private async Task RenameSelectedAsync(PaneState pane) + { + var selection = GetSelectedEntries(pane); + if (selection.Count == 0) + { + SetStatus("Select a file or folder to rename.", StatusSeverity.Warning); + return; + } + if (selection.Count == 1) + { + await RenameOneAsync(pane, selection[0]); + return; + } + await BatchRenameAsync(pane); + } + + /// Renames exactly one item with a single name box. The rename is + /// not applied directly: it is enqueued as a transfer job so it is reviewable, + /// cancellable and — like every finished transfer — undoable from History. + private async Task RenameOneAsync(PaneState pane, FileEntry selected) + { + var nameBox = new TextBox { Text = selected.Name, MinWidth = 280, SelectionStart = 0, SelectionLength = selected.Name.Length }; + AutomationProperties.SetName(nameBox, "New name"); + var renameContent = new StackPanel { Spacing = 8 }; + renameContent.Children.Add(new TextBlock + { + Text = "Only the name changes. The item stays in the same folder with its contents unchanged, and you can rename it again at any time.", + TextWrapping = TextWrapping.Wrap + }); + renameContent.Children.Add(nameBox); + var dialog = new ContentDialog + { + Title = "Rename item", + Content = renameContent, + PrimaryButtonText = "Rename", + CloseButtonText = "Cancel", + DefaultButton = ContentDialogButton.Primary, + XamlRoot = Content.XamlRoot + }; + + if (await dialog.ShowAsync() != ContentDialogResult.Primary) return; + + var newName = nameBox.Text; + if (string.IsNullOrWhiteSpace(newName)) + { + SetStatus("Enter a name.", StatusSeverity.Warning); + return; + } + try { FileOperations.ValidateNewName(newName); } + catch (ArgumentException ex) + { + SetStatus("Could not rename item · " + ex.Message, StatusSeverity.Error); + return; + } + + await EnqueueRenameAsync(pane, [(selected, newName)]); + } + + /// Builds a Rename transfer job from one old→new name plan per item + /// and enqueues it. The job runs through the same Transfers queue as copies + /// and moves: progress shows in the strip, and when it finishes it is recorded + /// in History where it can be undone. + private async Task EnqueueRenameAsync(PaneState pane, IReadOnlyList<(FileEntry Entry, string NewName)> planned) + { + planned = planned.Where(p => !string.Equals(p.Entry.Name, p.NewName, StringComparison.Ordinal)).ToList(); + if (planned.Count == 0) + { + SetStatus("Name unchanged."); + return; + } + var items = planned.Select(p => new TransferItem + { + SourcePath = p.Entry.FullPath, + DestinationPath = Path.Combine(Path.GetDirectoryName(p.Entry.FullPath)!, p.NewName), + IsDirectory = p.Entry.IsDirectory, + Size = p.Entry.SizeBytes, + SourceLastWriteUtc = p.Entry.ModifiedUtc, + }).ToList(); + + var job = TransferJob.Create( + TransferJobKind.Rename, + planned.Select(p => p.Entry.FullPath), + destinationFolder: string.Empty, + collisionPolicy: _nameCollisionPolicy); + job.Items = items; + if (items.Count > 1 || items.Any(i => string.Equals(i.SourcePath, i.DestinationPath, StringComparison.OrdinalIgnoreCase))) + job.RenameBatch = new RenameBatchJournal(); + + await _transfers.EnqueueAsync(job); + SetStatus(planned.Count == 1 + ? $"Renaming {Path.GetFileName(planned[0].Entry.FullPath)} · progress is in the Transfers strip below." + : $"Renaming {planned.Count:N0} items · progress is in the Transfers strip below.", + StatusSeverity.Success); + } + + /// The one rename entry point behind the toolbar button, the /// context menus, and F2: two or more selected items open the batch /// dialog; a single item — or none — keeps the long-standing @@ -402,19 +499,9 @@ void RefreshValidation() return; } - var result = BatchRename.Apply(plan); - await NavigateAsync(pane, pane.CurrentPath, addHistory: false); - if (result.Succeeded) - { - SetStatus($"Renamed {result.RenamedCount} items.", StatusSeverity.Success); - } - else - { - var firstFailure = result.Items.First(item => !item.Renamed).Failure ?? "Unknown error."; - SetStatus( - $"Renamed {result.RenamedCount} of {result.Items.Count} — {result.FailedCount} failed: {firstFailure}", - result.RenamedCount == 0 ? StatusSeverity.Error : StatusSeverity.Warning); - } + var proposed = SplitNameLines(namesBox.Text); + await EnqueueRenameAsync(pane, entries.Select((entry, index) => + (entry, proposed[index])).ToList()); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException or InvalidOperationException or System.Security.SecurityException) diff --git a/src/Meridian.App/MainWindow.Columns.cs b/src/Meridian.App/MainWindow.Columns.cs index 6679469..b35948c 100644 --- a/src/Meridian.App/MainWindow.Columns.cs +++ b/src/Meridian.App/MainWindow.Columns.cs @@ -271,6 +271,7 @@ private MenuFlyout BuildColumnContextFlyout(PaneState pane) flyout.Items.Add(copyPathItem); flyout.Items.Add(templatesItem); flyout.Items.Add(new MenuFlyoutSeparator()); + flyout.Items.Add(BuildColorTagMenu(pane)); flyout.Items.Add(renameItem); flyout.Items.Add(propertiesItem); flyout.Items.Add(shelfItem); @@ -371,8 +372,7 @@ private async Task HandleColumnKeyDownAsync(PaneState pane, ColumnViewState colu break; case VirtualKey.F2: e.Handled = true; - if (pane.SelectionList.SelectedItems.Count == 1) await RenameSelectedAsync(pane); - else if (pane.SelectionList.SelectedItems.Count > 1) await BatchRenameAsync(pane); + await RenameEntryAsync(pane); break; case VirtualKey.Space: { diff --git a/src/Meridian.App/MainWindow.Hooks.cs b/src/Meridian.App/MainWindow.Hooks.cs index 7439273..6217aab 100644 --- a/src/Meridian.App/MainWindow.Hooks.cs +++ b/src/Meridian.App/MainWindow.Hooks.cs @@ -206,6 +206,7 @@ private void FireTransferHooks(TransferJob job) TransferJobKind.Copy => HookEvents.AfterCopy, TransferJobKind.Move => HookEvents.AfterMove, TransferJobKind.Delete => HookEvents.AfterDelete, + TransferJobKind.Rename => HookEvents.AfterRename, _ => null, }; if (kindEvent is not null) diff --git a/src/Meridian.App/MainWindow.Tags.cs b/src/Meridian.App/MainWindow.Tags.cs index 04fc725..1e6bc45 100644 --- a/src/Meridian.App/MainWindow.Tags.cs +++ b/src/Meridian.App/MainWindow.Tags.cs @@ -16,6 +16,50 @@ namespace Meridian; /// public sealed partial class MainWindow { + private MenuFlyoutSubItem BuildColorTagMenu(PaneState pane) + { + var menu = new MenuFlyoutSubItem { Text = "Tag" }; + ToolTipService.SetToolTip(menu, "Assign or remove a color tag on the selected items"); + foreach (var color in Enum.GetValues()) + { + var item = new MenuFlyoutItem { Text = color.ToString() }; + item.Click += async (_, _) => await ApplyTagToSelectionAsync(pane, color); + menu.Items.Add(item); + } + menu.Loaded += (_, _) => menu.IsEnabled = GetSelectedEntries(pane).Count > 0 && !pane.IsHome && !pane.IsRecycleBin; + return menu; + } + + private async void TagColor_Click(object sender, RoutedEventArgs e) + { + if (sender is not MenuFlyoutItem item) return; + if (!Enum.TryParse(item.Text, ignoreCase: true, out var color)) return; + var pane = _tagContextPane; + if (pane is null) return; + await ApplyTagToSelectionAsync(pane, color); + } + + /// Assigns one color tag to every selected item, saves it, and + /// refreshes the rows so the tag badges appear immediately. + private async Task ApplyTagToSelectionAsync(PaneState pane, FileTagColor color) + { + var selection = GetSelectedEntries(pane); + if (selection.Count == 0 || pane.IsHome || pane.IsRecycleBin) return; + try { await _tagStore.SetTagsAsync(selection.Select(item => item.FullPath), color); } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or System.Text.Json.JsonException or NotSupportedException) + { + SetStatus("Tags unchanged. " + ex.Message + " Restore tag-store access before retrying.", StatusSeverity.Warning); + return; + } + foreach (var entry in selection) entry.TagColor = color; + var label = FileTagPalette.LabelFor(color); + SetStatus(color is FileTagColor.None + ? $"Cleared tags on {selection.Count:N0} item(s)." + : $"Tagged {selection.Count:N0} item(s) {label}.", + StatusSeverity.Success); + } + + private async void TagsFromLeft_Click(object sender, RoutedEventArgs e) => await EditTagsAsync(_left); private async void TagsFromRight_Click(object sender, RoutedEventArgs e) => await EditTagsAsync(_right); diff --git a/src/Meridian.App/MainWindow.Transfers.cs b/src/Meridian.App/MainWindow.Transfers.cs index 4cbe488..a58524d 100644 --- a/src/Meridian.App/MainWindow.Transfers.cs +++ b/src/Meridian.App/MainWindow.Transfers.cs @@ -47,6 +47,31 @@ private sealed record DropPlan(TransferJobKind Kind, string Destination, IReadOn private void InitializeTransfers() { + _transfers.RenameConfirmedAsync = item => + { + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + if (!DispatcherQueue.TryEnqueue(async () => + { + try + { + await _tagStore.MigrateRenameAsync(item.Id, item.SourcePath, item.DestinationPath!, item.IsDirectory); + completion.SetResult(); + } + catch (Exception ex) { completion.SetException(ex); } + })) completion.SetException(new IOException("The window closed before rename tags could be saved.")); + return completion.Task; + }; + _transfers.HasPendingBatchTagsAsync = _tagStore.HasPendingBatchAsync; + _transfers.BatchRenameTagsAsync = (job, commit) => + { + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + if (!DispatcherQueue.TryEnqueue(async () => + { + try { await _tagStore.MigrateBatchRenameAsync(job, commit); completion.SetResult(); } + catch (Exception ex) { completion.SetException(ex); } + })) completion.SetException(new IOException("The window closed before batch tags could be saved.")); + return completion.Task; + }; _transfers.Changed += Transfers_Changed; _transfers.ProgressChanged += Transfers_ProgressChanged; _transfers.JobSettled += Transfers_JobSettled; @@ -111,6 +136,7 @@ private void UpdateTransferStatusSummary(TransferProgressUpdate update) { TransferJobKind.Move => "Moving", TransferJobKind.Delete => "Deleting", + TransferJobKind.Rename => "Renaming", _ => "Copying", }; var summary = update.Progress.TotalBytes > 0 @@ -127,6 +153,7 @@ private void ReportSettledJob(TransferJob job) { TransferJobKind.Move => "Moved", TransferJobKind.Delete => "Deleted", + TransferJobKind.Rename => "Renamed", _ => "Copied", }; var destination = string.IsNullOrWhiteSpace(job.DestinationFolder) ? "" : $" to {job.DestinationFolder}"; @@ -232,6 +259,16 @@ private void BuildJobRows() TransfersRows.Children.Add(BuildJobRow(job)); } + private async Task MoveTransferFromUiAsync(TransferJob job, int delta) + { + try { await _transfers.MoveJobAsync(job, delta); } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or System.Text.Json.JsonException or NotSupportedException) + { + SetStatus("Queue order could not be saved. Restore access to Meridian's data folder before continuing. " + ex.Message, StatusSeverity.Error); + RefreshTransfersStrip(); + } + } + private Border BuildJobRow(TransferJob job) { var panel = new StackPanel { Spacing = 4 }; @@ -285,18 +322,25 @@ private Border BuildJobRow(TransferJob job) var moveUp = new Button { Content = "\u2191", IsEnabled = canMoveUp }; ToolTipService.SetToolTip(moveUp, "Run this transfer sooner: move it one place up the queue."); AutomationProperties.SetName(moveUp, $"Move transfer to {job.DestinationFolder} up the queue"); - moveUp.Click += async (s, e) => await _transfers.MoveJobAsync(job, -1); + moveUp.Click += async (s, e) => await MoveTransferFromUiAsync(job, -1); buttons.Children.Add(moveUp); var moveDown = new Button { Content = "\u2193", IsEnabled = canMoveDown }; ToolTipService.SetToolTip(moveDown, "Run this transfer later: move it one place down the queue."); AutomationProperties.SetName(moveDown, $"Move transfer to {job.DestinationFolder} down the queue"); - moveDown.Click += async (s, e) => await _transfers.MoveJobAsync(job, +1); + moveDown.Click += async (s, e) => await MoveTransferFromUiAsync(job, +1); buttons.Children.Add(moveDown); } var cancel = new Button { Content = "Cancel" }; ToolTipService.SetToolTip(cancel, "Stop this transfer. Files already finished stay where they are; a half-copied file is removed."); AutomationProperties.SetName(cancel, $"Cancel transfer to {job.DestinationFolder}"); - cancel.Click += async (s, e) => await _transfers.CancelJobAsync(job); + cancel.Click += async (s, e) => + { + try { await _transfers.CancelJobAsync(job); } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or System.Text.Json.JsonException or NotSupportedException) + { + SetStatus("Cancellation was not saved. Keep the queued job, restore data-folder access, then retry or Resume. " + ex.Message, StatusSeverity.Error); + } + }; buttons.Children.Add(cancel); panel.Children.Add(header); @@ -319,7 +363,8 @@ private Border BuildJobRow(TransferJob job) Text = TransferDetailText(job), FontSize = 11, Foreground = ThemeBrush("MeridianTextSecondaryBrush"), - TextTrimming = TextTrimming.CharacterEllipsis + TextWrapping = job.Items.Any(item => item.RecoveryRequired) ? TextWrapping.Wrap : TextWrapping.NoWrap, + TextTrimming = job.Items.Any(item => item.RecoveryRequired) ? TextTrimming.None : TextTrimming.CharacterEllipsis }; _transferDetailLabels[job.Id] = detail; panel.Children.Add(detail); @@ -365,6 +410,8 @@ private Border BuildConflictRow(TransferJob job, TransferItem item) ToolTipService.SetToolTip(replace, "Replace the existing file with the incoming one. The existing file is overwritten and cannot be brought back from here."); AutomationProperties.SetName(replace, $"Replace the existing {Path.GetFileName(item.SourcePath)}"); replace.Click += async (s, e) => await ResolveConflictFromUiAsync(job, item, CollisionDecisionKind.Replace, applyToAll.IsOn); + // A rename must never overwrite an existing item, so Replace is not offered for renames. + replace.Visibility = job.Kind == TransferJobKind.Rename ? Visibility.Collapsed : Visibility.Visible; var keepBoth = new Button { Content = "Keep both" }; ToolTipService.SetToolTip(keepBoth, "Keep both files. The incoming file is saved under a numbered name, like \u201cname (2)\u201d."); AutomationProperties.SetName(keepBoth, $"Keep both copies of {Path.GetFileName(item.SourcePath)}"); @@ -428,6 +475,7 @@ private Border BuildHistoryRow(TransferHistoryRecord record) { TransferJobKind.Move => "Moved", TransferJobKind.Delete => "Deleted", + TransferJobKind.Rename => "Renamed", _ => "Copied", }; var destination = string.IsNullOrWhiteSpace(record.DestinationFolder) ? "" : $" to {record.DestinationFolder}"; @@ -503,6 +551,9 @@ private static string TransferJobTitle(TransferJob job) TransferJobKind.Delete => job.State == TransferJobState.Running ? $"Deleting {count:N0} {items}" : $"Delete {count:N0} {items}", + TransferJobKind.Rename => job.State == TransferJobState.Running + ? $"Renaming {count:N0} {items}" + : $"Rename {count:N0} {items}", _ => job.State == TransferJobState.Running ? $"Copying {count:N0} {items} to {job.DestinationFolder}" : $"Copy {count:N0} {items} to {job.DestinationFolder}", @@ -511,6 +562,12 @@ private static string TransferJobTitle(TransferJob job) private string TransferDetailText(TransferJob job) { + if (job.Items.FirstOrDefault(item => item.RecoveryRequired) is { } unresolved) + { + var staged = job.RenameBatch?.TemporaryPaths?.Values; + var locations = staged is { Count: > 0 } ? " Owned temporary paths: " + string.Join("; ", staged) + "." : ""; + return $"Recovery required: {unresolved.SourcePath} → {unresolved.DestinationPath}.{locations} {unresolved.Error}"; + } var bytes = $"{FormatBytesCompact(job.BytesCopied)} of {FormatBytesCompact(job.TotalBytes)}"; switch (job.State) { diff --git a/src/Meridian.App/MainWindow.xaml b/src/Meridian.App/MainWindow.xaml index df19ec3..921a726 100644 --- a/src/Meridian.App/MainWindow.xaml +++ b/src/Meridian.App/MainWindow.xaml @@ -19,7 +19,13 @@ - + + + + @@ -29,7 +35,13 @@ block is tagged so the window can tint it by age bucket per realized row. --> - + + + + @@ -38,7 +50,13 @@ - + + + + @@ -48,7 +66,13 @@ - + + + + @@ -58,7 +82,13 @@ - + + + + @@ -69,7 +99,13 @@ - + + + + @@ -77,14 +113,26 @@ - + + + + - + + + + @@ -95,6 +143,10 @@ + @@ -105,6 +157,10 @@ + @@ -380,6 +436,17 @@ + + + + + + + + + + + @@ -527,6 +594,17 @@ + + + + + + + + + + + diff --git a/src/Meridian.App/MainWindow.xaml.cs b/src/Meridian.App/MainWindow.xaml.cs index 71100c7..f58f15c 100644 --- a/src/Meridian.App/MainWindow.xaml.cs +++ b/src/Meridian.App/MainWindow.xaml.cs @@ -52,6 +52,8 @@ public sealed partial class MainWindow : Window private double _splitDragStartX; private double _splitDragStartFraction; private readonly DropShelf _dropShelf = new(); + private readonly FileTagStore _tagStore = new(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Meridian", "file-tags.json")); + private PaneState? _tagContextPane; private readonly RecentPathsStore _recentsStore = new(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Meridian", "recents.json")); private readonly FolderViewStore _folderViewStore = new(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Meridian", "folder-views.json")); private readonly SavedSearchStore _savedSearchStore = new(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Meridian", "saved-searches.json")); @@ -231,6 +233,7 @@ private async Task InitializeAsync(string documents, string downloads) _nameCollisionPolicy = ParseCollisionPolicy(settings.NameCollisionPolicy); _transfersStripCollapsed = settings.TransfersStripCollapsed; } + await _tagStore.LoadAsync(); StartupLog.Milestone("settings-loaded"); await InitializeTransfersAsync(); StartupLog.Milestone("transfers-loaded"); @@ -1272,7 +1275,11 @@ private void SetHidePatterns(string? text) private IReadOnlyList PrepareListing(IReadOnlyList filtered) { var hideExtension = !_showFileExtensions; - foreach (var item in filtered) item.HideExtension = hideExtension; + foreach (var item in filtered) + { + item.HideExtension = hideExtension; + item.TagColor = _tagStore.GetTag(MetadataKeyFor(item)); + } return ListingVisibility.Visible(filtered, _showHiddenItems, _hidePatterns); } @@ -1876,57 +1883,6 @@ private async Task CreateNewFolderAsync() private async void RenameSelected_Click(object sender, RoutedEventArgs e) => await RenameEntryAsync(_activePane); - private async Task RenameSelectedAsync(PaneState pane) - { - if (pane.SelectionList.SelectedItem is not FileEntry selected) - { - SetStatus("Select a file or folder to rename.", StatusSeverity.Warning); - return; - } - - var parentPath = pane.CurrentPath; - var nameBox = new TextBox { Text = selected.Name, MinWidth = 280, SelectionStart = 0, SelectionLength = selected.Name.Length }; - AutomationProperties.SetName(nameBox, "New name"); - var renameContent = new StackPanel { Spacing = 8 }; - renameContent.Children.Add(new TextBlock - { - Text = "Only the name changes. The item stays in the same folder with its contents unchanged, and you can rename it again at any time.", - TextWrapping = TextWrapping.Wrap - }); - renameContent.Children.Add(nameBox); - var dialog = new ContentDialog - { - Title = "Rename item", - Content = renameContent, - PrimaryButtonText = "Rename", - CloseButtonText = "Cancel", - DefaultButton = ContentDialogButton.Primary, - XamlRoot = Content.XamlRoot - }; - - if (await dialog.ShowAsync() != ContentDialogResult.Primary) return; - - try - { - var renamedTo = FileOperations.RenameItem(selected.FullPath, nameBox.Text); - await NavigateAsync(pane, pane.CurrentPath, addHistory: false); - var unchanged = string.Equals(renamedTo, selected.FullPath, StringComparison.Ordinal); - SetStatus(unchanged - ? "Name unchanged." - : $"Renamed · {Path.GetFileName(renamedTo)}", - unchanged ? StatusSeverity.Info : StatusSeverity.Success); - // A no-op rename fires nothing: nothing was affected. A real - // rename fires AfterRename with the old path as FILE and the - // new path as DESTINATION (see MainWindow.Hooks.cs). - if (!unchanged) - FireHooks(HookEvents.AfterRename, parentPath, OppositeFolderOf(pane), [selected.FullPath], renamedTo); - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException or System.Security.SecurityException) - { - SetStatus("Could not rename item · " + ex.Message, StatusSeverity.Error); - } - } - private async void PropertiesSelected_Click(object sender, RoutedEventArgs e) => await PropertiesSelectedAsync(_activePane); private async Task PropertiesSelectedAsync(PaneState pane) @@ -2861,8 +2817,7 @@ private async Task HandleListKeyDownAsync(PaneState pane, KeyRoutedEventArgs e) break; case VirtualKey.F2: e.Handled = true; - if (pane.SelectionList.SelectedItems.Count == 1) await RenameSelectedAsync(pane); - else if (pane.SelectionList.SelectedItems.Count > 1) await BatchRenameAsync(pane); + await RenameEntryAsync(pane); break; case VirtualKey.Enter: e.Handled = true; @@ -2871,8 +2826,18 @@ private async Task HandleListKeyDownAsync(PaneState pane, KeyRoutedEventArgs e) } } - private void LeftContextMenu_Opening(object sender, object e) => UpdateContextMenu(_left, isLeftPane: true); - private void RightContextMenu_Opening(object sender, object e) => UpdateContextMenu(_right, isLeftPane: false); + private void LeftContextMenu_Opening(object sender, object e) + { + _tagContextPane = _left; + UpdateContextMenu(_left, isLeftPane: true); + LeftTagMenuItem.IsEnabled = _left.SelectionList.SelectedItems.Count > 0 && !_left.IsHome && !_left.IsRecycleBin; + } + private void RightContextMenu_Opening(object sender, object e) + { + _tagContextPane = _right; + UpdateContextMenu(_right, isLeftPane: false); + RightTagMenuItem.IsEnabled = _right.SelectionList.SelectedItems.Count > 0 && !_right.IsHome && !_right.IsRecycleBin; + } private void UpdateContextMenu(PaneState pane, bool isLeftPane) { @@ -2942,6 +2907,10 @@ private void UpdateContextMenuItems(PaneState pane, bool isLeftPane, MenuFlyoutI propertiesItem.IsEnabled = count == 1; shelfItem.IsEnabled = count > 0; deleteItem.IsEnabled = count > 0; + renameItem.Text = count > 1 ? $"Rename {count:N0} items" : "Rename"; + ToolTipService.SetToolTip(renameItem, count > 1 + ? $"Rename the {count:N0} selected items (F2). Each name is checked before anything is renamed." + : "Rename the selected item (F2)."); copyItem.Text = count > 1 ? $"Copy {count:N0} items to {direction} pane" : $"Copy to {direction} pane"; moveItem.Text = count > 1 ? $"Move {count:N0} items to {direction} pane" : $"Move to {direction} pane"; shelfItem.Text = count > 1 ? $"Add {count:N0} items to Drop Shelf" : "Add to Drop Shelf"; diff --git a/src/Meridian.App/Services/TransferCoordinator.cs b/src/Meridian.App/Services/TransferCoordinator.cs index f4bfa3d..31b41cb 100644 --- a/src/Meridian.App/Services/TransferCoordinator.cs +++ b/src/Meridian.App/Services/TransferCoordinator.cs @@ -28,19 +28,26 @@ internal sealed class TransferCoordinator private readonly object _sync = new(); private readonly Queue _startQueue = new(); private readonly HashSet _startRequested = new(); + private readonly HashSet _cancelling = new(); private readonly Dictionary _applyToAll = new(); private readonly Dictionary _meters = new(); private Task? _pumpTask; + private bool _persistenceFailed; public TransferCoordinator(string dataFolder) { - _store = new TransferQueueStore(dataFolder); + _store = new TransferQueueStore(dataFolder, () => HasPendingBatchTagsAsync?.Invoke() ?? Task.FromResult(null)); _files = new SystemTransferFileOperations(); - _executor = new TransferExecutor(_files); + _executor = new TransferExecutor(_files, () => _store.SaveRequiredAsync(), + item => RenameConfirmedAsync?.Invoke(item) ?? Task.CompletedTask, + (job, commit) => BatchRenameTagsAsync?.Invoke(job, commit) ?? throw new IOException("Batch tag migration is unavailable.")); } /// Raised whenever the queue, a job's state, or History changes. public event EventHandler? Changed; + public Func? RenameConfirmedAsync { get; set; } + public Func? BatchRenameTagsAsync { get; set; } + public Func>? HasPendingBatchTagsAsync { get; set; } /// Throttled progress for the job currently running (at most ~4 per second). public event EventHandler? ProgressChanged; @@ -57,6 +64,11 @@ public TransferCoordinator(string dataFolder) public async Task LoadAsync() { await _store.LoadAsync(); + foreach (var job in _store.Jobs.Where(job => job.Kind == TransferJobKind.Rename)) + { + try { await _executor.ReconcileRenamesAsync(job); } + catch (RenamePersistenceException) { job.State = TransferJobState.Paused; } + } RaiseChanged(); } @@ -154,20 +166,42 @@ public async Task MoveJobAsync(TransferJob job, int delta) /// that is not running is settled immediately. public async Task CancelJobAsync(TransferJob job) { - if (ReferenceEquals(ActiveJob, job)) + lock (_sync) { - _executor.Cancel(); - return; + if (ReferenceEquals(ActiveJob, job)) + { + _executor.Cancel(); + return; + } + if (job.IsTerminal || !_cancelling.Add(job.Id)) return; } - if (job.IsTerminal) return; - foreach (var item in job.Items) + var previousState = job.State; + var previousFinished = job.FinishedUtc; + var itemStates = job.Items.Select(item => item.State).ToArray(); + try + { + if (job.Items.Any(i => i.RecoveryRequired) || (job.RenameBatch is not null + && (!RenameBatchJournal.IsProvablyUnstarted(job) || HasPendingBatchTagsAsync is null + || await HasPendingBatchTagsAsync() != false))) + throw new IOException("This batch cannot be cancelled safely. Restore tag-store access and Resume the pending batch rename."); + foreach (var item in job.Items) + { + if (item.State is TransferItemState.Pending or TransferItemState.InProgress + or TransferItemState.WaitingOnConflict) + item.State = TransferItemState.Cancelled; + } + job.TransitionTo(TransferJobState.Cancelled); + await _store.RecordCompletionAsync(job); + } + catch { - if (item.State is TransferItemState.Pending or TransferItemState.InProgress - or TransferItemState.WaitingOnConflict) - item.State = TransferItemState.Cancelled; + job.State = previousState; + job.FinishedUtc = previousFinished; + for (var n = 0; n < itemStates.Length; n++) job.Items[n].State = itemStates[n]; + RaiseChanged(); + throw; } - job.TransitionTo(TransferJobState.Cancelled); - await _store.RecordCompletionAsync(job); + finally { lock (_sync) _cancelling.Remove(job.Id); } RaiseChanged(); JobSettled?.Invoke(this, job); } @@ -242,7 +276,7 @@ private async Task PumpAsync() { var id = _startQueue.Dequeue(); _startRequested.Remove(id); - next = _store.Jobs.FirstOrDefault(job => job.Id == id && !job.IsTerminal); + next = _store.Jobs.FirstOrDefault(job => job.Id == id && !job.IsTerminal && !_cancelling.Contains(id)); if (next is not null) break; } if (next is null) @@ -250,8 +284,15 @@ private async Task PumpAsync() _pumpTask = null; return; } + ActiveJob = next; } + _persistenceFailed = false; await RunJobAsync(next); + if (_persistenceFailed) + { + lock (_sync) _pumpTask = null; + return; + } } } @@ -263,6 +304,12 @@ private async Task RunJobAsync(TransferJob job) { await _executor.ExecuteAsync(job, new ProgressSink(this)); } + catch (RenamePersistenceException) + { + _persistenceFailed = true; + RaiseChanged(); + return; + } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or InvalidOperationException or NotSupportedException or System.Security.SecurityException) { @@ -293,7 +340,13 @@ private async Task RunJobAsync(TransferJob job) if (job.IsTerminal) { lock (_sync) _applyToAll.Remove(job.Id); - await _store.RecordCompletionAsync(job); + try { await _store.RecordCompletionAsync(job); } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or System.Text.Json.JsonException or NotSupportedException) + { + _persistenceFailed = true; + RaiseChanged(); + return; + } RaiseChanged(); JobSettled?.Invoke(this, job); return; diff --git a/src/Meridian.Core/Models/FileEntry.cs b/src/Meridian.Core/Models/FileEntry.cs index b5f92a1..e7f9cb9 100644 --- a/src/Meridian.Core/Models/FileEntry.cs +++ b/src/Meridian.Core/Models/FileEntry.cs @@ -104,6 +104,22 @@ public string? GitStatusLabel public event PropertyChangedEventHandler? PropertyChanged; + private FileTagColor? _tagColor; + + /// The color tag attached to this item, or null when untagged. + /// Tags are applied by the window after a folder read from the tag store, + /// so a fresh read rehydrates the tag for each entry. + public FileTagColor? TagColor + { + get => _tagColor; + set + { + if (Equals(_tagColor, value)) return; + _tagColor = value; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(TagColor))); + } + } + private bool _hideExtension; /// diff --git a/src/Meridian.Core/Models/FileTagColor.cs b/src/Meridian.Core/Models/FileTagColor.cs new file mode 100644 index 0000000..e9ea15f --- /dev/null +++ b/src/Meridian.Core/Models/FileTagColor.cs @@ -0,0 +1,20 @@ +namespace Meridian.Models; + +/// The color tags a user can attach to a file or folder. +/// +/// Color tags are a visual supplement to the item name — they never carry +/// meaning by color alone, and every tag has a plain-text label. The palette +/// is fixed and restrained so tags stay readable on both the dark and light +/// themes without following the accent color. +/// +public enum FileTagColor +{ + None, + Red, + Orange, + Yellow, + Green, + Blue, + Purple, + Gray, +} diff --git a/src/Meridian.Core/Services/FileOperations.cs b/src/Meridian.Core/Services/FileOperations.cs index 363d84c..778a722 100644 --- a/src/Meridian.Core/Services/FileOperations.cs +++ b/src/Meridian.Core/Services/FileOperations.cs @@ -36,6 +36,34 @@ public static string RenameItem(string sourcePath, string newName) return destination; } + /// The outcome of renaming one item in a batch: the path that + /// was renamed, the name requested, the resulting path on success, or the + /// error that stopped that one item without touching the others. + public sealed record RenameResult(string SourcePath, string NewName, string? ResultPath, string? Error); + + /// Renames each item independently, in the order given. One + /// item's failure never stops another: every result reports success or + /// the error that applied to that item only, so callers can show + /// per-item outcomes and keep everything that succeeded. + public static IReadOnlyList RenameItems(IEnumerable<(string SourcePath, string NewName)> items) + { + ArgumentNullException.ThrowIfNull(items); + var results = new List(); + foreach (var (sourcePath, newName) in items) + { + try + { + var resultPath = RenameItem(sourcePath, newName); + results.Add(new RenameResult(sourcePath, newName, resultPath, null)); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException or System.Security.SecurityException) + { + results.Add(new RenameResult(sourcePath, newName, null, ex.Message)); + } + } + return results; + } + // File.Move can emulate cross-volume moves with copy/delete. Do not enable // MOVEFILE_COPY_ALLOWED or MOVEFILE_REPLACE_EXISTING: an unsupported rename // must fail without staging a copy or removing the source. @@ -95,7 +123,7 @@ public static string CreateFolder(string parentDirectory, string name) [return: System.Runtime.InteropServices.MarshalAs(System.Runtime.InteropServices.UnmanagedType.Bool)] private static extern bool CreateDirectory(string path, IntPtr securityAttributes); - private static void ValidateNewName(string name) + public static void ValidateNewName(string name) { ArgumentException.ThrowIfNullOrWhiteSpace(name); if (name is "." or ".." || name.Length > 255 || name.EndsWith('.') || diff --git a/src/Meridian.Core/Services/FileTagPalette.cs b/src/Meridian.Core/Services/FileTagPalette.cs new file mode 100644 index 0000000..742baec --- /dev/null +++ b/src/Meridian.Core/Services/FileTagPalette.cs @@ -0,0 +1,47 @@ +using Meridian.Models; + +namespace Meridian.Services; + +/// Maps each to a restrained, +/// theme-independent hex color and its plain-text label. Colors are stored +/// as hex strings so the Core library stays free of WinUI dependencies; +/// the UI layer converts them to brushes. +public static class FileTagPalette +{ + private static readonly (FileTagColor Tag, string Hex, string Label)[] Entries = + [ + (FileTagColor.None, "transparent", "No tag"), + (FileTagColor.Red, "#E4584C", "Red tag"), + (FileTagColor.Orange, "#E8943C", "Orange tag"), + (FileTagColor.Yellow, "#E5C03C", "Yellow tag"), + (FileTagColor.Green, "#6FB05A", "Green tag"), + (FileTagColor.Blue, "#4C8BF5", "Blue tag"), + (FileTagColor.Purple, "#B57BDD", "Purple tag"), + (FileTagColor.Gray, "#8A8F98", "Gray tag"), + ]; + + /// The hex color to paint a tag indicator, or null when untagged. + public static string? HexFor(FileTagColor? tag) + { + if (tag is null or FileTagColor.None) return null; + return EntryFor(tag.Value).Hex; + } + + /// The plain-text label for a tag, used by accessibility names. + public static string LabelFor(FileTagColor? tag) + { + if (tag is null or FileTagColor.None) return "No tag"; + return EntryFor(tag.Value).Label; + } + + /// True when a tag is set. + public static bool IsTagged(FileTagColor? tag) => tag is not null and not FileTagColor.None; + + private static (FileTagColor Tag, string Hex, string Label) EntryFor(FileTagColor tag) + { + foreach (var entry in Entries) + if (entry.Tag == tag) return entry; + // A future enum value without a palette entry renders as untagged. + return Entries[0]; + } +} diff --git a/src/Meridian.Core/Services/FileTagStore.cs b/src/Meridian.Core/Services/FileTagStore.cs new file mode 100644 index 0000000..8580f97 --- /dev/null +++ b/src/Meridian.Core/Services/FileTagStore.cs @@ -0,0 +1,282 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Meridian.Models; +using Meridian.Core.Transfers; + +namespace Meridian.Services; + +/// +/// Persists the color tag attached to each file or folder: a mapping of +/// full path to tag color. Tags are a convenience — a missing or damaged +/// tag file reads as empty and never prevents the app from opening, and the +/// in-memory tags stay correct for the session even if a save fails. +/// +public sealed class FileTagStore +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + Converters = { new JsonStringEnumConverter() }, + }; + private readonly string _filePath; + private readonly SemaphoreSlim _saveGate = new(1, 1); + private readonly HashSet _appliedRenames = []; + private bool _loadFailed; + private bool _loaded; + private PendingBatchTags? _pendingBatch; + public sealed record BatchTagMove(string Source, string Destination, bool IsDirectory); + public sealed record PendingBatchTags(Guid JobId, BatchTagMove[] Moves, Dictionary Snapshot); + private readonly Dictionary _tags = new(StringComparer.OrdinalIgnoreCase); + + public FileTagStore(string filePath) => _filePath = Path.GetFullPath(filePath); + + /// The current tags, path to tag color. + public IReadOnlyDictionary Tags => _tags; + + public async Task HasPendingBatchAsync() + { + await _saveGate.WaitAsync(); + try { return _loadFailed || !_loaded ? null : _pendingBatch is not null; } + finally { _saveGate.Release(); } + } + + /// Loads the saved tags, replacing the in-memory map. A missing + /// or unreadable file yields an empty map. + public async Task LoadAsync() + { + _loadFailed = false; + _loaded = false; + _tags.Clear(); + _appliedRenames.Clear(); + _pendingBatch = null; + try + { + if (!File.Exists(_filePath)) { _loaded = true; return; } + await using var stream = new FileStream(_filePath, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, useAsync: true); + using var document = await JsonDocument.ParseAsync(stream); + var root = document.RootElement; + var versioned = root.TryGetProperty("Version", out var version); + if (versioned && version.GetInt32() != 1) throw new JsonException("Unsupported tag format."); + if (versioned && root.TryGetProperty("PendingBatch", out var pending)) + _pendingBatch = pending.Deserialize(JsonOptions); + var saved = (versioned ? root.GetProperty("Tags") : root).Deserialize>(JsonOptions); + _appliedRenames.Clear(); + if (versioned && root.TryGetProperty("AppliedRenames", out var applied)) + foreach (var id in applied.Deserialize() ?? []) _appliedRenames.Add(id); + if (saved is null) return; + foreach (var (path, color) in saved) + { + if (string.IsNullOrWhiteSpace(path)) continue; + _tags[path.Trim()] = color; + } + _loaded = true; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException) + { + // A damaged tag file must not prevent the app from opening. + _tags.Clear(); + _loadFailed = true; + } + } + + /// The tag on one path, or null when untagged. + public FileTagColor? GetTag(string path) + { + if (string.IsNullOrWhiteSpace(path)) return null; + return _tags.TryGetValue(path.Trim(), out var color) ? color : null; + } + + /// Assigns (or clears, when is null or + /// ) the tag on one path and saves. + public async Task SetTagAsync(string path, FileTagColor? color) + { + await _saveGate.WaitAsync(); + try + { + EnsureNoPendingBatch(); + if (string.IsNullOrWhiteSpace(path)) return; + var trimmed = path.Trim(); + if (color is null or FileTagColor.None) + _tags.Remove(trimmed); + else + _tags[trimmed] = color.Value; + await SaveCoreAsync(required: false); + } + finally { _saveGate.Release(); } + } + + /// Assigns one tag to every path given. + public async Task SetTagsAsync(IEnumerable paths, FileTagColor color) + { + await _saveGate.WaitAsync(); + try + { + EnsureNoPendingBatch(); + var previous = new Dictionary(_tags, StringComparer.OrdinalIgnoreCase); + var changed = false; + foreach (var path in paths) + { + if (string.IsNullOrWhiteSpace(path)) continue; + var trimmed = path.Trim(); + if (color is FileTagColor.None) + { + if (_tags.Remove(trimmed)) changed = true; + } + else if (!_tags.TryGetValue(trimmed, out var existing) || !Equals(existing, color)) + { + _tags[trimmed] = color; + changed = true; + } + } + if (changed) + { + try { await SaveCoreAsync(required: true); } + catch + { + _tags.Clear(); + foreach (var pair in previous) _tags.Add(pair.Key, pair.Value); + throw; + } + } + } + finally { _saveGate.Release(); } + } + + /// Removes the tag from every path given. + public async Task ClearTagsAsync(IEnumerable paths) + { + await _saveGate.WaitAsync(); + try + { + EnsureNoPendingBatch(); + var changed = false; + foreach (var path in paths) + { + if (string.IsNullOrWhiteSpace(path)) continue; + if (_tags.Remove(path.Trim())) changed = true; + } + if (changed) await SaveCoreAsync(required: false); + } + finally { _saveGate.Release(); } + } + + public async Task MigrateRenameAsync(Guid itemId, string source, string destination, bool isDirectory) + { + await _saveGate.WaitAsync(); + try + { + EnsureNoPendingBatch(); + if (_loadFailed) throw new IOException("File tags could not be loaded. Restore the saved tag file before resuming."); + if (_appliedRenames.Contains(itemId)) + { + await SaveCoreAsync(required: true); + return; + } + var prefix = Path.TrimEndingDirectorySeparator(source) + Path.DirectorySeparatorChar; + var moved = _tags.Where(pair => string.Equals(pair.Key, source, StringComparison.OrdinalIgnoreCase) + || (isDirectory && pair.Key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))).ToArray(); + var destinationPrefix = Path.TrimEndingDirectorySeparator(destination) + Path.DirectorySeparatorChar; + foreach (var key in _tags.Keys.Where(key => string.Equals(key, destination, StringComparison.OrdinalIgnoreCase) + || (isDirectory && key.StartsWith(destinationPrefix, StringComparison.OrdinalIgnoreCase))).ToArray()) + _tags.Remove(key); + foreach (var pair in moved) _tags.Remove(pair.Key); + foreach (var pair in moved) _tags[destination + pair.Key[source.Length..]] = pair.Value; + _appliedRenames.Add(itemId); + await SaveCoreAsync(required: true); + } + finally { _saveGate.Release(); } + } + + private void EnsureNoPendingBatch() + { + if (_pendingBatch is not null) throw new IOException("Resume the pending batch rename before changing tags."); + } + + public async Task MigrateBatchRenameAsync(TransferJob job, bool commit) + { + await _saveGate.WaitAsync(); + try + { + if (_loadFailed) throw new IOException("Restore the tag store before resuming the batch rename."); + var moves = job.Items.Select(i => new BatchTagMove(i.SourcePath, i.DestinationPath!, i.IsDirectory)).ToArray(); + if (_pendingBatch is not null && (_pendingBatch.JobId != job.Id || !_pendingBatch.Moves.SequenceEqual(moves))) + throw new IOException("A different batch tag mapping is pending; preserve it and resume that job first."); + if (_appliedRenames.Contains(job.Id)) + { + var previous = _pendingBatch; + _pendingBatch = null; + try { await SaveCoreAsync(required: true); } + catch { _pendingBatch = previous; throw; } + return; + } + if (_pendingBatch is null) + { + if (commit || job.RenameBatch?.Steps.Any(s => s.Intent) == true) + throw new IOException("The original batch tag snapshot is missing; preserve tags and the queue for recovery."); + _pendingBatch = new(job.Id, moves, new Dictionary(_tags, StringComparer.OrdinalIgnoreCase)); + } + if (!commit) + { + await SaveCoreAsync(required: true); + return; + } + var pending = _pendingBatch; + var original = new Dictionary(pending.Snapshot, StringComparer.OrdinalIgnoreCase); + var result = new Dictionary(original, StringComparer.OrdinalIgnoreCase); + static bool Includes(string path, string root, bool directory) => + string.Equals(path, root, StringComparison.OrdinalIgnoreCase) || (directory && path.StartsWith( + Path.TrimEndingDirectorySeparator(root) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)); + foreach (var key in original.Keys) + if (moves.Any(m => Includes(key, m.Source, m.IsDirectory) || Includes(key, m.Destination, m.IsDirectory))) result.Remove(key); + foreach (var move in moves) + foreach (var pair in original.Where(p => Includes(p.Key, move.Source, move.IsDirectory))) + result[move.Destination + pair.Key[move.Source.Length..]] = pair.Value; + _tags.Clear(); + foreach (var pair in result) _tags.Add(pair.Key, pair.Value); + _appliedRenames.Add(job.Id); + _pendingBatch = null; + try { await SaveCoreAsync(required: true); } + catch { _pendingBatch = pending; throw; } + } + finally { _saveGate.Release(); } + } + + private async Task SaveCoreAsync(bool required) + { + if (_loadFailed) + { + if (required) throw new IOException("File tags could not be loaded. Restore the saved tag file before resuming."); + return; + } + string? temporaryPath = null; + try + { + var directory = Path.GetDirectoryName(_filePath)!; + Directory.CreateDirectory(directory); + temporaryPath = _filePath + "." + Guid.NewGuid().ToString("N") + ".tmp"; + await using (var stream = new FileStream(temporaryPath, FileMode.CreateNew, FileAccess.Write, FileShare.None, 4096, useAsync: true)) + { + var json = JsonSerializer.Serialize(new { Version = 1, Tags = _tags, AppliedRenames = _appliedRenames, PendingBatch = _pendingBatch }, JsonOptions); + await stream.WriteAsync(System.Text.Encoding.UTF8.GetBytes(json)); + stream.Flush(flushToDisk: true); + } + + File.Move(temporaryPath, _filePath, overwrite: true); + temporaryPath = null; + _loaded = true; + } + catch (Exception ex) when (!required && ex is (IOException or UnauthorizedAccessException or JsonException)) + { + // Best-effort: the in-memory tags stay correct for this session. + } + finally + { + if (temporaryPath is not null) + { + try { File.Delete(temporaryPath); } + catch (IOException) { } + catch (UnauthorizedAccessException) { } + } + } + } +} diff --git a/src/Meridian.Core/Transfers/ITransferFileOperations.cs b/src/Meridian.Core/Transfers/ITransferFileOperations.cs index 27a3356..503cc00 100644 --- a/src/Meridian.Core/Transfers/ITransferFileOperations.cs +++ b/src/Meridian.Core/Transfers/ITransferFileOperations.cs @@ -7,6 +7,8 @@ namespace Meridian.Core.Transfers; /// public interface ITransferFileOperations { + string RenameItem(string sourcePath, string newName) => throw new NotSupportedException("Rename is unavailable for this filesystem."); + string? GetIdentity(string path) => null; bool FileExists(string path); bool DirectoryExists(string path); @@ -41,6 +43,8 @@ public interface ITransferFileOperations /// The real-filesystem . public sealed class SystemTransferFileOperations : ITransferFileOperations { + public string RenameItem(string sourcePath, string newName) => Meridian.Services.FileOperations.RenameItem(sourcePath, newName); + public string? GetIdentity(string path) => RenameIdentity.Read(path); public bool FileExists(string path) => File.Exists(path); public bool DirectoryExists(string path) => Directory.Exists(path); diff --git a/src/Meridian.Core/Transfers/RenameBatchJournal.cs b/src/Meridian.Core/Transfers/RenameBatchJournal.cs new file mode 100644 index 0000000..2f50848 --- /dev/null +++ b/src/Meridian.Core/Transfers/RenameBatchJournal.cs @@ -0,0 +1,327 @@ +using Meridian.Services; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Meridian.Core.Transfers; + +[JsonConverter(typeof(RenameBatchJournalConverter))] +public sealed class RenameBatchJournal +{ + [JsonIgnore] public JsonElement? UnsupportedData { get; set; } + public int Version { get; set; } = 1; + public bool Prepared { get; set; } + public bool TagsCommitted { get; set; } + public Dictionary TemporaryPaths { get; set; } = []; + public List Steps { get; set; } = []; + + public static bool IsProvablyUnstarted(TransferJob job) => + job.Kind == TransferJobKind.Rename + && job.RenameBatch is { Version: 1, UnsupportedData: null, Prepared: false, TagsCommitted: false, + Steps.Count: 0, TemporaryPaths.Count: 0 } + && job.Items.All(item => !item.RecoveryRequired && !item.RenameIntent + && item.State is TransferItemState.Pending or TransferItemState.Cancelled + && item.BytesCopied == 0 && item.DestinationSize is null && item.DestinationLastWriteUtc is null); +} + + +public sealed class RenameBatchJournalConverter : JsonConverter +{ + public override RenameBatchJournal Read(ref Utf8JsonReader reader, Type type, JsonSerializerOptions options) + { + using var document = JsonDocument.ParseValue(ref reader); + var root = document.RootElement; + try + { + if (root.GetProperty("Version").GetInt32() != 1) throw new JsonException("Unsupported batch version."); + return new RenameBatchJournal + { + Prepared = root.GetProperty("Prepared").GetBoolean(), + TagsCommitted = root.GetProperty("TagsCommitted").GetBoolean(), + TemporaryPaths = root.GetProperty("TemporaryPaths").Deserialize>(options)!, + Steps = root.GetProperty("Steps").Deserialize>(options)!, + }; + } + catch (Exception ex) when (ex is JsonException or InvalidOperationException or KeyNotFoundException or FormatException or OverflowException) + { + return new RenameBatchJournal { Version = -1, UnsupportedData = root.Clone() }; + } + } + + public override void Write(Utf8JsonWriter writer, RenameBatchJournal value, JsonSerializerOptions options) + { + if (value.UnsupportedData is { } raw) { raw.WriteTo(writer); return; } + JsonSerializer.Serialize(writer, new { value.Version, value.Prepared, value.TagsCommitted, value.TemporaryPaths, value.Steps }, options); + } +} + +public enum RenamePhysicalPurpose { Stage, Finalize, Restore } + +public sealed class RenamePhysicalStep +{ + public Guid ItemId { get; set; } + public string From { get; set; } = ""; + public string To { get; set; } = ""; + public RenamePhysicalPurpose Purpose { get; set; } + public bool Intent { get; set; } + public bool Completed { get; set; } +} + +public sealed partial class TransferExecutor +{ + private async Task ExecuteRenameBatchAsync(TransferJob job, IProgress? progress, CancellationToken token) + { + try + { + if (!job.RenameBatch!.Prepared) await PrepareBatchAsync(job); + ReconcileBatch(job); + if (_renameConfirmed is not null && _batchTags is null) + throw new IOException("Batch rename requires simultaneous tag migration support."); + if (_batchTags is not null) await _batchTags(job, false); + foreach (var step in job.RenameBatch.Steps) + { + if (step.Completed) continue; + await CheckpointAsync(job, token); + if (!step.Intent && step.Purpose == RenamePhysicalPurpose.Stage + && (_files.FileExists(step.To) || _files.DirectoryExists(step.To))) + { + var old = step.To; + var reserved = job.Items.SelectMany(i => new[] { i.SourcePath, i.DestinationPath! }) + .Concat(job.RenameBatch.TemporaryPaths.Values).ToHashSet(StringComparer.OrdinalIgnoreCase); + string? replacement = null; + for (var suffix = 0; suffix < 1000; suffix++) + { + var candidate = Path.Combine(Path.GetDirectoryName(step.From)!, $".meridian-rename-{job.Id:N}-{step.ItemId:N}-{suffix}.tmp"); + if (reserved.Contains(candidate) || _files.FileExists(candidate) || _files.DirectoryExists(candidate)) continue; + replacement = candidate; + break; + } + if (replacement is null) throw new IOException("No free temporary name remains."); + step.To = replacement; + job.RenameBatch.TemporaryPaths[step.ItemId] = replacement; + foreach (var later in job.RenameBatch.Steps.Where(s => s.ItemId == step.ItemId && s.From == old)) later.From = replacement; + await PersistBatchAsync(); + } + step.Intent = true; + await PersistBatchAsync(); + token.ThrowIfCancellationRequested(); + var item = job.Items.Single(item => item.Id == step.ItemId); + VerifyBatchLocation(item, step.From); + if (_files.FileExists(step.To) || _files.DirectoryExists(step.To)) + throw new IOException("A batch rename target is occupied: " + step.To); + var actual = _files.RenameItem(step.From, Path.GetFileName(step.To)); + if (!string.Equals(actual, step.To, StringComparison.Ordinal)) + throw new IOException("The filesystem returned an unexpected rename path."); + VerifyBatchLocation(item, step.To); + item.Error = "Last verified location: " + step.To + ". "; + step.Completed = true; + await PersistBatchAsync(); + ReportProgress(job, progress, item.SourcePath); + } + ReconcileBatch(job); + if (_batchTags is not null) await _batchTags(job, true); + job.RenameBatch.TagsCommitted = true; + foreach (var item in job.Items) + { + VerifyBatchLocation(item, item.DestinationPath!); + item.RenameIdentity = _files.GetIdentity(item.DestinationPath!); + var facts = _files.GetFileFacts(item.DestinationPath!); + item.DestinationSize = facts?.Size ?? item.Size; + item.DestinationLastWriteUtc = facts?.LastWriteUtc; + item.BytesCopied = item.Size; + item.State = TransferItemState.Completed; + item.RecoveryRequired = false; + item.RenameIntent = false; + item.Error = null; + } + await PersistBatchAsync(); + } + catch (Exception ex) + { + // No terminal cancellation while an owned temporary or pending tag map exists. + try { await ParkBatchAsync(job, ex); } + catch (RenamePersistenceException parking) when (ex is RenamePersistenceException) + { + throw new RenamePersistenceException("Batch journal and recovery parking both failed.", new AggregateException(ex, parking)); + } + if (ex is RenamePersistenceException) throw; + } + } + + private async Task PersistBatchAsync() + { + try { await _persist(); } + catch (Exception ex) { throw new RenamePersistenceException("Batch journal could not be saved; restore data-folder access before resuming.", ex); } + } + + private async Task ParkBatchAsync(TransferJob job, Exception error) + { + foreach (var item in job.Items) + RequireRecovery(item, (item.Error?.StartsWith("Last verified location:", StringComparison.Ordinal) == true ? item.Error : "") + "Batch rename needs recovery. Preserve all recorded paths, restore data-folder access, then Resume. " + error.Message); + await PersistBatchAsync(); + } + + private async Task PrepareBatchAsync(TransferJob job) + { + ValidateLogicalBatch(job); + var batch = job.RenameBatch!; + if (batch.Steps.Count != 0 || batch.TemporaryPaths.Count != 0) + throw new IOException("An unprepared batch contains unexpected physical records."); + batch = new RenameBatchJournal(); + var folder = Path.GetDirectoryName(job.Items[0].SourcePath)!; + var plan = BatchRename.BuildPlan(folder, job.Items.Select(i => Path.GetFileName(i.SourcePath)).ToArray(), + job.Items.Select(i => Path.GetFileName(i.DestinationPath!)).ToArray()); + if (plan.HasProblems) throw new IOException(plan.FatalProblem ?? plan.Items.First(i => i.HasIssues).Issues[0]); + var identities = new HashSet(StringComparer.Ordinal); + var reserved = job.Items.SelectMany(i => new[] { i.SourcePath, i.DestinationPath! }).ToHashSet(StringComparer.OrdinalIgnoreCase); + for (var index = 0; index < job.Items.Count; index++) + { + var item = job.Items[index]; + var identity = _files.GetIdentity(item.SourcePath); + if (identity is null || !identities.Add(BatchFileId(identity)) || (item.RenameIdentity is not null && BatchFileId(identity) != BatchFileId(item.RenameIdentity))) + throw new IOException("Batch sources require distinct, unchanged filesystem identities."); + item.RenameIdentity = identity; + if (!item.IsDirectory && _files.GetFileFacts(item.SourcePath) is { } sourceFacts) + { + item.Size = sourceFacts.Size; + item.SourceLastWriteUtc = sourceFacts.LastWriteUtc; + } + VerifyBatchLocation(item, item.SourcePath); + if (plan.Items[index].NeedsTemporaryName) + { + string? temporary = null; + for (var suffix = 0; suffix < 1000; suffix++) + { + var candidate = Path.Combine(folder, $".meridian-rename-{job.Id:N}-{item.Id:N}-{suffix}.tmp"); + if (reserved.Contains(candidate) || _files.FileExists(candidate) || _files.DirectoryExists(candidate)) continue; + temporary = candidate; + reserved.Add(candidate); + break; + } + if (temporary is null) throw new IOException("No free owned temporary name is available."); + batch.TemporaryPaths.Add(item.Id, temporary); + batch.Steps.Add(new() { ItemId = item.Id, From = item.SourcePath, To = temporary, Purpose = RenamePhysicalPurpose.Stage }); + } + } + foreach (var item in job.Items.OrderBy(i => batch.TemporaryPaths.ContainsKey(i.Id))) + batch.Steps.Add(new() { ItemId = item.Id, From = batch.TemporaryPaths.GetValueOrDefault(item.Id) ?? item.SourcePath, + To = item.DestinationPath!, Purpose = RenamePhysicalPurpose.Finalize }); + batch.Prepared = true; + job.RenameBatch = batch; + await PersistBatchAsync(); + } + + private static void ValidateLogicalBatch(TransferJob job) + { + var batch = job.RenameBatch!; + if (batch.Version != 1 || job.Kind != TransferJobKind.Rename || job.Items.Count == 0 + || batch.Steps is null || batch.TemporaryPaths is null) + throw new IOException("Unsupported batch rename journal; preserve this queue record."); + var folder = Path.GetDirectoryName(job.Items[0].SourcePath); + var sources = new HashSet(StringComparer.OrdinalIgnoreCase); + var destinations = new HashSet(StringComparer.OrdinalIgnoreCase); + var ids = new HashSet(); + foreach (var item in job.Items) + { + if (item.DestinationPath is null || !Path.IsPathFullyQualified(item.SourcePath) || !Path.IsPathFullyQualified(item.DestinationPath) + || !string.Equals(Path.GetFullPath(item.SourcePath), item.SourcePath, StringComparison.Ordinal) + || !string.Equals(Path.GetFullPath(item.DestinationPath), item.DestinationPath, StringComparison.Ordinal) + || !string.Equals(folder, Path.GetDirectoryName(item.SourcePath), StringComparison.OrdinalIgnoreCase) + || !string.Equals(folder, Path.GetDirectoryName(item.DestinationPath), StringComparison.OrdinalIgnoreCase) + || string.Equals(item.SourcePath, item.DestinationPath, StringComparison.Ordinal) + || !sources.Add(item.SourcePath) || !destinations.Add(item.DestinationPath) || !ids.Add(item.Id)) + throw new IOException("Invalid logical batch rename paths or duplicate participants."); + } + } + + private void ReconcileBatch(TransferJob job) + { + ValidateLogicalBatch(job); + var batch = job.RenameBatch!; + if (!batch.Prepared) return; + var reserved = job.Items.SelectMany(i => new[] { i.SourcePath, i.DestinationPath! }).ToHashSet(StringComparer.OrdinalIgnoreCase); + var sourcePaths = job.Items.Select(i => i.SourcePath).ToHashSet(StringComparer.OrdinalIgnoreCase); + if (job.Items.Any(i => sourcePaths.Contains(i.DestinationPath!) != batch.TemporaryPaths.ContainsKey(i.Id))) + throw new IOException("The staging journal does not match the logical dependency plan."); + foreach (var (id, temporary) in batch.TemporaryPaths) + { + var item = job.Items.SingleOrDefault(i => i.Id == id) ?? throw new IOException("Unknown temporary owner."); + var prefix = $".meridian-rename-{job.Id:N}-{item.Id:N}-"; + var name = Path.GetFileName(temporary); + if (!string.Equals(Path.GetDirectoryName(item.SourcePath), Path.GetDirectoryName(temporary), StringComparison.Ordinal) + || !name.StartsWith(prefix, StringComparison.Ordinal) || !name.EndsWith(".tmp", StringComparison.Ordinal) + || !int.TryParse(name[prefix.Length..^4], out var suffix) || suffix < 0 || suffix >= 1000 + || name != prefix + suffix + ".tmp" || !reserved.Add(temporary)) + throw new IOException("Invalid or overlapping owned temporary path."); + } + var expected = new List<(Guid Id, string From, string To, RenamePhysicalPurpose Purpose)>(); + foreach (var item in job.Items.Where(i => batch.TemporaryPaths.ContainsKey(i.Id))) + expected.Add((item.Id, item.SourcePath, batch.TemporaryPaths[item.Id], RenamePhysicalPurpose.Stage)); + foreach (var item in job.Items.OrderBy(i => batch.TemporaryPaths.ContainsKey(i.Id))) + expected.Add((item.Id, batch.TemporaryPaths.GetValueOrDefault(item.Id) ?? item.SourcePath, item.DestinationPath!, RenamePhysicalPurpose.Finalize)); + if (expected.Count != batch.Steps.Count) throw new IOException("Incomplete physical journal."); + var positions = job.Items.ToDictionary(i => i.Id, i => i.SourcePath); + var stopped = false; + RenamePhysicalStep? uncertain = null; + for (var n = 0; n < expected.Count; n++) + { + var step = batch.Steps[n]; + var e = expected[n]; + if ((step.ItemId, step.From, step.To, step.Purpose) != e || (step.Completed && (!step.Intent || stopped))) + throw new IOException("Invalid physical journal sequence."); + if (step.Completed) positions[step.ItemId] = step.To; + else + { + if (step.Intent) + { + if (stopped) throw new IOException("Multiple outstanding physical intents."); + uncertain = step; + } + stopped = true; + } + } + var identities = new HashSet(); + foreach (var item in job.Items) + { + if (item.RenameIdentity is null || !identities.Add(BatchFileId(item.RenameIdentity))) + throw new IOException("Missing or ambiguous batch identity."); + var candidates = job.Items.SelectMany(i => new[] { i.SourcePath, i.DestinationPath! }).Concat(batch.TemporaryPaths.Values).Distinct(StringComparer.Ordinal).Where(p => BatchFileId(_files.GetIdentity(p)) == BatchFileId(item.RenameIdentity) && ExactBatchPathExists(item, p)).ToArray(); + if (candidates.Length != 1) throw new IOException("A batch participant is missing or duplicated."); + var actual = candidates[0]; + if (uncertain?.ItemId == item.Id && actual == uncertain.To) + { + VerifyBatchLocation(item, actual); + uncertain.Completed = true; + positions[item.Id] = actual; + } + else if (actual != positions[item.Id]) throw new IOException("A batch participant changed location outside the journal."); + VerifyBatchLocation(item, actual); + item.RecoveryRequired = false; + item.Error = "Last verified location: " + actual + ". "; + } + } + + private bool ExactBatchPathExists(TransferItem item, string path) + { + var folder = Path.GetDirectoryName(path)!; + return (item.IsDirectory ? _files.EnumerateDirectories(folder) : _files.EnumerateFiles(folder)).Contains(path, StringComparer.Ordinal); + } + + private static string? BatchFileId(string? identity) + { + // Windows creation-time tunneling changes timestamps when reusing a vacated name. + // Only the volume serial and file index are the stable file identity in that format. + var parts = identity?.Split(':'); + return parts is { Length: 3 } && parts[0].Length == 8 && parts[1].Length == 16 && parts[2].Length == 16 + && parts.All(p => p.All(Uri.IsHexDigit)) ? parts[0] + ":" + parts[1] : identity; + } + + private void VerifyBatchLocation(TransferItem item, string path) + { + if (item.RenameIdentity is null || BatchFileId(_files.GetIdentity(path)) != BatchFileId(item.RenameIdentity) + || _files.DirectoryExists(path) != item.IsDirectory || !ExactBatchPathExists(item, path)) + throw new IOException("The batch participant identity, kind, or exact path changed: " + path); + if (!item.IsDirectory && item.SourceLastWriteUtc is { } expectedWrite + && (_files.GetFileFacts(path) is not { } facts || facts.Size != item.Size || facts.LastWriteUtc != expectedWrite)) + throw new IOException("The batch participant file facts changed: " + path); + } +} diff --git a/src/Meridian.Core/Transfers/RenameIdentity.cs b/src/Meridian.Core/Transfers/RenameIdentity.cs new file mode 100644 index 0000000..ed13b9a --- /dev/null +++ b/src/Meridian.Core/Transfers/RenameIdentity.cs @@ -0,0 +1,35 @@ +using System.Runtime.InteropServices; +using Microsoft.Win32.SafeHandles; + +namespace Meridian.Core.Transfers; + +internal static class RenameIdentity +{ + public static string? Read(string path) + { + if (!OperatingSystem.IsWindows()) return null; + try + { + if (Meridian.Services.FileOperations.FindReparsePointAncestor(path) is not null) return null; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { return null; } + using var handle = CreateFileW(path, 0, 7, IntPtr.Zero, 3, 0x02000000, IntPtr.Zero); + if (handle.IsInvalid || !GetFileInformationByHandle(handle, out var info)) return null; + return $"{info.VolumeSerial:X8}:{info.IndexHigh:X8}{info.IndexLow:X8}:{info.CreationHigh:X8}{info.CreationLow:X8}"; + } + + [StructLayout(LayoutKind.Sequential)] + private struct Information + { + public uint Attributes, CreationLow, CreationHigh, AccessLow, AccessHigh, + WriteLow, WriteHigh, VolumeSerial, SizeHigh, SizeLow, Links, IndexHigh, IndexLow; + } + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, ExactSpelling = true, SetLastError = true)] + private static extern SafeFileHandle CreateFileW(string path, uint access, uint share, + IntPtr security, uint disposition, uint flags, IntPtr template); + + [DllImport("kernel32.dll", ExactSpelling = true, SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool GetFileInformationByHandle(SafeFileHandle handle, out Information information); +} diff --git a/src/Meridian.Core/Transfers/RenamePersistenceException.cs b/src/Meridian.Core/Transfers/RenamePersistenceException.cs new file mode 100644 index 0000000..5bea955 --- /dev/null +++ b/src/Meridian.Core/Transfers/RenamePersistenceException.cs @@ -0,0 +1,3 @@ +namespace Meridian.Core.Transfers; + +public sealed class RenamePersistenceException(string message, Exception inner) : IOException(message, inner); diff --git a/src/Meridian.Core/Transfers/TransferExecutor.cs b/src/Meridian.Core/Transfers/TransferExecutor.cs index 7757f51..3b5dce1 100644 --- a/src/Meridian.Core/Transfers/TransferExecutor.cs +++ b/src/Meridian.Core/Transfers/TransferExecutor.cs @@ -18,18 +18,27 @@ public sealed record TransferProgress( /// after its copy is published (and verified, when requested). One item's failure is /// recorded on the item and never aborts the job. /// -public sealed class TransferExecutor +public sealed partial class TransferExecutor { private const int BufferSize = 128 * 1024; private readonly ITransferFileOperations _files; + private readonly Func _persist; + private readonly Func? _renameConfirmed; + private readonly Func? _batchTags; private readonly object _gateLock = new(); private CancellationTokenSource? _runCancellation; private TaskCompletionSource? _resumeSignal; private bool _pauseRequested; - public TransferExecutor(ITransferFileOperations? files = null) => + public TransferExecutor(ITransferFileOperations? files = null, Func? persist = null, + Func? renameConfirmed = null, Func? batchTags = null) + { _files = files ?? new SystemTransferFileOperations(); + _persist = persist ?? (() => Task.CompletedTask); + _renameConfirmed = renameConfirmed; + _batchTags = batchTags; + } /// Asks the running job to pause at its next checkpoint. No-op when nothing is running. public void Pause() @@ -84,7 +93,7 @@ public async Task ExecuteAsync( throw new InvalidOperationException("This job is already running."); if (job.Items.Count == 0 && job.Sources.Count == 0) throw new InvalidOperationException("The job has nothing to transfer."); - if (job.Items.Count == 0 && job.Kind != TransferJobKind.Delete) + if (job.Items.Count == 0 && job.Kind is not (TransferJobKind.Delete or TransferJobKind.Rename)) { // Fresh jobs fail fast on a bad destination. Jobs that already carry // their items (a resumed queue entry, an undo plan) are self-describing: @@ -107,6 +116,14 @@ public async Task ExecuteAsync( job.TransitionTo(TransferJobState.Running); try { + if (job.RenameBatch is not null) + { + await ExecuteRenameBatchAsync(job, progress, linked.Token); + FinishJob(job); + return job; + } + if (job.Kind == TransferJobKind.Rename) + await ReconcileRenamesAsync(job); if (job.Items.Count == 0) { if (job.Kind == TransferJobKind.Delete) BuildDeleteItems(job); @@ -118,11 +135,17 @@ public async Task ExecuteAsync( { await ProcessItemAsync(job, item, progress, linked.Token); ReportProgress(job, progress, item.SourcePath); + if (item.RecoveryRequired) break; } if (job.Kind == TransferJobKind.Move) RemoveEmptiedSourceFolders(job); FinishJob(job); } + catch (RenamePersistenceException) + { + job.TransitionTo(TransferJobState.Paused); + throw; + } catch (OperationCanceledException) when (linked.IsCancellationRequested) { foreach (var item in job.Items) @@ -402,11 +425,17 @@ private bool TryExpandDelete(TransferJob job, string root, ref int budget) private async Task ProcessItemAsync( TransferJob job, TransferItem item, IProgress? progress, CancellationToken cancellationToken) { + if (item.RecoveryRequired) return; if (item.State is not (TransferItemState.Pending or TransferItemState.InProgress)) return; await CheckpointAsync(job, cancellationToken); + if (job.Kind == TransferJobKind.Rename) + { + await ProcessRenameItemAsync(job, item, progress, cancellationToken); + return; + } if (job.Kind == TransferJobKind.Delete) { ProcessDeleteItem(item); @@ -445,6 +474,215 @@ private void ProcessDeleteItem(TransferItem item) } } + /// Renames one item in place within its own folder. A rename never + /// copies data: it is a single Windows rename, so a same-folder rename always + /// targets the same volume and a source is never left half-processed. One + /// item's failure is recorded on that item and never aborts the job. + private async Task ProcessRenameItemAsync( + TransferJob job, TransferItem item, IProgress? progress, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + var source = item.SourcePath; + var destination = item.DestinationPath; + if (destination is null) + { + item.State = TransferItemState.Failed; + item.Error = "The item has no destination."; + return; + } + if (string.Equals(source, destination, StringComparison.Ordinal)) + { + item.State = TransferItemState.Failed; + item.Error = "The name is unchanged."; + return; + } + if (!_files.FileExists(source) && !_files.DirectoryExists(source)) + { + item.State = TransferItemState.Failed; + item.Error = "The source no longer exists."; + return; + } + + // A rename's destination is a specific new name in the same folder. A file + // or folder already sitting there is a collision the user must resolve. + var caseOnly = string.Equals(source, destination, StringComparison.OrdinalIgnoreCase); + var destinationTaken = !caseOnly && (_files.FileExists(destination) || _files.DirectoryExists(destination)); + + CollisionDecision decision; + if (item.CollisionOverride is { } userDecision) + { + decision = userDecision switch + { + CollisionDecisionKind.Skip => new CollisionDecision(CollisionDecisionKind.Skip, null, "You chose to skip this rename."), + CollisionDecisionKind.Rename => new CollisionDecision(CollisionDecisionKind.Rename, destination, "You chose a new name for this item."), + CollisionDecisionKind.Replace => new CollisionDecision(CollisionDecisionKind.Replace, null, "A rename cannot replace an existing item."), + _ => new CollisionDecision(CollisionDecisionKind.Proceed, null, destinationTaken ? "A name is in the way." : "Nothing is in the way at the destination."), + }; + } + else + { + decision = destinationTaken + ? new CollisionDecision(CollisionDecisionKind.NeedsUserChoice, null, "A file or folder with this name already exists.") + : new CollisionDecision(CollisionDecisionKind.Proceed, null, "Nothing is in the way at the destination."); + } + + switch (decision.Kind) + { + case CollisionDecisionKind.NeedsUserChoice: + item.State = TransferItemState.WaitingOnConflict; + item.Error = null; + ReportProgress(job, progress, source); + return; + case CollisionDecisionKind.Skip: + item.State = TransferItemState.Skipped; + item.Error = null; + ReportProgress(job, progress, source); + return; + case CollisionDecisionKind.Rename: + destination = decision.NewDestinationPath ?? destination; + break; + case CollisionDecisionKind.Replace: + // Renaming must never overwrite an existing item. + item.State = TransferItemState.Failed; + item.Error = "Renaming cannot replace an existing item. Keep both (a numbered name) or skip instead."; + ReportProgress(job, progress, source); + return; + case CollisionDecisionKind.Proceed: + break; + } + + item.State = TransferItemState.InProgress; + item.BytesCopied = 0; + item.DestinationPath = destination; + var identity = _files.GetIdentity(source); + if (item.RenameIdentity is { } expected && identity != expected) + { + RequireRecovery(item, "The source identity changed. Inspect both paths before resolving this rename."); + return; + } + item.RenameIdentity = identity; + item.RenameIntent = true; + await PersistRenameAsync(item); + if (cancellationToken.IsCancellationRequested) + { + item.RenameIntent = false; + await PersistRenameAsync(item); + cancellationToken.ThrowIfCancellationRequested(); + } + ReportProgress(job, progress, source); + try + { + if (!string.Equals(Path.GetDirectoryName(source), Path.GetDirectoryName(destination), StringComparison.OrdinalIgnoreCase)) + throw new IOException("A rename must stay in the same folder."); + if (identity is not null && _files.GetIdentity(source) != identity) + throw new IOException("The source identity changed before rename. Inspect both paths before retrying."); + var renamed = _files.RenameItem(source, Path.GetFileName(destination)); + if (identity is not null && _files.GetIdentity(renamed) != identity) + throw new IOException("The destination identity changed after rename. Inspect both paths before resolving this record."); + var facts = _files.GetFileFacts(renamed); + item.DestinationPath = renamed; + item.DestinationSize = facts?.Size ?? item.Size; + item.DestinationLastWriteUtc = facts?.LastWriteUtc; + item.BytesCopied = item.Size; + item.State = TransferItemState.Completed; + item.Error = null; + } + catch (RenamePersistenceException) { throw; } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + item.State = TransferItemState.Cancelled; + throw; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException or System.Security.SecurityException) + { + RequireRecovery(item, "Rename did not report completion. Inspect both paths before retrying. " + ex.Message); + await PersistRenameAsync(item); + return; + } + await PersistRenameAsync(item); + if (item.State == TransferItemState.Completed && _renameConfirmed is not null) + await ConfirmRenameAsync(item); + item.RenameIntent = false; + await PersistRenameAsync(item); + ReportProgress(job, progress, source); + } + + private async Task PersistRenameAsync(TransferItem item) + { + try { await _persist(); } + catch (Exception ex) + { + item.RenameIntent = true; + RequireRecovery(item, "The rename record could not be saved. Restore access to Meridian's data folder, then resume to reconcile both paths. " + ex.Message); + throw new RenamePersistenceException(item.Error!, ex); + } + } + + private async Task ConfirmRenameAsync(TransferItem item) + { + try { await _renameConfirmed!(item); } + catch (Exception ex) + { + RequireRecovery(item, "The rename succeeded but its tags could not be saved. Restore access to Meridian's data folder, then resume. " + ex.Message); + throw new RenamePersistenceException(item.Error!, ex); + } + } + + public async Task ReconcileRenamesAsync(TransferJob job) + { + if (job.RenameBatch is not null) + { + try { ReconcileBatch(job); } + catch (Exception ex) { await ParkBatchAsync(job, ex); } + return; + } + foreach (var item in job.Items.Where(item => item.RenameIntent)) + { + var sourceExists = _files.FileExists(item.SourcePath) || _files.DirectoryExists(item.SourcePath); + var destinationExists = item.DestinationPath is { } path && (_files.FileExists(path) || _files.DirectoryExists(path)); + var sourceMatches = item.RenameIdentity is { } identity && _files.GetIdentity(item.SourcePath) == identity; + var destinationMatches = item.RenameIdentity is { } other && item.DestinationPath is { } destination && _files.GetIdentity(destination) == other; + if (item.DestinationPath is { } caseDestination && string.Equals(item.SourcePath, caseDestination, StringComparison.OrdinalIgnoreCase)) + { + var parent = Path.GetDirectoryName(item.SourcePath)!; + if (_files.DirectoryExists(parent)) + { + var entries = (item.IsDirectory ? _files.EnumerateDirectories(parent) : _files.EnumerateFiles(parent)).ToArray(); + sourceExists = entries.Contains(item.SourcePath, StringComparer.Ordinal); + destinationExists = entries.Contains(caseDestination, StringComparer.Ordinal); + } + } + if (sourceExists && !destinationExists && sourceMatches) + { + item.State = TransferItemState.Pending; + item.RecoveryRequired = false; + item.Error = null; + item.RenameIntent = false; + } + else if (!sourceExists && destinationExists && destinationMatches) + { + item.State = TransferItemState.Completed; + item.RecoveryRequired = false; + item.Error = null; + var facts = _files.GetFileFacts(item.DestinationPath!); + item.DestinationSize = facts?.Size ?? item.Size; + item.DestinationLastWriteUtc = facts?.LastWriteUtc; + await PersistRenameAsync(item); + if (_renameConfirmed is not null) await ConfirmRenameAsync(item); + item.RenameIntent = false; + } + else RequireRecovery(item, "Rename recovery is uncertain. Inspect both paths and preserve conflicting items elsewhere. Restore the original item at exactly one recorded path, then Resume; its recorded identity must match. Cancel only to abandon this record."); + await PersistRenameAsync(item); + } + } + + private static void RequireRecovery(TransferItem item, string error) + { + item.RecoveryRequired = true; + item.State = TransferItemState.InProgress; + item.Error = error; + } + private void ProcessDirectoryItem(TransferItem item) { // Folder collisions merge: an existing destination folder is the normal case. @@ -670,6 +908,11 @@ private void RemoveEmptiedSourceFolders(TransferJob job) private static void FinishJob(TransferJob job) { + if (job.Items.Any(item => item.RecoveryRequired)) + { + job.TransitionTo(TransferJobState.Paused); + return; + } if (job.Items.Any(item => item.State == TransferItemState.WaitingOnConflict)) { job.TransitionTo(TransferJobState.WaitingOnConflict); diff --git a/src/Meridian.Core/Transfers/TransferModel.cs b/src/Meridian.Core/Transfers/TransferModel.cs index 2f263d6..ffcab48 100644 --- a/src/Meridian.Core/Transfers/TransferModel.cs +++ b/src/Meridian.Core/Transfers/TransferModel.cs @@ -6,6 +6,13 @@ public enum TransferJobKind Copy, Move, + /// + /// Renames items in place, one new name per item. A rename is a same-folder + /// move: it never copies data, one item's failure never aborts the job, and a + /// finished rename can be undone by moving each item back to its original name. + /// + Rename, + /// /// Deletes the items named by the job. The transfer engine's deletes are /// permanent (they do not go to the Recycle Bin), and a finished Delete job @@ -50,6 +57,9 @@ public sealed record FileFacts(long Size, DateTimeOffset LastWriteUtc); /// public sealed class TransferItem { + public bool RenameIntent { get; set; } + public string? RenameIdentity { get; set; } + public bool RecoveryRequired { get; set; } public Guid Id { get; set; } = Guid.NewGuid(); /// The file or folder acted on. For a Delete job this is the item removed. @@ -135,6 +145,7 @@ public void ResolveConflict(CollisionDecision decision) /// public sealed class TransferJob { + public RenameBatchJournal? RenameBatch { get; set; } private static readonly IReadOnlyDictionary LegalTransitions = new Dictionary { diff --git a/src/Meridian.Core/Transfers/TransferQueueStore.cs b/src/Meridian.Core/Transfers/TransferQueueStore.cs index 5c6f6a2..985f3c4 100644 --- a/src/Meridian.Core/Transfers/TransferQueueStore.cs +++ b/src/Meridian.Core/Transfers/TransferQueueStore.cs @@ -17,7 +17,8 @@ public sealed record TransferHistoryItem( long? DestinationSize, DateTimeOffset? DestinationLastWriteUtc, TransferItemState FinalState, - string? Error); + string? Error, + string? RenameIdentity = null); /// The durable record of one finished job, kept in the queue store's history. public sealed record TransferHistoryRecord( @@ -55,7 +56,8 @@ public static TransferHistoryRecord FromJob(TransferJob job) item.DestinationSize, item.DestinationLastWriteUtc, item.State, - item.Error)).ToList()); + item.Error, + item.RenameIdentity)).ToList()); } } @@ -73,6 +75,7 @@ public static TransferHistoryRecord FromJob(TransferJob job) /// public sealed class TransferQueueStore { + private readonly SemaphoreSlim _saveGate = new(1, 1); /// The most finished-job records kept; older ones fall off the end. public const int MaximumHistoryEntries = 100; @@ -87,15 +90,18 @@ public sealed class TransferQueueStore private readonly string _queuePath; private readonly string _historyPath; + private readonly Func>? _hasPendingBatchTags; private readonly List _jobs = []; private readonly List _history = []; // most recent first + internal Action? BeforeWrite { get; set; } - public TransferQueueStore(string folderPath) + public TransferQueueStore(string folderPath, Func>? hasPendingBatchTags = null) { ArgumentException.ThrowIfNullOrWhiteSpace(folderPath); var folder = Path.GetFullPath(folderPath); _queuePath = Path.Combine(folder, QueueFileName); _historyPath = Path.Combine(folder, HistoryFileName); + _hasPendingBatchTags = hasPendingBatchTags; } /// Jobs that still owe work, in the order they were queued. @@ -118,7 +124,7 @@ public async Task LoadAsync(CancellationToken cancellationToken = default) job.Items ??= []; job.Sources ??= []; NormalizeAfterRestart(job); - if (!job.IsTerminal) _jobs.Add(job); + _jobs.Add(job); } } @@ -128,17 +134,37 @@ public async Task LoadAsync(CancellationToken cancellationToken = default) foreach (var record in savedHistory) { if (record is null) continue; - _history.Add(record); + if (_history.All(existing => existing.JobId != record.JobId)) _history.Add(record); if (_history.Count >= MaximumHistoryEntries) break; } } + foreach (var job in _jobs.Where(job => job.IsTerminal).ToArray()) + await RecordCompletionAsync(job, cancellationToken); } /// Persists the queue and history on a best-effort basis (see class remarks). public async Task SaveAsync(CancellationToken cancellationToken = default) { - await WriteAsync(_queuePath, _jobs, cancellationToken); + try { await SaveRequiredAsync(cancellationToken); } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException or NotSupportedException) { } + } + + public async Task SaveRequiredAsync(CancellationToken cancellationToken = default) + { + await _saveGate.WaitAsync(cancellationToken); + try + { + await SaveUnderGateAsync(cancellationToken); + } + finally { _saveGate.Release(); } + } + + private async Task SaveUnderGateAsync(CancellationToken cancellationToken) + { await WriteAsync(_historyPath, _history, cancellationToken); + var remaining = _jobs.Where(job => !job.IsTerminal || !_history.Any(record => record.JobId == job.Id)).ToList(); + await WriteAsync(_queuePath, remaining, cancellationToken); + _jobs.RemoveAll(job => !remaining.Contains(job)); } /// Adds a job to the queue and saves. @@ -147,7 +173,9 @@ public async Task EnqueueAsync(TransferJob job, CancellationToken cancellationTo ArgumentNullException.ThrowIfNull(job); if (job.IsTerminal) throw new InvalidOperationException("A finished job cannot be queued."); - _jobs.Add(job); + await _saveGate.WaitAsync(cancellationToken); + try { _jobs.Add(job); } + finally { _saveGate.Release(); } await SaveAsync(cancellationToken); } @@ -159,19 +187,27 @@ public async Task EnqueueAsync(TransferJob job, CancellationToken cancellationTo /// public async Task MoveAsync(Guid jobId, int delta, CancellationToken cancellationToken = default) { - var index = _jobs.FindIndex(job => job.Id == jobId); - if (index < 0) return false; - var target = index + delta; - if (target < 0 || target >= _jobs.Count) return false; - (_jobs[index], _jobs[target]) = (_jobs[target], _jobs[index]); - await SaveAsync(cancellationToken); - return true; + await _saveGate.WaitAsync(cancellationToken); + try + { + var index = _jobs.FindIndex(job => job.Id == jobId); + if (index < 0) return false; + var target = index + delta; + if (target < 0 || target >= _jobs.Count) return false; + (_jobs[index], _jobs[target]) = (_jobs[target], _jobs[index]); + await SaveUnderGateAsync(cancellationToken); + return true; + } + finally { _saveGate.Release(); } } /// Drops a queued (not running) job without recording it in history, and saves. public async Task RemoveAsync(Guid jobId, CancellationToken cancellationToken = default) { - var removed = _jobs.RemoveAll(job => job.Id == jobId) > 0; + bool removed; + await _saveGate.WaitAsync(cancellationToken); + try { removed = _jobs.RemoveAll(job => job.Id == jobId && job.RenameBatch is not ({ Prepared: true, TagsCommitted: false } or { Version: not 1 }) && !job.Items.Any(i => i.RecoveryRequired)) > 0; } + finally { _saveGate.Release(); } if (removed) await SaveAsync(cancellationToken); return removed; } @@ -183,13 +219,34 @@ public async Task RemoveAsync(Guid jobId, CancellationToken cancellationTo public async Task RecordCompletionAsync( TransferJob job, CancellationToken cancellationToken = default) { - var record = TransferHistoryRecord.FromJob(job); - _jobs.RemoveAll(existing => existing.Id == job.Id); - _history.Insert(0, record); - while (_history.Count > MaximumHistoryEntries) - _history.RemoveAt(_history.Count - 1); - await SaveAsync(cancellationToken); - return record; + await _saveGate.WaitAsync(cancellationToken); + try + { + var unstartedCancellation = job.State == TransferJobState.Cancelled + && RenameBatchJournal.IsProvablyUnstarted(job) + && _hasPendingBatchTags is not null && await _hasPendingBatchTags() == false; + if ((job.RenameBatch is { TagsCommitted: false } && !unstartedCancellation) + || (job.RenameBatch is not null && job.State == TransferJobState.Cancelled && !unstartedCancellation) + || job.RenameBatch is { Version: not 1 } or { UnsupportedData: not null } + || job.Items.Any(i => i.RecoveryRequired)) + throw new IOException("A batch requiring recovery cannot be settled."); + var record = _history.FirstOrDefault(existing => existing.JobId == job.Id) + ?? TransferHistoryRecord.FromJob(job); + await WriteAsync(_queuePath, _jobs, cancellationToken); + var previousHistory = _history.ToArray(); + if (!_history.Contains(record)) _history.Insert(0, record); + while (_history.Count > MaximumHistoryEntries) + _history.RemoveAt(_history.Count - 1); + try { await SaveUnderGateAsync(cancellationToken); } + catch + { + _history.Clear(); + _history.AddRange(previousHistory); + throw; + } + return record; + } + finally { _saveGate.Release(); } } private static void NormalizeAfterRestart(TransferJob job) @@ -197,11 +254,13 @@ private static void NormalizeAfterRestart(TransferJob job) // A job saved mid-run must not pretend it is still running: park it as // Paused and hand its interrupted item back to Pending so a deliberate // resume re-copies that item from scratch (the staged partial is gone). - if (job.State == TransferJobState.Running) + if (job.State == TransferJobState.Running || job.RenameBatch is { TagsCommitted: false } or { Version: not 1 }) job.State = TransferJobState.Paused; foreach (var item in job.Items) { - if (item.State == TransferItemState.InProgress) + if (job.Kind == TransferJobKind.Rename && item.State == TransferItemState.InProgress) + item.RenameIntent = true; + if (item.State == TransferItemState.InProgress && !item.RenameIntent) { item.State = TransferItemState.Pending; item.BytesCopied = 0; @@ -223,8 +282,9 @@ private static void NormalizeAfterRestart(TransferJob job) } } - private static async Task WriteAsync(string path, T value, CancellationToken cancellationToken) + private async Task WriteAsync(string path, T value, CancellationToken cancellationToken) { + BeforeWrite?.Invoke(path); string? temporaryPath = null; try { @@ -232,15 +292,15 @@ private static async Task WriteAsync(string path, T value, CancellationToken Directory.CreateDirectory(directory); temporaryPath = path + "." + Guid.NewGuid().ToString("N") + ".tmp"; var json = JsonSerializer.Serialize(value, JsonOptions); - await File.WriteAllTextAsync(temporaryPath, json, cancellationToken); + await using (var stream = new FileStream(temporaryPath, FileMode.CreateNew, FileAccess.Write, FileShare.None)) + { + var bytes = System.Text.Encoding.UTF8.GetBytes(json); + await stream.WriteAsync(bytes, cancellationToken); + stream.Flush(flushToDisk: true); + } File.Move(temporaryPath, path, overwrite: true); temporaryPath = null; } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException or NotSupportedException) - { - // Best-effort like the other Meridian stores: the in-memory queue stays - // correct for this session even when the disk write fails. - } finally { if (temporaryPath is not null) diff --git a/src/Meridian.Core/Transfers/UndoPlanner.cs b/src/Meridian.Core/Transfers/UndoPlanner.cs index c776dd7..b07a269 100644 --- a/src/Meridian.Core/Transfers/UndoPlanner.cs +++ b/src/Meridian.Core/Transfers/UndoPlanner.cs @@ -18,7 +18,9 @@ public sealed record UndoPlan(bool IsAvailable, string Reason, TransferJob? Inve /// Copy → the inverse is a Delete job over exactly the files the copy created (folders the /// copy created are left in place). Move → the inverse is a Move job returning each file to /// its original location; an original location that is occupied again gets the next free -/// "name (2)" style name rather than overwriting anything. Delete → never undoable: the +/// "name (2)" style name rather than overwriting anything. Rename → the inverse is a Rename +/// job returning each item to its original name; an original name that is occupied again gets +/// the next free numbered name. Delete → never undoable: the /// transfer engine's deletes are permanent and do not go to the Recycle Bin. Undoing an /// undo is how redo works — the inverse job's own history record plans back the other way. /// @@ -33,6 +35,9 @@ public static UndoPlan Plan(TransferHistoryRecord record, ITransferFileOperation { TransferJobKind.Copy => PlanCopyUndo(record, files), TransferJobKind.Move => PlanMoveUndo(record, files), + // A rename is a same-folder move: reverse it by moving each item back + // to its original name, with the same Keep-both / unchanged-file checks. + TransferJobKind.Rename => PlanRenameUndo(record, files), TransferJobKind.Delete => new UndoPlan(false, "Deletes cannot be undone from Meridian's records: the transfer engine removes files permanently instead of sending them to the Recycle Bin.", null), @@ -40,6 +45,40 @@ public static UndoPlan Plan(TransferHistoryRecord record, ITransferFileOperation }; } + private static UndoPlan PlanRenameUndo(TransferHistoryRecord record, ITransferFileOperations files) + { + var renamed = record.Items.Where(item => item.FinalState == TransferItemState.Completed && item.DestinationPath is not null).ToList(); + if (renamed.Count == 0) return new UndoPlan(false, "Nothing was renamed by this job.", null); + var inverse = new TransferJob { Kind = TransferJobKind.Rename, CollisionPolicy = CollisionPolicy.KeepBoth }; + var participants = renamed.Select(i => i.DestinationPath!).ToHashSet(StringComparer.OrdinalIgnoreCase); + var reserved = renamed.Select(i => i.SourcePath).ToHashSet(StringComparer.OrdinalIgnoreCase); + foreach (var item in renamed) + { + if (item.RenameIdentity is null || files.GetIdentity(item.DestinationPath!) != item.RenameIdentity) + return new UndoPlan(false, "The renamed item's identity cannot be confirmed. Inspect its current path before undoing.", null); + if (item.IsDirectory != files.DirectoryExists(item.DestinationPath!)) + return new UndoPlan(false, "The renamed item's kind changed.", null); + if (!item.IsDirectory && CheckStillUnchanged(item, files) is { } error) + return new UndoPlan(false, error, null); + if (!files.DirectoryExists(Path.GetDirectoryName(item.SourcePath)!)) + return new UndoPlan(false, "The original parent folder no longer exists.", null); + var destination = item.SourcePath; + if (!string.Equals(destination, item.DestinationPath, StringComparison.OrdinalIgnoreCase) + && !participants.Contains(destination) && (files.FileExists(destination) || files.DirectoryExists(destination))) + destination = CollisionResolver.FirstFreeName(destination, path => reserved.Contains(path) || participants.Contains(path) || files.FileExists(path) || files.DirectoryExists(path)); + reserved.Add(destination); + inverse.Items.Add(new TransferItem + { + SourcePath = item.DestinationPath!, DestinationPath = destination, + IsDirectory = item.IsDirectory, Size = item.Size, RenameIdentity = item.RenameIdentity, + }); + } + if (inverse.Items.Count > 1 || inverse.Items.Any(i => string.Equals(i.SourcePath, i.DestinationPath, StringComparison.OrdinalIgnoreCase))) + inverse.RenameBatch = new RenameBatchJournal(); + inverse.Sources = inverse.Items.Select(item => item.SourcePath).ToList(); + return new UndoPlan(true, $"Rename {inverse.Items.Count} item(s) back without replacing existing items.", inverse); + } + private static UndoPlan PlanCopyUndo(TransferHistoryRecord record, ITransferFileOperations files) { var created = CompletedFileItems(record); @@ -135,7 +174,9 @@ private static UndoPlan PlanMoveUndo(TransferHistoryRecord record, ITransferFile var inverse = new TransferJob { - Kind = TransferJobKind.Move, + // Renames reverse as renames (a single in-place rename); moves reverse + // as copy-then-delete, which is the existing safe behavior. + Kind = record.Kind == TransferJobKind.Rename ? TransferJobKind.Rename : TransferJobKind.Move, Sources = inverseItems.Select(item => item.SourcePath).ToList(), DestinationFolder = Path.GetDirectoryName(moved[0].SourcePath) ?? string.Empty, // The plan already chose free (or numbered) destinations; KeepBoth is diff --git a/tests/Meridian.Core.Tests/BatchRenameBlockerTests.cs b/tests/Meridian.Core.Tests/BatchRenameBlockerTests.cs new file mode 100644 index 0000000..8a1a15f --- /dev/null +++ b/tests/Meridian.Core.Tests/BatchRenameBlockerTests.cs @@ -0,0 +1,245 @@ +using System.Reflection; +using System.Text.Json; +using Meridian.Core.Transfers; +using Meridian.Models; +using Meridian.Services; + +namespace Meridian.Core.Tests; + +public sealed class BatchRenameBlockerTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "meridian-three-b-" + Guid.NewGuid().ToString("N")); + public BatchRenameBlockerTests() => Directory.CreateDirectory(_root); + public void Dispose() => Directory.Delete(_root, true); + private TransferJob Job() => new() + { + Kind = TransferJobKind.Rename, RenameBatch = new(), + Items = [new() { SourcePath = Path.Combine(_root, "a"), DestinationPath = Path.Combine(_root, "b") }] + }; + private static TransferQueueStore Store(TransferCoordinator coordinator) => + (TransferQueueStore)typeof(TransferCoordinator).GetField("_store", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(coordinator)!; + + [Fact] + public async Task PristineCancellation_SettlesOnceAndSurvivesReload() + { + var coordinator = new TransferCoordinator(_root) { HasPendingBatchTagsAsync = () => Task.FromResult(false) }; + var job = Job(); + await Store(coordinator).EnqueueAsync(job); + var settled = 0; + coordinator.JobSettled += (_, _) => settled++; + await coordinator.CancelJobAsync(job); + await coordinator.CancelJobAsync(job); + Assert.Equal(1, settled); + Assert.Empty(coordinator.Jobs); + Assert.Equal(TransferJobState.Cancelled, Assert.Single(coordinator.History).FinalState); + var reloaded = new TransferQueueStore(_root, () => Task.FromResult(false)); + await reloaded.LoadAsync(); + Assert.Empty(reloaded.Jobs); + Assert.Equal(job.Id, Assert.Single(reloaded.History).JobId); + } + + [Theory] + [InlineData("prepared")] [InlineData("version")] [InlineData("unsupported")] + [InlineData("steps")] [InlineData("temporary")] [InlineData("nullSteps")] [InlineData("nullTemporary")] + [InlineData("recovery")] [InlineData("intent")] [InlineData("completed")] [InlineData("inProgress")] + [InlineData("bytes")] [InlineData("destinationSize")] [InlineData("destinationTime")] + [InlineData("tagsCommitted")] + public async Task DisqualifiedJournal_RefusesCoordinatorAndIndependentSettlement(string condition) + { + var coordinator = new TransferCoordinator(_root) { HasPendingBatchTagsAsync = () => Task.FromResult(false) }; + var store = Store(coordinator); + var job = Job(); + switch (condition) + { + case "prepared": job.RenameBatch!.Prepared = true; break; + case "version": job.RenameBatch!.Version = 99; break; + case "unsupported": job.RenameBatch!.UnsupportedData = JsonDocument.Parse("{}").RootElement.Clone(); break; + case "steps": job.RenameBatch!.Steps.Add(new()); break; + case "temporary": job.RenameBatch!.TemporaryPaths.Add(job.Items[0].Id, "temporary"); break; + case "nullSteps": job.RenameBatch!.Steps = null!; break; + case "nullTemporary": job.RenameBatch!.TemporaryPaths = null!; break; + case "recovery": job.Items[0].RecoveryRequired = true; break; + case "intent": job.Items[0].RenameIntent = true; break; + case "completed": job.Items[0].State = TransferItemState.Completed; break; + case "inProgress": job.Items[0].State = TransferItemState.InProgress; break; + case "bytes": job.Items[0].BytesCopied = 1; break; + case "destinationSize": job.Items[0].DestinationSize = 0; break; + case "destinationTime": job.Items[0].DestinationLastWriteUtc = DateTimeOffset.UtcNow; break; + case "tagsCommitted": job.RenameBatch!.TagsCommitted = true; break; + } + await store.EnqueueAsync(job); + await Assert.ThrowsAsync(() => coordinator.CancelJobAsync(job)); + Assert.Equal(TransferJobState.Queued, job.State); + job.State = TransferJobState.Cancelled; + await Assert.ThrowsAsync(() => store.RecordCompletionAsync(job)); + Assert.Same(job, Assert.Single(store.Jobs)); + Assert.Empty(store.History); + } + + [Theory] + [InlineData(true)] [InlineData(null)] + public async Task PendingOrUnknownTags_RefuseSettlement(bool? pending) + { + var coordinator = new TransferCoordinator(_root) { HasPendingBatchTagsAsync = () => Task.FromResult(pending) }; + var job = Job(); + await Store(coordinator).EnqueueAsync(job); + await Assert.ThrowsAsync(() => coordinator.CancelJobAsync(job)); + Assert.Equal(TransferJobState.Queued, job.State); + job.State = TransferJobState.Cancelled; + await Assert.ThrowsAsync(() => Store(coordinator).RecordCompletionAsync(job)); + Assert.Single(coordinator.Jobs); + Assert.Empty(coordinator.History); + } + + [Fact] + public async Task IndependentStoreCheck_RefusesNewPendingSnapshotAndRestoresCancellation() + { + var checks = 0; + var coordinator = new TransferCoordinator(_root) { HasPendingBatchTagsAsync = () => Task.FromResult(++checks != 1) }; + var job = Job(); + await Store(coordinator).EnqueueAsync(job); + var settled = 0; + coordinator.JobSettled += (_, _) => settled++; + await Assert.ThrowsAsync(() => coordinator.CancelJobAsync(job)); + Assert.Equal(2, checks); + Assert.Equal(0, settled); + Assert.Equal(TransferJobState.Queued, job.State); + Assert.Equal(TransferItemState.Pending, job.Items[0].State); + Assert.Null(job.FinishedUtc); + Assert.Single(coordinator.Jobs); + Assert.Empty(coordinator.History); + } + + [Fact] + public async Task FailedCancellationWrite_RetainsQueueAndCanBeRetried() + { + var coordinator = new TransferCoordinator(_root) { HasPendingBatchTagsAsync = () => Task.FromResult(false) }; + var store = Store(coordinator); + var job = Job(); + await store.EnqueueAsync(job); + var writes = 0; + store.BeforeWrite = _ => { if (++writes == 3) throw new IOException("queue commit failed"); }; + var settled = 0; + coordinator.JobSettled += (_, _) => settled++; + await Assert.ThrowsAsync(() => coordinator.CancelJobAsync(job)); + Assert.Equal(0, settled); + Assert.Single(store.Jobs); + Assert.Empty(store.History); + Assert.Equal(TransferJobState.Queued, job.State); + store.BeforeWrite = null; + await coordinator.CancelJobAsync(job); + Assert.Equal(1, settled); + Assert.Empty(store.Jobs); + Assert.Single(store.History); + } + + [Fact] + public async Task CancellationExcludesPumpWhileTagQueryAwaits() + { + var query = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var coordinator = new TransferCoordinator(_root) { HasPendingBatchTagsAsync = () => query.Task }; + var job = Job(); + await Store(coordinator).EnqueueAsync(job); + var cancellation = coordinator.CancelJobAsync(job); + coordinator.RequestStart(job.Id); + var pump = (Task?)typeof(TransferCoordinator).GetField("_pumpTask", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(coordinator) ?? Task.CompletedTask; + await pump.WaitAsync(TimeSpan.FromSeconds(5)); + Assert.Null(job.StartedUtc); + query.SetResult(false); + await cancellation; + Assert.Equal(TransferJobState.Cancelled, job.State); + } + + [Fact] + public async Task JournalAndParkingFailures_RetainBothErrors() + { + var job = Job(); + File.WriteAllText(job.Items[0].SourcePath, "original"); + var first = new IOException("first save"); + var second = new IOException("parking save"); + var writes = 0; + var executor = new TransferExecutor(new SystemTransferFileOperations(), () => throw (++writes == 1 ? first : second)); + var error = await Assert.ThrowsAsync(() => executor.ExecuteAsync(job)); + var combined = Assert.IsType(error.InnerException); + Assert.Same(first, combined.InnerExceptions[0].InnerException); + Assert.Same(second, combined.InnerExceptions[1].InnerException); + Assert.Equal(2, writes); + Assert.Equal(TransferJobState.Paused, job.State); + Assert.True(job.Items[0].RecoveryRequired); + Assert.Equal("original", File.ReadAllText(job.Items[0].SourcePath)); + } + + [Fact] + public async Task JournalFailureWithSuccessfulParking_StopsLaterDispatch() + { + var coordinator = new TransferCoordinator(_root); + var store = Store(coordinator); + var job = Job(); + File.WriteAllText(job.Items[0].SourcePath, "original"); + var later = new TransferJob { Kind = TransferJobKind.Delete }; + await store.EnqueueAsync(job); + await store.EnqueueAsync(later); + var failed = false; + var reachedFailure = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var releaseFailure = new ManualResetEventSlim(); + store.BeforeWrite = _ => + { + if (!failed && job.RenameBatch!.Prepared) + { + failed = true; + reachedFailure.SetResult(); + if (!releaseFailure.Wait(TimeSpan.FromSeconds(10))) throw new TimeoutException("Test did not release the injected failure."); + throw new IOException("journal failed once"); + } + }; + coordinator.RequestStart(job.Id); + var pump = (Task)typeof(TransferCoordinator).GetField("_pumpTask", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(coordinator)!; + await reachedFailure.Task.WaitAsync(TimeSpan.FromSeconds(5)); + coordinator.RequestStart(later.Id); + releaseFailure.Set(); + await pump.WaitAsync(TimeSpan.FromSeconds(10)); + Assert.True(failed); + Assert.Equal(TransferJobState.Paused, job.State); + Assert.True(job.Items[0].RecoveryRequired); + Assert.Equal(TransferJobState.Queued, later.State); + Assert.Null(later.StartedUtc); + Assert.Empty(store.History); + var reloaded = new TransferQueueStore(_root); + await reloaded.LoadAsync(); + Assert.Equal(2, reloaded.Jobs.Count); + } + + [Fact] + public async Task TagQueryAndMutation_PreservePendingAndUnreadableState() + { + var path = Path.Combine(_root, "tags.json"); + var tags = new FileTagStore(path); + Assert.Null(await tags.HasPendingBatchAsync()); + await tags.LoadAsync(); + Assert.False(await tags.HasPendingBatchAsync()); + var job = Job(); + await tags.SetTagsAsync([job.Items[0].SourcePath], FileTagColor.Red); + await tags.MigrateBatchRenameAsync(job, false); + Assert.True(await tags.HasPendingBatchAsync()); + var error = await Assert.ThrowsAsync(() => tags.SetTagsAsync([job.Items[0].SourcePath], FileTagColor.Blue)); + Assert.Contains("Resume the pending batch rename", error.Message); + Assert.Equal(FileTagColor.Red, tags.GetTag(job.Items[0].SourcePath)); + await File.WriteAllTextAsync(path, "invalid json"); + await tags.LoadAsync(); + Assert.Null(await tags.HasPendingBatchAsync()); + await Assert.ThrowsAsync(() => tags.SetTagsAsync([job.Items[0].SourcePath], FileTagColor.Blue)); + Assert.Null(tags.GetTag(job.Items[0].SourcePath)); + } + + [Fact] + public async Task FailedTagWrite_RestoresColorsAndReportsFailure() + { + var path = Path.Combine(_root, "tags.json"); + var tags = new FileTagStore(path); + await tags.SetTagsAsync(["file"], FileTagColor.Red); + File.Delete(path); + Directory.CreateDirectory(path); + await Assert.ThrowsAsync(() => tags.SetTagsAsync(["file"], FileTagColor.Blue)); + Assert.Equal(FileTagColor.Red, tags.GetTag("file")); + } +} diff --git a/tests/Meridian.Core.Tests/FileOperationsTests.cs b/tests/Meridian.Core.Tests/FileOperationsTests.cs index fe7ea93..75e8219 100644 --- a/tests/Meridian.Core.Tests/FileOperationsTests.cs +++ b/tests/Meridian.Core.Tests/FileOperationsTests.cs @@ -343,6 +343,88 @@ public void MoveItem_RejectsMovingDirectoryIntoDescendant() Assert.Empty(Directory.GetDirectories(descendant)); } + [Fact] + public void RenameItems_RenamesEachItemInTheBatch() + { + using var temp = new TemporaryDirectory(); + var parent = Directory.CreateDirectory(temp.Path).FullName; + var first = Path.Combine(parent, "first.txt"); + File.WriteAllText(first, "contents one"); + var second = Path.Combine(parent, "second.txt"); + File.WriteAllText(second, "contents two"); + + var results = FileOperations.RenameItems(new[] + { + (first, "alpha.txt"), + (second, "beta.txt"), + }); + + Assert.Equal(2, results.Count); + Assert.All(results, result => Assert.Null(result.Error)); + Assert.Equal(Path.Combine(parent, "alpha.txt"), results[0].ResultPath); + Assert.Equal(Path.Combine(parent, "beta.txt"), results[1].ResultPath); + Assert.False(File.Exists(first)); + Assert.False(File.Exists(second)); + Assert.Equal("contents one", File.ReadAllText(results[0].ResultPath!)); + Assert.Equal("contents two", File.ReadAllText(results[1].ResultPath!)); + } + + [Fact] + public void RenameItems_KeepsGoingWhenOneItemFails() + { + using var temp = new TemporaryDirectory(); + var parent = Directory.CreateDirectory(temp.Path).FullName; + var good = Path.Combine(parent, "good.txt"); + File.WriteAllText(good, "keep"); + var blocked = Path.Combine(parent, "blocked.txt"); + File.WriteAllText(blocked, "stays"); + var taken = Path.Combine(parent, "taken.txt"); + File.WriteAllText(taken, "occupant"); + + var results = FileOperations.RenameItems(new[] + { + (good, "renamed.txt"), + (blocked, ".txt"), + (taken, "renamed.txt"), + }); + + Assert.Equal(3, results.Count); + Assert.Null(results[0].Error); + Assert.Equal(Path.Combine(parent, "renamed.txt"), results[0].ResultPath); + Assert.NotNull(results[1].Error); + Assert.NotNull(results[2].Error); + Assert.True(File.Exists(blocked)); + Assert.True(File.Exists(taken)); + Assert.False(File.Exists(good)); + Assert.Equal("keep", File.ReadAllText(Path.Combine(parent, "renamed.txt"))); + } + + [Fact] + public void RenameItems_CaseOnlyChangeRenamesInPlace() + { + using var temp = new TemporaryDirectory(); + var parent = Directory.CreateDirectory(temp.Path).FullName; + var source = Path.Combine(parent, "Casing.Txt"); + File.WriteAllText(source, "same bytes"); + + var results = FileOperations.RenameItems(new[] { (source, "casing.txt") }); + + Assert.Single(results); + Assert.Null(results[0].Error); + // On case-insensitive filesystems File.Exists(source) still matches + // the renamed file, so check the directory listing ordinally instead. + var after = Directory.GetFiles(parent); + Assert.DoesNotContain(source, after, StringComparer.Ordinal); + Assert.Contains(Path.Combine(parent, "casing.txt"), after, StringComparer.Ordinal); + } + + [Fact] + public void RenameItems_EmptyBatchReturnsEmptyResults() + { + var results = FileOperations.RenameItems(Array.Empty<(string SourcePath, string NewName)>()); + Assert.Empty(results); + } + private sealed class TemporaryDirectory : IDisposable { public string Path { get; } = System.IO.Path.Combine( diff --git a/tests/Meridian.Core.Tests/FileTagPaletteTests.cs b/tests/Meridian.Core.Tests/FileTagPaletteTests.cs new file mode 100644 index 0000000..8474709 --- /dev/null +++ b/tests/Meridian.Core.Tests/FileTagPaletteTests.cs @@ -0,0 +1,47 @@ +using Meridian.Models; +using Meridian.Services; + +namespace Meridian.Core.Tests; + +public sealed class FileTagPaletteTests +{ + [Fact] + public void HexFor_ReturnsNullForNone() + { + Assert.Null(FileTagPalette.HexFor(null)); + Assert.Null(FileTagPalette.HexFor(FileTagColor.None)); + } + + [Fact] + public void HexFor_ReturnsADistinctHexColorForEachTag() + { + var tags = Enum.GetValues().Where(tag => tag != FileTagColor.None); + var colors = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var tag in tags) + { + var hex = FileTagPalette.HexFor(tag); + Assert.NotNull(hex); + Assert.False(hex.Equals("transparent", StringComparison.OrdinalIgnoreCase)); + Assert.True(colors.Add(hex), $"The {tag} tag shares its color with another tag."); + } + } + + [Fact] + public void LabelFor_ReturnsAPlainTextLabelForEachTag() + { + Assert.Equal("No tag", FileTagPalette.LabelFor(null)); + Assert.Equal("No tag", FileTagPalette.LabelFor(FileTagColor.None)); + Assert.Equal("Red tag", FileTagPalette.LabelFor(FileTagColor.Red)); + Assert.Equal("Green tag", FileTagPalette.LabelFor(FileTagColor.Green)); + Assert.Equal("Blue tag", FileTagPalette.LabelFor(FileTagColor.Blue)); + } + + [Fact] + public void IsTagged_IsTrueOnlyForRealTags() + { + Assert.False(FileTagPalette.IsTagged(null)); + Assert.False(FileTagPalette.IsTagged(FileTagColor.None)); + foreach (var tag in Enum.GetValues().Where(tag => tag != FileTagColor.None)) + Assert.True(FileTagPalette.IsTagged(tag)); + } +} diff --git a/tests/Meridian.Core.Tests/FileTagStoreTests.cs b/tests/Meridian.Core.Tests/FileTagStoreTests.cs new file mode 100644 index 0000000..3b08c9e --- /dev/null +++ b/tests/Meridian.Core.Tests/FileTagStoreTests.cs @@ -0,0 +1,145 @@ +using Meridian.Models; +using Meridian.Services; + +namespace Meridian.Core.Tests; + +public sealed class FileTagStoreTests +{ + private static FileTagStore NewStore(string root) => new(Path.Combine(root, "file-tags.json")); + + private static string NewRoot() + { + var root = Path.Combine(Path.GetTempPath(), $"meridian-tags-{Guid.NewGuid():N}"); + Directory.CreateDirectory(root); + return root; + } + + [Fact] + public async Task GetTag_ReturnsNullUntilATagIsSet() + { + var root = NewRoot(); + try + { + var store = NewStore(root); + await store.LoadAsync(); + Assert.Null(store.GetTag(@"C:\Docs\a.txt")); + } + finally { Directory.Delete(root, recursive: true); } + } + + [Fact] + public async Task SetTag_ThenGetTag_ReturnsTheSameColor() + { + var root = NewRoot(); + try + { + var store = NewStore(root); + await store.LoadAsync(); + await store.SetTagAsync(@"C:\Docs\a.txt", FileTagColor.Red); + + Assert.Equal(FileTagColor.Red, store.GetTag(@"C:\Docs\a.txt")); + Assert.Null(store.GetTag(@"C:\Docs\b.txt")); + } + finally { Directory.Delete(root, recursive: true); } + } + + [Fact] + public async Task SetTag_None_ClearsTheTag() + { + var root = NewRoot(); + try + { + var store = NewStore(root); + await store.LoadAsync(); + await store.SetTagAsync(@"C:\Docs\a.txt", FileTagColor.Red); + await store.SetTagAsync(@"C:\Docs\a.txt", FileTagColor.None); + + Assert.Null(store.GetTag(@"C:\Docs\a.txt")); + Assert.Empty(store.Tags); + } + finally { Directory.Delete(root, recursive: true); } + } + + [Fact] + public async Task SetTagsAsync_AppliesOneColorToManyPaths() + { + var root = NewRoot(); + try + { + var store = NewStore(root); + await store.LoadAsync(); + await store.SetTagsAsync(new[] + { + @"C:\Docs\a.txt", @"C:\Docs\b.txt", @"C:\Docs\sub\c.txt", + }, FileTagColor.Blue); + + Assert.Equal(FileTagColor.Blue, store.GetTag(@"C:\Docs\a.txt")); + Assert.Equal(FileTagColor.Blue, store.GetTag(@"C:\Docs\b.txt")); + Assert.Equal(FileTagColor.Blue, store.GetTag(@"C:\Docs\sub\c.txt")); + Assert.Equal(3, store.Tags.Count); + } + finally { Directory.Delete(root, recursive: true); } + } + + [Fact] + public async Task ClearTagsAsync_RemovesTheGivenPaths() + { + var root = NewRoot(); + try + { + var store = NewStore(root); + await store.LoadAsync(); + await store.SetTagsAsync(new[] { @"C:\Docs\a.txt", @"C:\Docs\b.txt" }, FileTagColor.Green); + await store.ClearTagsAsync(new[] { @"C:\Docs\a.txt" }); + + Assert.Null(store.GetTag(@"C:\Docs\a.txt")); + Assert.Equal(FileTagColor.Green, store.GetTag(@"C:\Docs\b.txt")); + } + finally { Directory.Delete(root, recursive: true); } + } + + [Fact] + public async Task Load_PersistedTags_RoundTripsAcrossStoreInstances() + { + var root = NewRoot(); + try + { + var first = NewStore(root); + await first.LoadAsync(); + await first.SetTagAsync(@"C:\Docs\a.txt", FileTagColor.Purple); + + var second = NewStore(root); + await second.LoadAsync(); + Assert.Equal(FileTagColor.Purple, second.GetTag(@"C:\Docs\a.txt")); + } + finally { Directory.Delete(root, recursive: true); } + } + + [Fact] + public async Task MissingFile_LoadsAsEmpty() + { + var root = NewRoot(); + try + { + var store = NewStore(root); + await store.LoadAsync(); + Assert.Empty(store.Tags); + } + finally { Directory.Delete(root, recursive: true); } + } + + [Fact] + public async Task PathLookup_IsCaseInsensitive() + { + var root = NewRoot(); + try + { + var store = NewStore(root); + await store.LoadAsync(); + await store.SetTagAsync(@"C:\Docs\a.txt", FileTagColor.Orange); + + Assert.Equal(FileTagColor.Orange, store.GetTag(@"c:\docs\a.txt")); + } + finally { Directory.Delete(root, recursive: true); } + } +} diff --git a/tests/Meridian.Core.Tests/Meridian.Core.Tests.csproj b/tests/Meridian.Core.Tests/Meridian.Core.Tests.csproj index 6a7f6c1..cfd956c 100644 --- a/tests/Meridian.Core.Tests/Meridian.Core.Tests.csproj +++ b/tests/Meridian.Core.Tests/Meridian.Core.Tests.csproj @@ -17,4 +17,5 @@ + diff --git a/tests/Meridian.Core.Tests/RenameBatchJournalTests.cs b/tests/Meridian.Core.Tests/RenameBatchJournalTests.cs new file mode 100644 index 0000000..ad068b4 --- /dev/null +++ b/tests/Meridian.Core.Tests/RenameBatchJournalTests.cs @@ -0,0 +1,392 @@ +using System.Text.Json; +using Meridian.Core.Transfers; +using Meridian.Models; +using Meridian.Services; + +namespace Meridian.Core.Tests; + +public sealed class RenameBatchJournalTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "meridian-swaps-b-" + Guid.NewGuid().ToString("N")); + private readonly SystemTransferFileOperations _files = new(); + public RenameBatchJournalTests() => Directory.CreateDirectory(_root); + public void Dispose() => Directory.Delete(_root, true); + private string P(string name) => Path.GetFullPath(Path.Combine(_root, name)); + private TransferJob Job(string[] sources, string[] targets, int directory = -1) + { + for (var n = 0; n < sources.Length; n++) + { + if (n == directory) { Directory.CreateDirectory(P(sources[n])); File.WriteAllText(P(sources[n] + "/child"), sources[n]); } + else File.WriteAllText(P(sources[n]), sources[n]); + } + return new() { Kind = TransferJobKind.Rename, RenameBatch = new(), Items = sources.Select((s, n) => new TransferItem { + SourcePath = P(s), DestinationPath = P(targets[n]), IsDirectory = n == directory }).ToList() }; + } + private Task Run(TransferJob job, Func? persist = null, ITransferFileOperations? files = null, FileTagStore? tags = null, CancellationToken token = default) => + new TransferExecutor(files ?? _files, persist, batchTags: tags is null ? null : tags.MigrateBatchRenameAsync).ExecuteAsync(job, cancellationToken: token); + private void Verify(TransferJob job) + { + Assert.True(job.State == TransferJobState.Completed, string.Join(" | ", job.Items.Select(i => i.Error))); + Assert.All(job.Items, i => { + Assert.False(i.RecoveryRequired); + Assert.Equal(i.RenameIdentity, _files.GetIdentity(i.DestinationPath!)); + Assert.Equal(Path.GetFileName(i.SourcePath), File.ReadAllText(i.IsDirectory ? Path.Combine(i.DestinationPath!, "child") : i.DestinationPath!)); + }); + Assert.DoesNotContain(Directory.EnumerateFileSystemEntries(_root), p => Path.GetFileName(p).StartsWith(".meridian-rename-")); + } + + [Theory] + [InlineData("b,a", -1)] + [InlineData("b,a", 1)] + [InlineData("b,c,a", -1)] + [InlineData("b,c,d", -1)] + [InlineData("A", -1)] + public async Task LogicalMappings_KeepIdentityAndSupportUndoRedo(string targetList, int directory) + { + var targets = targetList.Split(','); + var job = Job(new[] { "a", "b", "c" }[..targets.Length], targets, directory); + await Run(job); + Verify(job); + for (var n = 0; n < 2; n++) + { + var undo = UndoPlanner.Plan(TransferHistoryRecord.FromJob(job), _files); + Assert.True(undo.IsAvailable, undo.Reason); + job = undo.InverseJob!; + await Run(job); + Assert.True(job.State == TransferJobState.Completed, string.Join(" | ", job.Items.Select(i => i.Error))); + Assert.All(job.Items, i => Assert.Equal(i.RenameIdentity, _files.GetIdentity(i.DestinationPath!))); + } + } + + [Theory] + [InlineData(1)] [InlineData(2)] [InlineData(3)] [InlineData(4)] [InlineData(5)] [InlineData(6)] + [InlineData(7)] [InlineData(8)] [InlineData(9)] [InlineData(10)] [InlineData(11)] [InlineData(12)] + [InlineData(13)] [InlineData(14)] + public async Task EveryJournalWriteFailure_RestartResumesWithoutLosingParticipants(int failedSave) + { + var job = Job(["a", "b", "c"], ["b", "c", "a"]); + var saved = JsonSerializer.Serialize(job); + var saves = 0; + var failure = await Assert.ThrowsAsync(() => Run(job, () => { + if (++saves == failedSave) throw new IOException("injected journal failure"); + saved = JsonSerializer.Serialize(job); + return Task.CompletedTask; + })); + Assert.Equal("injected journal failure", failure.InnerException!.Message); + Assert.Equal(TransferJobState.Paused, job.State); + Assert.All(job.Items, item => Assert.True(item.RecoveryRequired)); + var restored = JsonSerializer.Deserialize(saved)!; + restored.State = TransferJobState.Paused; + await Run(restored); + Verify(restored); + } + + [Theory] + [InlineData(1, false)] [InlineData(2, false)] [InlineData(3, false)] + [InlineData(4, false)] [InlineData(5, false)] [InlineData(6, false)] + [InlineData(1, true)] [InlineData(2, true)] [InlineData(3, true)] + [InlineData(4, true)] [InlineData(5, true)] [InlineData(6, true)] + public async Task EveryPhysicalMoveFailure_ReconcilesIntentBeforeNextMove(int failMove, bool after) + { + var job = Job(["a", "b", "c"], ["b", "c", "a"]); + var saved = ""; + await Run(job, () => { saved = JsonSerializer.Serialize(job); return Task.CompletedTask; }, new MoveFailure(_files, failMove, after)); + Assert.False(job.IsTerminal); + Assert.All(job.Items, i => Assert.True(i.RecoveryRequired)); + var restarted = JsonSerializer.Deserialize(saved)!; + restarted.State = TransferJobState.Paused; + await Run(restarted); + Verify(restarted); + } + + [Theory] + [InlineData(1)] [InlineData(2)] [InlineData(3)] [InlineData(4)] [InlineData(5)] [InlineData(6)] + public async Task CancellationAfterEachMove_RetainsJournalForResume(int cancelMove) + { + var job = Job(["a", "b", "c"], ["b", "c", "a"]); + using var cancellation = new CancellationTokenSource(); + await Run(job, files: new MoveFailure(_files, cancelMove, true, cancellation.Cancel), token: cancellation.Token); + if (!job.IsTerminal) { Assert.Equal(TransferJobState.Paused, job.State); await Run(job); } + Verify(job); + } + + [Fact] + public async Task DirectoryTags_SnapshotSurvivesRestartAndReplay() + { + var job = Job(["a", "b"], ["b", "a"], 1); + var tags = new FileTagStore(P("tags.json")); + await tags.SetTagAsync(P("a"), FileTagColor.Red); + await tags.SetTagAsync(P("b/child"), FileTagColor.Blue); + await tags.MigrateBatchRenameAsync(job, false); + await Assert.ThrowsAsync(() => tags.SetTagAsync(P("a"), FileTagColor.Green)); + tags = new FileTagStore(P("tags.json")); + await tags.LoadAsync(); + await Run(job, tags: tags); + Verify(job); + Assert.Equal(FileTagColor.Red, tags.GetTag(P("b"))); + Assert.Equal(FileTagColor.Blue, tags.GetTag(P("a/child"))); + Assert.Null(tags.GetTag(P("b/child"))); + await tags.MigrateBatchRenameAsync(job, true); + var loaded = new FileTagStore(P("tags.json")); + await loaded.LoadAsync(); + Assert.Equal(FileTagColor.Red, loaded.GetTag(P("b"))); + Assert.Equal(FileTagColor.Blue, loaded.GetTag(P("a/child"))); + } + + [Fact] + public async Task TagCallbackFailureAfterStaging_RemainsRecoverable() + { + var job = Job(["a", "b"], ["b", "a"]); + var tags = new FileTagStore(P("tags.json")); + await tags.SetTagAsync(P("a"), FileTagColor.Red); + var executor = new TransferExecutor(_files, batchTags: async (j, commit) => { + if (commit) throw new IOException("metadata unavailable"); + await tags.MigrateBatchRenameAsync(j, false); + }); + await executor.ExecuteAsync(job); + Assert.False(job.IsTerminal); + Assert.All(job.Items, i => Assert.True(i.RecoveryRequired)); + await Run(job, tags: tags); + Verify(job); + Assert.Equal(FileTagColor.Red, tags.GetTag(P("b"))); + } + + [Fact] + public async Task TempCollision_IsSkippedAndNeverOverwritten() + { + var job = Job(["a", "b"], ["b", "a"]); + var occupied = P($".meridian-rename-{job.Id:N}-{job.Items[0].Id:N}-0.tmp"); + File.WriteAllText(occupied, "external"); + await Run(job); + Assert.True(job.State == TransferJobState.Completed, string.Join(" | ", job.Items.Select(i => i.Error))); + Assert.Equal("external", File.ReadAllText(occupied)); + Assert.EndsWith("-1.tmp", job.RenameBatch!.TemporaryPaths[job.Items[0].Id]); + } + + [Fact] + public async Task ExternalCollisionAndUnknownJournal_StopWithoutMutation() + { + var job = Job(["a", "b"], ["b", "c"]); + File.WriteAllText(P("c"), "external"); + await Run(job); + Assert.False(job.IsTerminal); + Assert.Equal("a", File.ReadAllText(P("a"))); + Assert.Equal("external", File.ReadAllText(P("c"))); + File.Delete(P("c")); + job.RenameBatch!.Version = 99; + await Run(job); + Assert.False(job.IsTerminal); + Assert.Equal("b", File.ReadAllText(P("b"))); + } + + [Fact] + public async Task ChangedParticipant_PreventsRecoveryAndSettlement() + { + var job = Job(["a", "b"], ["b", "a"]); + await Run(job, files: new MoveFailure(_files, 2, false)); + var staged = job.RenameBatch!.TemporaryPaths[job.Items[0].Id]; + File.Delete(staged); + File.WriteAllText(staged, "replacement"); + await Run(job); + Assert.False(job.IsTerminal); + Assert.True(job.Items[0].RecoveryRequired); + var store = new TransferQueueStore(P("queue")); + await store.EnqueueAsync(job); + Assert.False(await store.RemoveAsync(job.Id)); + await Assert.ThrowsAsync(() => store.RecordCompletionAsync(job)); + } + + [Fact] + public async Task LegacyJson_RetainsSingleRenamePath() + { + var job = Job(["a"], ["z"]); + job.RenameBatch = null; + var json = JsonSerializer.Serialize(job).Replace("\"RenameBatch\":null,", ""); + var loaded = JsonSerializer.Deserialize(json)!; + Assert.Null(loaded.RenameBatch); + await Run(loaded); + Verify(loaded); + } + + [Fact] + public async Task LateTemporaryCollision_ReallocatesBeforeIntent() + { + var job = Job(["a", "b"], ["b", "a"]); + string? occupied = null; + await Run(job, () => + { + if (occupied is null && job.RenameBatch!.Prepared) + { + occupied = job.RenameBatch.TemporaryPaths[job.Items[0].Id]; + File.WriteAllText(occupied, "external late collision"); + } + return Task.CompletedTask; + }); + Assert.True(job.State == TransferJobState.Completed, job.Items[0].Error); + Assert.NotEqual(occupied, job.RenameBatch!.TemporaryPaths[job.Items[0].Id]); + Assert.Equal("external late collision", File.ReadAllText(occupied!)); + } + + [Theory] + [InlineData("{\"Version\":99,\"future\":true}")] + [InlineData("{\"Version\":1,\"Prepared\":true,\"TagsCommitted\":false,\"TemporaryPaths\":{},\"Steps\":[{\"Purpose\":\"RestoreV2\"}]}")] + [InlineData("{\"Version\":1,\"Prepared\":true,\"TagsCommitted\":false,\"TemporaryPaths\":{\"bad-guid\":\"bad-path\"},\"Steps\":[]}")] + public async Task UnsupportedBatchJson_PreservesEntireQueueAndRawMetadata(string rawBatch) + { + var job = Job(["a", "b"], ["b", "a"]); + var folder = P("queue"); + Directory.CreateDirectory(folder); + var json = JsonSerializer.Serialize(job); + using var doc = JsonDocument.Parse(json); + var properties = doc.RootElement.EnumerateObject().Where(p => p.Name != "RenameBatch") + .Select(p => JsonSerializer.Serialize(p.Name) + ":" + p.Value.GetRawText()); + var rawJob = "{" + string.Join(",", properties) + ",\"RenameBatch\":" + rawBatch + "}"; + await File.WriteAllTextAsync(Path.Combine(folder, "transfer-queue.json"), "[" + rawJob + "]"); + var store = new TransferQueueStore(folder); + await store.LoadAsync(); + var restored = Assert.Single(store.Jobs); + await new TransferExecutor(_files, () => store.SaveRequiredAsync()).ReconcileRenamesAsync(restored); + Assert.All(restored.Items, i => Assert.True(i.RecoveryRequired)); + await store.SaveRequiredAsync(); + using var saved = JsonDocument.Parse(await File.ReadAllTextAsync(Path.Combine(folder, "transfer-queue.json"))); + Assert.Equal(JsonDocument.Parse(rawBatch).RootElement.GetRawText(), saved.RootElement[0].GetProperty("RenameBatch").GetRawText().Replace("\r", "").Replace("\n", "").Replace(" ", "")); + Assert.Equal("a", File.ReadAllText(P("a"))); + } + + [Fact] + public async Task MissingPendingSnapshotAfterMove_StopsMetadataReplay() + { + var job = Job(["a", "b"], ["b", "a"]); + await Run(job, files: new MoveFailure(_files, 2, false)); + var tags = new FileTagStore(P("fresh-tags.json")); + await Assert.ThrowsAsync(() => tags.MigrateBatchRenameAsync(job, false)); + await Run(job, tags: tags); + Assert.False(job.IsTerminal); + } + + [Theory] + [InlineData(1)] [InlineData(2)] [InlineData(3)] [InlineData(4)] [InlineData(5)] [InlineData(6)] + public async Task CancellationAtEveryIntent_ResumesUsingSavedIdentity(int atIntent) + { + var job = Job(["a", "b", "c"], ["b", "c", "a"]); + using var cancellation = new CancellationTokenSource(); + await Run(job, () => + { + if (job.RenameBatch!.Steps.Count(s => s.Intent) == atIntent) cancellation.Cancel(); + return Task.CompletedTask; + }, token: cancellation.Token); + Assert.False(job.IsTerminal); + await Run(job); + Verify(job); + } + + [Theory] + [InlineData(true)] [InlineData(false)] + public async Task AmbiguousOrMissingIdentity_RefusesAllPhysicalMoves(bool duplicate) + { + var job = Job(["a", "b"], ["b", "a"]); + await Run(job, files: new MoveFailure(_files, int.MaxValue, false, identity: _ => duplicate ? "duplicate" : null)); + Assert.False(job.IsTerminal); + Assert.Equal("a", File.ReadAllText(P("a"))); + Assert.Equal("b", File.ReadAllText(P("b"))); + } + + [Fact] + public async Task ExternalFinalCollisionAfterStage_IsPreservedAndResumable() + { + var job = Job(["a", "b"], ["b", "c"]); + var introduced = false; + await Run(job, () => + { + if (!introduced && job.RenameBatch!.Steps.Any(s => s.Completed)) + { + introduced = true; + File.WriteAllText(P("c"), "external"); + } + return Task.CompletedTask; + }); + Assert.False(job.IsTerminal); + Assert.Equal("external", File.ReadAllText(P("c"))); + File.Delete(P("c")); + await Run(job); + Verify(job); + } + + [Fact] + public async Task UndoChain_ReservesInverseParticipantsAndKeepsExternalOriginal() + { + var job = Job(["a", "b"], ["b", "c"]); + await Run(job); + Verify(job); + File.WriteAllText(P("a"), "external"); + var plan = UndoPlanner.Plan(TransferHistoryRecord.FromJob(job), _files); + Assert.True(plan.IsAvailable, plan.Reason); + Assert.Equal(P("b"), plan.InverseJob!.Items[1].DestinationPath); + Assert.NotEqual(P("a"), plan.InverseJob.Items[0].DestinationPath); + await Run(plan.InverseJob); + Assert.True(plan.InverseJob.State == TransferJobState.Completed, plan.InverseJob.Items[0].Error); + Assert.Equal("external", File.ReadAllText(P("a"))); + Assert.Equal("a", File.ReadAllText(plan.InverseJob.Items[0].DestinationPath!)); + Assert.Equal("b", File.ReadAllText(P("b"))); + } + + [Fact] + public async Task SavedPhysicalPathTampering_NeverMovesToArbitrarySavedPath() + { + var job = Job(["a", "b"], ["b", "a"]); + await Run(job, files: new MoveFailure(_files, 1, false)); + var malicious = P("unplanned"); + job.RenameBatch!.Steps[0].To = malicious; + await Run(job); + Assert.False(job.IsTerminal); + Assert.False(File.Exists(malicious)); + Assert.Equal("a", File.ReadAllText(P("a"))); + } + + [Fact] + public async Task UnsupportedBatchMetadataCallback_RefusesBeforeMoving() + { + var job = Job(["a", "b"], ["b", "a"]); + await new TransferExecutor(_files, renameConfirmed: _ => Task.CompletedTask).ExecuteAsync(job); + Assert.False(job.IsTerminal); + Assert.Equal("a", File.ReadAllText(P("a"))); + } + + [Fact] + public async Task ModifiedStagedFile_StopsResumeWithoutPublishingNewContents() + { + var job = Job(["a", "b"], ["b", "a"]); + await Run(job, files: new MoveFailure(_files, 2, false)); + File.WriteAllText(job.RenameBatch!.TemporaryPaths[job.Items[0].Id], "newer contents"); + await Run(job); + Assert.False(job.IsTerminal); + Assert.Equal("b", File.ReadAllText(P("b"))); + Assert.True(job.Items[0].RecoveryRequired); + } + + private sealed class MoveFailure(ITransferFileOperations inner, int failMove, bool after, Action? action = null, Func? identity = null) : ITransferFileOperations + { + private int _moves; + public string RenameItem(string source, string name) + { + var fail = ++_moves == failMove; + if (fail && !after) throw new IOException("before move"); + var result = inner.RenameItem(source, name); + if (fail) { if (action is not null) action(); else throw new IOException("after move"); } + return result; + } + public string? GetIdentity(string p) => identity is null ? inner.GetIdentity(p) : identity(p); + public bool FileExists(string p) => inner.FileExists(p); + public bool DirectoryExists(string p) => inner.DirectoryExists(p); + public FileFacts? GetFileFacts(string p) => inner.GetFileFacts(p); + public IEnumerable EnumerateFiles(string p) => inner.EnumerateFiles(p); + public IEnumerable EnumerateDirectories(string p) => inner.EnumerateDirectories(p); + public Stream OpenRead(string p) => inner.OpenRead(p); + public Stream CreateFile(string p) => inner.CreateFile(p); + public void CreateDirectory(string p) => inner.CreateDirectory(p); + public void PublishFile(string s, string d, bool o) => inner.PublishFile(s, d, o); + public void DeleteFile(string p) => inner.DeleteFile(p); + public void DeleteDirectory(string p, bool r) => inner.DeleteDirectory(p, r); + public void SetLastWriteUtc(string p, DateTimeOffset t) => inner.SetLastWriteUtc(p, t); + } +} diff --git a/tests/Meridian.Core.Tests/RenameRecoveryTests.cs b/tests/Meridian.Core.Tests/RenameRecoveryTests.cs new file mode 100644 index 0000000..e26abff --- /dev/null +++ b/tests/Meridian.Core.Tests/RenameRecoveryTests.cs @@ -0,0 +1,438 @@ +using Meridian.Core.Transfers; +using Meridian.Models; +using Meridian.Services; + +namespace Meridian.Core.Tests; + +public sealed class RenameRecoveryTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "meridian-rename-" + Guid.NewGuid().ToString("N")); + private readonly SystemTransferFileOperations _files = new(); + public RenameRecoveryTests() => Directory.CreateDirectory(_root); + public void Dispose() => Directory.Delete(_root, true); + + private TransferJob Job(bool directory = false, string name = "original") + { + var source = Path.Combine(_root, name); + if (directory) Directory.CreateDirectory(source); else File.WriteAllText(source, "owned test bytes"); + return new TransferJob + { + Kind = TransferJobKind.Rename, + Items = [new TransferItem { SourcePath = source, DestinationPath = source + "-renamed", IsDirectory = directory }], + }; + } + + [Theory] + [InlineData(2)] + [InlineData(3)] + public async Task SettlementFailure_ReloadRecoversOneUndoReceipt(int failedWrite) + { + var job = Job(); + var folder = Path.Combine(_root, "queue"); + var store = new TransferQueueStore(folder); + await store.EnqueueAsync(job); + await new TransferExecutor(_files, () => store.SaveRequiredAsync()).ExecuteAsync(job); + var writes = 0; + store.BeforeWrite = _ => + { + if (++writes == failedWrite) throw new IOException("injected settlement interruption"); + }; + var advanced = false; + await Assert.ThrowsAsync(async () => + { + await store.RecordCompletionAsync(job); + advanced = true; + }); + Assert.False(advanced); + Assert.Single(store.Jobs); + var restarted = new TransferQueueStore(folder); + await restarted.LoadAsync(); + Assert.Empty(restarted.Jobs); + var receipt = Assert.Single(restarted.History); + Assert.Equal(job.Id, receipt.JobId); + Assert.True(UndoPlanner.Plan(receipt, _files).IsAvailable); + await restarted.RecordCompletionAsync(job); + await restarted.LoadAsync(); + Assert.Equal(receipt.Id, Assert.Single(restarted.History).Id); + } + + [Theory] + [InlineData(2)] + [InlineData(3)] + public async Task SettlementFailure_CoordinatorDoesNotSettleOrAdvance(int failedWrite) + { + var first = Job(); + var second = Job(name: "second"); + var coordinator = new TransferCoordinator(Path.Combine(_root, "queue")); + const System.Reflection.BindingFlags flags = System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic; + var store = (TransferQueueStore)typeof(TransferCoordinator).GetField("_store", flags)!.GetValue(coordinator)!; + await store.EnqueueAsync(first); + await store.EnqueueAsync(second); + var writes = 0; + store.BeforeWrite = _ => + { + if (first.IsTerminal && ++writes == failedWrite) throw new IOException("injected settlement failure"); + }; + var queue = (Queue)typeof(TransferCoordinator).GetField("_startQueue", flags)!.GetValue(coordinator)!; + queue.Enqueue(first.Id); + queue.Enqueue(second.Id); + var settled = 0; + coordinator.JobSettled += (_, _) => settled++; + await (Task)typeof(TransferCoordinator).GetMethod("PumpAsync", flags)!.Invoke(coordinator, null)!; + Assert.Equal(0, settled); + Assert.Equal(TransferJobState.Queued, second.State); + Assert.True(File.Exists(second.Items[0].SourcePath)); + var restarted = new TransferQueueStore(Path.Combine(_root, "queue")); + await restarted.LoadAsync(); + Assert.Equal(first.Id, Assert.Single(restarted.History).JobId); + Assert.True(UndoPlanner.Plan(restarted.History[0], _files).IsAvailable); + } + + [Fact] + public async Task IntentSaveFailure_PreventsMutationAndStopsBatch() + { + var job = Job(); + job.Items.Add(Job(name: "second").Items[0]); + var executor = new TransferExecutor(_files, () => throw new IOException("injected intent failure")); + await Assert.ThrowsAsync(() => executor.ExecuteAsync(job)); + Assert.All(job.Items, item => Assert.True(File.Exists(item.SourcePath))); + Assert.True(job.Items[0].RenameIntent); + Assert.Equal(TransferJobState.Paused, job.State); + Assert.Equal(TransferItemState.Pending, job.Items[1].State); + } + + [Fact] + public async Task CompletionSaveFailure_RestartConfirmsIdentityAndReplaysTagsOnce() + { + var job = Job(); + var store = new TransferQueueStore(Path.Combine(_root, "queue")); + await store.EnqueueAsync(job); + var saves = 0; + var executor = new TransferExecutor(_files, async () => + { + if (++saves == 2) throw new IOException("injected completion failure"); + await store.SaveRequiredAsync(); + }); + await Assert.ThrowsAsync(() => executor.ExecuteAsync(job)); + Assert.False(File.Exists(job.Items[0].SourcePath)); + var restarted = new TransferQueueStore(Path.Combine(_root, "queue")); + await restarted.LoadAsync(); + var restored = Assert.Single(restarted.Jobs); + var confirmations = 0; + var recovery = new TransferExecutor(_files, () => restarted.SaveRequiredAsync(), item => + { + confirmations++; + return Task.CompletedTask; + }); + await recovery.ReconcileRenamesAsync(restored); + await recovery.ReconcileRenamesAsync(restored); + Assert.Equal(1, confirmations); + Assert.Equal(TransferItemState.Completed, restored.Items[0].State); + Assert.False(restored.Items[0].RenameIntent); + } + + [Theory] + [InlineData("both")] + [InlineData("neither")] + [InlineData("wrongIdentity")] + [InlineData("missingIdentity")] + public async Task AmbiguousRecovery_PreservesPathsAndBlocksRetry(string scenario) + { + var job = Job(); + var item = job.Items[0]; + item.RenameIntent = true; + item.RenameIdentity = _files.GetIdentity(item.SourcePath); + if (scenario == "both") File.WriteAllText(item.DestinationPath!, "competing bytes"); + if (scenario is "neither" or "wrongIdentity") File.Delete(item.SourcePath); + if (scenario == "wrongIdentity") File.WriteAllText(item.DestinationPath!, "replacement"); + if (scenario == "missingIdentity") item.RenameIdentity = null; + await new TransferExecutor(_files).ExecuteAsync(job); + Assert.True(item.RecoveryRequired); + Assert.Equal(TransferJobState.Paused, job.State); + Assert.Contains("Inspect", item.Error); + if (scenario == "both") Assert.Equal("competing bytes", File.ReadAllText(item.DestinationPath!)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task RenameUndo_HandlesFoldersAndMixedItemsWithoutReplacing(bool mixed) + { + var job = Job(directory: true); + File.WriteAllText(Path.Combine(job.Items[0].SourcePath, "child"), "child bytes"); + if (mixed) job.Items.Add(Job(name: "file").Items[0]); + await new TransferExecutor(_files).ExecuteAsync(job); + Directory.CreateDirectory(job.Items[0].SourcePath); + File.WriteAllText(Path.Combine(job.Items[0].SourcePath, "competitor"), "keep"); + var plan = UndoPlanner.Plan(TransferHistoryRecord.FromJob(job), _files); + Assert.True(plan.IsAvailable, plan.Reason); + await new TransferExecutor(_files).ExecuteAsync(plan.InverseJob!); + Assert.Equal("keep", File.ReadAllText(Path.Combine(job.Items[0].SourcePath, "competitor"))); + var inverseFolder = plan.InverseJob!.Items[0]; + Assert.NotEqual(job.Items[0].SourcePath, inverseFolder.DestinationPath); + Assert.Equal("child bytes", File.ReadAllText(Path.Combine(inverseFolder.DestinationPath!, "child"))); + Assert.All(plan.InverseJob.Items, item => Assert.Equal(TransferItemState.Completed, item.State)); + } + + [Fact] + public async Task CaseOnlyRename_CompletesAndCanUndo() + { + var job = Job(name: "lowercase"); + job.Items[0].DestinationPath = Path.Combine(_root, "LOWERCASE"); + await new TransferExecutor(_files).ExecuteAsync(job); + Assert.Equal(TransferJobState.Completed, job.State); + Assert.Contains(Path.Combine(_root, "LOWERCASE"), Directory.GetFiles(_root)); + var plan = UndoPlanner.Plan(TransferHistoryRecord.FromJob(job), _files); + Assert.True(plan.IsAvailable, plan.Reason); + await new TransferExecutor(_files).ExecuteAsync(plan.InverseJob!); + Assert.Contains(Path.Combine(_root, "lowercase"), Directory.GetFiles(_root)); + } + + [Fact] + public async Task CancellationDuringIntentSave_DoesNotRename() + { + var job = Job(); + using var cancellation = new CancellationTokenSource(); + await new TransferExecutor(_files, () => { cancellation.Cancel(); return Task.CompletedTask; }) + .ExecuteAsync(job, cancellationToken: cancellation.Token); + Assert.True(File.Exists(job.Items[0].SourcePath)); + Assert.False(job.Items[0].RenameIntent); + Assert.Equal(TransferJobState.Cancelled, job.State); + } + + [Fact] + public async Task TagMigration_MovesDescendantsAtBoundaryAndReplayPreservesReplacementTag() + { + var path = Path.Combine(_root, "tags.json"); + var tags = new FileTagStore(path); + var source = Path.Combine(_root, "folder"); + var destination = source + "-renamed"; + await tags.SetTagsAsync([source, Path.Combine(source, "child"), source + "-other"], FileTagColor.Red); + var id = Guid.NewGuid(); + await tags.MigrateRenameAsync(id, source, destination, true); + Assert.Null(tags.GetTag(source)); + Assert.Null(tags.GetTag(Path.Combine(source, "child"))); + Assert.Equal(FileTagColor.Red, tags.GetTag(Path.Combine(destination, "child"))); + Assert.Equal(FileTagColor.Red, tags.GetTag(source + "-other")); + await tags.SetTagAsync(source, FileTagColor.Blue); + var restarted = new FileTagStore(path); + await restarted.LoadAsync(); + await restarted.MigrateRenameAsync(id, source, destination, true); + Assert.Equal(FileTagColor.Blue, restarted.GetTag(source)); + Assert.Equal(FileTagColor.Red, restarted.GetTag(destination)); + await restarted.MigrateRenameAsync(Guid.NewGuid(), destination, source + " (2)", true); + Assert.Null(restarted.GetTag(destination)); + Assert.Equal(FileTagColor.Red, restarted.GetTag(Path.Combine(source + " (2)", "child"))); + } + + [Fact] + public async Task RequiredQueueSave_ReportsFailure() + { + var blocked = Path.Combine(_root, "blocked"); + File.WriteAllText(blocked, "not a directory"); + await Assert.ThrowsAsync(() => new TransferQueueStore(blocked).SaveRequiredAsync()); + } + + [Fact] + public async Task AwaitedCheckpoints_PrecedeMutationAndConfirmation() + { + var job = Job(); + var item = job.Items[0]; + var saves = 0; + var confirmations = 0; + var executor = new TransferExecutor(_files, async () => + { + await Task.Yield(); + saves++; + if (saves == 1) + { + Assert.True(item.RenameIntent); + Assert.NotNull(item.RenameIdentity); + Assert.True(File.Exists(item.SourcePath)); + Assert.False(File.Exists(item.DestinationPath)); + } + if (saves == 2) + { + Assert.Equal(TransferItemState.Completed, item.State); + Assert.True(File.Exists(item.DestinationPath)); + Assert.Equal(0, confirmations); + } + }, completed => + { + Assert.Equal(2, saves); + confirmations++; + return Task.CompletedTask; + }); + await executor.ExecuteAsync(job); + Assert.Equal(3, saves); + Assert.Equal(1, confirmations); + } + + [Fact] + public async Task UnmutatedIntent_ReconcilesThenCanRetry() + { + var job = Job(); + var item = job.Items[0]; + item.RenameIdentity = _files.GetIdentity(item.SourcePath); + item.RenameIntent = true; + item.State = TransferItemState.InProgress; + await new TransferExecutor(_files).ExecuteAsync(job); + Assert.Equal(TransferJobState.Completed, job.State); + Assert.True(File.Exists(item.DestinationPath)); + } + + [Fact] + public async Task CaseOnlyInterruptedRename_ReconcilesUsingExactNameAndIdentity() + { + var job = Job(name: "case-original"); + var item = job.Items[0]; + item.DestinationPath = Path.Combine(_root, "CASE-ORIGINAL"); + item.RenameIdentity = _files.GetIdentity(item.SourcePath); + item.RenameIntent = true; + _files.RenameItem(item.SourcePath, "CASE-ORIGINAL"); + await new TransferExecutor(_files).ReconcileRenamesAsync(job); + Assert.Equal(TransferItemState.Completed, item.State); + Assert.False(item.RecoveryRequired); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task UndoRejectsReplacementEvenWithMatchingMetadata(bool directory) + { + var job = Job(directory); + await new TransferExecutor(_files).ExecuteAsync(job); + var item = job.Items[0]; + var destination = item.DestinationPath!; + if (directory) + { + Directory.Move(destination, destination + "-retained"); + Directory.CreateDirectory(destination); + } + else + { + File.Move(destination, destination + "-retained"); + File.Copy(destination + "-retained", destination); + File.SetLastWriteTimeUtc(destination, item.DestinationLastWriteUtc!.Value.UtcDateTime); + } + Assert.False(UndoPlanner.Plan(TransferHistoryRecord.FromJob(job), _files).IsAvailable); + } + + [Fact] + public async Task UntaggedRenameClearsStaleDestinationTags() + { + var tags = new FileTagStore(Path.Combine(_root, "tags.json")); + var source = Path.Combine(_root, "source"); + var destination = Path.Combine(_root, "destination"); + await tags.SetTagsAsync([destination, Path.Combine(destination, "child")], FileTagColor.Red); + await tags.MigrateRenameAsync(Guid.NewGuid(), source, destination, true); + Assert.Null(tags.GetTag(destination)); + Assert.Null(tags.GetTag(Path.Combine(destination, "child"))); + } + + [Fact] + public async Task FailedTagSave_HidesOldPathTagsAndCanRetryWithoutMovingReplacementTags() + { + var folder = Path.Combine(_root, "tag-storage"); + Directory.CreateDirectory(folder); + var path = Path.Combine(folder, "tags.json"); + var tags = new FileTagStore(path); + var source = Path.Combine(_root, "old"); + var destination = Path.Combine(_root, "new"); + await tags.SetTagAsync(source, FileTagColor.Red); + File.Delete(path); + Directory.Delete(folder); + File.WriteAllText(folder, "blocks save"); + var id = Guid.NewGuid(); + await Assert.ThrowsAsync(() => tags.MigrateRenameAsync(id, source, destination, false)); + Assert.Null(tags.GetTag(source)); + Assert.Equal(FileTagColor.Red, tags.GetTag(destination)); + File.Delete(folder); + await tags.SetTagAsync(source, FileTagColor.Blue); + await tags.MigrateRenameAsync(id, source, destination, false); + var restarted = new FileTagStore(path); + await restarted.LoadAsync(); + Assert.Equal(FileTagColor.Blue, restarted.GetTag(source)); + Assert.Equal(FileTagColor.Red, restarted.GetTag(destination)); + } + + [Fact] + public async Task UnreadableTagDocument_IsPreservedAndBlocksMigration() + { + var path = Path.Combine(_root, "tags.json"); + await File.WriteAllTextAsync(path, "damaged original"); + var tags = new FileTagStore(path); + await tags.LoadAsync(); + await Assert.ThrowsAsync(() => tags.MigrateRenameAsync(Guid.NewGuid(), "old", "new", false)); + Assert.Equal("damaged original", await File.ReadAllTextAsync(path)); + } + + [Fact] + public async Task LegacyInterruptedRenameWithoutIdentity_CannotRetry() + { + var job = Job(); + job.State = TransferJobState.Running; + job.Items[0].State = TransferItemState.InProgress; + var storage = Path.Combine(_root, "queue"); + var store = new TransferQueueStore(storage); + await store.EnqueueAsync(job); + var restarted = new TransferQueueStore(storage); + await restarted.LoadAsync(); + var restored = Assert.Single(restarted.Jobs); + await new TransferExecutor(_files).ExecuteAsync(restored); + Assert.True(restored.Items[0].RecoveryRequired); + Assert.True(File.Exists(restored.Items[0].SourcePath)); + Assert.False(File.Exists(restored.Items[0].DestinationPath)); + } + + [Fact] + public async Task ConcurrentQueueSaves_LeaveParseableLatestRecord() + { + var job = Job(); + var storage = Path.Combine(_root, "queue"); + var store = new TransferQueueStore(storage); + await store.EnqueueAsync(job); + await Task.WhenAll(Enumerable.Range(0, 16).Select(_ => store.SaveRequiredAsync())); + var restarted = new TransferQueueStore(storage); + await restarted.LoadAsync(); + Assert.Equal(job.Id, Assert.Single(restarted.Jobs).Id); + Assert.Empty(Directory.GetFiles(storage, "*.tmp")); + } + + [Fact] + public async Task TagConfirmationFailure_StopsBatchUntilRecovery() + { + var job = Job(); + job.Items.Add(Job(name: "second").Items[0]); + var calls = 0; + var executor = new TransferExecutor(_files, renameConfirmed: item => + { + if (++calls == 1) throw new IOException("injected tag persistence failure"); + return Task.CompletedTask; + }); + await Assert.ThrowsAsync(() => executor.ExecuteAsync(job)); + Assert.True(File.Exists(job.Items[0].DestinationPath)); + Assert.True(File.Exists(job.Items[1].SourcePath)); + Assert.True(job.Items[0].RenameIntent); + await executor.ExecuteAsync(job); + Assert.Equal(TransferJobState.Completed, job.State); + Assert.All(job.Items, item => Assert.True(File.Exists(item.DestinationPath))); + Assert.Equal(3, calls); + } + + [Fact] + public async Task LegacyFlatTags_MigrateAndRoundTrip() + { + var path = Path.Combine(_root, "tags.json"); + var source = Path.Combine(_root, "name"); + var destination = Path.Combine(_root, "NAME"); + await File.WriteAllTextAsync(path, System.Text.Json.JsonSerializer.Serialize(new Dictionary { [source] = "Red" })); + var tags = new FileTagStore(path); + await tags.LoadAsync(); + await tags.MigrateRenameAsync(Guid.NewGuid(), source, destination, false); + var restarted = new FileTagStore(path); + await restarted.LoadAsync(); + Assert.Equal(FileTagColor.Red, restarted.GetTag(destination)); + Assert.Equal(destination, Assert.Single(restarted.Tags).Key); + } +} diff --git a/tests/Meridian.Core.Tests/TransferExecutorTests.cs b/tests/Meridian.Core.Tests/TransferExecutorTests.cs index 093019e..733cab0 100644 --- a/tests/Meridian.Core.Tests/TransferExecutorTests.cs +++ b/tests/Meridian.Core.Tests/TransferExecutorTests.cs @@ -396,6 +396,138 @@ public async Task CopyFolderIntoItself_FailsWithoutCopying() Assert.Empty(Directory.EnumerateFileSystemEntries(inner)); } + [Fact] + public async Task RenameFiles_RenamesEachItemInPlace() + { + using var fixture = new Fixture(); + var first = Path.Combine(fixture.VolumeA, "first.txt"); + var second = Path.Combine(fixture.VolumeA, "second.txt"); + await File.WriteAllTextAsync(first, "contents one"); + await File.WriteAllTextAsync(second, "contents two"); + + var job = TransferJob.Create(TransferJobKind.Rename, [first, second], string.Empty, CollisionPolicy.Skip); + job.Items = new List + { + new TransferItem { SourcePath = first, DestinationPath = Path.Combine(fixture.VolumeA, "alpha.txt"), IsDirectory = false, Size = 0 }, + new TransferItem { SourcePath = second, DestinationPath = Path.Combine(fixture.VolumeA, "beta.txt"), IsDirectory = false, Size = 0 }, + }; + + var result = await new TransferExecutor().ExecuteAsync(job); + + Assert.Equal(TransferJobState.Completed, result.State); + Assert.False(File.Exists(first)); + Assert.False(File.Exists(second)); + Assert.Equal("contents one", await File.ReadAllTextAsync(Path.Combine(fixture.VolumeA, "alpha.txt"))); + Assert.Equal("contents two", await File.ReadAllTextAsync(Path.Combine(fixture.VolumeA, "beta.txt"))); + Assert.All(result.Items, item => Assert.Equal(TransferItemState.Completed, item.State)); + } + + [Fact] + public async Task Rename_OneMissingSource_DoesNotAbortTheJob() + { + using var fixture = new Fixture(); + var good = Path.Combine(fixture.VolumeA, "good.txt"); + await File.WriteAllTextAsync(good, "good"); + var missing = Path.Combine(fixture.VolumeA, "missing.txt"); + + var job = TransferJob.Create(TransferJobKind.Rename, [good, missing], string.Empty, CollisionPolicy.Skip); + job.Items = new List + { + new TransferItem { SourcePath = good, DestinationPath = Path.Combine(fixture.VolumeA, "renamed-good.txt"), IsDirectory = false, Size = 0 }, + new TransferItem { SourcePath = missing, DestinationPath = Path.Combine(fixture.VolumeA, "renamed-missing.txt"), IsDirectory = false, Size = 0 }, + }; + + var result = await new TransferExecutor().ExecuteAsync(job); + + Assert.Equal(TransferJobState.CompletedWithErrors, result.State); + Assert.True(File.Exists(Path.Combine(fixture.VolumeA, "renamed-good.txt"))); + Assert.False(File.Exists(good)); + var failed = result.Items.Single(item => item.SourcePath == missing); + Assert.Equal(TransferItemState.Failed, failed.State); + Assert.Contains("no longer exists", failed.Error); + } + + [Fact] + public async Task RenameCollisionKeepBoth_ParksAndRenamesToNextFreeName() + { + using var fixture = new Fixture(); + var source = Path.Combine(fixture.VolumeA, "report.txt"); + await File.WriteAllTextAsync(source, "new content"); + var occupied = Path.Combine(fixture.VolumeA, "report (2).txt"); + await File.WriteAllTextAsync(occupied, "existing"); + + var job = TransferJob.Create(TransferJobKind.Rename, [source], string.Empty, CollisionPolicy.KeepBoth); + job.Items = new List + { + new TransferItem { SourcePath = source, DestinationPath = occupied, IsDirectory = false, Size = 0 }, + }; + + // A rename cannot overwrite an occupied name, so the item parks for a decision. + var result = await new TransferExecutor().ExecuteAsync(job); + Assert.Equal(TransferJobState.WaitingOnConflict, result.State); + var waiting = result.Items.Single(item => item.State == TransferItemState.WaitingOnConflict); + + waiting.ResolveConflict(new CollisionDecision(CollisionDecisionKind.Rename, Path.Combine(fixture.VolumeA, "report (3).txt"), "test")); + + var second = await new TransferExecutor().ExecuteAsync(result); + Assert.Equal(TransferJobState.Completed, second.State); + Assert.Equal("new content", await File.ReadAllTextAsync(Path.Combine(fixture.VolumeA, "report (3).txt"))); + Assert.Equal("existing", await File.ReadAllTextAsync(occupied)); // never replaced + Assert.False(File.Exists(source)); + } + + [Fact] + public async Task RenameCollisionAsk_ParksUntilTheUserResolvesIt() + { + using var fixture = new Fixture(); + var plain = Path.Combine(fixture.VolumeA, "plain.txt"); + await File.WriteAllTextAsync(plain, "plain"); + var taken = Path.Combine(fixture.VolumeA, "taken.txt"); + await File.WriteAllTextAsync(taken, "taken"); + + var job = TransferJob.Create(TransferJobKind.Rename, [plain], string.Empty, CollisionPolicy.Ask); + job.Items = new List + { + new TransferItem { SourcePath = plain, DestinationPath = taken, IsDirectory = false, Size = 0 }, + }; + + var executor = new TransferExecutor(); + var first = await executor.ExecuteAsync(job); + + Assert.Equal(TransferJobState.WaitingOnConflict, first.State); + var waiting = first.Items.Single(item => item.State == TransferItemState.WaitingOnConflict); + + // Keep both: the rename lands under a numbered name instead of replacing. + waiting.ResolveConflict(new CollisionDecision(CollisionDecisionKind.Rename, Path.Combine(fixture.VolumeA, "plain (2).txt"), "test")); + var second = await executor.ExecuteAsync(first); + + Assert.Equal(TransferJobState.Completed, second.State); + Assert.Equal("plain", await File.ReadAllTextAsync(Path.Combine(fixture.VolumeA, "plain (2).txt"))); + Assert.Equal("taken", await File.ReadAllTextAsync(taken)); // never replaced + } + + [Fact] + public async Task RenameFolder_RenamesTheFolder() + { + using var fixture = new Fixture(); + var folder = Path.Combine(fixture.VolumeA, "bundle"); + Directory.CreateDirectory(folder); + await File.WriteAllTextAsync(Path.Combine(folder, "note.txt"), "keep"); + + var job = TransferJob.Create(TransferJobKind.Rename, [folder], string.Empty, CollisionPolicy.Skip); + job.Items = new List + { + new TransferItem { SourcePath = folder, DestinationPath = Path.Combine(fixture.VolumeA, "archive"), IsDirectory = true, Size = 0 }, + }; + + var result = await new TransferExecutor().ExecuteAsync(job); + + Assert.Equal(TransferJobState.Completed, result.State); + Assert.False(Directory.Exists(folder)); + Assert.True(Directory.Exists(Path.Combine(fixture.VolumeA, "archive"))); + Assert.Equal("keep", await File.ReadAllTextAsync(Path.Combine(fixture.VolumeA, "archive", "note.txt"))); + } + /// Writes report.txt with new content in volume A and old content in volume B. private static async Task<(string Source, string Destination)> WritePair(Fixture fixture, string sourceContent) { diff --git a/tests/Meridian.Core.Tests/TransferQueueStoreTests.cs b/tests/Meridian.Core.Tests/TransferQueueStoreTests.cs index 426a4d7..7df0db0 100644 --- a/tests/Meridian.Core.Tests/TransferQueueStoreTests.cs +++ b/tests/Meridian.Core.Tests/TransferQueueStoreTests.cs @@ -192,4 +192,66 @@ public async Task Move_SwapsNeighbours_Persists_AndStopsAtTheEnds() Directory.Delete(folder, recursive: true); } } + [Fact] + public async Task MoveAndEnqueue_WaitForInFlightRequiredSave_AndPersistOrder() + { + var folder = NewFolder(); + using var release = new ManualResetEventSlim(); + try + { + var store = new TransferQueueStore(folder); + var first = TransferJob.Create(TransferJobKind.Copy, ["a"], "dest"); + var second = TransferJob.Create(TransferJobKind.Copy, ["b"], "dest"); + var third = TransferJob.Create(TransferJobKind.Copy, ["c"], "dest"); + await store.EnqueueAsync(first); + await store.EnqueueAsync(second); + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var writes = 0; + store.BeforeWrite = _ => + { + if (Interlocked.Increment(ref writes) != 1) return; + entered.SetResult(); + if (!release.Wait(TimeSpan.FromSeconds(10))) throw new TimeoutException("Test save was not released."); + }; + var saving = Task.Run(() => store.SaveRequiredAsync()); + await entered.Task.WaitAsync(TimeSpan.FromSeconds(5)); + var moving = store.MoveAsync(second.Id, -1); + var adding = store.EnqueueAsync(third); + try + { + Assert.False(moving.IsCompleted); + Assert.False(adding.IsCompleted); + Assert.Equal([first.Id, second.Id], store.Jobs.Select(job => job.Id).ToArray()); + } + finally { release.Set(); } + await Task.WhenAll(saving, moving, adding).WaitAsync(TimeSpan.FromSeconds(10)); + Assert.True(await moving); + var reloaded = new TransferQueueStore(folder); + await reloaded.LoadAsync(); + Assert.Equal([second.Id, first.Id, third.Id], reloaded.Jobs.Select(job => job.Id).ToArray()); + } + finally + { + release.Set(); + Directory.Delete(folder, recursive: true); + } + } + + [Fact] + public async Task Move_RequiredWriteFailurePropagates() + { + var folder = NewFolder(); + try + { + var store = new TransferQueueStore(folder); + var first = TransferJob.Create(TransferJobKind.Copy, ["a"], "dest"); + var second = TransferJob.Create(TransferJobKind.Copy, ["b"], "dest"); + await store.EnqueueAsync(first); + await store.EnqueueAsync(second); + store.BeforeWrite = _ => throw new IOException("injected reorder persistence failure"); + await Assert.ThrowsAsync(() => store.MoveAsync(second.Id, -1)); + } + finally { Directory.Delete(folder, recursive: true); } + } + } diff --git a/tests/Meridian.Core.Tests/UndoPlannerTests.cs b/tests/Meridian.Core.Tests/UndoPlannerTests.cs index 667817d..00ac949 100644 --- a/tests/Meridian.Core.Tests/UndoPlannerTests.cs +++ b/tests/Meridian.Core.Tests/UndoPlannerTests.cs @@ -163,6 +163,94 @@ public void DeleteRecord_IsNeverUndoable() Assert.Contains("Recycle Bin", plan.Reason); } + [Fact] + public async Task RenameUndo_MovesEachItemBackToItsOriginalName() + { + using var fixture = new Fixture(); + var first = Path.Combine(fixture.VolumeA, "alpha.txt"); + var second = Path.Combine(fixture.VolumeA, "beta.txt"); + await File.WriteAllTextAsync(first, "one"); + await File.WriteAllTextAsync(second, "two"); + var job = TransferJob.Create(TransferJobKind.Rename, [first, second], string.Empty, CollisionPolicy.Skip); + job.Items = new List + { + new TransferItem { SourcePath = first, DestinationPath = Path.Combine(fixture.VolumeA, "renamed-alpha.txt"), IsDirectory = false, Size = 0 }, + new TransferItem { SourcePath = second, DestinationPath = Path.Combine(fixture.VolumeA, "renamed-beta.txt"), IsDirectory = false, Size = 0 }, + }; + var result = await new TransferExecutor().ExecuteAsync(job); + Assert.Equal(TransferJobState.Completed, result.State); + Assert.False(File.Exists(first)); + Assert.False(File.Exists(second)); + + var record = TransferHistoryRecord.FromJob(job); + var plan = UndoPlanner.Plan(record, Files); + + Assert.True(plan.IsAvailable, plan.Reason); + Assert.Equal(TransferJobKind.Rename, plan.InverseJob!.Kind); + await new TransferExecutor().ExecuteAsync(plan.InverseJob); + + Assert.Equal("one", await File.ReadAllTextAsync(first)); + Assert.Equal("two", await File.ReadAllTextAsync(second)); + Assert.False(File.Exists(Path.Combine(fixture.VolumeA, "renamed-alpha.txt"))); + Assert.False(File.Exists(Path.Combine(fixture.VolumeA, "renamed-beta.txt"))); + } + + [Fact] + public async Task RenameUndo_OriginalNameOccupiedAgain_ComesBackUnderNumberedName() + { + using var fixture = new Fixture(); + var source = Path.Combine(fixture.VolumeA, "report.txt"); + await File.WriteAllTextAsync(source, "moved-content"); + var job = TransferJob.Create(TransferJobKind.Rename, [source], string.Empty, CollisionPolicy.Skip); + job.Items = new List + { + new TransferItem { SourcePath = source, DestinationPath = Path.Combine(fixture.VolumeA, "archive.txt"), IsDirectory = false, Size = 0 }, + }; + var result = await new TransferExecutor().ExecuteAsync(job); + Assert.Equal(TransferJobState.Completed, result.State); + + // Someone recreated the original name while the rename sits in history. + await File.WriteAllTextAsync(source, "someone else's new file"); + + var record = TransferHistoryRecord.FromJob(job); + var plan = UndoPlanner.Plan(record, Files); + + Assert.True(plan.IsAvailable, plan.Reason); + await new TransferExecutor().ExecuteAsync(plan.InverseJob!); + + // The original was never overwritten. + Assert.Equal("someone else's new file", await File.ReadAllTextAsync(source)); + // The renamed file came back under a numbered name. + Assert.Equal("moved-content", await File.ReadAllTextAsync(Path.Combine(fixture.VolumeA, "report (2).txt"))); + Assert.False(File.Exists(Path.Combine(fixture.VolumeA, "archive.txt"))); + } + + [Fact] + public async Task RenameUndo_DestinationChangedSince_IsUnavailable() + { + using var fixture = new Fixture(); + var source = Path.Combine(fixture.VolumeA, "doc.txt"); + await File.WriteAllTextAsync(source, "v1"); + var job = TransferJob.Create(TransferJobKind.Rename, [source], string.Empty, CollisionPolicy.Skip); + job.Items = new List + { + new TransferItem { SourcePath = source, DestinationPath = Path.Combine(fixture.VolumeA, "doc-edited.txt"), IsDirectory = false, Size = 0 }, + }; + var result = await new TransferExecutor().ExecuteAsync(job); + Assert.Equal(TransferJobState.Completed, result.State); + + var renamed = Path.Combine(fixture.VolumeA, "doc-edited.txt"); + await File.WriteAllTextAsync(renamed, "v1 — edited since"); + File.SetLastWriteTimeUtc(renamed, DateTime.UtcNow.AddMinutes(5)); + + var record = TransferHistoryRecord.FromJob(job); + var plan = UndoPlanner.Plan(record, Files); + + Assert.False(plan.IsAvailable); + Assert.Null(plan.InverseJob); + Assert.Contains("has changed", plan.Reason); + } + private sealed class Fixture : IDisposable { public Fixture()