// 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);
}
}