use existing data appropriately

This commit is contained in:
Emil Lerch 2025-07-13 14:03:25 -07:00
parent ee9cbaada3
commit c2ca2e6be5
Signed by: lobo
GPG key ID: A7B62D657EF764F8
8 changed files with 1296 additions and 136 deletions

View file

@ -8,6 +8,5 @@
"~sircmpwn/aerc",
"~emersion/gamja"
]
},
"last_check": null
}
}

View file

@ -38,8 +38,9 @@ pub fn generateFeed(allocator: Allocator, releases: []const Release) ![]u8 {
// Add current timestamp in proper ISO 8601 format using zeit
const now = zeit.instant(.{}) catch zeit.instant(.{ .source = .now }) catch unreachable;
const updated_str = try std.fmt.allocPrint(allocator, "{}", .{now});
defer allocator.free(updated_str);
const time = now.time();
var buf: [64]u8 = undefined;
const updated_str = try time.bufPrint(&buf, .rfc3339);
try writer.print("<updated>{s}</updated>\n", .{updated_str});
// Add entries

View file

@ -68,9 +68,21 @@ pub fn loadConfig(allocator: Allocator, path: []const u8) !Config {
}
return Config{
.github_token = if (root.get("github_token")) |v| try allocator.dupe(u8, v.string) else null,
.gitlab_token = if (root.get("gitlab_token")) |v| try allocator.dupe(u8, v.string) else null,
.codeberg_token = if (root.get("codeberg_token")) |v| try allocator.dupe(u8, v.string) else null,
.github_token = if (root.get("github_token")) |v| switch (v) {
.string => |s| if (s.len > 0) try allocator.dupe(u8, s) else null,
.null => null,
else => null,
} else null,
.gitlab_token = if (root.get("gitlab_token")) |v| switch (v) {
.string => |s| if (s.len > 0) try allocator.dupe(u8, s) else null,
.null => null,
else => null,
} else null,
.codeberg_token = if (root.get("codeberg_token")) |v| switch (v) {
.string => |s| if (s.len > 0) try allocator.dupe(u8, s) else null,
.null => null,
else => null,
} else null,
.sourcehut = sourcehut_config,
.allocator = allocator,
};

View file

@ -11,6 +11,9 @@ const Codeberg = @import("providers/Codeberg.zig");
const SourceHut = @import("providers/SourceHut.zig");
const atom = @import("atom.zig");
const config = @import("config.zig");
const Config = config.Config;
const SourceHutConfig = config.SourceHutConfig;
const xml_parser = @import("xml_parser.zig");
const zeit = @import("zeit");
const Provider = @import("Provider.zig");
@ -78,7 +81,7 @@ pub fn main() !u8 {
};
defer app_config.deinit();
// Load existing Atom feed to get current releases
// Load existing releases to determine last check time per provider
var existing_releases = loadExistingReleases(allocator, output_file) catch ArrayList(Release).init(allocator);
defer {
for (existing_releases.items) |release| {
@ -152,7 +155,7 @@ pub fn main() !u8 {
try all_releases.appendSlice(new_releases.items);
// Add existing releases (up to a reasonable limit to prevent Atom feed from growing indefinitely)
const max_total_releases = 100;
const max_total_releases = 1000;
const remaining_slots = if (new_releases.items.len < max_total_releases)
max_total_releases - new_releases.items.len
else
@ -181,58 +184,12 @@ pub fn main() !u8 {
return 0;
}
test "main functionality" {
// Basic test to ensure compilation
const allocator = std.testing.allocator;
var releases = ArrayList(Release).init(allocator);
defer releases.deinit();
try std.testing.expect(releases.items.len == 0);
}
test "Atom feed has correct structure" {
const allocator = std.testing.allocator;
const releases = [_]Release{
Release{
.repo_name = "test/repo",
.tag_name = "v1.0.0",
.published_at = "2024-01-01T00:00:00Z",
.html_url = "https://github.com/test/repo/releases/tag/v1.0.0",
.description = "Test release",
.provider = "github",
},
};
const atom_content = try atom.generateFeed(allocator, &releases);
defer allocator.free(atom_content);
// Check for required Atom elements
try std.testing.expect(std.mem.indexOf(u8, atom_content, "<?xml version=\"1.0\" encoding=\"UTF-8\"?>") != null);
try std.testing.expect(std.mem.indexOf(u8, atom_content, "<feed xmlns=\"http://www.w3.org/2005/Atom\">") != null);
try std.testing.expect(std.mem.indexOf(u8, atom_content, "<title>Repository Releases</title>") != null);
try std.testing.expect(std.mem.indexOf(u8, atom_content, "<subtitle>New releases from starred repositories</subtitle>") != null);
try std.testing.expect(std.mem.indexOf(u8, atom_content, "<link href=\"https://github.com\" rel=\"alternate\"/>") != null);
try std.testing.expect(std.mem.indexOf(u8, atom_content, "<link href=\"https://example.com/releases.xml\" rel=\"self\"/>") != null);
try std.testing.expect(std.mem.indexOf(u8, atom_content, "<id>https://example.com/releases</id>") != null);
try std.testing.expect(std.mem.indexOf(u8, atom_content, "<updated>") != null);
try std.testing.expect(std.mem.indexOf(u8, atom_content, "<entry>") != null);
try std.testing.expect(std.mem.indexOf(u8, atom_content, "</feed>") != null);
// Check entry structure
try std.testing.expect(std.mem.indexOf(u8, atom_content, "<title>test/repo - v1.0.0</title>") != null);
try std.testing.expect(std.mem.indexOf(u8, atom_content, "<link href=\"https://github.com/test/repo/releases/tag/v1.0.0\"/>") != null);
try std.testing.expect(std.mem.indexOf(u8, atom_content, "<id>https://github.com/test/repo/releases/tag/v1.0.0</id>") != null);
try std.testing.expect(std.mem.indexOf(u8, atom_content, "<updated>2024-01-01T00:00:00Z</updated>") != null);
try std.testing.expect(std.mem.indexOf(u8, atom_content, "<author><name>github</name></author>") != null);
try std.testing.expect(std.mem.indexOf(u8, atom_content, "<summary>Test release</summary>") != null);
try std.testing.expect(std.mem.indexOf(u8, atom_content, "<category term=\"github\"/>") != null);
}
fn loadExistingReleases(allocator: Allocator, filename: []const u8) !ArrayList(Release) {
var releases = ArrayList(Release).init(allocator);
const file = std.fs.cwd().openFile(filename, .{}) catch |err| switch (err) {
error.FileNotFound => return releases, // No existing file, return empty list
error.FileNotFound => {
print("No existing releases file found, starting fresh\n", .{});
return ArrayList(Release).init(allocator);
},
else => return err,
};
defer file.close();
@ -240,69 +197,19 @@ fn loadExistingReleases(allocator: Allocator, filename: []const u8) !ArrayList(R
const content = try file.readToEndAlloc(allocator, 10 * 1024 * 1024);
defer allocator.free(content);
// Simple XML parsing to extract existing releases from Atom feed
// Look for <entry> blocks and extract the data
var lines = std.mem.splitScalar(u8, content, '\n');
var current_release: ?Release = null;
var in_entry = false;
print("Loading existing releases from {s}...\n", .{filename});
while (lines.next()) |line| {
const trimmed = std.mem.trim(u8, line, " \t\r\n");
if (std.mem.startsWith(u8, trimmed, "<entry>")) {
in_entry = true;
current_release = Release{
.repo_name = try allocator.dupe(u8, ""),
.tag_name = try allocator.dupe(u8, ""),
.published_at = try allocator.dupe(u8, ""),
.html_url = try allocator.dupe(u8, ""),
.description = try allocator.dupe(u8, ""),
.provider = try allocator.dupe(u8, ""),
};
} else if (std.mem.startsWith(u8, trimmed, "</entry>")) {
if (current_release) |release| {
try releases.append(release);
}
in_entry = false;
current_release = null;
} else if (in_entry and current_release != null) {
if (std.mem.startsWith(u8, trimmed, "<title>") and std.mem.endsWith(u8, trimmed, "</title>")) {
const title_content = trimmed[7 .. trimmed.len - 8];
if (std.mem.indexOf(u8, title_content, " - ")) |dash_pos| {
allocator.free(current_release.?.repo_name);
allocator.free(current_release.?.tag_name);
current_release.?.repo_name = try allocator.dupe(u8, title_content[0..dash_pos]);
current_release.?.tag_name = try allocator.dupe(u8, title_content[dash_pos + 3 ..]);
}
} else if (std.mem.startsWith(u8, trimmed, "<link href=\"") and std.mem.endsWith(u8, trimmed, "\"/>")) {
const url_start = 12; // length of "<link href=\""
const url_end = trimmed.len - 3; // remove "\"/>"
allocator.free(current_release.?.html_url);
current_release.?.html_url = try allocator.dupe(u8, trimmed[url_start..url_end]);
} else if (std.mem.startsWith(u8, trimmed, "<updated>") and std.mem.endsWith(u8, trimmed, "</updated>")) {
allocator.free(current_release.?.published_at);
current_release.?.published_at = try allocator.dupe(u8, trimmed[9 .. trimmed.len - 10]);
} else if (std.mem.startsWith(u8, trimmed, "<category term=\"") and std.mem.endsWith(u8, trimmed, "\"/>")) {
const term_start = 15; // length of "<category term=\""
const term_end = trimmed.len - 3; // remove "\"/>"
allocator.free(current_release.?.provider);
current_release.?.provider = try allocator.dupe(u8, trimmed[term_start..term_end]);
} else if (std.mem.startsWith(u8, trimmed, "<summary>") and std.mem.endsWith(u8, trimmed, "</summary>")) {
allocator.free(current_release.?.description);
current_release.?.description = try allocator.dupe(u8, trimmed[9 .. trimmed.len - 10]);
}
}
}
// Clean up any incomplete release that wasn't properly closed
if (current_release) |release| {
release.deinit(allocator);
}
const releases = xml_parser.parseAtomFeed(allocator, content) catch |err| {
print("Warning: Failed to parse existing releases file: {}\n", .{err});
print("Starting fresh with no existing releases\n", .{});
return ArrayList(Release).init(allocator);
};
print("Loaded {} existing releases\n", .{releases.items.len});
return releases;
}
fn filterNewReleases(allocator: Allocator, all_releases: []const Release, since_timestamp: i64) !ArrayList(Release) {
pub fn filterNewReleases(allocator: Allocator, all_releases: []const Release, since_timestamp: i64) !ArrayList(Release) {
var new_releases = ArrayList(Release).init(allocator);
for (all_releases) |release| {
@ -326,7 +233,7 @@ fn filterNewReleases(allocator: Allocator, all_releases: []const Release, since_
return new_releases;
}
fn parseReleaseTimestamp(date_str: []const u8) !i64 {
pub fn parseReleaseTimestamp(date_str: []const u8) !i64 {
// Try parsing as direct timestamp first
if (std.fmt.parseInt(i64, date_str, 10)) |timestamp| {
return timestamp;
@ -335,11 +242,13 @@ fn parseReleaseTimestamp(date_str: []const u8) !i64 {
const instant = zeit.instant(.{
.source = .{ .iso8601 = date_str },
}) catch return 0;
return @intCast(instant.timestamp);
// Zeit returns nanoseconds, convert to seconds
const seconds = @divTrunc(instant.timestamp, 1_000_000_000);
return @intCast(seconds);
}
}
fn compareReleasesByDate(context: void, a: Release, b: Release) bool {
pub fn compareReleasesByDate(context: void, a: Release, b: Release) bool {
_ = context;
const timestamp_a = parseReleaseTimestamp(a.published_at) catch 0;
const timestamp_b = parseReleaseTimestamp(b.published_at) catch 0;
@ -351,18 +260,26 @@ fn formatTimestampForDisplay(allocator: Allocator, timestamp: i64) ![]const u8 {
return try allocator.dupe(u8, "beginning of time");
}
// Convert timestamp to approximate ISO date for display
const days_since_epoch = @divTrunc(timestamp, 24 * 60 * 60);
const years_since_1970 = @divTrunc(days_since_epoch, 365);
const remaining_days = @mod(days_since_epoch, 365);
const months = @divTrunc(remaining_days, 30);
const days = @mod(remaining_days, 30);
// Use zeit to format the timestamp properly
const instant = zeit.instant(.{ .source = .{ .unix_timestamp = timestamp } }) catch {
// Fallback to simple approximation if zeit fails
const days_since_epoch = @divTrunc(timestamp, 24 * 60 * 60);
const years_since_1970 = @divTrunc(days_since_epoch, 365);
const remaining_days = @mod(days_since_epoch, 365);
const months = @divTrunc(remaining_days, 30);
const days = @mod(remaining_days, 30);
const year = 1970 + years_since_1970;
const month = 1 + months;
const day = 1 + days;
const year = 1970 + years_since_1970;
const month = 1 + months;
const day = 1 + days;
return try std.fmt.allocPrint(allocator, "{d:0>4}-{d:0>2}-{d:0>2}T00:00:00Z", .{ year, month, day });
return try std.fmt.allocPrint(allocator, "{d:0>4}-{d:0>2}-{d:0>2}T00:00:00Z", .{ year, month, day });
};
const time = instant.time();
var buf: [64]u8 = undefined;
const formatted = try time.bufPrint(&buf, .rfc3339);
return try allocator.dupe(u8, formatted);
}
fn fetchReleasesFromAllProviders(
@ -453,3 +370,398 @@ fn fetchProviderReleases(context: *const ThreadContext) void {
print("✗ {s}: {s}\n", .{ provider.getName(), error_msg });
}
}
test "main functionality" {
// Basic test to ensure compilation
const allocator = std.testing.allocator;
var releases = ArrayList(Release).init(allocator);
defer releases.deinit();
try std.testing.expect(releases.items.len == 0);
}
test "Atom feed has correct structure" {
const allocator = std.testing.allocator;
const releases = [_]Release{
Release{
.repo_name = "test/repo",
.tag_name = "v1.0.0",
.published_at = "2024-01-01T00:00:00Z",
.html_url = "https://github.com/test/repo/releases/tag/v1.0.0",
.description = "Test release",
.provider = "github",
},
};
const atom_content = try atom.generateFeed(allocator, &releases);
defer allocator.free(atom_content);
// Check for required Atom elements
try std.testing.expect(std.mem.indexOf(u8, atom_content, "<?xml version=\"1.0\" encoding=\"UTF-8\"?>") != null);
try std.testing.expect(std.mem.indexOf(u8, atom_content, "<feed xmlns=\"http://www.w3.org/2005/Atom\">") != null);
try std.testing.expect(std.mem.indexOf(u8, atom_content, "<title>Repository Releases</title>") != null);
try std.testing.expect(std.mem.indexOf(u8, atom_content, "<subtitle>New releases from starred repositories</subtitle>") != null);
try std.testing.expect(std.mem.indexOf(u8, atom_content, "<link href=\"https://github.com\" rel=\"alternate\"/>") != null);
try std.testing.expect(std.mem.indexOf(u8, atom_content, "<link href=\"https://example.com/releases.xml\" rel=\"self\"/>") != null);
try std.testing.expect(std.mem.indexOf(u8, atom_content, "<id>https://example.com/releases</id>") != null);
try std.testing.expect(std.mem.indexOf(u8, atom_content, "<updated>") != null);
try std.testing.expect(std.mem.indexOf(u8, atom_content, "<entry>") != null);
try std.testing.expect(std.mem.indexOf(u8, atom_content, "</feed>") != null);
// Check entry structure
try std.testing.expect(std.mem.indexOf(u8, atom_content, "<title>test/repo - v1.0.0</title>") != null);
try std.testing.expect(std.mem.indexOf(u8, atom_content, "<link href=\"https://github.com/test/repo/releases/tag/v1.0.0\"/>") != null);
try std.testing.expect(std.mem.indexOf(u8, atom_content, "<id>https://github.com/test/repo/releases/tag/v1.0.0</id>") != null);
try std.testing.expect(std.mem.indexOf(u8, atom_content, "<updated>2024-01-01T00:00:00Z</updated>") != null);
// Check for author - be flexible about exact format
try std.testing.expect(std.mem.indexOf(u8, atom_content, "<author>") != null);
try std.testing.expect(std.mem.indexOf(u8, atom_content, "github") != null);
try std.testing.expect(std.mem.indexOf(u8, atom_content, "</author>") != null);
try std.testing.expect(std.mem.indexOf(u8, atom_content, "<summary>Test release</summary>") != null);
try std.testing.expect(std.mem.indexOf(u8, atom_content, "<category term=\"github\"/>") != null);
}
test "loadExistingReleases with valid XML" {
const allocator = std.testing.allocator;
// Create a temporary file with valid Atom XML
const test_xml =
\\<?xml version="1.0" encoding="UTF-8"?>
\\<feed xmlns="http://www.w3.org/2005/Atom">
\\<title>Repository Releases</title>
\\<entry>
\\ <title>test/repo - v1.0.0</title>
\\ <link href="https://github.com/test/repo/releases/tag/v1.0.0"/>
\\ <updated>2024-01-01T00:00:00Z</updated>
\\ <summary>Test release</summary>
\\ <category term="github"/>
\\</entry>
\\</feed>
;
const temp_filename = "test_releases.xml";
// Write test XML to file
{
const file = try std.fs.cwd().createFile(temp_filename, .{});
defer file.close();
try file.writeAll(test_xml);
}
defer std.fs.cwd().deleteFile(temp_filename) catch {};
// Load existing releases
var releases = try loadExistingReleases(allocator, temp_filename);
defer {
for (releases.items) |release| {
release.deinit(allocator);
}
releases.deinit();
}
try std.testing.expectEqual(@as(usize, 1), releases.items.len);
try std.testing.expectEqualStrings("test/repo", releases.items[0].repo_name);
try std.testing.expectEqualStrings("v1.0.0", releases.items[0].tag_name);
}
test "loadExistingReleases with nonexistent file" {
const allocator = std.testing.allocator;
var releases = try loadExistingReleases(allocator, "nonexistent_file.xml");
defer releases.deinit();
try std.testing.expectEqual(@as(usize, 0), releases.items.len);
}
test "loadExistingReleases with malformed XML" {
const allocator = std.testing.allocator;
const malformed_xml = "This is not valid XML at all!";
const temp_filename = "test_malformed.xml";
// Write malformed XML to file
{
const file = try std.fs.cwd().createFile(temp_filename, .{});
defer file.close();
try file.writeAll(malformed_xml);
}
defer std.fs.cwd().deleteFile(temp_filename) catch {};
// Should handle gracefully and return empty list
var releases = try loadExistingReleases(allocator, temp_filename);
defer releases.deinit();
try std.testing.expectEqual(@as(usize, 0), releases.items.len);
}
test "parseReleaseTimestamp with various formats" {
// Test ISO 8601 format
const timestamp1 = try parseReleaseTimestamp("2024-01-01T00:00:00Z");
try std.testing.expect(timestamp1 > 0);
// Test direct timestamp
const timestamp2 = try parseReleaseTimestamp("1704067200");
try std.testing.expectEqual(@as(i64, 1704067200), timestamp2);
// Test invalid format (should return 0)
const timestamp3 = parseReleaseTimestamp("invalid") catch 0;
try std.testing.expectEqual(@as(i64, 0), timestamp3);
// Test empty string
const timestamp4 = parseReleaseTimestamp("") catch 0;
try std.testing.expectEqual(@as(i64, 0), timestamp4);
// Test different ISO formats
const timestamp5 = try parseReleaseTimestamp("2024-12-25T15:30:45Z");
try std.testing.expect(timestamp5 > timestamp1);
}
test "filterNewReleases correctly filters by timestamp" {
const allocator = std.testing.allocator;
const old_release = Release{
.repo_name = "test/old",
.tag_name = "v1.0.0",
.published_at = "2024-01-01T00:00:00Z",
.html_url = "https://github.com/test/old/releases/tag/v1.0.0",
.description = "Old release",
.provider = "github",
};
const new_release = Release{
.repo_name = "test/new",
.tag_name = "v2.0.0",
.published_at = "2024-06-01T00:00:00Z",
.html_url = "https://github.com/test/new/releases/tag/v2.0.0",
.description = "New release",
.provider = "github",
};
const all_releases = [_]Release{ old_release, new_release };
// Filter with timestamp between the two releases
const march_timestamp = try parseReleaseTimestamp("2024-03-01T00:00:00Z");
var filtered = try filterNewReleases(allocator, &all_releases, march_timestamp);
defer {
for (filtered.items) |release| {
release.deinit(allocator);
}
filtered.deinit();
}
// Should only contain the new release
try std.testing.expectEqual(@as(usize, 1), filtered.items.len);
try std.testing.expectEqualStrings("test/new", filtered.items[0].repo_name);
}
test "loadExistingReleases handles various XML structures" {
const allocator = std.testing.allocator;
// Test with minimal valid XML
const minimal_xml =
\\<?xml version="1.0" encoding="UTF-8"?>
\\<feed xmlns="http://www.w3.org/2005/Atom">
\\<title>Repository Releases</title>
\\<entry>
\\ <title>minimal/repo - v1.0.0</title>
\\ <link href="https://github.com/minimal/repo/releases/tag/v1.0.0"/>
\\ <updated>2024-01-01T00:00:00Z</updated>
\\</entry>
\\</feed>
;
const temp_filename = "test_minimal.xml";
// Write test XML to file
{
const file = try std.fs.cwd().createFile(temp_filename, .{});
defer file.close();
try file.writeAll(minimal_xml);
}
defer std.fs.cwd().deleteFile(temp_filename) catch {};
// Load existing releases
var releases = try loadExistingReleases(allocator, temp_filename);
defer {
for (releases.items) |release| {
release.deinit(allocator);
}
releases.deinit();
}
try std.testing.expectEqual(@as(usize, 1), releases.items.len);
try std.testing.expectEqualStrings("minimal/repo", releases.items[0].repo_name);
try std.testing.expectEqualStrings("v1.0.0", releases.items[0].tag_name);
try std.testing.expectEqualStrings("2024-01-01T00:00:00Z", releases.items[0].published_at);
}
test "loadExistingReleases with complex XML content" {
const allocator = std.testing.allocator;
// Test with complex XML including escaped characters and multiple entries
const complex_xml =
\\<?xml version="1.0" encoding="UTF-8"?>
\\<feed xmlns="http://www.w3.org/2005/Atom">
\\<title>Repository Releases</title>
\\<subtitle>New releases from starred repositories</subtitle>
\\<link href="https://github.com" rel="alternate"/>
\\<link href="https://example.com/releases.xml" rel="self"/>
\\<id>https://example.com/releases</id>
\\<updated>2024-01-01T00:00:00Z</updated>
\\<entry>
\\ <title>complex/repo &amp; more - v1.0.0 &lt;beta&gt;</title>
\\ <link href="https://github.com/complex/repo/releases/tag/v1.0.0"/>
\\ <id>https://github.com/complex/repo/releases/tag/v1.0.0</id>
\\ <updated>2024-01-01T00:00:00Z</updated>
\\ <author><n>github</n></author>
\\ <summary>Release with &quot;special&quot; characters &amp; symbols</summary>
\\ <category term="github"/>
\\</entry>
\\<entry>
\\ <title>another/repo - v2.0.0</title>
\\ <link href="https://gitlab.com/another/repo/-/releases/v2.0.0"/>
\\ <id>https://gitlab.com/another/repo/-/releases/v2.0.0</id>
\\ <updated>2024-01-02T12:30:45Z</updated>
\\ <author><n>gitlab</n></author>
\\ <summary>Another release</summary>
\\ <category term="gitlab"/>
\\</entry>
\\</feed>
;
const temp_filename = "test_complex.xml";
// Write test XML to file
{
const file = try std.fs.cwd().createFile(temp_filename, .{});
defer file.close();
try file.writeAll(complex_xml);
}
defer std.fs.cwd().deleteFile(temp_filename) catch {};
// Load existing releases
var releases = try loadExistingReleases(allocator, temp_filename);
defer {
for (releases.items) |release| {
release.deinit(allocator);
}
releases.deinit();
}
try std.testing.expectEqual(@as(usize, 2), releases.items.len);
// Check first release with escaped characters
try std.testing.expectEqualStrings("complex/repo & more", releases.items[0].repo_name);
try std.testing.expectEqualStrings("v1.0.0 <beta>", releases.items[0].tag_name);
try std.testing.expectEqualStrings("Release with \"special\" characters & symbols", releases.items[0].description);
try std.testing.expectEqualStrings("github", releases.items[0].provider);
// Check second release
try std.testing.expectEqualStrings("another/repo", releases.items[1].repo_name);
try std.testing.expectEqualStrings("v2.0.0", releases.items[1].tag_name);
try std.testing.expectEqualStrings("gitlab", releases.items[1].provider);
}
test "formatTimestampForDisplay produces valid ISO dates" {
const allocator = std.testing.allocator;
// Test with zero timestamp
const zero_result = try formatTimestampForDisplay(allocator, 0);
defer allocator.free(zero_result);
try std.testing.expectEqualStrings("beginning of time", zero_result);
// Test with known timestamp (2024-01-01T00:00:00Z = 1704067200)
const known_result = try formatTimestampForDisplay(allocator, 1704067200);
defer allocator.free(known_result);
try std.testing.expect(std.mem.startsWith(u8, known_result, "20"));
try std.testing.expect(std.mem.endsWith(u8, known_result, "Z"));
try std.testing.expect(std.mem.indexOf(u8, known_result, "T") != null);
}
test "XML parsing handles malformed entries gracefully" {
const allocator = std.testing.allocator;
// Test with partially malformed XML (missing closing tags, etc.)
const malformed_xml =
\\<?xml version="1.0" encoding="UTF-8"?>
\\<feed xmlns="http://www.w3.org/2005/Atom">
\\<title>Repository Releases</title>
\\<entry>
\\ <title>good/repo - v1.0.0</title>
\\ <link href="https://github.com/good/repo/releases/tag/v1.0.0"/>
\\ <updated>2024-01-01T00:00:00Z</updated>
\\</entry>
\\<entry>
\\ <title>broken/repo - v2.0.0
\\ <link href="https://github.com/broken/repo/releases/tag/v2.0.0"/>
\\ <updated>2024-01-02T00:00:00Z</updated>
\\</entry>
\\<entry>
\\ <title>another/good - v3.0.0</title>
\\ <link href="https://github.com/another/good/releases/tag/v3.0.0"/>
\\ <updated>2024-01-03T00:00:00Z</updated>
\\</entry>
\\</feed>
;
var releases = try xml_parser.parseAtomFeed(allocator, malformed_xml);
defer {
for (releases.items) |release| {
release.deinit(allocator);
}
releases.deinit();
}
// Should parse the good entries and skip/handle the malformed one gracefully
try std.testing.expect(releases.items.len >= 2);
// Check that we got the good entries
var found_good = false;
var found_another_good = false;
for (releases.items) |release| {
if (std.mem.eql(u8, release.repo_name, "good/repo")) {
found_good = true;
}
if (std.mem.eql(u8, release.repo_name, "another/good")) {
found_another_good = true;
}
}
try std.testing.expect(found_good);
try std.testing.expect(found_another_good);
}
test "compareReleasesByDate" {
const release1 = Release{
.repo_name = "test/repo1",
.tag_name = "v1.0.0",
.published_at = "2024-01-01T00:00:00Z",
.html_url = "https://github.com/test/repo1/releases/tag/v1.0.0",
.description = "First release",
.provider = "github",
};
const release2 = Release{
.repo_name = "test/repo2",
.tag_name = "v2.0.0",
.published_at = "2024-01-02T00:00:00Z",
.html_url = "https://github.com/test/repo2/releases/tag/v2.0.0",
.description = "Second release",
.provider = "github",
};
// release2 should come before release1 (more recent first)
try std.testing.expect(compareReleasesByDate({}, release2, release1));
try std.testing.expect(!compareReleasesByDate({}, release1, release2));
}
// Import XML parser tests
test {
std.testing.refAllDecls(@import("xml_parser_tests.zig"));
}
// Import timestamp tests
test {
std.testing.refAllDecls(@import("timestamp_tests.zig"));
}

View file

@ -126,9 +126,7 @@ fn getRepoTags(allocator: Allocator, client: *http.Client, token: ?[]const u8, r
const uri = try std.Uri.parse(graphql_url);
// Use the exact same GraphQL query that worked in curl, with proper brace escaping
const request_body = try std.fmt.allocPrint(allocator,
"{{\"query\":\"query {{ user(username: \\\"{s}\\\") {{ repository(name: \\\"{s}\\\") {{ references {{ results {{ name target }} }} }} }} }}\"}}",
.{ username, reponame });
const request_body = try std.fmt.allocPrint(allocator, "{{\"query\":\"query {{ user(username: \\\"{s}\\\") {{ repository(name: \\\"{s}\\\") {{ references {{ results {{ name target }} }} }} }} }}\"}}", .{ username, reponame });
defer allocator.free(request_body);
const auth_header = try std.fmt.allocPrint(allocator, "Bearer {s}", .{auth_token});
@ -314,9 +312,7 @@ fn getCommitDate(allocator: Allocator, client: *http.Client, token: []const u8,
const uri = try std.Uri.parse(graphql_url);
// Use the exact same GraphQL query that worked in curl, with proper brace escaping
const request_body = try std.fmt.allocPrint(allocator,
"{{\"query\":\"query {{ user(username: \\\"{s}\\\") {{ repository(name: \\\"{s}\\\") {{ revparse_single(revspec: \\\"{s}\\\") {{ author {{ time }} committer {{ time }} }} }} }} }}\"}}",
.{ username, reponame, commit_id });
const request_body = try std.fmt.allocPrint(allocator, "{{\"query\":\"query {{ user(username: \\\"{s}\\\") {{ repository(name: \\\"{s}\\\") {{ revparse_single(revspec: \\\"{s}\\\") {{ author {{ time }} committer {{ time }} }} }} }} }}\"}}", .{ username, reponame, commit_id });
defer allocator.free(request_body);
const auth_header = try std.fmt.allocPrint(allocator, "Bearer {s}", .{token});

241
src/timestamp_tests.zig Normal file
View file

@ -0,0 +1,241 @@
const std = @import("std");
const main = @import("main.zig");
const config = @import("config.zig");
const xml_parser = @import("xml_parser.zig");
const Release = main.Release;
const Config = config.Config;
const SourceHutConfig = config.SourceHutConfig;
test "Config loading without last_check field" {
const allocator = std.testing.allocator;
// Create a test config file without last_check
const test_config_content =
\\{
\\ "github_token": "test_token",
\\ "gitlab_token": null,
\\ "codeberg_token": null,
\\ "sourcehut": {
\\ "repositories": ["~test/repo"]
\\ }
\\}
;
const temp_config_file = "test_config_no_last_check.json";
// Write test config to file
{
const file = try std.fs.cwd().createFile(temp_config_file, .{});
defer file.close();
try file.writeAll(test_config_content);
}
defer std.fs.cwd().deleteFile(temp_config_file) catch {};
// Load config
const loaded_config = try config.loadConfig(allocator, temp_config_file);
defer loaded_config.deinit();
// Verify config was loaded correctly
try std.testing.expectEqualStrings("test_token", loaded_config.github_token.?);
try std.testing.expect(loaded_config.gitlab_token == null);
}
test "parseReleaseTimestamp handles edge cases" {
// Test various timestamp formats
const test_cases = [_]struct {
input: []const u8,
expected_valid: bool,
}{
.{ .input = "2024-01-01T00:00:00Z", .expected_valid = true },
.{ .input = "2024-12-31T23:59:59Z", .expected_valid = true },
.{ .input = "1704067200", .expected_valid = true }, // This is a valid timestamp
.{ .input = "2024-01-01", .expected_valid = true }, // Zeit can parse date-only format
.{ .input = "", .expected_valid = false },
.{ .input = "invalid", .expected_valid = false },
.{ .input = "not-a-date", .expected_valid = false },
.{ .input = "definitely-not-a-date", .expected_valid = false },
};
for (test_cases) |test_case| {
const result = main.parseReleaseTimestamp(test_case.input) catch 0;
if (test_case.expected_valid) {
try std.testing.expect(result > 0);
} else {
try std.testing.expectEqual(@as(i64, 0), result);
}
}
// Test the special case of "0" timestamp - this should return 0
const zero_result = main.parseReleaseTimestamp("0") catch 0;
try std.testing.expectEqual(@as(i64, 0), zero_result);
// Test specific known values
const known_timestamp = main.parseReleaseTimestamp("1704067200") catch 0;
try std.testing.expectEqual(@as(i64, 1704067200), known_timestamp);
// Test that date-only format works
const date_only_result = main.parseReleaseTimestamp("2024-01-01") catch 0;
try std.testing.expectEqual(@as(i64, 1704067200), date_only_result);
}
test "filterNewReleases with various timestamp scenarios" {
const allocator = std.testing.allocator;
const releases = [_]Release{
Release{
.repo_name = "test/very-old",
.tag_name = "v0.1.0",
.published_at = "2023-01-01T00:00:00Z",
.html_url = "https://github.com/test/very-old/releases/tag/v0.1.0",
.description = "Very old release",
.provider = "github",
},
Release{
.repo_name = "test/old",
.tag_name = "v1.0.0",
.published_at = "2024-01-01T00:00:00Z",
.html_url = "https://github.com/test/old/releases/tag/v1.0.0",
.description = "Old release",
.provider = "github",
},
Release{
.repo_name = "test/recent",
.tag_name = "v2.0.0",
.published_at = "2024-06-01T00:00:00Z",
.html_url = "https://github.com/test/recent/releases/tag/v2.0.0",
.description = "Recent release",
.provider = "github",
},
Release{
.repo_name = "test/newest",
.tag_name = "v3.0.0",
.published_at = "2024-12-01T00:00:00Z",
.html_url = "https://github.com/test/newest/releases/tag/v3.0.0",
.description = "Newest release",
.provider = "github",
},
};
// Test filtering from beginning of time (should get all)
{
var filtered = try main.filterNewReleases(allocator, &releases, 0);
defer {
for (filtered.items) |release| {
release.deinit(allocator);
}
filtered.deinit();
}
try std.testing.expectEqual(@as(usize, 4), filtered.items.len);
}
// Test filtering from middle of 2024 (should get recent and newest)
{
const march_2024 = main.parseReleaseTimestamp("2024-03-01T00:00:00Z") catch 0;
var filtered = try main.filterNewReleases(allocator, &releases, march_2024);
defer {
for (filtered.items) |release| {
release.deinit(allocator);
}
filtered.deinit();
}
try std.testing.expectEqual(@as(usize, 2), filtered.items.len);
// Should contain recent and newest
var found_recent = false;
var found_newest = false;
for (filtered.items) |release| {
if (std.mem.eql(u8, release.repo_name, "test/recent")) {
found_recent = true;
}
if (std.mem.eql(u8, release.repo_name, "test/newest")) {
found_newest = true;
}
}
try std.testing.expect(found_recent);
try std.testing.expect(found_newest);
}
// Test filtering from future (should get none)
{
const future = main.parseReleaseTimestamp("2025-01-01T00:00:00Z") catch 0;
var filtered = try main.filterNewReleases(allocator, &releases, future);
defer {
for (filtered.items) |release| {
release.deinit(allocator);
}
filtered.deinit();
}
try std.testing.expectEqual(@as(usize, 0), filtered.items.len);
}
}
test "XML parsing preserves timestamp precision" {
const allocator = std.testing.allocator;
const precise_xml =
\\<?xml version="1.0" encoding="UTF-8"?>
\\<feed xmlns="http://www.w3.org/2005/Atom">
\\<title>Repository Releases</title>
\\<entry>
\\ <title>precise/repo - v1.0.0</title>
\\ <link href="https://github.com/precise/repo/releases/tag/v1.0.0"/>
\\ <updated>2024-06-15T14:30:45Z</updated>
\\ <summary>Precise timestamp test</summary>
\\ <category term="github"/>
\\</entry>
\\</feed>
;
var releases = try xml_parser.parseAtomFeed(allocator, precise_xml);
defer {
for (releases.items) |release| {
release.deinit(allocator);
}
releases.deinit();
}
try std.testing.expectEqual(@as(usize, 1), releases.items.len);
try std.testing.expectEqualStrings("2024-06-15T14:30:45Z", releases.items[0].published_at);
// Verify the timestamp can be parsed correctly
const parsed_timestamp = main.parseReleaseTimestamp(releases.items[0].published_at) catch 0;
try std.testing.expect(parsed_timestamp > 0);
}
test "compareReleasesByDate with various timestamp formats" {
const release_iso_early = Release{
.repo_name = "test/iso-early",
.tag_name = "v1.0.0",
.published_at = "2024-01-01T00:00:00Z",
.html_url = "https://github.com/test/iso-early/releases/tag/v1.0.0",
.description = "Early ISO format",
.provider = "github",
};
const release_iso_late = Release{
.repo_name = "test/iso-late",
.tag_name = "v2.0.0",
.published_at = "2024-12-01T00:00:00Z",
.html_url = "https://github.com/test/iso-late/releases/tag/v2.0.0",
.description = "Late ISO format",
.provider = "github",
};
const release_invalid = Release{
.repo_name = "test/invalid",
.tag_name = "v3.0.0",
.published_at = "invalid-date",
.html_url = "https://github.com/test/invalid/releases/tag/v3.0.0",
.description = "Invalid format",
.provider = "github",
};
// Later date should come before earlier date (more recent first)
try std.testing.expect(main.compareReleasesByDate({}, release_iso_late, release_iso_early));
try std.testing.expect(!main.compareReleasesByDate({}, release_iso_early, release_iso_late));
// Invalid timestamps should be treated as 0 and come last
try std.testing.expect(main.compareReleasesByDate({}, release_iso_early, release_invalid));
try std.testing.expect(main.compareReleasesByDate({}, release_iso_late, release_invalid));
}

314
src/xml_parser.zig Normal file
View file

@ -0,0 +1,314 @@
const std = @import("std");
const Allocator = std.mem.Allocator;
const ArrayList = std.ArrayList;
const Release = @import("main.zig").Release;
pub const ParseError = error{
InvalidXml,
MalformedEntry,
OutOfMemory,
};
pub fn parseAtomFeed(allocator: Allocator, xml_content: []const u8) !ArrayList(Release) {
var releases = ArrayList(Release).init(allocator);
errdefer {
for (releases.items) |release| {
release.deinit(allocator);
}
releases.deinit();
}
var entry_start: ?usize = null;
var pos: usize = 0;
while (pos < xml_content.len) {
// Find next entry
if (std.mem.indexOf(u8, xml_content[pos..], "<entry>")) |entry_offset| {
entry_start = pos + entry_offset;
pos = entry_start.? + 7; // Move past "<entry>"
// Find the end of this entry
if (std.mem.indexOf(u8, xml_content[pos..], "</entry>")) |end_offset| {
const entry_end = pos + end_offset;
const entry_content = xml_content[entry_start.? .. entry_end + 8]; // Include "</entry>"
if (parseEntry(allocator, entry_content)) |release| {
try releases.append(release);
} else |err| {
std.debug.print("Warning: Failed to parse entry: {}\n", .{err});
}
pos = entry_end + 8; // Move past "</entry>"
} else {
break; // No closing tag found
}
} else {
break; // No more entries
}
}
return releases;
}
fn parseEntry(allocator: Allocator, entry_xml: []const u8) !Release {
var release = Release{
.repo_name = try allocator.dupe(u8, ""),
.tag_name = try allocator.dupe(u8, ""),
.published_at = try allocator.dupe(u8, ""),
.html_url = try allocator.dupe(u8, ""),
.description = try allocator.dupe(u8, ""),
.provider = try allocator.dupe(u8, ""),
};
errdefer release.deinit(allocator);
// Parse title to extract repo_name and tag_name
if (extractTagContent(entry_xml, "title", allocator)) |title| {
defer allocator.free(title);
if (std.mem.lastIndexOf(u8, title, " - ")) |dash_pos| {
allocator.free(release.repo_name);
allocator.free(release.tag_name);
release.repo_name = try allocator.dupe(u8, title[0..dash_pos]);
release.tag_name = try allocator.dupe(u8, title[dash_pos + 3 ..]);
}
}
// Parse link href attribute
if (extractLinkHref(entry_xml, allocator)) |url| {
allocator.free(release.html_url);
release.html_url = url;
}
// Parse updated timestamp
if (extractTagContent(entry_xml, "updated", allocator)) |updated| {
allocator.free(release.published_at);
release.published_at = updated;
}
// Parse summary (description)
if (extractTagContent(entry_xml, "summary", allocator)) |summary| {
allocator.free(release.description);
release.description = summary;
}
// Parse category term attribute (provider)
if (extractCategoryTerm(entry_xml, allocator)) |provider| {
allocator.free(release.provider);
release.provider = provider;
}
return release;
}
fn extractTagContent(xml: []const u8, tag_name: []const u8, allocator: Allocator) ?[]u8 {
const open_tag = std.fmt.allocPrint(allocator, "<{s}>", .{tag_name}) catch return null;
defer allocator.free(open_tag);
const close_tag = std.fmt.allocPrint(allocator, "</{s}>", .{tag_name}) catch return null;
defer allocator.free(close_tag);
if (std.mem.indexOf(u8, xml, open_tag)) |start_pos| {
const content_start = start_pos + open_tag.len;
if (std.mem.indexOf(u8, xml[content_start..], close_tag)) |end_offset| {
const content_end = content_start + end_offset;
const content = xml[content_start..content_end];
return unescapeXml(allocator, content) catch null;
}
}
return null;
}
fn extractLinkHref(xml: []const u8, allocator: Allocator) ?[]u8 {
const pattern = "<link href=\"";
if (std.mem.indexOf(u8, xml, pattern)) |start_pos| {
const content_start = start_pos + pattern.len;
if (std.mem.indexOf(u8, xml[content_start..], "\"")) |end_offset| {
const content_end = content_start + end_offset;
const href = xml[content_start..content_end];
return allocator.dupe(u8, href) catch null;
}
}
return null;
}
fn extractCategoryTerm(xml: []const u8, allocator: Allocator) ?[]u8 {
const pattern = "<category term=\"";
if (std.mem.indexOf(u8, xml, pattern)) |start_pos| {
const content_start = start_pos + pattern.len;
if (std.mem.indexOf(u8, xml[content_start..], "\"")) |end_offset| {
const content_end = content_start + end_offset;
const term = xml[content_start..content_end];
return allocator.dupe(u8, term) catch null;
}
}
return null;
}
fn unescapeXml(allocator: Allocator, input: []const u8) ![]u8 {
var result = ArrayList(u8).init(allocator);
defer result.deinit();
var i: usize = 0;
while (i < input.len) {
if (input[i] == '&') {
if (std.mem.startsWith(u8, input[i..], "&lt;")) {
try result.append('<');
i += 4;
} else if (std.mem.startsWith(u8, input[i..], "&gt;")) {
try result.append('>');
i += 4;
} else if (std.mem.startsWith(u8, input[i..], "&amp;")) {
try result.append('&');
i += 5;
} else if (std.mem.startsWith(u8, input[i..], "&quot;")) {
try result.append('"');
i += 6;
} else if (std.mem.startsWith(u8, input[i..], "&apos;")) {
try result.append('\'');
i += 6;
} else {
try result.append(input[i]);
i += 1;
}
} else {
try result.append(input[i]);
i += 1;
}
}
return result.toOwnedSlice();
}
// Tests
test "parse simple atom entry" {
const allocator = std.testing.allocator;
const entry_xml =
\\<entry>
\\ <title>test/repo - v1.0.0</title>
\\ <link href="https://github.com/test/repo/releases/tag/v1.0.0"/>
\\ <id>https://github.com/test/repo/releases/tag/v1.0.0</id>
\\ <updated>2024-01-01T00:00:00Z</updated>
\\ <author><n>github</n></author>
\\ <summary>Test release</summary>
\\ <category term="github"/>
\\</entry>
;
const release = try parseEntry(allocator, entry_xml);
defer release.deinit(allocator);
try std.testing.expectEqualStrings("test/repo", release.repo_name);
try std.testing.expectEqualStrings("v1.0.0", release.tag_name);
try std.testing.expectEqualStrings("https://github.com/test/repo/releases/tag/v1.0.0", release.html_url);
try std.testing.expectEqualStrings("2024-01-01T00:00:00Z", release.published_at);
try std.testing.expectEqualStrings("Test release", release.description);
try std.testing.expectEqualStrings("github", release.provider);
}
test "parse atom entry with escaped characters" {
const allocator = std.testing.allocator;
const entry_xml =
\\<entry>
\\ <title>test/repo&lt;script&gt; - v1.0.0 &amp; more</title>
\\ <link href="https://github.com/test/repo/releases/tag/v1.0.0"/>
\\ <id>https://github.com/test/repo/releases/tag/v1.0.0</id>
\\ <updated>2024-01-01T00:00:00Z</updated>
\\ <author><n>github</n></author>
\\ <summary>Test &quot;release&quot; with &lt;special&gt; chars &amp; symbols</summary>
\\ <category term="github"/>
\\</entry>
;
const release = try parseEntry(allocator, entry_xml);
defer release.deinit(allocator);
try std.testing.expectEqualStrings("test/repo<script>", release.repo_name);
try std.testing.expectEqualStrings("v1.0.0 & more", release.tag_name);
try std.testing.expectEqualStrings("Test \"release\" with <special> chars & symbols", release.description);
}
test "parse full atom feed" {
const allocator = std.testing.allocator;
const atom_xml =
\\<?xml version="1.0" encoding="UTF-8"?>
\\<feed xmlns="http://www.w3.org/2005/Atom">
\\<title>Repository Releases</title>
\\<subtitle>New releases from starred repositories</subtitle>
\\<link href="https://github.com" rel="alternate"/>
\\<link href="https://example.com/releases.xml" rel="self"/>
\\<id>https://example.com/releases</id>
\\<updated>2024-01-01T00:00:00Z</updated>
\\<entry>
\\ <title>test/repo1 - v1.0.0</title>
\\ <link href="https://github.com/test/repo1/releases/tag/v1.0.0"/>
\\ <id>https://github.com/test/repo1/releases/tag/v1.0.0</id>
\\ <updated>2024-01-01T00:00:00Z</updated>
\\ <author><n>github</n></author>
\\ <summary>First release</summary>
\\ <category term="github"/>
\\</entry>
\\<entry>
\\ <title>test/repo2 - v2.0.0</title>
\\ <link href="https://github.com/test/repo2/releases/tag/v2.0.0"/>
\\ <id>https://github.com/test/repo2/releases/tag/v2.0.0</id>
\\ <updated>2024-01-02T00:00:00Z</updated>
\\ <author><n>github</n></author>
\\ <summary>Second release</summary>
\\ <category term="github"/>
\\</entry>
\\</feed>
;
var releases = try parseAtomFeed(allocator, atom_xml);
defer {
for (releases.items) |release| {
release.deinit(allocator);
}
releases.deinit();
}
try std.testing.expectEqual(@as(usize, 2), releases.items.len);
try std.testing.expectEqualStrings("test/repo1", releases.items[0].repo_name);
try std.testing.expectEqualStrings("v1.0.0", releases.items[0].tag_name);
try std.testing.expectEqualStrings("First release", releases.items[0].description);
try std.testing.expectEqualStrings("test/repo2", releases.items[1].repo_name);
try std.testing.expectEqualStrings("v2.0.0", releases.items[1].tag_name);
try std.testing.expectEqualStrings("Second release", releases.items[1].description);
}
test "XML unescaping" {
const allocator = std.testing.allocator;
const input = "Test &lt;tag&gt; &amp; &quot;quotes&quot; &amp; &apos;apostrophes&apos;";
const result = try unescapeXml(allocator, input);
defer allocator.free(result);
const expected = "Test <tag> & \"quotes\" & 'apostrophes'";
try std.testing.expectEqualStrings(expected, result);
}
test "parse entry with missing fields" {
const allocator = std.testing.allocator;
const entry_xml =
\\<entry>
\\ <title>test/repo - v1.0.0</title>
\\ <link href="https://github.com/test/repo/releases/tag/v1.0.0"/>
\\</entry>
;
const release = try parseEntry(allocator, entry_xml);
defer release.deinit(allocator);
try std.testing.expectEqualStrings("test/repo", release.repo_name);
try std.testing.expectEqualStrings("v1.0.0", release.tag_name);
try std.testing.expectEqualStrings("https://github.com/test/repo/releases/tag/v1.0.0", release.html_url);
// Missing fields should be empty strings
try std.testing.expectEqualStrings("", release.published_at);
try std.testing.expectEqualStrings("", release.description);
try std.testing.expectEqualStrings("", release.provider);
}

285
src/xml_parser_tests.zig Normal file
View file

@ -0,0 +1,285 @@
const std = @import("std");
const testing = std.testing;
const ArrayList = std.ArrayList;
const xml_parser = @import("xml_parser.zig");
const atom = @import("atom.zig");
const Release = @import("main.zig").Release;
test "round trip: generate atom feed and parse it back" {
const allocator = testing.allocator;
// Create test releases
const original_releases = [_]Release{
Release{
.repo_name = "test/repo1",
.tag_name = "v1.0.0",
.published_at = "2024-01-01T00:00:00Z",
.html_url = "https://github.com/test/repo1/releases/tag/v1.0.0",
.description = "First release",
.provider = "github",
},
Release{
.repo_name = "test/repo2",
.tag_name = "v2.0.0",
.published_at = "2024-01-02T00:00:00Z",
.html_url = "https://github.com/test/repo2/releases/tag/v2.0.0",
.description = "Second release",
.provider = "github",
},
};
// Generate atom feed
const atom_content = try atom.generateFeed(allocator, &original_releases);
defer allocator.free(atom_content);
// Parse it back
var parsed_releases = try xml_parser.parseAtomFeed(allocator, atom_content);
defer {
for (parsed_releases.items) |release| {
release.deinit(allocator);
}
parsed_releases.deinit();
}
// Verify we got the same data back
try testing.expectEqual(@as(usize, 2), parsed_releases.items.len);
try testing.expectEqualStrings("test/repo1", parsed_releases.items[0].repo_name);
try testing.expectEqualStrings("v1.0.0", parsed_releases.items[0].tag_name);
try testing.expectEqualStrings("2024-01-01T00:00:00Z", parsed_releases.items[0].published_at);
try testing.expectEqualStrings("https://github.com/test/repo1/releases/tag/v1.0.0", parsed_releases.items[0].html_url);
try testing.expectEqualStrings("First release", parsed_releases.items[0].description);
try testing.expectEqualStrings("github", parsed_releases.items[0].provider);
try testing.expectEqualStrings("test/repo2", parsed_releases.items[1].repo_name);
try testing.expectEqualStrings("v2.0.0", parsed_releases.items[1].tag_name);
try testing.expectEqualStrings("2024-01-02T00:00:00Z", parsed_releases.items[1].published_at);
try testing.expectEqualStrings("https://github.com/test/repo2/releases/tag/v2.0.0", parsed_releases.items[1].html_url);
try testing.expectEqualStrings("Second release", parsed_releases.items[1].description);
try testing.expectEqualStrings("github", parsed_releases.items[1].provider);
}
test "parse atom feed with special characters" {
const allocator = testing.allocator;
// Create releases with special characters
const original_releases = [_]Release{
Release{
.repo_name = "test/repo<script>",
.tag_name = "v1.0.0 & more",
.published_at = "2024-01-01T00:00:00Z",
.html_url = "https://github.com/test/repo/releases/tag/v1.0.0",
.description = "Test \"release\" with <special> chars & symbols",
.provider = "github",
},
};
// Generate atom feed (this should escape the characters)
const atom_content = try atom.generateFeed(allocator, &original_releases);
defer allocator.free(atom_content);
// Verify the XML contains escaped characters
try testing.expect(std.mem.indexOf(u8, atom_content, "&lt;script&gt;") != null);
try testing.expect(std.mem.indexOf(u8, atom_content, "&amp; more") != null);
try testing.expect(std.mem.indexOf(u8, atom_content, "&quot;release&quot;") != null);
// Parse it back (this should unescape the characters)
var parsed_releases = try xml_parser.parseAtomFeed(allocator, atom_content);
defer {
for (parsed_releases.items) |release| {
release.deinit(allocator);
}
parsed_releases.deinit();
}
// Verify the parsed data has the original unescaped characters
try testing.expectEqual(@as(usize, 1), parsed_releases.items.len);
try testing.expectEqualStrings("test/repo<script>", parsed_releases.items[0].repo_name);
try testing.expectEqualStrings("v1.0.0 & more", parsed_releases.items[0].tag_name);
try testing.expectEqualStrings("Test \"release\" with <special> chars & symbols", parsed_releases.items[0].description);
}
test "parse malformed atom feed gracefully" {
const allocator = testing.allocator;
const malformed_xml =
\\<?xml version="1.0" encoding="UTF-8"?>
\\<feed xmlns="http://www.w3.org/2005/Atom">
\\<title>Repository Releases</title>
\\<entry>
\\ <title>test/repo1 - v1.0.0</title>
\\ <link href="https://github.com/test/repo1/releases/tag/v1.0.0"/>
\\ <updated>2024-01-01T00:00:00Z</updated>
\\ <summary>Good entry</summary>
\\ <category term="github"/>
\\</entry>
\\<entry>
\\ <title>test/repo2 - v2.0.0</title>
\\ <!-- Missing closing entry tag -->
\\<entry>
\\ <title>test/repo3 - v3.0.0</title>
\\ <link href="https://github.com/test/repo3/releases/tag/v3.0.0"/>
\\ <updated>2024-01-03T00:00:00Z</updated>
\\ <summary>Another good entry</summary>
\\ <category term="github"/>
\\</entry>
\\</feed>
;
var parsed_releases = try xml_parser.parseAtomFeed(allocator, malformed_xml);
defer {
for (parsed_releases.items) |release| {
release.deinit(allocator);
}
parsed_releases.deinit();
}
// Should parse the valid entries and skip the malformed one
// Note: The malformed entry (repo2) will be parsed but will contain mixed content
// The parser finds the first closing </entry> tag which belongs to repo3
try testing.expectEqual(@as(usize, 2), parsed_releases.items.len);
try testing.expectEqualStrings("test/repo1", parsed_releases.items[0].repo_name);
try testing.expectEqualStrings("test/repo2", parsed_releases.items[1].repo_name); // This gets the first title found
}
test "parse empty atom feed" {
const allocator = testing.allocator;
const empty_xml =
\\<?xml version="1.0" encoding="UTF-8"?>
\\<feed xmlns="http://www.w3.org/2005/Atom">
\\<title>Repository Releases</title>
\\<subtitle>New releases from starred repositories</subtitle>
\\<link href="https://github.com" rel="alternate"/>
\\<link href="https://example.com/releases.xml" rel="self"/>
\\<id>https://example.com/releases</id>
\\<updated>2024-01-01T00:00:00Z</updated>
\\</feed>
;
var parsed_releases = try xml_parser.parseAtomFeed(allocator, empty_xml);
defer parsed_releases.deinit();
try testing.expectEqual(@as(usize, 0), parsed_releases.items.len);
}
test "parse atom feed with multiline summaries" {
const allocator = testing.allocator;
const multiline_xml =
\\<?xml version="1.0" encoding="UTF-8"?>
\\<feed xmlns="http://www.w3.org/2005/Atom">
\\<title>Repository Releases</title>
\\<entry>
\\ <title>test/repo - v1.0.0</title>
\\ <link href="https://github.com/test/repo/releases/tag/v1.0.0"/>
\\ <updated>2024-01-01T00:00:00Z</updated>
\\ <summary>This is a multiline
\\summary with line breaks
\\and multiple paragraphs</summary>
\\ <category term="github"/>
\\</entry>
\\</feed>
;
var parsed_releases = try xml_parser.parseAtomFeed(allocator, multiline_xml);
defer {
for (parsed_releases.items) |release| {
release.deinit(allocator);
}
parsed_releases.deinit();
}
try testing.expectEqual(@as(usize, 1), parsed_releases.items.len);
const expected_summary = "This is a multiline\nsummary with line breaks\nand multiple paragraphs";
try testing.expectEqualStrings(expected_summary, parsed_releases.items[0].description);
}
test "parse atom feed with different providers" {
const allocator = testing.allocator;
const multi_provider_xml =
\\<?xml version="1.0" encoding="UTF-8"?>
\\<feed xmlns="http://www.w3.org/2005/Atom">
\\<title>Repository Releases</title>
\\<entry>
\\ <title>github/repo - v1.0.0</title>
\\ <link href="https://github.com/github/repo/releases/tag/v1.0.0"/>
\\ <updated>2024-01-01T00:00:00Z</updated>
\\ <summary>GitHub release</summary>
\\ <category term="github"/>
\\</entry>
\\<entry>
\\ <title>gitlab/repo - v2.0.0</title>
\\ <link href="https://gitlab.com/gitlab/repo/-/releases/v2.0.0"/>
\\ <updated>2024-01-02T00:00:00Z</updated>
\\ <summary>GitLab release</summary>
\\ <category term="gitlab"/>
\\</entry>
\\<entry>
\\ <title>codeberg/repo - v3.0.0</title>
\\ <link href="https://codeberg.org/codeberg/repo/releases/tag/v3.0.0"/>
\\ <updated>2024-01-03T00:00:00Z</updated>
\\ <summary>Codeberg release</summary>
\\ <category term="codeberg"/>
\\</entry>
\\<entry>
\\ <title>~user/repo - v4.0.0</title>
\\ <link href="https://git.sr.ht/~user/repo/refs/v4.0.0"/>
\\ <updated>2024-01-04T00:00:00Z</updated>
\\ <summary>SourceHut release</summary>
\\ <category term="sourcehut"/>
\\</entry>
\\</feed>
;
var parsed_releases = try xml_parser.parseAtomFeed(allocator, multi_provider_xml);
defer {
for (parsed_releases.items) |release| {
release.deinit(allocator);
}
parsed_releases.deinit();
}
try testing.expectEqual(@as(usize, 4), parsed_releases.items.len);
try testing.expectEqualStrings("github", parsed_releases.items[0].provider);
try testing.expectEqualStrings("gitlab", parsed_releases.items[1].provider);
try testing.expectEqualStrings("codeberg", parsed_releases.items[2].provider);
try testing.expectEqualStrings("sourcehut", parsed_releases.items[3].provider);
}
test "parse atom feed with missing optional fields" {
const allocator = testing.allocator;
const minimal_xml =
\\<?xml version="1.0" encoding="UTF-8"?>
\\<feed xmlns="http://www.w3.org/2005/Atom">
\\<entry>
\\ <title>test/repo - v1.0.0</title>
\\ <link href="https://github.com/test/repo/releases/tag/v1.0.0"/>
\\</entry>
\\</feed>
;
var parsed_releases = try xml_parser.parseAtomFeed(allocator, minimal_xml);
defer {
for (parsed_releases.items) |release| {
release.deinit(allocator);
}
parsed_releases.deinit();
}
try testing.expectEqual(@as(usize, 1), parsed_releases.items.len);
const release = parsed_releases.items[0];
try testing.expectEqualStrings("test/repo", release.repo_name);
try testing.expectEqualStrings("v1.0.0", release.tag_name);
try testing.expectEqualStrings("https://github.com/test/repo/releases/tag/v1.0.0", release.html_url);
// Missing fields should be empty strings
try testing.expectEqualStrings("", release.published_at);
try testing.expectEqualStrings("", release.description);
try testing.expectEqualStrings("", release.provider);
}