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
+ \\
+ \\ 2024-01-02T00:00:00Z
+ \\
+ \\
+ \\ 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, "{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 = "');
+ 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