@@ -26,11 +26,15 @@ pub const ParseResult = union(enum) {
2626 run : config.Config ,
2727};
2828
29- /// Parse command-line arguments
29+ /// Parse command-line arguments from process
3030pub fn parse (allocator : Allocator ) ParseError ! ParseResult {
3131 var args = std .process .args ();
3232 _ = args .skip (); // Skip program name
33+ return parseArgs (allocator , & args );
34+ }
3335
36+ /// Parse command-line arguments from an iterator (testable version)
37+ fn parseArgs (allocator : Allocator , args : anytype ) ParseError ! ParseResult {
3438 var cfg = config .Config .loadFromEnv ();
3539 var planning_model : ? []const u8 = null ;
3640 var execution_model : ? []const u8 = null ;
@@ -92,6 +96,29 @@ pub fn parse(allocator: Allocator) ParseError!ParseResult {
9296 return .{ .run = cfg };
9397}
9498
99+ /// Test helper: parse arguments from a slice
100+ fn parseFromSlice (allocator : Allocator , args : []const []const u8 ) ParseError ! ParseResult {
101+ var iter = ArgIterator .init (args );
102+ return parseArgs (allocator , & iter );
103+ }
104+
105+ /// Simple argument iterator for testing
106+ const ArgIterator = struct {
107+ args : []const []const u8 ,
108+ index : usize ,
109+
110+ fn init (args : []const []const u8 ) ArgIterator {
111+ return .{ .args = args , .index = 0 };
112+ }
113+
114+ fn next (self : * ArgIterator ) ? []const u8 {
115+ if (self .index >= self .args .len ) return null ;
116+ const arg = self .args [self .index ];
117+ self .index += 1 ;
118+ return arg ;
119+ }
120+ };
121+
95122const usage_text =
96123 \\opencoder v
97124++ config .version ++
@@ -183,3 +210,310 @@ test "usage_text contains expected strings" {
183210test "usage_text contains version" {
184211 try std .testing .expect (std .mem .indexOf (u8 , usage_text , config .version ) != null );
185212}
213+
214+ // ============================================================================
215+ // Parse Function Tests
216+ // ============================================================================
217+
218+ test "parse help flag short form" {
219+ const args = &[_ ][]const u8 {"-h" };
220+ const result = try parseFromSlice (std .testing .allocator , args );
221+ try std .testing .expectEqual (ParseResult .help , result );
222+ }
223+
224+ test "parse help flag long form" {
225+ const args = &[_ ][]const u8 {"--help" };
226+ const result = try parseFromSlice (std .testing .allocator , args );
227+ try std .testing .expectEqual (ParseResult .help , result );
228+ }
229+
230+ test "parse version flag" {
231+ const args = &[_ ][]const u8 {"--version" };
232+ const result = try parseFromSlice (std .testing .allocator , args );
233+ try std .testing .expectEqual (ParseResult .version , result );
234+ }
235+
236+ test "parse with github provider preset" {
237+ const args = &[_ ][]const u8 { "--provider" , "github" };
238+ const result = try parseFromSlice (std .testing .allocator , args );
239+ defer if (result == .run ) std .testing .allocator .free (result .run .project_dir );
240+
241+ try std .testing .expect (result == .run );
242+ try std .testing .expectEqualStrings ("github-copilot/claude-opus-4.5" , result .run .planning_model );
243+ try std .testing .expectEqualStrings ("github-copilot/claude-sonnet-4.5" , result .run .execution_model );
244+ }
245+
246+ test "parse with anthropic provider preset" {
247+ const args = &[_ ][]const u8 { "--provider" , "anthropic" };
248+ const result = try parseFromSlice (std .testing .allocator , args );
249+ defer if (result == .run ) std .testing .allocator .free (result .run .project_dir );
250+
251+ try std .testing .expect (result == .run );
252+ try std .testing .expectEqualStrings ("anthropic/claude-sonnet-4" , result .run .planning_model );
253+ try std .testing .expectEqualStrings ("anthropic/claude-haiku" , result .run .execution_model );
254+ }
255+
256+ test "parse with openai provider preset" {
257+ const args = &[_ ][]const u8 { "--provider" , "openai" };
258+ const result = try parseFromSlice (std .testing .allocator , args );
259+ defer if (result == .run ) std .testing .allocator .free (result .run .project_dir );
260+
261+ try std .testing .expect (result == .run );
262+ try std .testing .expectEqualStrings ("openai/gpt-4" , result .run .planning_model );
263+ try std .testing .expectEqualStrings ("openai/gpt-4o-mini" , result .run .execution_model );
264+ }
265+
266+ test "parse with opencode provider preset" {
267+ const args = &[_ ][]const u8 { "--provider" , "opencode" };
268+ const result = try parseFromSlice (std .testing .allocator , args );
269+ defer if (result == .run ) std .testing .allocator .free (result .run .project_dir );
270+
271+ try std .testing .expect (result == .run );
272+ try std .testing .expectEqualStrings ("opencode/glm-4.7-free" , result .run .planning_model );
273+ try std .testing .expectEqualStrings ("opencode/minimax-m2.1-free" , result .run .execution_model );
274+ }
275+
276+ test "parse with explicit models short form" {
277+ const args = &[_ ][]const u8 { "-P" , "my/planning-model" , "-E" , "my/execution-model" };
278+ const result = try parseFromSlice (std .testing .allocator , args );
279+ defer if (result == .run ) std .testing .allocator .free (result .run .project_dir );
280+
281+ try std .testing .expect (result == .run );
282+ try std .testing .expectEqualStrings ("my/planning-model" , result .run .planning_model );
283+ try std .testing .expectEqualStrings ("my/execution-model" , result .run .execution_model );
284+ }
285+
286+ test "parse with explicit models long form" {
287+ const args = &[_ ][]const u8 { "--planning-model" , "my/planning-model" , "--execution-model" , "my/execution-model" };
288+ const result = try parseFromSlice (std .testing .allocator , args );
289+ defer if (result == .run ) std .testing .allocator .free (result .run .project_dir );
290+
291+ try std .testing .expect (result == .run );
292+ try std .testing .expectEqualStrings ("my/planning-model" , result .run .planning_model );
293+ try std .testing .expectEqualStrings ("my/execution-model" , result .run .execution_model );
294+ }
295+
296+ test "parse with verbose flag short form" {
297+ const args = &[_ ][]const u8 { "--provider" , "github" , "-v" };
298+ const result = try parseFromSlice (std .testing .allocator , args );
299+ defer if (result == .run ) std .testing .allocator .free (result .run .project_dir );
300+
301+ try std .testing .expect (result == .run );
302+ try std .testing .expectEqual (true , result .run .verbose );
303+ }
304+
305+ test "parse with verbose flag long form" {
306+ const args = &[_ ][]const u8 { "--provider" , "github" , "--verbose" };
307+ const result = try parseFromSlice (std .testing .allocator , args );
308+ defer if (result == .run ) std .testing .allocator .free (result .run .project_dir );
309+
310+ try std .testing .expect (result == .run );
311+ try std .testing .expectEqual (true , result .run .verbose );
312+ }
313+
314+ test "parse with project directory short form" {
315+ const args = &[_ ][]const u8 { "--provider" , "github" , "-p" , "." };
316+ const result = try parseFromSlice (std .testing .allocator , args );
317+ defer if (result == .run ) std .testing .allocator .free (result .run .project_dir );
318+
319+ try std .testing .expect (result == .run );
320+ // project_dir should be resolved to absolute path
321+ try std .testing .expect (result .run .project_dir .len > 0 );
322+ }
323+
324+ test "parse with project directory long form" {
325+ const args = &[_ ][]const u8 { "--provider" , "github" , "--project" , "." };
326+ const result = try parseFromSlice (std .testing .allocator , args );
327+ defer if (result == .run ) std .testing .allocator .free (result .run .project_dir );
328+
329+ try std .testing .expect (result == .run );
330+ try std .testing .expect (result .run .project_dir .len > 0 );
331+ }
332+
333+ test "parse with user hint" {
334+ const args = &[_ ][]const u8 { "--provider" , "github" , "build a todo app" };
335+ const result = try parseFromSlice (std .testing .allocator , args );
336+ defer if (result == .run ) std .testing .allocator .free (result .run .project_dir );
337+
338+ try std .testing .expect (result == .run );
339+ try std .testing .expect (result .run .user_hint != null );
340+ try std .testing .expectEqualStrings ("build a todo app" , result .run .user_hint .? );
341+ }
342+
343+ test "parse with all options combined" {
344+ const args = &[_ ][]const u8 { "--provider" , "anthropic" , "-v" , "-p" , "." , "create a REST API" };
345+ const result = try parseFromSlice (std .testing .allocator , args );
346+ defer if (result == .run ) std .testing .allocator .free (result .run .project_dir );
347+
348+ try std .testing .expect (result == .run );
349+ try std .testing .expectEqual (true , result .run .verbose );
350+ try std .testing .expect (result .run .project_dir .len > 0 );
351+ try std .testing .expect (result .run .user_hint != null );
352+ try std .testing .expectEqualStrings ("create a REST API" , result .run .user_hint .? );
353+ }
354+
355+ test "parse with mixed explicit models and provider (explicit wins)" {
356+ const args = &[_ ][]const u8 { "--provider" , "github" , "-P" , "custom/planning" , "-E" , "custom/execution" };
357+ const result = try parseFromSlice (std .testing .allocator , args );
358+ defer if (result == .run ) std .testing .allocator .free (result .run .project_dir );
359+
360+ try std .testing .expect (result == .run );
361+ // Explicit models should override provider preset
362+ try std .testing .expectEqualStrings ("custom/planning" , result .run .planning_model );
363+ try std .testing .expectEqualStrings ("custom/execution" , result .run .execution_model );
364+ }
365+
366+ test "parse with options in different order" {
367+ const args = &[_ ][]const u8 { "-v" , "build something" , "--provider" , "openai" , "-p" , "." };
368+ const result = try parseFromSlice (std .testing .allocator , args );
369+ defer if (result == .run ) std .testing .allocator .free (result .run .project_dir );
370+
371+ try std .testing .expect (result == .run );
372+ try std .testing .expectEqual (true , result .run .verbose );
373+ try std .testing .expectEqualStrings ("build something" , result .run .user_hint .? );
374+ }
375+
376+ // ============================================================================
377+ // Error Case Tests
378+ // ============================================================================
379+
380+ test "parse error: no arguments" {
381+ const args = &[_ ][]const u8 {};
382+ const result = parseFromSlice (std .testing .allocator , args );
383+ try std .testing .expectError (ParseError .MissingRequiredArgs , result );
384+ }
385+
386+ test "parse error: only verbose flag" {
387+ const args = &[_ ][]const u8 {"-v" };
388+ const result = parseFromSlice (std .testing .allocator , args );
389+ try std .testing .expectError (ParseError .MissingRequiredArgs , result );
390+ }
391+
392+ test "parse error: missing planning model" {
393+ const args = &[_ ][]const u8 { "-E" , "my/execution-model" };
394+ const result = parseFromSlice (std .testing .allocator , args );
395+ try std .testing .expectError (ParseError .MissingRequiredArgs , result );
396+ }
397+
398+ test "parse error: missing execution model" {
399+ const args = &[_ ][]const u8 { "-P" , "my/planning-model" };
400+ const result = parseFromSlice (std .testing .allocator , args );
401+ try std .testing .expectError (ParseError .MissingRequiredArgs , result );
402+ }
403+
404+ test "parse error: unknown option" {
405+ const args = &[_ ][]const u8 { "--provider" , "github" , "--unknown" };
406+ const result = parseFromSlice (std .testing .allocator , args );
407+ try std .testing .expectError (ParseError .UnknownOption , result );
408+ }
409+
410+ test "parse error: unknown option short form" {
411+ const args = &[_ ][]const u8 { "--provider" , "github" , "-x" };
412+ const result = parseFromSlice (std .testing .allocator , args );
413+ try std .testing .expectError (ParseError .UnknownOption , result );
414+ }
415+
416+ test "parse error: unknown provider" {
417+ const args = &[_ ][]const u8 { "--provider" , "unknown" };
418+ const result = parseFromSlice (std .testing .allocator , args );
419+ try std .testing .expectError (ParseError .UnknownProvider , result );
420+ }
421+
422+ test "parse error: provider with empty string" {
423+ const args = &[_ ][]const u8 { "--provider" , "" };
424+ const result = parseFromSlice (std .testing .allocator , args );
425+ try std .testing .expectError (ParseError .UnknownProvider , result );
426+ }
427+
428+ test "parse error: missing provider value" {
429+ const args = &[_ ][]const u8 {"--provider" };
430+ const result = parseFromSlice (std .testing .allocator , args );
431+ try std .testing .expectError (ParseError .MissingOptionValue , result );
432+ }
433+
434+ test "parse error: missing planning model value" {
435+ const args = &[_ ][]const u8 { "-E" , "my/execution-model" , "-P" };
436+ const result = parseFromSlice (std .testing .allocator , args );
437+ try std .testing .expectError (ParseError .MissingOptionValue , result );
438+ }
439+
440+ test "parse error: missing execution model value" {
441+ const args = &[_ ][]const u8 { "-P" , "my/planning-model" , "-E" };
442+ const result = parseFromSlice (std .testing .allocator , args );
443+ try std .testing .expectError (ParseError .MissingOptionValue , result );
444+ }
445+
446+ test "parse error: missing project directory value" {
447+ const args = &[_ ][]const u8 { "--provider" , "github" , "-p" };
448+ const result = parseFromSlice (std .testing .allocator , args );
449+ try std .testing .expectError (ParseError .MissingOptionValue , result );
450+ }
451+
452+ test "parse error: invalid project directory" {
453+ const args = &[_ ][]const u8 { "--provider" , "github" , "-p" , "/nonexistent/directory/path" };
454+ const result = parseFromSlice (std .testing .allocator , args );
455+ try std .testing .expectError (ParseError .InvalidProjectDir , result );
456+ }
457+
458+ // ============================================================================
459+ // Edge Case Tests
460+ // ============================================================================
461+
462+ test "parse edge case: help flag takes precedence" {
463+ const args = &[_ ][]const u8 { "--provider" , "github" , "--help" };
464+ const result = try parseFromSlice (std .testing .allocator , args );
465+ try std .testing .expectEqual (ParseResult .help , result );
466+ }
467+
468+ test "parse edge case: version flag takes precedence" {
469+ const args = &[_ ][]const u8 { "--provider" , "github" , "--version" };
470+ const result = try parseFromSlice (std .testing .allocator , args );
471+ try std .testing .expectEqual (ParseResult .version , result );
472+ }
473+
474+ test "parse edge case: multiple verbose flags" {
475+ const args = &[_ ][]const u8 { "--provider" , "github" , "-v" , "--verbose" };
476+ const result = try parseFromSlice (std .testing .allocator , args );
477+ defer if (result == .run ) std .testing .allocator .free (result .run .project_dir );
478+
479+ try std .testing .expect (result == .run );
480+ try std .testing .expectEqual (true , result .run .verbose );
481+ }
482+
483+ test "parse edge case: last user hint wins" {
484+ const args = &[_ ][]const u8 { "--provider" , "github" , "first hint" , "second hint" };
485+ const result = try parseFromSlice (std .testing .allocator , args );
486+ defer if (result == .run ) std .testing .allocator .free (result .run .project_dir );
487+
488+ try std .testing .expect (result == .run );
489+ // Only the last positional argument is used as hint
490+ try std .testing .expectEqualStrings ("second hint" , result .run .user_hint .? );
491+ }
492+
493+ test "parse edge case: model names with special characters" {
494+ const args = &[_ ][]const u8 { "-P" , "provider/model-v1.2.3" , "-E" , "provider/model_beta" };
495+ const result = try parseFromSlice (std .testing .allocator , args );
496+ defer if (result == .run ) std .testing .allocator .free (result .run .project_dir );
497+
498+ try std .testing .expect (result == .run );
499+ try std .testing .expectEqualStrings ("provider/model-v1.2.3" , result .run .planning_model );
500+ try std .testing .expectEqualStrings ("provider/model_beta" , result .run .execution_model );
501+ }
502+
503+ test "parse edge case: user hint with spaces preserved" {
504+ const args = &[_ ][]const u8 { "--provider" , "github" , "build a complex web application" };
505+ const result = try parseFromSlice (std .testing .allocator , args );
506+ defer if (result == .run ) std .testing .allocator .free (result .run .project_dir );
507+
508+ try std .testing .expect (result == .run );
509+ try std .testing .expectEqualStrings ("build a complex web application" , result .run .user_hint .? );
510+ }
511+
512+ test "parse edge case: no user hint results in null" {
513+ const args = &[_ ][]const u8 { "--provider" , "github" };
514+ const result = try parseFromSlice (std .testing .allocator , args );
515+ defer if (result == .run ) std .testing .allocator .free (result .run .project_dir );
516+
517+ try std .testing .expect (result == .run );
518+ try std .testing .expectEqual (@as (? []const u8 , null ), result .run .user_hint );
519+ }
0 commit comments