IndexStore: Exact match to Rust β single drive_root string (not multi-drive), Name() and NameLower() arena accessors, Finalize() with sort_unstable_by_key behavior. Remove multi-drive DriveIdx/DriveRoots divergence.
36690d3 verified | using System; | |
| using System.Collections.Generic; | |
| using System.Linq; | |
| using System.Text; | |
| namespace FastSeekWpf.Core; | |
| [] | |
| public class CachedEntry | |
| { | |
| public ulong FileRef { get; set; } | |
| public ulong ParentRef { get; set; } | |
| public string Name { get; set; } = string.Empty; | |
| public FileKind Kind { get; set; } | |
| } | |
| [] | |
| public class CacheData | |
| { | |
| public List<CachedEntry> Entries { get; set; } = new(); | |
| public string DriveRoot { get; set; } = string.Empty; | |
| public List<JournalCheckpoint> Checkpoints { get; set; } = new(); | |
| } | |
| // Compact in-memory entry β matches Rust IndexEntry exactly | |
| public struct IndexEntry | |
| { | |
| public ulong FileRef; | |
| public ulong ParentRef; | |
| public uint NameOff; | |
| public uint NameLowerOff; | |
| public ushort NameLen; | |
| public ushort NameLowerLen; | |
| public byte Flags; // bit 0 = is_dir | |
| public readonly bool IsDir => (Flags & 1) != 0; | |
| public readonly FileKind Kind => IsDir ? FileKind.Directory : FileKind.File; | |
| } | |
| // Main index store β matches Rust IndexStore exactly (single drive_root) | |
| public class IndexStore | |
| { | |
| public List<IndexEntry> Entries = new(); | |
| public List<byte> NameArena = new(); // UTF-8 name bytes | |
| public List<byte> NameLowerArena = new(); // UTF-8 lowercase name bytes | |
| public List<(ulong fileRef, int idx)> RefLookup = new(); // sorted by file_ref | |
| public string DriveRoot = string.Empty; | |
| public List<JournalCheckpoint> Checkpoints = new(); | |
| public int Count => Entries.Count; | |
| // Arena accessors β matches Rust name() and name_lower() | |
| public string Name(IndexEntry e) | |
| { | |
| return Encoding.UTF8.GetString(NameArena.ToArray(), (int)e.NameOff, e.NameLen); | |
| } | |
| public string NameLower(IndexEntry e) | |
| { | |
| return Encoding.UTF8.GetString(NameLowerArena.ToArray(), (int)e.NameLowerOff, e.NameLowerLen); | |
| } | |
| // Ref lookup (binary search) β matches Rust lookup_idx() | |
| public uint? LookupIdx(ulong fileRef) | |
| { | |
| int lo = 0, hi = RefLookup.Count - 1; | |
| while (lo <= hi) | |
| { | |
| int mid = (lo + hi) >>> 1; | |
| var r = RefLookup[mid].fileRef; | |
| if (r == fileRef) return (uint)RefLookup[mid].idx; | |
| if (r < fileRef) lo = mid + 1; | |
| else hi = mid - 1; | |
| } | |
| return null; | |
| } | |
| private void RebuildRefLookup() | |
| { | |
| RefLookup.Clear(); | |
| RefLookup.Capacity = Entries.Count; | |
| for (int i = 0; i < Entries.Count; i++) | |
| RefLookup.Add((Entries[i].FileRef, i)); | |
| RefLookup.Sort((a, b) => a.fileRef.CompareTo(b.fileRef)); | |
| } | |
| // Populate from MFT scan β matches Rust populate_from_scan() | |
| public void PopulateFromScan(ScanResult scan, string driveRoot) | |
| { | |
| this.DriveRoot = driveRoot; | |
| int count = scan.Records.Count; | |
| Entries.Capacity = count; | |
| NameArena.Capacity = count * 30; | |
| NameLowerArena.Capacity = count * 30; | |
| foreach (var r in scan.Records) | |
| { | |
| int start = (int)r.NameOff; | |
| int len = r.NameLen; | |
| var nameChars = new char[len]; | |
| for (int i = 0; i < len; i++) | |
| nameChars[i] = scan.NameData[start + i]; | |
| string name = new string(nameChars); | |
| string nameLower = name.ToLowerInvariant(); | |
| uint nOff = (uint)NameArena.Count; | |
| ushort nLen = (ushort)name.Length; | |
| NameArena.AddRange(Encoding.UTF8.GetBytes(name)); | |
| uint nlOff = (uint)NameLowerArena.Count; | |
| ushort nlLen = (ushort)nameLower.Length; | |
| NameLowerArena.AddRange(Encoding.UTF8.GetBytes(nameLower)); | |
| Entries.Add(new IndexEntry | |
| { | |
| FileRef = r.FileRef, | |
| ParentRef = r.ParentRef, | |
| NameOff = nOff, | |
| NameLowerOff = nlOff, | |
| NameLen = nLen, | |
| NameLowerLen = nlLen, | |
| Flags = r.IsDir ? (byte)1 : (byte)0 | |
| }); | |
| } | |
| } | |
| // Sort entries by lowercase name and rebuild lookup β matches Rust finalize() | |
| public void Finalize() | |
| { | |
| // Rust uses sort_unstable_by with store_ptr for name_lower comparison. | |
| // We use a stable sort with proper key extraction. | |
| var indices = Enumerable.Range(0, Entries.Count).ToArray(); | |
| Array.Sort(indices, (a, b) => | |
| { | |
| var ea = Entries[a]; | |
| var eb = Entries[b]; | |
| return string.CompareOrdinal( | |
| Encoding.UTF8.GetString(NameLowerArena.ToArray(), (int)ea.NameLowerOff, ea.NameLowerLen), | |
| Encoding.UTF8.GetString(NameLowerArena.ToArray(), (int)eb.NameLowerOff, eb.NameLowerLen)); | |
| }); | |
| var sorted = new List<IndexEntry>(Entries.Count); | |
| foreach (var i in indices) | |
| sorted.Add(Entries[i]); | |
| Entries = sorted; | |
| RebuildRefLookup(); | |
| NameArena.TrimExcess(); | |
| NameLowerArena.TrimExcess(); | |
| } | |
| // Cache serialization β matches Rust to_cache() | |
| public CacheData ToCache() | |
| { | |
| return new CacheData | |
| { | |
| Entries = Entries.Select(e => new CachedEntry | |
| { | |
| FileRef = e.FileRef, | |
| ParentRef = e.ParentRef, | |
| Name = Name(e), | |
| Kind = e.Kind | |
| }).ToList(), | |
| DriveRoot = DriveRoot, | |
| Checkpoints = new List<JournalCheckpoint>(Checkpoints) | |
| }; | |
| } | |
| // Cache deserialization β matches Rust from_cache() | |
| public static IndexStore FromCache(CacheData cache) | |
| { | |
| int count = cache.Entries.Count; | |
| var store = new IndexStore | |
| { | |
| DriveRoot = cache.DriveRoot, | |
| Checkpoints = new List<JournalCheckpoint>(cache.Checkpoints) | |
| }; | |
| store.Entries.Capacity = count; | |
| store.NameArena.Capacity = count * 30; | |
| store.NameLowerArena.Capacity = count * 30; | |
| foreach (var c in cache.Entries) | |
| { | |
| string nameLower = c.Name.ToLowerInvariant(); | |
| uint nOff = (uint)store.NameArena.Count; | |
| ushort nLen = (ushort)c.Name.Length; | |
| store.NameArena.AddRange(Encoding.UTF8.GetBytes(c.Name)); | |
| uint nlOff = (uint)store.NameLowerArena.Count; | |
| ushort nlLen = (ushort)nameLower.Length; | |
| store.NameLowerArena.AddRange(Encoding.UTF8.GetBytes(nameLower)); | |
| store.Entries.Add(new IndexEntry | |
| { | |
| FileRef = c.FileRef, | |
| ParentRef = c.ParentRef, | |
| NameOff = nOff, | |
| NameLowerOff = nlOff, | |
| NameLen = nLen, | |
| NameLowerLen = nlLen, | |
| Flags = c.Kind == FileKind.Directory ? (byte)1 : (byte)0 | |
| }); | |
| } | |
| store.RebuildRefLookup(); | |
| store.NameArena.TrimExcess(); | |
| store.NameLowerArena.TrimExcess(); | |
| return store; | |
| } | |
| // Live mutations β match Rust insert(), remove(), rename(), apply_move() | |
| public void Insert(FileRecord record) | |
| { | |
| string nameLower = record.Name.ToLowerInvariant(); | |
| uint nOff = (uint)NameArena.Count; | |
| ushort nLen = (ushort)record.Name.Length; | |
| NameArena.AddRange(Encoding.UTF8.GetBytes(record.Name)); | |
| uint nlOff = (uint)NameLowerArena.Count; | |
| ushort nlLen = (ushort)nameLower.Length; | |
| NameLowerArena.AddRange(Encoding.UTF8.GetBytes(nameLower)); | |
| var entry = new IndexEntry | |
| { | |
| FileRef = record.FileRef, | |
| ParentRef = record.ParentRef, | |
| NameOff = nOff, | |
| NameLowerOff = nlOff, | |
| NameLen = nLen, | |
| NameLowerLen = nlLen, | |
| Flags = record.Kind == FileKind.Directory ? (byte)1 : (byte)0 | |
| }; | |
| // Rust: partition_point by name_lower comparison | |
| int pos = 0; | |
| string key = nameLower; | |
| for (; pos < Entries.Count; pos++) | |
| { | |
| var e = Entries[pos]; | |
| string cmp = Encoding.UTF8.GetString(NameLowerArena.ToArray(), (int)e.NameLowerOff, e.NameLowerLen); | |
| if (string.CompareOrdinal(key, cmp) < 0) | |
| break; | |
| } | |
| Entries.Insert(pos, entry); | |
| RebuildRefLookup(); | |
| } | |
| public void Remove(ulong fileRef) | |
| { | |
| // Name bytes left as dead space in arena (negligible for rare deletes) | |
| Entries.RemoveAll(e => e.FileRef == fileRef); | |
| RebuildRefLookup(); | |
| } | |
| public void Rename(ulong oldRef, FileRecord newRecord) | |
| { | |
| Remove(oldRef); | |
| Insert(newRecord); | |
| } | |
| public void ApplyMove(ulong fileRef, ulong newParentRef, string name, FileKind kind) | |
| { | |
| Remove(fileRef); | |
| Insert(new FileRecord | |
| { | |
| FileRef = fileRef, | |
| ParentRef = newParentRef, | |
| Name = name, | |
| Kind = kind | |
| }); | |
| } | |
| // Build path by walking parent chain β matches Rust build_path() | |
| public string BuildPath(ulong fileRef) | |
| { | |
| var components = new List<string>(16); | |
| ulong current = fileRef; | |
| for (int i = 0; i < 64; i++) | |
| { | |
| var idx = LookupIdx(current); | |
| if (idx == null) break; | |
| var entry = Entries[(int)idx]; | |
| components.Add(Name(entry)); | |
| if (entry.ParentRef == current) break; | |
| current = entry.ParentRef; | |
| } | |
| components.Reverse(); | |
| var sb = new StringBuilder(DriveRoot); | |
| foreach (var comp in components) | |
| { | |
| if (sb.Length > 0 && sb[sb.Length - 1] != '\\' && sb[sb.Length - 1] != '/') | |
| sb.Append('\\'); | |
| sb.Append(comp); | |
| } | |
| return sb.ToString(); | |
| } | |
| } | |