// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. using Microsoft.CodeAnalysis; using Microsoft.Testing.Platform.OutputDevice; namespace Microsoft.Testing.Platform.Services; [Embedded] internal static partial class ArtifactNamingHelper { #if NET private static readonly Regex TemplateFieldRegex = GetTemplateFieldRegex(); [GeneratedRegex(@"\{([^}]+)\}")] private static partial Regex GetTemplateFieldRegex(); #else private static readonly Regex TemplateFieldRegex = new(@"\{([^}]+)\}", RegexOptions.Compiled); #endif /// /// Builds the standard set of placeholder replacements for artifact naming. /// Consumers pass process-specific values; the helper resolves the rest (asm, tfm, arch). /// /// The name of the process (resolves {pname}). /// The process ID (resolves {pid}). /// The timestamp to use (resolves {time}). public static Dictionary GetStandardReplacements(string processName, string processId, DateTimeOffset timestamp) { var replacements = new Dictionary(StringComparer.Ordinal) { ["pname"] = processName, ["pid"] = processId, ["time"] = timestamp.ToString("yyyy-MM-dd_HH-mm-ss.fffffff", CultureInfo.InvariantCulture), ["arch"] = RuntimeInformation.ProcessArchitecture.ToString().ToLowerInvariant(), }; string? asmName = Assembly.GetEntryAssembly()?.GetName().Name; replacements["asm"] = asmName ?? "unknown"; string? tfm = TargetFrameworkParser.GetShortTargetFrameworkIncludingPlatform(Assembly.GetEntryAssembly()); replacements["tfm"] = tfm ?? "unknown"; return replacements; } /// /// Resolves a template pattern by replacing {placeholder} tokens with values from the provided dictionary. /// Unknown placeholders are preserved as-is. Placeholder matching is always case-sensitive (ordinal). /// public static string ResolveTemplate(string template, IDictionary? replacements = null) { if (RoslynString.IsNullOrWhiteSpace(template)) { throw new ArgumentException("Template cannot be null, empty, or whitespace.", nameof(template)); } if (replacements is null || replacements.Count == 0) { return template; } // Ensure ordinal (case-sensitive) comparison regardless of the caller-provided dictionary comparer. Dictionary ordinalReplacements = replacements is Dictionary dict && dict.Comparer == StringComparer.Ordinal ? dict : new Dictionary(replacements, StringComparer.Ordinal); return TemplateFieldRegex.Replace(template, match => { string fieldName = match.Groups[1].Value; return ordinalReplacements.TryGetValue(fieldName, out string? value) ? value : match.Value; }); } /// /// Resolves the standard placeholders in and sanitizes the leaf file name /// portion of the result using . The directory portion (if any) /// is preserved without sanitization so that absolute or relative paths containing path separators are /// kept intact. /// /// The file name template that may contain {pname}, {pid}, {time}, etc. placeholders. /// The process name (resolves {pname}). /// The process ID (resolves {pid}). /// The timestamp to use (resolves {time}). /// The sanitizer applied to the resolved leaf file name. /// The resolved and sanitized file name (or path, if the template contained a directory component). /// The resolved template does not produce a leaf file name (e.g. it ends with a directory separator). public static string ResolveAndSanitize(string template, string processName, string processId, DateTimeOffset timestamp, Func sanitizeLeafFileName) { Dictionary replacements = GetStandardReplacements(processName, processId, timestamp); string resolved = ResolveTemplate(template, replacements); string directoryPart = Path.GetDirectoryName(resolved) ?? string.Empty; string leafName = Path.GetFileName(resolved); if (leafName.Length == 0) { throw new ArgumentException("The resolved template must produce a file name, not just a directory path.", nameof(template)); } string sanitized = sanitizeLeafFileName(leafName); return directoryPart.Length == 0 ? sanitized : Path.Combine(directoryPart, sanitized); } }