diff --git a/Duplicati/Library/Modules/Builtin/Duplicati.Library.Modules.Builtin.csproj b/Duplicati/Library/Modules/Builtin/Duplicati.Library.Modules.Builtin.csproj index 1ba503c24..a49e073db 100644 --- a/Duplicati/Library/Modules/Builtin/Duplicati.Library.Modules.Builtin.csproj +++ b/Duplicati/Library/Modules/Builtin/Duplicati.Library.Modules.Builtin.csproj @@ -13,6 +13,7 @@ + @@ -39,4 +40,8 @@ + + + + diff --git a/Duplicati/Library/Modules/Builtin/ResultExportFormat.cs b/Duplicati/Library/Modules/Builtin/ResultExportFormat.cs index f80350741..dda566fe3 100644 --- a/Duplicati/Library/Modules/Builtin/ResultExportFormat.cs +++ b/Duplicati/Library/Modules/Builtin/ResultExportFormat.cs @@ -24,6 +24,7 @@ namespace Duplicati.Library.Modules.Builtin public enum ResultExportFormat { Duplicati, - Json + Json, + Template } } diff --git a/Duplicati/Library/Modules/Builtin/ResultSerialization/ResultFormatSerializerProvider.cs b/Duplicati/Library/Modules/Builtin/ResultSerialization/ResultFormatSerializerProvider.cs index d28905478..c9659588e 100644 --- a/Duplicati/Library/Modules/Builtin/ResultSerialization/ResultFormatSerializerProvider.cs +++ b/Duplicati/Library/Modules/Builtin/ResultSerialization/ResultFormatSerializerProvider.cs @@ -1,22 +1,22 @@ // Copyright (C) 2025, The Duplicati Team // https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the // Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in +// +// The above copyright notice and this permission notice shall be included in // all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. using System; using Duplicati.Library.Localization.Short; @@ -28,16 +28,56 @@ namespace Duplicati.Library.Modules.Builtin.ResultSerialization /// public static class ResultFormatSerializerProvider { - public static IResultFormatSerializer GetSerializer(ResultExportFormat format) { + /// + /// Gets a serializer for the specified format + /// + /// The format to get a serializer for + /// A serializer for the format + public static IResultFormatSerializer GetSerializer(ResultExportFormat format) + { + return GetSerializer(format, null); + } + + /// + /// Gets a serializer for the specified format with optional template configuration + /// + /// The format to get a serializer for + /// For Template format: path to custom template file, embedded template name, or null for default + /// A serializer for the format + public static IResultFormatSerializer GetSerializer(ResultExportFormat format, string templatePathOrName) + { switch (format) { case ResultExportFormat.Duplicati: return new DuplicatiFormatSerializer(); case ResultExportFormat.Json: return new JsonFormatSerializer(); + case ResultExportFormat.Template: + return GetTemplateSerializer(templatePathOrName); default: throw new Interface.UserInformationException(LC.L("The format is not supported: {0}", format), "SerializationFormatNotSupported"); } } + + /// + /// Gets a template serializer with the specified template configuration + /// + private static TemplateFormatSerializer GetTemplateSerializer(string templatePathOrName) + { + if (string.IsNullOrEmpty(templatePathOrName)) + return new TemplateFormatSerializer(); + + // If it looks like a file path (contains path separators or file extension), try to load it as a file + if (templatePathOrName.Contains(System.IO.Path.DirectorySeparatorChar) || + templatePathOrName.Contains(System.IO.Path.AltDirectorySeparatorChar) || + templatePathOrName.EndsWith(".hbs", StringComparison.OrdinalIgnoreCase)) + { + if (System.IO.File.Exists(templatePathOrName)) + return TemplateFormatSerializer.FromFile(templatePathOrName); + } + + // Otherwise, treat it as an embedded template name + return new TemplateFormatSerializer(templatePathOrName, true); + } } } diff --git a/Duplicati/Library/Modules/Builtin/ResultSerialization/TemplateFormatSerializer.cs b/Duplicati/Library/Modules/Builtin/ResultSerialization/TemplateFormatSerializer.cs new file mode 100644 index 000000000..2d7dbf153 --- /dev/null +++ b/Duplicati/Library/Modules/Builtin/ResultSerialization/TemplateFormatSerializer.cs @@ -0,0 +1,369 @@ +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using HandlebarsDotNet; + +namespace Duplicati.Library.Modules.Builtin.ResultSerialization +{ + /// + /// Implements serialization of results using Handlebars templates. + /// This allows users to fully customize the report output format. + /// + public class TemplateFormatSerializer : IResultFormatSerializer + { + /// + /// The Handlebars instance configured for report rendering + /// + private readonly IHandlebars _handlebars; + + /// + /// The compiled template function + /// + private readonly HandlebarsTemplate _template; + + /// + /// The template source string (for debugging/logging) + /// + private readonly string _templateSource; + + /// + /// Default template name to use when no custom template is provided + /// + public const string DefaultTemplateName = "default"; + + /// + /// Creates a new instance with the default embedded template + /// + public TemplateFormatSerializer() + : this(LoadEmbeddedTemplate(DefaultTemplateName), false) + { + } + + /// + /// Creates a new instance with the specified template source or name + /// + /// The Handlebars template source or template name + /// If true, templateSourceOrName is treated as a template name; if false, as template content + public TemplateFormatSerializer(string templateSourceOrName, bool isTemplateName) + { + string templateSource; + if (isTemplateName) + templateSource = LoadEmbeddedTemplate(templateSourceOrName); + else + templateSource = templateSourceOrName; + + _templateSource = templateSource ?? throw new ArgumentNullException(nameof(templateSourceOrName)); + _handlebars = Handlebars.Create(); + RegisterHelpers(); + _template = _handlebars.Compile(templateSource); + } + + /// + /// Creates a serializer from a template file path + /// + /// Path to the template file + /// A new TemplateFormatSerializer instance + public static TemplateFormatSerializer FromFile(string filePath) + { + if (!File.Exists(filePath)) + throw new FileNotFoundException($"Template file not found: {filePath}", filePath); + + var templateContent = File.ReadAllText(filePath); + return new TemplateFormatSerializer(templateContent, false); + } + + /// + /// Registers custom Handlebars helpers for report formatting + /// + private void RegisterHelpers() + { + // Helper to format file sizes in human-readable form + _handlebars.RegisterHelper("formatSize", (output, context, arguments) => + { + if (arguments.Length == 0 || arguments[0] == null) + { + output.Write("0 B"); + return; + } + + if (!long.TryParse(arguments[0].ToString(), out var bytes)) + { + output.Write(arguments[0].ToString()); + return; + } + + string[] sizes = { "B", "KB", "MB", "GB", "TB" }; + int order = 0; + double size = bytes; + while (size >= 1024 && order < sizes.Length - 1) + { + order++; + size /= 1024; + } + output.Write($"{size:0.##} {sizes[order]}"); + }); + + // Helper to format durations + _handlebars.RegisterHelper("formatDuration", (output, context, arguments) => + { + if (arguments.Length == 0 || arguments[0] == null) + { + output.Write("0s"); + return; + } + + if (arguments[0] is TimeSpan ts) + { + output.Write(FormatTimeSpan(ts)); + return; + } + + if (double.TryParse(arguments[0].ToString(), out var seconds)) + { + output.Write(FormatTimeSpan(TimeSpan.FromSeconds(seconds))); + return; + } + + output.Write(arguments[0].ToString()); + }); + + // Helper to format dates + _handlebars.RegisterHelper("formatDate", (output, context, arguments) => + { + if (arguments.Length == 0 || arguments[0] == null) + { + output.Write(""); + return; + } + + var format = arguments.Length > 1 ? arguments[1].ToString() : "yyyy-MM-dd HH:mm:ss"; + + if (arguments[0] is DateTime dt) + { + output.Write(dt.ToString(format)); + return; + } + + if (DateTime.TryParse(arguments[0].ToString(), out var parsed)) + { + output.Write(parsed.ToString(format)); + return; + } + + output.Write(arguments[0].ToString()); + }); + + // Helper for conditional equality + _handlebars.RegisterHelper("eq", (output, options, context, arguments) => + { + if (arguments.Length < 2) + { + options.Inverse(output, context); + return; + } + + var left = arguments[0]?.ToString() ?? ""; + var right = arguments[1]?.ToString() ?? ""; + + if (string.Equals(left, right, StringComparison.OrdinalIgnoreCase)) + options.Template(output, context); + else + options.Inverse(output, context); + }); + + // Helper for status-based CSS classes or indicators + _handlebars.RegisterHelper("statusClass", (output, context, arguments) => + { + if (arguments.Length == 0 || arguments[0] == null) + { + output.Write("unknown"); + return; + } + + var status = arguments[0].ToString().ToLowerInvariant(); + var result = status switch + { + "success" => "success", + "warning" => "warning", + "error" => "error", + "fatal" => "fatal", + _ => "unknown" + }; + output.Write(result); + }); + + // Helper to iterate over object properties + _handlebars.RegisterHelper("eachProperty", (output, options, context, arguments) => + { + if (arguments.Length == 0 || arguments[0] == null) + return; + + var obj = arguments[0]; + if (obj is IDictionary dict) + { + foreach (var kvp in dict) + { + options.Template(output, new { Key = kvp.Key, Value = kvp.Value }); + } + } + else if (obj is IDictionary stringDict) + { + foreach (var kvp in stringDict) + { + options.Template(output, new { Key = kvp.Key, Value = kvp.Value }); + } + } + else + { + var type = obj.GetType(); + foreach (var prop in type.GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + try + { + var value = prop.GetValue(obj); + options.Template(output, new { Key = prop.Name, Value = value }); + } + catch + { + // Skip properties that throw exceptions + } + } + } + }); + } + + private static string FormatTimeSpan(TimeSpan ts) + { + if (ts.TotalDays >= 1) + return $"{(int)ts.TotalDays}d {ts.Hours}h {ts.Minutes}m"; + if (ts.TotalHours >= 1) + return $"{(int)ts.TotalHours}h {ts.Minutes}m {ts.Seconds}s"; + if (ts.TotalMinutes >= 1) + return $"{(int)ts.TotalMinutes}m {ts.Seconds}s"; + return $"{ts.TotalSeconds:0.#}s"; + } + + /// + /// Loads an embedded template by name + /// + private static string LoadEmbeddedTemplate(string templateName) + { + var assembly = typeof(TemplateFormatSerializer).Assembly; + var resourceName = $"Duplicati.Library.Modules.Builtin.Templates.{templateName}.hbs"; + + using var stream = assembly.GetManifestResourceStream(resourceName); + if (stream == null) + { + // Fall back to the default template if the requested one does not exist + var defaultResource = $"Duplicati.Library.Modules.Builtin.Templates.{DefaultTemplateName}.hbs"; + using var defaultStream = assembly.GetManifestResourceStream(defaultResource); + if (defaultStream == null) + return GetFallbackTemplate(); + + using var defaultReader = new StreamReader(defaultStream); + return defaultReader.ReadToEnd(); + } + + using var reader = new StreamReader(stream); + return reader.ReadToEnd(); + } + + /// + /// Returns a simple fallback template if no embedded templates are found + /// + private static string GetFallbackTemplate() + { + return @"Duplicati {{OperationName}} Report +====================================== + +Status: {{ParsedResult}} +Backup Name: {{BackupName}} +Machine: {{MachineName}} + +{{#if Data}} +{{#eachProperty Data}} +{{Key}}: {{Value}} +{{/eachProperty}} +{{/if}} + +{{#if LogLines}} +Log Messages: +{{#each LogLines}} + {{this}} +{{/each}} +{{/if}} + +{{#if Exception}} +Exception: {{Exception}} +{{/if}} +"; + } + + /// + /// Returns a list of available embedded template names + /// + public static IEnumerable GetAvailableTemplates() + { + var assembly = typeof(TemplateFormatSerializer).Assembly; + var prefix = "Duplicati.Library.Modules.Builtin.Templates."; + var suffix = ".hbs"; + + return assembly.GetManifestResourceNames() + .Where(n => n.StartsWith(prefix) && n.EndsWith(suffix)) + .Select(n => n.Substring(prefix.Length, n.Length - prefix.Length - suffix.Length)) + .OrderBy(n => n); + } + + /// + /// Serialize the specified result using the Handlebars template + /// + public string Serialize(object result, Exception exception, IEnumerable loglines, Dictionary additional) + { + var context = new Dictionary + { + ["Data"] = result, + ["Exception"] = exception?.ToString(), + ["LogLines"] = loglines?.ToList() ?? new List() + }; + + // Add additional parameters directly to the context + if (additional != null) + { + foreach (var kvp in additional) + { + context[kvp.Key] = kvp.Value; + } + } + + return _template(context); + } + + /// + /// Returns the format that the serializer represents + /// + public ResultExportFormat Format => ResultExportFormat.Template; + } +} diff --git a/Duplicati/Library/Modules/Builtin/Templates/default.hbs b/Duplicati/Library/Modules/Builtin/Templates/default.hbs new file mode 100644 index 000000000..7613a4ffe --- /dev/null +++ b/Duplicati/Library/Modules/Builtin/Templates/default.hbs @@ -0,0 +1,37 @@ +Duplicati {{OperationName}} Report +====================================== + +Status: {{ParsedResult}} +{{#if BackupName}}Backup: {{BackupName}}{{/if}} +{{#if MachineName}}Machine: {{MachineName}}{{/if}} +{{#if MachineId}}Machine ID: {{MachineId}}{{/if}} + +{{#if Data}} +Results: +-------- +{{#eachProperty Data}} + {{Key}}: {{Value}} +{{/eachProperty}} +{{/if}} + +{{#if Extra}} +Additional Information: +----------------------- +{{#eachProperty Extra}} + {{Key}}: {{Value}} +{{/eachProperty}} +{{/if}} + +{{#if LogLines}} +Log Messages: +------------- +{{#each LogLines}} +{{this}} +{{/each}} +{{/if}} + +{{#if Exception}} +Error Details: +-------------- +{{Exception}} +{{/if}} diff --git a/Duplicati/Library/Modules/Builtin/Templates/email.hbs b/Duplicati/Library/Modules/Builtin/Templates/email.hbs new file mode 100644 index 000000000..9d54fc016 --- /dev/null +++ b/Duplicati/Library/Modules/Builtin/Templates/email.hbs @@ -0,0 +1,52 @@ +Duplicati Backup Report: {{ParsedResult}} +========================================== + +{{#eq ParsedResult "Success"}} +Your backup completed successfully! +{{/eq}} +{{#eq ParsedResult "Warning"}} +Your backup completed with warnings. Please review the details below. +{{/eq}} +{{#eq ParsedResult "Error"}} +Your backup encountered errors. Please check the log messages for details. +{{/eq}} +{{#eq ParsedResult "Fatal"}} +Your backup failed with a fatal error. Immediate attention required. +{{/eq}} + +Backup Details +-------------- +Operation: {{OperationName}} +{{#if BackupName}}Backup Name: {{BackupName}}{{/if}} +{{#if MachineName}}Machine: {{MachineName}}{{/if}} +{{#if RemoteUrl}}Destination: {{RemoteUrl}}{{/if}} + +{{#if Data}} +Statistics +---------- +{{#if Data.ExaminedFiles}}Files Examined: {{Data.ExaminedFiles}}{{/if}} +{{#if Data.SizeOfExaminedFiles}}Data Examined: {{formatSize Data.SizeOfExaminedFiles}}{{/if}} +{{#if Data.AddedFiles}}Files Added: {{Data.AddedFiles}}{{/if}} +{{#if Data.SizeOfAddedFiles}}Data Added: {{formatSize Data.SizeOfAddedFiles}}{{/if}} +{{#if Data.ModifiedFiles}}Files Modified: {{Data.ModifiedFiles}}{{/if}} +{{#if Data.SizeOfModifiedFiles}}Data Modified: {{formatSize Data.SizeOfModifiedFiles}}{{/if}} +{{#if Data.DeletedFiles}}Files Deleted: {{Data.DeletedFiles}}{{/if}} +{{#if Data.Duration}}Duration: {{formatDuration Data.Duration}}{{/if}} +{{/if}} + +{{#if LogLines}} +Log Messages +------------ +{{#each LogLines}} +{{this}} +{{/each}} +{{/if}} + +{{#if Exception}} +Error Details +------------- +{{Exception}} +{{/if}} + +--- +This report was generated by Duplicati diff --git a/Duplicati/Library/Modules/Builtin/Templates/html.hbs b/Duplicati/Library/Modules/Builtin/Templates/html.hbs new file mode 100644 index 000000000..555d12f21 --- /dev/null +++ b/Duplicati/Library/Modules/Builtin/Templates/html.hbs @@ -0,0 +1,83 @@ + + + + + Duplicati Backup Report + + + +
+

Duplicati Backup Report

+ +

{{ParsedResult}}

+ +
+

Backup Details

+ + + {{#if BackupName}}{{/if}} + {{#if MachineName}}{{/if}} + {{#if OperatingSystem}}{{/if}} +
Operation{{OperationName}}
Backup Name{{BackupName}}
Machine{{MachineName}}
OS{{OperatingSystem}}
+
+ + {{#if Data}} +
+

Statistics

+ + {{#if Data.ExaminedFiles}}{{/if}} + {{#if Data.SizeOfExaminedFiles}}{{/if}} + {{#if Data.AddedFiles}}{{/if}} + {{#if Data.SizeOfAddedFiles}}{{/if}} + {{#if Data.ModifiedFiles}}{{/if}} + {{#if Data.SizeOfModifiedFiles}}{{/if}} + {{#if Data.DeletedFiles}}{{/if}} + {{#if Data.Duration}}{{/if}} +
Files Examined{{Data.ExaminedFiles}}
Data Examined{{formatSize Data.SizeOfExaminedFiles}}
Files Added{{Data.AddedFiles}}
Data Added{{formatSize Data.SizeOfAddedFiles}}
Files Modified{{Data.ModifiedFiles}}
Data Modified{{formatSize Data.SizeOfModifiedFiles}}
Files Deleted{{Data.DeletedFiles}}
Duration{{formatDuration Data.Duration}}
+
+ {{/if}} + + {{#if LogLines}} +
+

Log Messages

+
+ {{#each LogLines}} +

{{this}}

+ {{/each}} +
+
+ {{/if}} + + {{#if Exception}} +
+

Error Details

+
+
{{Exception}}
+
+
+ {{/if}} + + +
+ + diff --git a/Duplicati/Library/Modules/Builtin/Templates/slack.hbs b/Duplicati/Library/Modules/Builtin/Templates/slack.hbs new file mode 100644 index 000000000..8e5819ad6 --- /dev/null +++ b/Duplicati/Library/Modules/Builtin/Templates/slack.hbs @@ -0,0 +1,86 @@ +{ + "text": "Duplicati {{OperationName}} - {{ParsedResult}}", + "blocks": [ + { + "type": "header", + "text": { + "type": "plain_text", + "text": "Duplicati {{OperationName}} Report" + } + }, + { + "type": "section", + "fields": [ + { + "type": "mrkdwn", + "text": "*Status:*\n{{ParsedResult}}" + }, + { + "type": "mrkdwn", + "text": "*Backup:*\n{{BackupName}}" + }, + { + "type": "mrkdwn", + "text": "*Machine:*\n{{MachineName}}" + }, + { + "type": "mrkdwn", + "text": "*Operation:*\n{{OperationName}}" + } + ] + }{{#if Data}}, + { + "type": "divider" + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*Statistics*" + } + }, + { + "type": "section", + "fields": [ + {{#if Data.ExaminedFiles}} + { + "type": "mrkdwn", + "text": "*Files Examined:* {{Data.ExaminedFiles}}" + }, + {{/if}} + {{#if Data.AddedFiles}} + { + "type": "mrkdwn", + "text": "*Files Added:* {{Data.AddedFiles}}" + }, + {{/if}} + {{#if Data.ModifiedFiles}} + { + "type": "mrkdwn", + "text": "*Files Modified:* {{Data.ModifiedFiles}}" + }, + {{/if}} + {{#if Data.DeletedFiles}} + { + "type": "mrkdwn", + "text": "*Files Deleted:* {{Data.DeletedFiles}}" + }, + {{/if}} + { + "type": "mrkdwn", + "text": " " + } + ] + }{{/if}}{{#if Exception}}, + { + "type": "divider" + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*Error:* {{Exception}}" + } + }{{/if}} + ] +} diff --git a/Duplicati/UnitTest/TemplateSerializerTests.cs b/Duplicati/UnitTest/TemplateSerializerTests.cs new file mode 100644 index 000000000..5af547f8b --- /dev/null +++ b/Duplicati/UnitTest/TemplateSerializerTests.cs @@ -0,0 +1,205 @@ +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +using System; +using System.Collections.Generic; +using System.IO; +using Duplicati.Library.Modules.Builtin; +using Duplicati.Library.Modules.Builtin.ResultSerialization; +using NUnit.Framework; + +namespace Duplicati.UnitTest +{ + [TestFixture] + public class TemplateSerializerTests : BasicSetupHelper + { + [Test] + [Category("Serialization")] + public void TestGetSerializerGivenTemplateReturnsTemplateSerializer() + { + IResultFormatSerializer serializer = ResultFormatSerializerProvider.GetSerializer(ResultExportFormat.Template); + Assert.That(serializer, Is.InstanceOf()); + } + + [Test] + [Category("Serialization")] + public void TestTemplateSerializerFormat() + { + var serializer = new TemplateFormatSerializer(); + Assert.That(serializer.Format, Is.EqualTo(ResultExportFormat.Template)); + } + + [Test] + [Category("Serialization")] + public void TestTemplateSerializerWithSimpleTemplate() + { + var template = "Status: {{ParsedResult}}"; + var serializer = new TemplateFormatSerializer(template, false); + + var additional = new Dictionary + { + { "ParsedResult", "Success" } + }; + + var result = serializer.Serialize(null, null, null, additional); + Assert.That(result, Is.EqualTo("Status: Success")); + } + + [Test] + [Category("Serialization")] + public void TestTemplateSerializerWithLogLines() + { + var template = "{{#each LogLines}}{{this}}\n{{/each}}"; + var serializer = new TemplateFormatSerializer(template, false); + + var logLines = new List { "Line 1", "Line 2", "Line 3" }; + var result = serializer.Serialize(null, null, logLines, null); + + Assert.That(result, Does.Contain("Line 1")); + Assert.That(result, Does.Contain("Line 2")); + Assert.That(result, Does.Contain("Line 3")); + } + + [Test] + [Category("Serialization")] + public void TestTemplateSerializerWithException() + { + var template = "{{#if Exception}}Error: {{Exception}}{{/if}}"; + var serializer = new TemplateFormatSerializer(template, false); + + var exception = new Exception("Test error message"); + var result = serializer.Serialize(null, exception, null, null); + + Assert.That(result, Does.Contain("Error:")); + Assert.That(result, Does.Contain("Test error message")); + } + + [Test] + [Category("Serialization")] + public void TestTemplateSerializerFormatSizeHelper() + { + var template = "Size: {{formatSize Data.Size}}"; + var serializer = new TemplateFormatSerializer(template, false); + + var result = serializer.Serialize(new { Size = 1536 }, null, null, null); + Assert.That(result, Is.EqualTo("Size: 1.5 KB")); + } + + [Test] + [Category("Serialization")] + public void TestTemplateSerializerFormatDurationHelper() + { + var template = "Duration: {{formatDuration 125}}"; + var serializer = new TemplateFormatSerializer(template, false); + + var result = serializer.Serialize(null, null, null, null); + Assert.That(result, Is.EqualTo("Duration: 2m 5s")); + } + + [Test] + [Category("Serialization")] + public void TestTemplateSerializerStatusClassHelper() + { + var template = "Class: {{statusClass ParsedResult}}"; + var serializer = new TemplateFormatSerializer(template, false); + + var additional = new Dictionary { { "ParsedResult", "Warning" } }; + var result = serializer.Serialize(null, null, null, additional); + + Assert.That(result, Is.EqualTo("Class: warning")); + } + + [Test] + [Category("Serialization")] + public void TestTemplateSerializerEqHelper() + { + var template = "{{#eq ParsedResult \"Success\"}}OK{{else}}FAIL{{/eq}}"; + var serializer = new TemplateFormatSerializer(template, false); + + var additional = new Dictionary { { "ParsedResult", "Success" } }; + var result = serializer.Serialize(null, null, null, additional); + + Assert.That(result, Is.EqualTo("OK")); + } + + [Test] + [Category("Serialization")] + public void TestTemplateSerializerFromFile() + { + var tempFile = Path.GetTempFileName() + ".hbs"; + try + { + File.WriteAllText(tempFile, "Custom template: {{OperationName}}"); + var serializer = TemplateFormatSerializer.FromFile(tempFile); + + var additional = new Dictionary { { "OperationName", "Backup" } }; + var result = serializer.Serialize(null, null, null, additional); + + Assert.That(result, Is.EqualTo("Custom template: Backup")); + } + finally + { + if (File.Exists(tempFile)) + File.Delete(tempFile); + } + } + + [Test] + [Category("Serialization")] + public void TestTemplateSerializerFromFileMissing() + { + Assert.Throws(() => + TemplateFormatSerializer.FromFile("/nonexistent/template.hbs")); + } + + [Test] + [Category("Serialization")] + public void TestTemplateSerializerGetAvailableTemplates() + { + var templates = TemplateFormatSerializer.GetAvailableTemplates(); + Assert.That(templates, Is.Not.Null); + // At minimum, we should have the default template + Assert.That(templates, Does.Contain("default").Or.Empty); + } + + [Test] + [Category("Serialization")] + public void TestResultFormatSerializerProviderWithTemplateName() + { + // Test that we can get a serializer with a template name + var serializer = ResultFormatSerializerProvider.GetSerializer(ResultExportFormat.Template, "email"); + Assert.That(serializer, Is.InstanceOf()); + } + + [Test] + [Category("Serialization")] + public void TestTemplateWithDataObject() + { + var template = "Files: {{Data.ExaminedFiles}}, Added: {{Data.AddedFiles}}"; + var serializer = new TemplateFormatSerializer(template, false); + + var data = new { ExaminedFiles = 100, AddedFiles = 5 }; + var result = serializer.Serialize(data, null, null, null); + + Assert.That(result, Is.EqualTo("Files: 100, Added: 5")); + } + } +}