Merge pull request #6702 from AndyBodnar/feature/templating-engine-6280

Implement templating engine for reports
This commit is contained in:
Kenneth Skovhede
2026-01-20 10:52:57 +01:00
committed by GitHub
9 changed files with 895 additions and 17 deletions
@@ -13,6 +13,7 @@
<PackageReference Include="Artalk.Xmpp" Version="1.0.5" />
<PackageReference Include="DnsClient" Version="1.8.0" />
<PackageReference Include="Handlebars.Net" Version="2.1.6" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="MailKit" Version="4.12.1" />
<PackageReference Include="MimeKit" Version="4.12.0" />
@@ -39,4 +40,8 @@
</Content>
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Templates\**\*" />
</ItemGroup>
</Project>
@@ -24,6 +24,7 @@ namespace Duplicati.Library.Modules.Builtin
public enum ResultExportFormat
{
Duplicati,
Json
Json,
Template
}
}
@@ -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
/// </summary>
public static class ResultFormatSerializerProvider
{
public static IResultFormatSerializer GetSerializer(ResultExportFormat format) {
/// <summary>
/// Gets a serializer for the specified format
/// </summary>
/// <param name="format">The format to get a serializer for</param>
/// <returns>A serializer for the format</returns>
public static IResultFormatSerializer GetSerializer(ResultExportFormat format)
{
return GetSerializer(format, null);
}
/// <summary>
/// Gets a serializer for the specified format with optional template configuration
/// </summary>
/// <param name="format">The format to get a serializer for</param>
/// <param name="templatePathOrName">For Template format: path to custom template file, embedded template name, or null for default</param>
/// <returns>A serializer for the format</returns>
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");
}
}
/// <summary>
/// Gets a template serializer with the specified template configuration
/// </summary>
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);
}
}
}
@@ -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
{
/// <summary>
/// Implements serialization of results using Handlebars templates.
/// This allows users to fully customize the report output format.
/// </summary>
public class TemplateFormatSerializer : IResultFormatSerializer
{
/// <summary>
/// The Handlebars instance configured for report rendering
/// </summary>
private readonly IHandlebars _handlebars;
/// <summary>
/// The compiled template function
/// </summary>
private readonly HandlebarsTemplate<object, object> _template;
/// <summary>
/// The template source string (for debugging/logging)
/// </summary>
private readonly string _templateSource;
/// <summary>
/// Default template name to use when no custom template is provided
/// </summary>
public const string DefaultTemplateName = "default";
/// <summary>
/// Creates a new instance with the default embedded template
/// </summary>
public TemplateFormatSerializer()
: this(LoadEmbeddedTemplate(DefaultTemplateName), false)
{
}
/// <summary>
/// Creates a new instance with the specified template source or name
/// </summary>
/// <param name="templateSourceOrName">The Handlebars template source or template name</param>
/// <param name="isTemplateName">If true, templateSourceOrName is treated as a template name; if false, as template content</param>
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);
}
/// <summary>
/// Creates a serializer from a template file path
/// </summary>
/// <param name="filePath">Path to the template file</param>
/// <returns>A new TemplateFormatSerializer instance</returns>
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);
}
/// <summary>
/// Registers custom Handlebars helpers for report formatting
/// </summary>
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<string, object> dict)
{
foreach (var kvp in dict)
{
options.Template(output, new { Key = kvp.Key, Value = kvp.Value });
}
}
else if (obj is IDictionary<string, string> 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";
}
/// <summary>
/// Loads an embedded template by name
/// </summary>
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();
}
/// <summary>
/// Returns a simple fallback template if no embedded templates are found
/// </summary>
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}}
";
}
/// <summary>
/// Returns a list of available embedded template names
/// </summary>
public static IEnumerable<string> 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);
}
/// <summary>
/// Serialize the specified result using the Handlebars template
/// </summary>
public string Serialize(object result, Exception exception, IEnumerable<string> loglines, Dictionary<string, string> additional)
{
var context = new Dictionary<string, object>
{
["Data"] = result,
["Exception"] = exception?.ToString(),
["LogLines"] = loglines?.ToList() ?? new List<string>()
};
// Add additional parameters directly to the context
if (additional != null)
{
foreach (var kvp in additional)
{
context[kvp.Key] = kvp.Value;
}
}
return _template(context);
}
/// <summary>
/// Returns the format that the serializer represents
/// </summary>
public ResultExportFormat Format => ResultExportFormat.Template;
}
}
@@ -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}}
@@ -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
@@ -0,0 +1,83 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Duplicati Backup Report</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif; line-height: 1.6; max-width: 800px; margin: 0 auto; padding: 20px; background: #f5f5f5; }
.container { background: white; border-radius: 8px; padding: 30px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
h1 { margin-top: 0; color: #333; }
.status { display: inline-block; padding: 8px 16px; border-radius: 4px; font-weight: bold; text-transform: uppercase; }
.status.success { background: #d4edda; color: #155724; }
.status.warning { background: #fff3cd; color: #856404; }
.status.error { background: #f8d7da; color: #721c24; }
.status.fatal { background: #721c24; color: white; }
.section { margin: 20px 0; padding: 15px; background: #f8f9fa; border-radius: 4px; }
.section h2 { margin-top: 0; font-size: 1.1em; color: #495057; }
table { width: 100%; border-collapse: collapse; }
td { padding: 8px 0; border-bottom: 1px solid #eee; }
td:first-child { font-weight: 500; color: #666; width: 40%; }
.logs { background: #2d2d2d; color: #f8f8f2; padding: 15px; border-radius: 4px; font-family: monospace; font-size: 13px; overflow-x: auto; max-height: 400px; overflow-y: auto; }
.logs p { margin: 4px 0; }
.error-box { background: #f8d7da; border: 1px solid #f5c6cb; padding: 15px; border-radius: 4px; color: #721c24; }
.footer { margin-top: 20px; padding-top: 20px; border-top: 1px solid #eee; color: #888; font-size: 0.9em; }
</style>
</head>
<body>
<div class="container">
<h1>Duplicati Backup Report</h1>
<p><span class="status {{statusClass ParsedResult}}">{{ParsedResult}}</span></p>
<div class="section">
<h2>Backup Details</h2>
<table>
<tr><td>Operation</td><td>{{OperationName}}</td></tr>
{{#if BackupName}}<tr><td>Backup Name</td><td>{{BackupName}}</td></tr>{{/if}}
{{#if MachineName}}<tr><td>Machine</td><td>{{MachineName}}</td></tr>{{/if}}
{{#if OperatingSystem}}<tr><td>OS</td><td>{{OperatingSystem}}</td></tr>{{/if}}
</table>
</div>
{{#if Data}}
<div class="section">
<h2>Statistics</h2>
<table>
{{#if Data.ExaminedFiles}}<tr><td>Files Examined</td><td>{{Data.ExaminedFiles}}</td></tr>{{/if}}
{{#if Data.SizeOfExaminedFiles}}<tr><td>Data Examined</td><td>{{formatSize Data.SizeOfExaminedFiles}}</td></tr>{{/if}}
{{#if Data.AddedFiles}}<tr><td>Files Added</td><td>{{Data.AddedFiles}}</td></tr>{{/if}}
{{#if Data.SizeOfAddedFiles}}<tr><td>Data Added</td><td>{{formatSize Data.SizeOfAddedFiles}}</td></tr>{{/if}}
{{#if Data.ModifiedFiles}}<tr><td>Files Modified</td><td>{{Data.ModifiedFiles}}</td></tr>{{/if}}
{{#if Data.SizeOfModifiedFiles}}<tr><td>Data Modified</td><td>{{formatSize Data.SizeOfModifiedFiles}}</td></tr>{{/if}}
{{#if Data.DeletedFiles}}<tr><td>Files Deleted</td><td>{{Data.DeletedFiles}}</td></tr>{{/if}}
{{#if Data.Duration}}<tr><td>Duration</td><td>{{formatDuration Data.Duration}}</td></tr>{{/if}}
</table>
</div>
{{/if}}
{{#if LogLines}}
<div class="section">
<h2>Log Messages</h2>
<div class="logs">
{{#each LogLines}}
<p>{{this}}</p>
{{/each}}
</div>
</div>
{{/if}}
{{#if Exception}}
<div class="section">
<h2>Error Details</h2>
<div class="error-box">
<pre>{{Exception}}</pre>
</div>
</div>
{{/if}}
<div class="footer">
Generated by Duplicati
</div>
</div>
</body>
</html>
@@ -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}}
]
}
@@ -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<TemplateFormatSerializer>());
}
[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<string, string>
{
{ "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<string> { "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<string, string> { { "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<string, string> { { "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<string, string> { { "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<FileNotFoundException>(() =>
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<TemplateFormatSerializer>());
}
[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"));
}
}
}