feat(vtank): slice 1c step 2 — one-time flat-file migration into metas/navs
Existing installs may still have real .af files sitting flat at the VtankProfiles root from before step 1's two-folder cutover. Each store now runs a one-time (per-instance, guarded) sweep on first LoadCurrent(): MossTankRouteProfileStore moves every flat nav_*.af/--nav_*.af file into navs/ with the marker stripped (VtankProfileDirectory.StripLegacyNavMarker); MossTankMetaProfileStore moves every OTHER flat .af file into metas/ unmarked. The partition predicate (IsLegacyFlatRouteFileName) is shared in VtankProfileDirectory so both stores agree on which file belongs to which sweep and neither touches the other's share. Both sweeps run before the older legacy-JSON migrations so those see .af content already at its new folder-qualified path. Collision rule: when the real destination already exists, the flat file is left in place untouched (never overwritten) and the collision is logged with both paths. Mutation demonstrated: the 7 new tests (MetaStoreMigratesFlatAfFileIntoMetasFolder, MetaStoreLeavesFlatFileInPlaceWhenMetasDestinationAlreadyExists, RouteStoreMigratesFlatNavMarkedFileIntoNavsFolderWithMarkerStripped, RouteStoreMigratesFlatHiddenAutoRouteFileWithMarkerStripped, RouteStoreLeavesFlatFileInPlaceWhenNavsDestinationAlreadyExists) were run against the store/VtankProfileDirectory code from the prior commit (no migration sweep) and failed 4/7 (the other 3 pass vacuously since they only assert the ABSENCE of cross-contamination, which trivially holds without any sweep at all) before the MigrateFlatFilesTo*FolderIfNeeded methods were added. All 632 tests (625 + 7 new) pass after. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
812a533b83
commit
303c8a8687
5 changed files with 383 additions and 2 deletions
|
|
@ -40,6 +40,7 @@ internal sealed class MossTankMetaProfileStore
|
|||
private string _selected = ByCharacter;
|
||||
private string? _pendingLegacyBareName;
|
||||
private bool _rosterSwept;
|
||||
private bool _flatFolderMigrationSwept;
|
||||
|
||||
public MossTankMetaProfileStore(IPluginHost host)
|
||||
{
|
||||
|
|
@ -109,6 +110,7 @@ internal sealed class MossTankMetaProfileStore
|
|||
|
||||
public MetaProfile LoadCurrent()
|
||||
{
|
||||
MigrateFlatFilesToMetasFolderIfNeeded();
|
||||
SweepLegacyRosterIfNeeded();
|
||||
MigrateLegacyIfNeeded();
|
||||
string fileName = CurrentFileName();
|
||||
|
|
@ -308,6 +310,59 @@ internal sealed class MossTankMetaProfileStore
|
|||
return empty;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Slice 1c step 2: one-time flat-file -> metas/ folder migration.
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Owner decision 2026-09-07: a ONE-TIME sweep (guarded by
|
||||
/// <see cref="_flatFolderMigrationSwept"/>) that moves every flat
|
||||
/// root-level <c>.af</c> file that is NOT a legacy route file (see
|
||||
/// <see cref="VtankProfileDirectory.IsLegacyFlatRouteFileName"/> — those
|
||||
/// belong to <see cref="MossTankRouteProfileStore"/>'s own sweep) into
|
||||
/// <see cref="VtankProfileDirectory.MetaFolder"/>, unmarked, since Meta
|
||||
/// files never carried a marker. Runs BEFORE the older
|
||||
/// <see cref="SweepLegacyRosterIfNeeded"/>/<see cref="MigrateLegacyIfNeeded"/>
|
||||
/// JSON migrations so those see the real <c>.af</c> content already at
|
||||
/// its new folder-qualified path rather than treating it as missing.
|
||||
/// When the real destination already exists, the flat file is left in
|
||||
/// place untouched (never overwritten) and the collision is logged —
|
||||
/// this can only happen from an external/manual file drop, since this
|
||||
/// store itself never wrote a flat file.
|
||||
/// </summary>
|
||||
private void MigrateFlatFilesToMetasFolderIfNeeded()
|
||||
{
|
||||
if (_flatFolderMigrationSwept)
|
||||
return;
|
||||
_flatFolderMigrationSwept = true;
|
||||
if (!VtankStorage.IsAvailable)
|
||||
return;
|
||||
int migrated = 0;
|
||||
foreach (string bareName in VtankProfileDirectory.ListFlatAfFileNames(VtankStorage))
|
||||
{
|
||||
if (VtankProfileDirectory.IsLegacyFlatRouteFileName(bareName))
|
||||
continue; // MossTankRouteProfileStore's own sweep owns this one.
|
||||
string destination = $"{VtankProfileDirectory.MetaFolder}/{bareName}";
|
||||
if (VtankStorage.ReadText(destination) is not null)
|
||||
{
|
||||
_host.Log.Warn(
|
||||
$"MossTank left flat Meta profile '{bareName}' in place: '{destination}' already exists.");
|
||||
continue;
|
||||
}
|
||||
string? content = VtankStorage.ReadText(bareName);
|
||||
if (content is null)
|
||||
continue; // listed but unreadable; skip defensively.
|
||||
VtankStorage.WriteText(destination, content);
|
||||
VtankStorage.Delete(bareName);
|
||||
migrated++;
|
||||
}
|
||||
if (migrated > 0)
|
||||
{
|
||||
_host.Log.Warn(
|
||||
$"Migrated {migrated} flat MossTank Meta profile(s) into {VtankProfileDirectory.MetaFolder}/.");
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Legacy JSON -> .af migration.
|
||||
// ------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ internal sealed class MossTankRouteProfileStore
|
|||
private string _selected = ByCharacter;
|
||||
private string? _pendingLegacyBareName;
|
||||
private bool _rosterSwept;
|
||||
private bool _flatFolderMigrationSwept;
|
||||
|
||||
public MossTankRouteProfileStore(IPluginHost host)
|
||||
{
|
||||
|
|
@ -173,6 +174,7 @@ internal sealed class MossTankRouteProfileStore
|
|||
{
|
||||
ArgumentNullException.ThrowIfNull(target);
|
||||
ArgumentNullException.ThrowIfNull(spells);
|
||||
MigrateFlatFilesToNavsFolderIfNeeded();
|
||||
SweepLegacyRosterIfNeeded();
|
||||
MigrateLegacyIfNeeded(target, spells);
|
||||
string fileName = CurrentFileName();
|
||||
|
|
@ -269,6 +271,60 @@ internal sealed class MossTankRouteProfileStore
|
|||
SaveCurrent(target);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Slice 1c step 2: one-time flat-file -> navs/ folder migration.
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Owner decision 2026-09-07: a ONE-TIME sweep (guarded by
|
||||
/// <see cref="_flatFolderMigrationSwept"/>) that moves every flat
|
||||
/// root-level route file recognized by
|
||||
/// <see cref="VtankProfileDirectory.IsLegacyFlatRouteFileName"/>
|
||||
/// (<c>nav_Name.af</c>, hidden <c>--nav_Name_Server.af</c>) into
|
||||
/// <see cref="VtankProfileDirectory.NavFolder"/> with the marker
|
||||
/// stripped (<see cref="VtankProfileDirectory.StripLegacyNavMarker"/>).
|
||||
/// Every other flat <c>.af</c> file is left alone — it belongs to
|
||||
/// <see cref="MossTankMetaProfileStore"/>'s own sweep. Runs BEFORE the
|
||||
/// older <see cref="SweepLegacyRosterIfNeeded"/>/<see cref="MigrateLegacyIfNeeded"/>
|
||||
/// JSON migrations so those see the real <c>.af</c> content already at
|
||||
/// its new folder-qualified path rather than treating it as missing.
|
||||
/// When the real destination already exists, the flat file is left in
|
||||
/// place untouched (never overwritten) and the collision is logged.
|
||||
/// </summary>
|
||||
private void MigrateFlatFilesToNavsFolderIfNeeded()
|
||||
{
|
||||
if (_flatFolderMigrationSwept)
|
||||
return;
|
||||
_flatFolderMigrationSwept = true;
|
||||
if (!VtankStorage.IsAvailable)
|
||||
return;
|
||||
int migrated = 0;
|
||||
foreach (string bareName in VtankProfileDirectory.ListFlatAfFileNames(VtankStorage))
|
||||
{
|
||||
if (!VtankProfileDirectory.IsLegacyFlatRouteFileName(bareName))
|
||||
continue; // MossTankMetaProfileStore's own sweep owns this one.
|
||||
string strippedName = VtankProfileDirectory.StripLegacyNavMarker(bareName);
|
||||
string destination = $"{VtankProfileDirectory.NavFolder}/{strippedName}";
|
||||
if (VtankStorage.ReadText(destination) is not null)
|
||||
{
|
||||
_host.Log.Warn(
|
||||
$"MossTank left flat route profile '{bareName}' in place: '{destination}' already exists.");
|
||||
continue;
|
||||
}
|
||||
string? content = VtankStorage.ReadText(bareName);
|
||||
if (content is null)
|
||||
continue; // listed but unreadable; skip defensively.
|
||||
VtankStorage.WriteText(destination, content);
|
||||
VtankStorage.Delete(bareName);
|
||||
migrated++;
|
||||
}
|
||||
if (migrated > 0)
|
||||
{
|
||||
_host.Log.Warn(
|
||||
$"Migrated {migrated} flat MossTank route profile(s) into {VtankProfileDirectory.NavFolder}/.");
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Legacy JSON -> .af migration.
|
||||
// ------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -51,6 +51,58 @@ internal static class VtankProfileDirectory
|
|||
/// <summary>See <see cref="MetaFolder"/>.</summary>
|
||||
internal const string NavFolder = "navs";
|
||||
|
||||
/// <summary>
|
||||
/// The pre-slice-1c flat-directory route marker (round 3 items 3/4):
|
||||
/// used ONLY by the one-time flat-file migration sweep
|
||||
/// (<see cref="IsLegacyFlatRouteFileName"/>/<see cref="StripLegacyNavMarker"/>)
|
||||
/// to recognize an old <c>nav_Name.af</c>/<c>--nav_Name_Server.af</c>
|
||||
/// file left over from before the two-folder cutover. No code writes
|
||||
/// this marker anymore.
|
||||
/// </summary>
|
||||
private const string LegacyNavMarker = "nav_";
|
||||
|
||||
/// <summary>
|
||||
/// True when <paramref name="bareFileName"/> (a flat, un-prefixed root
|
||||
/// key with no folder) is a pre-slice-1c route file: <c>nav_*.af</c>,
|
||||
/// or the hidden per-character auto shape <c>--nav_*.af</c> (hidden
|
||||
/// prefix first, marker second — the old
|
||||
/// <c>AutoCharacterFileName(..., NavMarker)</c> overload's output).
|
||||
/// Every OTHER flat <c>.af</c> file is a Meta profile. Used ONLY to
|
||||
/// partition old root-level files between <see cref="MetaFolder"/> and
|
||||
/// <see cref="NavFolder"/> during the one-time migration sweep.
|
||||
/// </summary>
|
||||
internal static bool IsLegacyFlatRouteFileName(string bareFileName) =>
|
||||
bareFileName.StartsWith(LegacyNavMarker, StringComparison.Ordinal)
|
||||
|| (bareFileName.StartsWith(HiddenPrefix, StringComparison.Ordinal)
|
||||
&& bareFileName[HiddenPrefix.Length..].StartsWith(
|
||||
LegacyNavMarker, StringComparison.Ordinal));
|
||||
|
||||
/// <summary>
|
||||
/// Strips the pre-slice-1c <c>nav_</c> marker from a flat route file
|
||||
/// name recognized by <see cref="IsLegacyFlatRouteFileName"/>, keeping
|
||||
/// a leading hidden <see cref="HiddenPrefix"/> in place ahead of the
|
||||
/// stripped remainder: <c>nav_Hunt.af</c> → <c>Hunt.af</c>,
|
||||
/// <c>--nav_Name_Server.af</c> → <c>--Name_Server.af</c>. Not meaningful
|
||||
/// (and not called) for a file <see cref="IsLegacyFlatRouteFileName"/>
|
||||
/// returns <see langword="false"/> for.
|
||||
/// </summary>
|
||||
internal static string StripLegacyNavMarker(string bareFileName)
|
||||
{
|
||||
bool isHidden = bareFileName.StartsWith(HiddenPrefix, StringComparison.Ordinal);
|
||||
string rest = isHidden ? bareFileName[HiddenPrefix.Length..] : bareFileName;
|
||||
rest = rest[LegacyNavMarker.Length..];
|
||||
return isHidden ? HiddenPrefix + rest : rest;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lists every flat (root-level, un-prefixed) <c>.af</c> file name —
|
||||
/// the one-time flat-file migration sweep's own input, materialized
|
||||
/// eagerly since both stores mutate <paramref name="storage"/> (move
|
||||
/// files out of the root) while consuming this list.
|
||||
/// </summary>
|
||||
internal static IReadOnlyList<string> ListFlatAfFileNames(IPluginStorage storage) =>
|
||||
EnumerateFileNames(storage, ".af").ToList();
|
||||
|
||||
/// <summary>
|
||||
/// VTank's single per-character default filename
|
||||
/// (<c>uTank2/PluginCore.cs:3863-3865</c>): <c>--Name_Server.ext</c>.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue