using System.Threading.Channels;
using NpgsqlRest;
namespace NpgsqlRestClient;
///
/// Shared file-watching primitives used by both watch modes: the test runner's in-process watch
/// () and the server watch supervisor ().
///
public static class WatchUtils
{
/// A cwd-relative path against a cwd-relative glob (leading "./" tolerated on the pattern).
public static bool MatchesCwdRelativePattern(string relativePath, string pattern)
{
pattern = pattern.Replace('\\', '/');
if (pattern.StartsWith("./"))
{
pattern = pattern[2..];
}
return Parser.IsPatternMatch(relativePath, pattern);
}
///
/// The deepest fixed (wildcard-free) directory of a glob pattern — same logic file discovery uses.
/// Null when the directory does not exist.
///
public static string? GetWatchBaseDir(string pattern)
{
if (string.IsNullOrWhiteSpace(pattern))
{
return null;
}
int firstWildcard = pattern.IndexOfAny(['*', '?']);
if (firstWildcard < 0)
{
var dir = Path.GetDirectoryName(pattern);
return string.IsNullOrEmpty(dir) ? "." : dir;
}
int lastSlash = pattern.LastIndexOf('/', firstWildcard);
var baseDir = lastSlash >= 0 ? pattern[..lastSlash] : ".";
return Directory.Exists(baseDir) ? baseDir : null;
}
///
/// First change (blocking), then a quiet period so editor write bursts coalesce into one batch.
/// Returns null when cancelled.
///
public static async Task?> NextChangeBatchAsync(ChannelReader reader, CancellationToken ct)
{
string first;
try
{
first = await reader.ReadAsync(ct);
}
catch (OperationCanceledException)
{
return null;
}
var batch = new List { first };
while (true)
{
try { await Task.Delay(300, ct); } catch (OperationCanceledException) { return null; }
bool any = false;
while (reader.TryRead(out var more))
{
batch.Add(more);
any = true;
}
if (!any) return batch;
}
}
///
/// A recursive *.sql FileSystemWatcher over a directory, feeding full paths into the channel.
///
public static FileSystemWatcher NewSqlWatcher(string fullDir, ChannelWriter writer)
{
var w = new FileSystemWatcher(fullDir, "*.sql")
{
IncludeSubdirectories = true,
NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.FileName | NotifyFilters.Size,
};
void OnFsEvent(object s, FileSystemEventArgs e) => writer.TryWrite(e.FullPath);
w.Changed += OnFsEvent;
w.Created += OnFsEvent;
w.Renamed += (s, e) => writer.TryWrite(e.FullPath);
w.EnableRaisingEvents = true;
return w;
}
///
/// True when the ecosystem-standard DOTNET_USE_POLLING_FILE_WATCHER is set — the escape hatch for
/// environments where inotify events don't cross the filesystem boundary (Docker Desktop bind mounts,
/// some network shares).
///
public static bool UsePollingWatcher =>
Environment.GetEnvironmentVariable("DOTNET_USE_POLLING_FILE_WATCHER") is { } v
&& (v == "1" || string.Equals(v, "true", StringComparison.OrdinalIgnoreCase));
///
/// Polling fallback: scans *.sql under plus the extra files every second
/// and writes changed/new paths into the channel. Runs until the token is cancelled.
///
public static void StartPollingWatcher(string fullDir, IReadOnlyList extraFiles, ChannelWriter writer, CancellationToken ct)
{
_ = Task.Run(async () =>
{
var known = new Dictionary(StringComparer.Ordinal);
void Scan(bool emit)
{
IEnumerable files = Directory.Exists(fullDir)
? Directory.EnumerateFiles(fullDir, "*.sql", SearchOption.AllDirectories)
: [];
foreach (var f in files.Concat(extraFiles))
{
DateTime mtime;
try { mtime = File.GetLastWriteTimeUtc(f); } catch { continue; }
if (known.TryGetValue(f, out var prev))
{
if (prev != mtime)
{
known[f] = mtime;
if (emit) writer.TryWrite(f);
}
}
else
{
known[f] = mtime;
if (emit) writer.TryWrite(f);
}
}
}
Scan(emit: false);
while (ct.IsCancellationRequested is false)
{
try { await Task.Delay(1000, ct); } catch (OperationCanceledException) { break; }
Scan(emit: true);
}
}, CancellationToken.None);
}
}