From c2ca2e6be54fa650b5652a4c11641d580871692c Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Sun, 13 Jul 2025 14:03:25 -0700 Subject: [PATCH] use existing data appropriately --- config.example.json | 3 +- src/atom.zig | 5 +- src/config.zig | 18 +- src/main.zig | 558 ++++++++++++++++++++++++++++-------- src/providers/SourceHut.zig | 8 +- src/timestamp_tests.zig | 241 ++++++++++++++++ src/xml_parser.zig | 314 ++++++++++++++++++++ src/xml_parser_tests.zig | 285 ++++++++++++++++++ 8 files changed, 1296 insertions(+), 136 deletions(-) create mode 100644 src/timestamp_tests.zig create mode 100644 src/xml_parser.zig create mode 100644 src/xml_parser_tests.zig diff --git a/config.example.json b/config.example.json index d3ea1c2..f898b3c 100644 --- a/config.example.json +++ b/config.example.json @@ -8,6 +8,5 @@ "~sircmpwn/aerc", "~emersion/gamja" ] - }, - "last_check": null + } } \ No newline at end of file diff --git a/src/atom.zig b/src/atom.zig index cfa6085..2d18d36 100644 --- a/src/atom.zig +++ b/src/atom.zig @@ -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("{s}\n", .{updated_str}); // Add entries diff --git a/src/config.zig b/src/config.zig index eb85b77..4d49518 100644 --- a/src/config.zig +++ b/src/config.zig @@ -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, }; diff --git a/src/main.zig b/src/main.zig index d988e87..05a5ed5 100644 --- a/src/main.zig +++ b/src/main.zig @@ -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, "") != null); - try std.testing.expect(std.mem.indexOf(u8, atom_content, "") != null); - try std.testing.expect(std.mem.indexOf(u8, atom_content, "Repository Releases") != null); - try std.testing.expect(std.mem.indexOf(u8, atom_content, "New releases from starred repositories") != null); - try std.testing.expect(std.mem.indexOf(u8, atom_content, "") != null); - try std.testing.expect(std.mem.indexOf(u8, atom_content, "") != null); - try std.testing.expect(std.mem.indexOf(u8, atom_content, "https://example.com/releases") != null); - try std.testing.expect(std.mem.indexOf(u8, atom_content, "") != null); - try std.testing.expect(std.mem.indexOf(u8, atom_content, "") != null); - try std.testing.expect(std.mem.indexOf(u8, atom_content, "") != null); - - // Check entry structure - try std.testing.expect(std.mem.indexOf(u8, atom_content, "test/repo - v1.0.0") != null); - try std.testing.expect(std.mem.indexOf(u8, atom_content, "") != null); - try std.testing.expect(std.mem.indexOf(u8, atom_content, "https://github.com/test/repo/releases/tag/v1.0.0") != null); - try std.testing.expect(std.mem.indexOf(u8, atom_content, "2024-01-01T00:00:00Z") != null); - try std.testing.expect(std.mem.indexOf(u8, atom_content, "github") != null); - try std.testing.expect(std.mem.indexOf(u8, atom_content, "Test release") != null); - try std.testing.expect(std.mem.indexOf(u8, atom_content, "") != 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 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, "")) { - 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, "")) { - 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, "") and std.mem.endsWith(u8, trimmed, "")) { - 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, "")) { - const url_start = 12; // length of "" - 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, "") and std.mem.endsWith(u8, trimmed, "")) { - 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, "")) { - const term_start = 15; // length of "" - allocator.free(current_release.?.provider); - current_release.?.provider = try allocator.dupe(u8, trimmed[term_start..term_end]); - } else if (std.mem.startsWith(u8, trimmed, "") and std.mem.endsWith(u8, trimmed, "")) { - 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, "") != null); + try std.testing.expect(std.mem.indexOf(u8, atom_content, "") != null); + try std.testing.expect(std.mem.indexOf(u8, atom_content, "Repository Releases") != null); + try std.testing.expect(std.mem.indexOf(u8, atom_content, "New releases from starred repositories") != null); + try std.testing.expect(std.mem.indexOf(u8, atom_content, "") != null); + try std.testing.expect(std.mem.indexOf(u8, atom_content, "") != null); + try std.testing.expect(std.mem.indexOf(u8, atom_content, "https://example.com/releases") != null); + try std.testing.expect(std.mem.indexOf(u8, atom_content, "") != null); + try std.testing.expect(std.mem.indexOf(u8, atom_content, "") != null); + try std.testing.expect(std.mem.indexOf(u8, atom_content, "") != null); + + // Check entry structure + try std.testing.expect(std.mem.indexOf(u8, atom_content, "test/repo - v1.0.0") != null); + try std.testing.expect(std.mem.indexOf(u8, atom_content, "") != null); + try std.testing.expect(std.mem.indexOf(u8, atom_content, "https://github.com/test/repo/releases/tag/v1.0.0") != null); + try std.testing.expect(std.mem.indexOf(u8, atom_content, "2024-01-01T00:00:00Z") != null); + + // Check for author - be flexible about exact format + try std.testing.expect(std.mem.indexOf(u8, atom_content, "") != null); + try std.testing.expect(std.mem.indexOf(u8, atom_content, "github") != null); + try std.testing.expect(std.mem.indexOf(u8, atom_content, "") != null); + + try std.testing.expect(std.mem.indexOf(u8, atom_content, "Test release") != null); + try std.testing.expect(std.mem.indexOf(u8, atom_content, "") != null); +} + +test "loadExistingReleases with valid XML" { + const allocator = std.testing.allocator; + + // Create a temporary file with valid Atom XML + const test_xml = + \\ + \\ + \\Repository Releases + \\ + \\ test/repo - v1.0.0 + \\ + \\ 2024-01-01T00:00:00Z + \\ Test release + \\ + \\ + \\ + ; + + 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 = + \\ + \\ + \\Repository Releases + \\ + \\ minimal/repo - v1.0.0 + \\ + \\ 2024-01-01T00:00:00Z + \\ + \\ + ; + + 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 = + \\ + \\ + \\Repository Releases + \\New releases from starred repositories + \\ + \\ + \\https://example.com/releases + \\2024-01-01T00:00:00Z + \\ + \\ complex/repo & more - v1.0.0 <beta> + \\ + \\ https://github.com/complex/repo/releases/tag/v1.0.0 + \\ 2024-01-01T00:00:00Z + \\ github + \\ Release with "special" characters & symbols + \\ + \\ + \\ + \\ another/repo - v2.0.0 + \\ + \\ https://gitlab.com/another/repo/-/releases/v2.0.0 + \\ 2024-01-02T12:30:45Z + \\ gitlab + \\ Another release + \\ + \\ + \\ + ; + + 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 ", 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 = + \\ + \\ + \\Repository Releases + \\ + \\ good/repo - v1.0.0 + \\ + \\ 2024-01-01T00:00:00Z + \\ + \\ + \\ 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 + \\ + \\ 2024-01-03T00:00:00Z + \\ + \\ + ; + + 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")); +} diff --git a/src/providers/SourceHut.zig b/src/providers/SourceHut.zig index faa949e..a2c723a 100644 --- a/src/providers/SourceHut.zig +++ b/src/providers/SourceHut.zig @@ -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}); diff --git a/src/timestamp_tests.zig b/src/timestamp_tests.zig new file mode 100644 index 0000000..e7200f3 --- /dev/null +++ b/src/timestamp_tests.zig @@ -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 = + \\ + \\ + \\Repository Releases + \\ + \\ precise/repo - v1.0.0 + \\ + \\ 2024-06-15T14:30:45Z + \\ Precise timestamp test + \\ + \\ + \\ + ; + + 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)); +} diff --git a/src/xml_parser.zig b/src/xml_parser.zig new file mode 100644 index 0000000..d83004e --- /dev/null +++ b/src/xml_parser.zig @@ -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_offset| { + entry_start = pos + entry_offset; + pos = entry_start.? + 7; // Move past "" + + // Find the end of this entry + if (std.mem.indexOf(u8, xml_content[pos..], "")) |end_offset| { + const entry_end = pos + end_offset; + const entry_content = xml_content[entry_start.? .. entry_end + 8]; // Include "" + + 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 "" + } 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, "", .{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 = "'); + i += 4; + } else if (std.mem.startsWith(u8, input[i..], "&")) { + try result.append('&'); + i += 5; + } else if (std.mem.startsWith(u8, input[i..], """)) { + try result.append('"'); + i += 6; + } else if (std.mem.startsWith(u8, input[i..], "'")) { + 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 = + \\ + \\ test/repo - v1.0.0 + \\ + \\ https://github.com/test/repo/releases/tag/v1.0.0 + \\ 2024-01-01T00:00:00Z + \\ github + \\ Test release + \\ + \\ + ; + + 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 = + \\ + \\ test/repo<script> - v1.0.0 & more + \\ + \\ https://github.com/test/repo/releases/tag/v1.0.0 + \\ 2024-01-01T00:00:00Z + \\ github + \\ Test "release" with <special> chars & symbols + \\ + \\ + ; + + const release = try parseEntry(allocator, entry_xml); + defer release.deinit(allocator); + + try std.testing.expectEqualStrings("test/repo