Skip to content

Commit 97ace89

Browse files
committed
feat(ideas): create ideas queue module
Implement new ideas.zig module for managing user-defined development tasks. Provides Idea struct, IdeaList container, and helper functions for: - Loading ideas from .opencoder/ideas/ directory - Formatting ideas for AI selection prompt - Removing idea files after selection - Extracting idea summaries for logging Handles edge cases: - Skips and deletes empty/unreadable idea files - Validates markdown files (.md extension) - Allocates memory safely with proper deallocation Includes comprehensive unit tests covering all major functions. Signed-off-by: leocavalcante <[email protected]>
1 parent 9efbb4f commit 97ace89

File tree

1 file changed

+335
-0
lines changed

1 file changed

+335
-0
lines changed

src/ideas.zig

Lines changed: 335 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,335 @@
1+
//! Ideas queue management for opencoder.
2+
//!
3+
//! Handles reading, listing, and removing idea files from the
4+
//! .opencoder/ideas/ directory. Ideas are user-defined markdown files
5+
//! that opencoder prioritizes before generating its own plans.
6+
7+
const std = @import("std");
8+
const fs = std.fs;
9+
const Allocator = std.mem.Allocator;
10+
11+
const fsutil = @import("fs.zig");
12+
13+
/// An idea loaded from the ideas directory
14+
pub const Idea = struct {
15+
path: []const u8,
16+
filename: []const u8,
17+
content: []const u8,
18+
19+
/// Free allocated memory for idea fields
20+
pub fn deinit(self: *Idea, allocator: Allocator) void {
21+
allocator.free(self.path);
22+
allocator.free(self.filename);
23+
allocator.free(self.content);
24+
}
25+
26+
/// Get a summary of the idea (first line or truncated to ~100 chars)
27+
pub fn getSummary(self: Idea, allocator: Allocator) ![]const u8 {
28+
const trimmed = std.mem.trim(u8, self.content, " \t\n\r");
29+
if (trimmed.len == 0) {
30+
return try allocator.dupe(u8, "(empty)");
31+
}
32+
33+
// Find first newline
34+
if (std.mem.indexOf(u8, trimmed, "\n")) |newline_pos| {
35+
const first_line = trimmed[0..newline_pos];
36+
if (first_line.len <= 100) {
37+
return try allocator.dupe(u8, first_line);
38+
}
39+
// Truncate first line if too long
40+
return try std.fmt.allocPrint(allocator, "{s}...", .{first_line[0..97]});
41+
}
42+
43+
// No newline, use full content or truncate
44+
if (trimmed.len <= 100) {
45+
return try allocator.dupe(u8, trimmed);
46+
}
47+
return try std.fmt.allocPrint(allocator, "{s}...", .{trimmed[0..97]});
48+
}
49+
};
50+
51+
/// List of ideas for AI selection
52+
pub const IdeaList = struct {
53+
ideas: []Idea,
54+
allocator: Allocator,
55+
56+
/// Free all ideas and the list
57+
pub fn deinit(self: *IdeaList) void {
58+
for (self.ideas) |*idea| {
59+
idea.deinit(self.allocator);
60+
}
61+
self.allocator.free(self.ideas);
62+
}
63+
};
64+
65+
/// Load all valid ideas from the ideas directory
66+
/// Returns null if no valid ideas exist
67+
/// Skips and deletes empty or unreadable idea files
68+
pub fn loadAllIdeas(ideas_dir: []const u8, allocator: Allocator, max_size: usize) !?IdeaList {
69+
var dir = fs.cwd().openDir(ideas_dir, .{ .iterate = true }) catch |err| {
70+
if (err == error.FileNotFound) return null;
71+
return err;
72+
};
73+
defer dir.close();
74+
75+
var ideas = std.ArrayListUnmanaged(Idea){};
76+
errdefer {
77+
for (ideas.items) |*idea| {
78+
idea.deinit(allocator);
79+
}
80+
ideas.deinit(allocator);
81+
}
82+
83+
var iter = dir.iterate();
84+
while (try iter.next()) |entry| {
85+
if (entry.kind != .file) continue;
86+
if (!std.mem.endsWith(u8, entry.name, ".md")) continue;
87+
88+
const full_path = try std.fs.path.join(allocator, &.{ ideas_dir, entry.name });
89+
errdefer allocator.free(full_path);
90+
91+
// Try to read the file
92+
const content = fsutil.readFile(full_path, allocator, max_size) catch {
93+
// Skip unreadable files, but delete them
94+
allocator.free(full_path);
95+
removeIdea(ideas_dir, entry.name) catch {};
96+
continue;
97+
};
98+
99+
// Skip empty files, delete them
100+
const trimmed = std.mem.trim(u8, content, " \t\n\r");
101+
if (trimmed.len == 0) {
102+
allocator.free(content);
103+
allocator.free(full_path);
104+
removeIdea(ideas_dir, entry.name) catch {};
105+
continue;
106+
}
107+
108+
const filename = try allocator.dupe(u8, entry.name);
109+
errdefer allocator.free(filename);
110+
111+
try ideas.append(allocator, Idea{
112+
.path = full_path,
113+
.filename = filename,
114+
.content = content,
115+
});
116+
}
117+
118+
if (ideas.items.len == 0) {
119+
ideas.deinit(allocator);
120+
return null;
121+
}
122+
123+
return IdeaList{
124+
.ideas = try ideas.toOwnedSlice(allocator),
125+
.allocator = allocator,
126+
};
127+
}
128+
129+
/// Remove an idea file from the ideas directory
130+
pub fn removeIdea(ideas_dir: []const u8, filename: []const u8) !void {
131+
var dir = try fs.cwd().openDir(ideas_dir, .{});
132+
defer dir.close();
133+
try dir.deleteFile(filename);
134+
}
135+
136+
/// Remove an idea by its full path
137+
pub fn removeIdeaByPath(path: []const u8) !void {
138+
try fs.cwd().deleteFile(path);
139+
}
140+
141+
/// Format ideas for AI selection prompt
142+
/// Returns a formatted string listing all ideas with their content
143+
pub fn formatIdeasForSelection(ideas: []const Idea, allocator: Allocator) ![]const u8 {
144+
var result = std.ArrayListUnmanaged(u8){};
145+
errdefer result.deinit(allocator);
146+
147+
for (ideas, 0..) |idea, i| {
148+
try result.writer(allocator).print("## Idea {d}: {s}\n\n", .{ i + 1, idea.filename });
149+
try result.appendSlice(allocator, idea.content);
150+
try result.appendSlice(allocator, "\n\n---\n\n");
151+
}
152+
153+
return result.toOwnedSlice(allocator);
154+
}
155+
156+
// ============================================================================
157+
// Tests
158+
// ============================================================================
159+
160+
test "Idea.getSummary returns first line" {
161+
const allocator = std.testing.allocator;
162+
163+
const idea = Idea{
164+
.path = "/test/path.md",
165+
.filename = "test.md",
166+
.content = "First line of idea\nSecond line\nThird line",
167+
};
168+
169+
const summary = try idea.getSummary(allocator);
170+
defer allocator.free(summary);
171+
172+
try std.testing.expectEqualStrings("First line of idea", summary);
173+
}
174+
175+
test "Idea.getSummary truncates long first line" {
176+
const allocator = std.testing.allocator;
177+
178+
var long_line: [150]u8 = undefined;
179+
@memset(&long_line, 'a');
180+
181+
const idea = Idea{
182+
.path = "/test/path.md",
183+
.filename = "test.md",
184+
.content = &long_line,
185+
};
186+
187+
const summary = try idea.getSummary(allocator);
188+
defer allocator.free(summary);
189+
190+
try std.testing.expect(summary.len == 100); // 97 chars + "..."
191+
try std.testing.expect(std.mem.endsWith(u8, summary, "..."));
192+
}
193+
194+
test "Idea.getSummary handles empty content" {
195+
const allocator = std.testing.allocator;
196+
197+
const idea = Idea{
198+
.path = "/test/path.md",
199+
.filename = "test.md",
200+
.content = " \n\t ",
201+
};
202+
203+
const summary = try idea.getSummary(allocator);
204+
defer allocator.free(summary);
205+
206+
try std.testing.expectEqualStrings("(empty)", summary);
207+
}
208+
209+
test "loadAllIdeas returns null for nonexistent directory" {
210+
const allocator = std.testing.allocator;
211+
const result = try loadAllIdeas("/nonexistent/ideas", allocator, 1024 * 1024);
212+
try std.testing.expectEqual(@as(?IdeaList, null), result);
213+
}
214+
215+
test "loadAllIdeas returns null for empty directory" {
216+
const allocator = std.testing.allocator;
217+
const test_dir = "/tmp/opencoder_test_ideas_empty";
218+
219+
// Clean up and create empty directory
220+
fs.cwd().deleteTree(test_dir) catch {};
221+
try fs.cwd().makePath(test_dir);
222+
defer fs.cwd().deleteTree(test_dir) catch {};
223+
224+
const result = try loadAllIdeas(test_dir, allocator, 1024 * 1024);
225+
try std.testing.expectEqual(@as(?IdeaList, null), result);
226+
}
227+
228+
test "loadAllIdeas skips non-markdown files" {
229+
const allocator = std.testing.allocator;
230+
const test_dir = "/tmp/opencoder_test_ideas_skip";
231+
232+
// Setup
233+
fs.cwd().deleteTree(test_dir) catch {};
234+
try fs.cwd().makePath(test_dir);
235+
defer fs.cwd().deleteTree(test_dir) catch {};
236+
237+
// Create non-markdown file
238+
const txt_path = try std.fs.path.join(allocator, &.{ test_dir, "readme.txt" });
239+
defer allocator.free(txt_path);
240+
try fsutil.writeFile(txt_path, "This is not markdown");
241+
242+
const result = try loadAllIdeas(test_dir, allocator, 1024 * 1024);
243+
try std.testing.expectEqual(@as(?IdeaList, null), result);
244+
}
245+
246+
test "loadAllIdeas loads valid ideas" {
247+
const allocator = std.testing.allocator;
248+
const test_dir = "/tmp/opencoder_test_ideas_valid";
249+
250+
// Setup
251+
fs.cwd().deleteTree(test_dir) catch {};
252+
try fs.cwd().makePath(test_dir);
253+
defer fs.cwd().deleteTree(test_dir) catch {};
254+
255+
// Create valid idea files
256+
const idea1_path = try std.fs.path.join(allocator, &.{ test_dir, "idea1.md" });
257+
defer allocator.free(idea1_path);
258+
try fsutil.writeFile(idea1_path, "First idea content");
259+
260+
const idea2_path = try std.fs.path.join(allocator, &.{ test_dir, "idea2.md" });
261+
defer allocator.free(idea2_path);
262+
try fsutil.writeFile(idea2_path, "Second idea content");
263+
264+
const result = try loadAllIdeas(test_dir, allocator, 1024 * 1024);
265+
try std.testing.expect(result != null);
266+
267+
var list = result.?;
268+
defer list.deinit();
269+
270+
try std.testing.expectEqual(@as(usize, 2), list.ideas.len);
271+
}
272+
273+
test "loadAllIdeas deletes empty idea files" {
274+
const allocator = std.testing.allocator;
275+
const test_dir = "/tmp/opencoder_test_ideas_empty_file";
276+
277+
// Setup
278+
fs.cwd().deleteTree(test_dir) catch {};
279+
try fs.cwd().makePath(test_dir);
280+
defer fs.cwd().deleteTree(test_dir) catch {};
281+
282+
// Create empty idea file
283+
const empty_path = try std.fs.path.join(allocator, &.{ test_dir, "empty.md" });
284+
defer allocator.free(empty_path);
285+
try fsutil.writeFile(empty_path, " \n ");
286+
287+
// Should return null and delete the empty file
288+
const result = try loadAllIdeas(test_dir, allocator, 1024 * 1024);
289+
try std.testing.expectEqual(@as(?IdeaList, null), result);
290+
291+
// Verify file was deleted
292+
try std.testing.expect(!fsutil.fileExists(empty_path));
293+
}
294+
295+
test "formatIdeasForSelection formats correctly" {
296+
const allocator = std.testing.allocator;
297+
298+
const ideas = [_]Idea{
299+
.{
300+
.path = "/test/idea1.md",
301+
.filename = "idea1.md",
302+
.content = "Content of idea 1",
303+
},
304+
.{
305+
.path = "/test/idea2.md",
306+
.filename = "idea2.md",
307+
.content = "Content of idea 2",
308+
},
309+
};
310+
311+
const formatted = try formatIdeasForSelection(&ideas, allocator);
312+
defer allocator.free(formatted);
313+
314+
try std.testing.expect(std.mem.indexOf(u8, formatted, "## Idea 1: idea1.md") != null);
315+
try std.testing.expect(std.mem.indexOf(u8, formatted, "## Idea 2: idea2.md") != null);
316+
try std.testing.expect(std.mem.indexOf(u8, formatted, "Content of idea 1") != null);
317+
try std.testing.expect(std.mem.indexOf(u8, formatted, "Content of idea 2") != null);
318+
}
319+
320+
test "removeIdeaByPath deletes file" {
321+
const allocator = std.testing.allocator;
322+
const test_path = "/tmp/opencoder_test_remove_idea.md";
323+
324+
// Create test file
325+
try fsutil.writeFile(test_path, "Test idea");
326+
try std.testing.expect(fsutil.fileExists(test_path));
327+
328+
// Remove it
329+
try removeIdeaByPath(test_path);
330+
331+
// Verify deletion
332+
try std.testing.expect(!fsutil.fileExists(test_path));
333+
334+
_ = allocator;
335+
}

0 commit comments

Comments
 (0)