Skip to main content

every_other_token/
cli.rs

1//! Command-line argument definitions and helper functions.
2//!
3//! [`Args`] is the root Clap struct parsed in `main.rs`.  Helper functions
4//! ([`resolve_model`], [`validate_model`], [`parse_rate_range`], [`apply_template`])
5//! are kept here rather than in `main.rs` so they can be unit-tested in isolation.
6
7use crate::providers::Provider;
8use clap::Parser;
9
10#[derive(Parser)]
11#[command(name = "every-other-token")]
12#[command(version = "4.0.0")]
13#[command(about = "A real-time token stream mutator for LLM interpretability research")]
14pub struct Args {
15    /// Input prompt to send to the LLM (optional when using --web)
16    #[arg(default_value = "")]
17    pub prompt: String,
18
19    /// Transformation type (reverse, uppercase, mock, noise)
20    #[arg(default_value = "reverse")]
21    pub transform: String,
22
23    /// Model name (e.g. gpt-4, claude-sonnet-4-20250514)
24    #[arg(default_value = "gpt-3.5-turbo")]
25    pub model: String,
26
27    /// LLM provider: openai or anthropic
28    #[arg(long, value_enum, default_value = "openai")]
29    pub provider: Provider,
30
31    /// Enable visual mode with color-coded tokens
32    #[arg(long, short)]
33    pub visual: bool,
34
35    /// Enable token importance heatmap
36    #[arg(long)]
37    pub heatmap: bool,
38
39    /// Route through tokio-prompt-orchestrator MCP pipeline at localhost:3000
40    #[arg(long)]
41    pub orchestrator: bool,
42
43    /// Launch web UI on localhost instead of terminal output
44    #[arg(long)]
45    pub web: bool,
46
47    /// Port for the web UI server
48    #[arg(long, default_value = "8888")]
49    pub port: u16,
50
51    /// Enable headless research mode — runs N times and outputs JSON stats
52    #[arg(long)]
53    pub research: bool,
54
55    /// Number of runs in research mode
56    #[arg(long, default_value = "10")]
57    pub runs: u32,
58
59    /// Output file path for research JSON (defaults to stdout)
60    #[arg(long, default_value = "research_output.json")]
61    pub output: String,
62
63    /// System prompt A for A/B experiment mode
64    #[arg(long)]
65    pub system_a: Option<String>,
66
67    /// Number of top alternative tokens to return per position (OpenAI only, 0–20)
68    #[arg(long, default_value = "5")]
69    pub top_logprobs: u8,
70
71    /// System prompt B for A/B experiment mode
72    #[arg(long)]
73    pub system_b: Option<String>,
74
75    /// Path to SQLite database for persisting experiment results (optional)
76    #[arg(long)]
77    pub db: Option<String>,
78
79    /// Compute statistical significance (two-sample t-test) when ≥2 A/B runs available
80    #[arg(long)]
81    pub significance: bool,
82
83    /// Export per-position token confidence heatmap to CSV at this path
84    #[arg(long)]
85    pub heatmap_export: Option<String>,
86
87    /// Minimum average confidence to include a position in heatmap CSV export (0.0–1.0)
88    #[arg(long, default_value = "0.0")]
89    pub heatmap_min_confidence: f32,
90
91    /// Sort heatmap CSV rows by "position" (default) or "confidence"
92    #[arg(long, default_value = "position")]
93    pub heatmap_sort_by: String,
94
95    /// Record token events to a JSON replay file at this path
96    #[arg(long)]
97    pub record: Option<String>,
98
99    /// Replay token events from a JSON file (bypasses live LLM call)
100    #[arg(long)]
101    pub replay: Option<String>,
102
103    /// Fraction of tokens to intercept and transform (0.0–1.0, default 0.5).
104    /// At 0.5 every other token is transformed; at 0.3 roughly one in three.
105    /// Uses a deterministic Bresenham spread so results are reproducible when
106    /// combined with --seed.
107    ///
108    /// Stored as `Option<f64>` so the config-file loader can distinguish
109    /// "the user explicitly passed --rate" from "the user left it at the
110    /// default".  The effective value is `rate.unwrap_or(0.5)`.
111    #[arg(long)]
112    pub rate: Option<f64>,
113
114    /// Fixed RNG seed for reproducible Noise/Chaos transforms.
115    /// Omit to use entropy-seeded randomness (default behaviour).
116    #[arg(long)]
117    pub seed: Option<u64>,
118
119    /// Path to SQLite experiment log database (requires sqlite-log feature)
120    #[arg(long)]
121    pub log_db: Option<String>,
122
123    /// Enable per-position confidence baseline comparison (research mode)
124    #[arg(long)]
125    pub baseline: bool,
126
127    /// Path to a file with one prompt per line for batch research
128    #[arg(long)]
129    pub prompt_file: Option<String>,
130
131    /// Run two parallel streams (OpenAI + Anthropic) and print side-by-side diff in terminal
132    #[arg(long)]
133    pub diff_terminal: bool,
134
135    /// Print one JSON line per token to stdout instead of colored text
136    #[arg(long)]
137    pub json_stream: bool,
138
139    /// Generate shell completions for the given shell and exit
140    #[arg(long, value_name = "SHELL")]
141    pub completions: Option<clap_complete::Shell>,
142
143    /// HelixRouter base URL for cross-repo pressure feedback (e.g. http://127.0.0.1:8080).
144    ///
145    /// When set in --web mode, a HelixBridge background task polls HelixRouter's
146    /// /api/stats and feeds its pressure_score into the self-improvement loop,
147    /// letting EOT adapt token-stream parameters based on downstream load.
148    #[cfg(feature = "helix-bridge")]
149    #[arg(long)]
150    pub helix_url: Option<String>,
151
152    /// Rate range for stochastic experiments, e.g. "0.3-0.7". When set, the
153    /// interceptor randomly picks a rate in [min, max] for each run.
154    /// Overrides --rate when provided. Format: "MIN-MAX" (e.g. "0.2-0.8").
155    #[arg(long)]
156    pub rate_range: Option<String>,
157
158    /// Dry-run mode: show what transforms would be applied without calling any API.
159    /// Applies the configured transform to a sample token list and prints results.
160    #[arg(long)]
161    pub dry_run: bool,
162
163    /// Prompt template with {input} placeholder. When set, the positional prompt
164    /// is substituted into the template. Example: "Answer this: {input}"
165    #[arg(long)]
166    pub template: Option<String>,
167
168    /// Only transform tokens whose API confidence is below this threshold.
169    /// Tokens with confidence >= threshold are passed through unchanged.
170    /// When no confidence data is available (Anthropic), falls back to rate-based selection.
171    /// Range: 0.0–1.0. Example: --min-confidence 0.8
172    #[arg(long)]
173    pub min_confidence: Option<f64>,
174
175    /// Output format for research mode: "json" (default), "jsonl" (one JSON object per line).
176    #[arg(long, default_value = "json")]
177    pub format: String,
178
179    /// Number of consecutive low-confidence tokens to consider a "collapse" in research mode.
180    /// Default: 5.
181    #[arg(long, default_value = "5")]
182    pub collapse_window: usize,
183
184    /// Base URL for the MCP orchestrator pipeline (default: http://localhost:3000).
185    #[arg(long, default_value = "http://localhost:3000")]
186    pub orchestrator_url: String,
187
188    /// Maximum API retry attempts on 429/5xx errors (default: 3).
189    #[arg(long, default_value = "3")]
190    pub max_retries: u32,
191
192    /// Maximum tokens in the Anthropic response (default: 4096).
193    /// Ignored when using the OpenAI provider.
194    #[arg(long, default_value = "4096")]
195    pub anthropic_max_tokens: u32,
196
197    /// Path to a TSV or key=value file of additional synonym pairs to merge with the built-in map.
198    /// Format: one `word\treplacement` or `word = replacement` pair per line.
199    #[arg(long)]
200    pub synonym_file: Option<String>,
201
202    /// Optional API key required for /api/ endpoints in web UI mode.
203    /// When set, requests to /api/* must include `Authorization: Bearer <key>`.
204    #[arg(long)]
205    pub api_key: Option<String>,
206
207    /// Replay speed multiplier for --replay mode. 1.0 = real-time, 2.0 = double speed, 0.0 = instant.
208    #[arg(long, default_value = "1.0")]
209    pub replay_speed: f64,
210
211    /// Stream hang timeout in seconds. The stream is forcibly dropped if no token
212    /// arrives within this duration. Default: 120 (2 minutes). Set to 0 to disable.
213    #[arg(long, default_value = "120")]
214    pub timeout: u64,
215
216    /// Export per-run timeseries data to a CSV file at this path.
217    /// Columns: run,token_index,confidence,perplexity
218    #[arg(long)]
219    pub export_timeseries: Option<String>,
220
221    /// Print the embedded research JSON schema and exit.
222    #[arg(long)]
223    pub json_schema: bool,
224
225    /// List known models for a provider: "openai", "anthropic", or "all".
226    #[arg(long)]
227    pub list_models: Option<String>,
228
229    /// Validate configuration (print resolved values and exit).
230    #[arg(long)]
231    pub validate_config: bool,
232}
233
234/// Select the appropriate default model for the given provider when the user
235/// hasn't explicitly chosen one (i.e. the model is still the OpenAI default).
236pub fn resolve_model(provider: &Provider, model: &str) -> String {
237    match provider {
238        Provider::Anthropic if model == "gpt-3.5-turbo" => "claude-sonnet-4-6".to_string(),
239        Provider::Mock => "mock-fixture-v1".to_string(),
240        _ => model.to_string(),
241    }
242}
243
244/// Known-good model identifiers for basic validation (#18).
245///
246/// This list is non-exhaustive — new models are released regularly.
247/// An unknown model string produces a warning, not an error.
248const KNOWN_OPENAI_MODELS: &[&str] = &[
249    "gpt-3.5-turbo",
250    "gpt-3.5-turbo-0125",
251    "gpt-4",
252    "gpt-4-turbo",
253    "gpt-4o",
254    "gpt-4o-mini",
255    "gpt-4.1",
256    "gpt-4.1-mini",
257    "o1",
258    "o1-mini",
259    "o3",
260    "o3-mini",
261];
262
263const KNOWN_ANTHROPIC_MODELS: &[&str] = &[
264    "claude-3-haiku-20240307",
265    "claude-3-sonnet-20240229",
266    "claude-3-opus-20240229",
267    "claude-3-5-sonnet-20241022",
268    "claude-3-5-haiku-20241022",
269    "claude-haiku-4-5-20251001",
270    "claude-sonnet-4-6",
271    "claude-opus-4-6",
272];
273
274/// Warn if `model` does not match any known model for `provider`.
275/// Never errors — unknown models are still forwarded to the API.
276pub fn validate_model(provider: &Provider, model: &str) {
277    let known: &[&str] = match provider {
278        Provider::Openai => KNOWN_OPENAI_MODELS,
279        Provider::Anthropic => KNOWN_ANTHROPIC_MODELS,
280        Provider::Mock => return,
281    };
282    if !known.contains(&model) {
283        eprintln!(
284            "[warn] '{}' is not in the known {} model list — verify the model name is correct",
285            model, provider
286        );
287    }
288}
289
290/// Parse "MIN-MAX" rate range string. Returns (min, max) or None on error.
291///
292/// Uses `rfind('-')` to locate the separator so that scientific-notation
293/// values such as `"1e-3-0.5"` are parsed correctly (`1e-3` = 0.001).
294pub fn parse_rate_range(s: &str) -> Option<(f64, f64)> {
295    let sep = s.rfind('-')?;
296    let min = s[..sep].parse::<f64>().ok()?;
297    let max = s[sep + 1..].parse::<f64>().ok()?;
298    if min <= max && min >= 0.0 && max <= 1.0 {
299        Some((min, max))
300    } else {
301        None
302    }
303}
304
305/// Apply template substitution: replace "{input}" with the prompt.
306///
307/// The prompt is inserted verbatim — any literal `{input}` occurrences
308/// already inside the prompt are not re-expanded because we use a single
309/// non-recursive `replace` on the *template* string only.
310pub fn apply_template(template: &str, prompt: &str) -> String {
311    // Split on the literal placeholder and rejoin with the prompt so that
312    // braces inside `prompt` itself are never interpreted as placeholders.
313    template.split("{input}").collect::<Vec<_>>().join(prompt)
314}
315
316#[cfg(test)]
317mod tests {
318    use super::*;
319
320    #[test]
321    fn test_resolve_model_anthropic_default_swap() {
322        assert_eq!(
323            resolve_model(&Provider::Anthropic, "gpt-3.5-turbo"),
324            "claude-sonnet-4-6"
325        );
326    }
327
328    #[test]
329    fn test_resolve_model_anthropic_explicit_model_kept() {
330        assert_eq!(
331            resolve_model(&Provider::Anthropic, "claude-haiku-4-5-20251001"),
332            "claude-haiku-4-5-20251001"
333        );
334    }
335
336    #[test]
337    fn test_resolve_model_openai_default_kept() {
338        assert_eq!(
339            resolve_model(&Provider::Openai, "gpt-3.5-turbo"),
340            "gpt-3.5-turbo"
341        );
342    }
343
344    #[test]
345    fn test_resolve_model_openai_explicit_model_kept() {
346        assert_eq!(resolve_model(&Provider::Openai, "gpt-4"), "gpt-4");
347    }
348
349    #[test]
350    fn test_args_parse_minimal() {
351        let args = Args::parse_from(["eot", "hello world"]);
352        assert_eq!(args.prompt, "hello world");
353        assert_eq!(args.transform, "reverse");
354        assert_eq!(args.model, "gpt-3.5-turbo");
355        assert_eq!(args.provider, Provider::Openai);
356        assert!(!args.visual);
357        assert!(!args.heatmap);
358        assert!(!args.orchestrator);
359        assert!(!args.web);
360        assert_eq!(args.port, 8888);
361        assert_eq!(args.top_logprobs, 5);
362    }
363
364    #[test]
365    fn test_args_parse_full() {
366        let args = Args::parse_from([
367            "eot",
368            "test prompt",
369            "uppercase",
370            "gpt-4",
371            "--provider",
372            "anthropic",
373            "--visual",
374            "--heatmap",
375            "--orchestrator",
376            "--web",
377            "--port",
378            "9000",
379        ]);
380        assert_eq!(args.prompt, "test prompt");
381        assert_eq!(args.transform, "uppercase");
382        assert_eq!(args.model, "gpt-4");
383        assert_eq!(args.provider, Provider::Anthropic);
384        assert!(args.visual);
385        assert!(args.heatmap);
386        assert!(args.orchestrator);
387        assert!(args.web);
388        assert_eq!(args.port, 9000);
389    }
390
391    #[test]
392    fn test_args_parse_provider_openai() {
393        let args = Args::parse_from(["eot", "prompt", "--provider", "openai"]);
394        assert_eq!(args.provider, Provider::Openai);
395    }
396
397    #[test]
398    fn test_args_parse_provider_anthropic() {
399        let args = Args::parse_from(["eot", "prompt", "--provider", "anthropic"]);
400        assert_eq!(args.provider, Provider::Anthropic);
401    }
402
403    #[test]
404    fn test_args_parse_short_visual() {
405        let args = Args::parse_from(["eot", "prompt", "-v"]);
406        assert!(args.visual);
407    }
408
409    #[test]
410    fn test_args_default_port() {
411        let args = Args::parse_from(["eot", "prompt"]);
412        assert_eq!(args.port, 8888);
413    }
414
415    #[test]
416    fn test_args_custom_port() {
417        let args = Args::parse_from(["eot", "prompt", "--port", "3000"]);
418        assert_eq!(args.port, 3000);
419    }
420
421    #[test]
422    fn test_args_research_flag_default_false() {
423        let args = Args::parse_from(["eot", "prompt"]);
424        assert!(!args.research);
425    }
426
427    #[test]
428    fn test_args_research_flag_set() {
429        let args = Args::parse_from(["eot", "prompt", "--research"]);
430        assert!(args.research);
431    }
432
433    #[test]
434    fn test_args_runs_default_one() {
435        let args = Args::parse_from(["eot", "prompt"]);
436        assert_eq!(args.runs, 10);
437    }
438
439    #[test]
440    fn test_args_runs_custom() {
441        let args = Args::parse_from(["eot", "prompt", "--runs", "50"]);
442        assert_eq!(args.runs, 50);
443    }
444
445    #[test]
446    fn test_args_output_default_none() {
447        let args = Args::parse_from(["eot", "prompt"]);
448        assert_eq!(args.output, "research_output.json");
449    }
450
451    #[test]
452    fn test_args_output_custom() {
453        let args = Args::parse_from(["eot", "prompt", "--output", "results.json"]);
454        assert_eq!(args.output, "results.json");
455    }
456
457    #[test]
458    fn test_args_system_prompt_default_none() {
459        let args = Args::parse_from(["eot", "prompt"]);
460        assert!(args.system_a.is_none());
461    }
462
463    #[test]
464    fn test_args_system_prompt_set() {
465        let args = Args::parse_from(["eot", "prompt", "--system-a", "Be concise."]);
466        assert_eq!(args.system_a.as_deref(), Some("Be concise."));
467    }
468
469    #[test]
470    fn test_args_research_with_runs_and_output() {
471        let args = Args::parse_from([
472            "eot",
473            "test prompt",
474            "--research",
475            "--runs",
476            "100",
477            "--output",
478            "out.json",
479        ]);
480        assert!(args.research);
481        assert_eq!(args.runs, 100);
482        assert_eq!(args.output, "out.json");
483    }
484
485    #[test]
486    fn test_args_research_does_not_require_web() {
487        let args = Args::parse_from(["eot", "prompt", "--research"]);
488        assert!(!args.web);
489        assert!(args.research);
490    }
491
492    #[test]
493    fn test_args_parse_research_flag() {
494        let args = Args::parse_from(["eot", "prompt", "--research"]);
495        assert!(args.research);
496        assert_eq!(args.runs, 10);
497        assert_eq!(args.output, "research_output.json");
498    }
499
500    #[test]
501    fn test_args_parse_research_custom_runs() {
502        let args = Args::parse_from(["eot", "prompt", "--research", "--runs", "50"]);
503        assert_eq!(args.runs, 50);
504    }
505
506    #[test]
507    fn test_args_parse_research_custom_output() {
508        let args = Args::parse_from(["eot", "prompt", "--research", "--output", "out.json"]);
509        assert_eq!(args.output, "out.json");
510    }
511
512    #[test]
513    fn test_args_parse_system_a() {
514        let args = Args::parse_from(["eot", "prompt", "--system-a", "Be concise."]);
515        assert_eq!(args.system_a, Some("Be concise.".to_string()));
516    }
517
518    #[test]
519    fn test_args_parse_system_b() {
520        let args = Args::parse_from(["eot", "prompt", "--system-b", "Be verbose."]);
521        assert_eq!(args.system_b, Some("Be verbose.".to_string()));
522    }
523
524    #[cfg(feature = "helix-bridge")]
525    #[test]
526    fn test_args_helix_url_default_none() {
527        let args = Args::parse_from(["eot", "prompt"]);
528        assert!(args.helix_url.is_none());
529    }
530
531    #[cfg(feature = "helix-bridge")]
532    #[test]
533    fn test_args_helix_url_set() {
534        let args = Args::parse_from(["eot", "prompt", "--helix-url", "http://127.0.0.1:8080"]);
535        assert_eq!(args.helix_url.as_deref(), Some("http://127.0.0.1:8080"));
536    }
537
538    #[test]
539    fn test_parse_rate_range_valid() {
540        assert_eq!(parse_rate_range("0.3-0.7"), Some((0.3, 0.7)));
541    }
542
543    #[test]
544    fn test_parse_rate_range_equal() {
545        assert_eq!(parse_rate_range("0.5-0.5"), Some((0.5, 0.5)));
546    }
547
548    #[test]
549    fn test_parse_rate_range_invalid() {
550        assert_eq!(parse_rate_range("invalid"), None);
551    }
552
553    #[test]
554    fn test_parse_rate_range_min_greater_than_max() {
555        assert_eq!(parse_rate_range("0.8-0.2"), None);
556    }
557
558    #[test]
559    fn test_parse_rate_range_scientific_notation() {
560        // rfind ensures the separator is the last '-', so "1e-3" parses correctly.
561        let result = parse_rate_range("1e-3-0.5");
562        assert!(result.is_some());
563        let (min, max) = result.unwrap();
564        assert!((min - 0.001).abs() < 1e-9);
565        assert!((max - 0.5).abs() < 1e-9);
566    }
567
568    #[test]
569    fn test_parse_rate_range_no_separator_returns_none() {
570        assert_eq!(parse_rate_range("0.5"), None);
571    }
572
573    #[test]
574    fn test_apply_template_with_placeholder() {
575        assert_eq!(apply_template("Answer: {input}", "hello"), "Answer: hello");
576    }
577
578    #[test]
579    fn test_apply_template_no_placeholder() {
580        assert_eq!(apply_template("No placeholder", "hello"), "No placeholder");
581    }
582
583    #[test]
584    fn test_args_dry_run_flag() {
585        let args = Args::parse_from(["eot", "prompt", "--dry-run"]);
586        assert!(args.dry_run);
587    }
588
589    #[test]
590    fn test_args_min_confidence() {
591        let args = Args::parse_from(["eot", "prompt", "--min-confidence", "0.8"]);
592        assert_eq!(args.min_confidence, Some(0.8));
593    }
594
595    #[test]
596    fn test_args_collapse_window() {
597        let args = Args::parse_from(["eot", "prompt", "--collapse-window", "10"]);
598        assert_eq!(args.collapse_window, 10);
599    }
600
601    #[test]
602    fn test_args_format_jsonl() {
603        let args = Args::parse_from(["eot", "prompt", "--format", "jsonl"]);
604        assert_eq!(args.format, "jsonl");
605    }
606
607    // -- validate_model tests (#18) --
608
609    #[test]
610    fn test_validate_model_known_openai_no_warn() {
611        // Should not panic; just verifying the function runs without error
612        validate_model(&Provider::Openai, "gpt-4");
613        validate_model(&Provider::Openai, "gpt-3.5-turbo");
614        validate_model(&Provider::Openai, "gpt-4o");
615    }
616
617    #[test]
618    fn test_validate_model_known_anthropic_no_warn() {
619        validate_model(&Provider::Anthropic, "claude-sonnet-4-6");
620        validate_model(&Provider::Anthropic, "claude-opus-4-6");
621        validate_model(&Provider::Anthropic, "claude-haiku-4-5-20251001");
622    }
623
624    #[test]
625    fn test_validate_model_unknown_does_not_panic() {
626        // Unknown models emit a warning but must not panic
627        validate_model(&Provider::Openai, "gpt-9-turbo-ultra");
628        validate_model(&Provider::Anthropic, "claude-99");
629    }
630
631    #[test]
632    fn test_validate_model_mock_always_silent() {
633        // Mock provider skips validation entirely
634        validate_model(&Provider::Mock, "any-model-string");
635    }
636
637    #[test]
638    fn test_known_openai_models_nonempty() {
639        assert!(!KNOWN_OPENAI_MODELS.is_empty());
640    }
641
642    #[test]
643    fn test_known_anthropic_models_nonempty() {
644        assert!(!KNOWN_ANTHROPIC_MODELS.is_empty());
645    }
646
647    #[test]
648    fn test_known_openai_models_contain_gpt4() {
649        assert!(KNOWN_OPENAI_MODELS.contains(&"gpt-4"));
650    }
651
652    #[test]
653    fn test_known_anthropic_models_contain_sonnet() {
654        assert!(KNOWN_ANTHROPIC_MODELS.contains(&"claude-sonnet-4-6"));
655    }
656
657    // -- Template injection safety tests (#6) --
658
659    #[test]
660    fn test_apply_template_prompt_with_placeholder_not_reexpanded() {
661        // Prompt containing "{input}" must NOT be re-expanded
662        let result = apply_template("Q: {input}", "what is {input}?");
663        assert_eq!(result, "Q: what is {input}?");
664    }
665
666    #[test]
667    fn test_apply_template_multiple_placeholders() {
668        let result = apply_template("{input} and {input}", "hello");
669        assert_eq!(result, "hello and hello");
670    }
671
672    // -- New CLI flags tests (#12, #13) --
673
674    #[test]
675    fn test_args_orchestrator_url_default() {
676        let args = Args::parse_from(["eot", "prompt"]);
677        assert_eq!(args.orchestrator_url, "http://localhost:3000");
678    }
679
680    #[test]
681    fn test_args_orchestrator_url_custom() {
682        let args = Args::parse_from([
683            "eot",
684            "prompt",
685            "--orchestrator-url",
686            "http://10.0.0.1:9000",
687        ]);
688        assert_eq!(args.orchestrator_url, "http://10.0.0.1:9000");
689    }
690
691    #[test]
692    fn test_args_max_retries_default() {
693        let args = Args::parse_from(["eot", "prompt"]);
694        assert_eq!(args.max_retries, 3);
695    }
696
697    #[test]
698    fn test_args_max_retries_custom() {
699        let args = Args::parse_from(["eot", "prompt", "--max-retries", "5"]);
700        assert_eq!(args.max_retries, 5);
701    }
702
703    #[test]
704    fn test_args_max_retries_zero() {
705        let args = Args::parse_from(["eot", "prompt", "--max-retries", "0"]);
706        assert_eq!(args.max_retries, 0);
707    }
708
709    #[test]
710    fn test_args_timeout_default() {
711        let args = Args::parse_from(["eot", "prompt"]);
712        assert_eq!(args.timeout, 120);
713    }
714
715    #[test]
716    fn test_args_timeout_custom() {
717        let args = Args::parse_from(["eot", "prompt", "--timeout", "60"]);
718        assert_eq!(args.timeout, 60);
719    }
720
721    #[test]
722    fn test_args_timeout_zero_disables() {
723        let args = Args::parse_from(["eot", "prompt", "--timeout", "0"]);
724        assert_eq!(args.timeout, 0);
725    }
726
727    // -- Item 14: --validate-config flag --
728    #[test]
729    fn test_validate_config_flag_exists() {
730        let args = Args::parse_from(["eot", "prompt"]);
731        assert!(!args.validate_config, "validate_config should default to false");
732        let args2 = Args::parse_from(["eot", "prompt", "--validate-config"]);
733        assert!(args2.validate_config);
734    }
735
736    // -- Item 15: --list-models flag --
737    #[test]
738    fn test_list_models_openai_includes_gpt4() {
739        let openai_models = ["gpt-3.5-turbo", "gpt-4", "gpt-4o", "gpt-4o-mini", "gpt-4-turbo"];
740        assert!(openai_models.contains(&"gpt-4"), "openai list should include gpt-4");
741    }
742
743    #[test]
744    fn test_list_models_flag_accepts_openai() {
745        let args = Args::parse_from(["eot", "prompt", "--list-models", "openai"]);
746        assert_eq!(args.list_models.as_deref(), Some("openai"));
747    }
748
749    #[test]
750    fn test_list_models_flag_accepts_all() {
751        let args = Args::parse_from(["eot", "prompt", "--list-models", "all"]);
752        assert_eq!(args.list_models.as_deref(), Some("all"));
753    }
754
755    // -- Item 17: --json-schema flag --
756    #[test]
757    fn test_json_schema_flag_outputs_valid_json() {
758        const RESEARCH_SCHEMA: &str = include_str!("../docs/research-schema.json");
759        let result = serde_json::from_str::<serde_json::Value>(RESEARCH_SCHEMA);
760        assert!(result.is_ok(), "research-schema.json must be valid JSON");
761    }
762
763    // -- Item 16: --record path unwritable detected --
764    #[test]
765    fn test_record_path_unwritable_detected() {
766        // Test that trying to open a path in a non-existent directory fails
767        let bad_path = "/nonexistent_dir_eot_test/output.json";
768        let result = std::fs::OpenOptions::new()
769            .create(true)
770            .write(true)
771            .open(bad_path);
772        assert!(result.is_err(), "opening a path in a nonexistent dir should fail");
773    }
774
775    // -- Item 12: --export-timeseries flag --
776    #[test]
777    fn test_export_timeseries_flag_default_none() {
778        let args = Args::parse_from(["eot", "prompt"]);
779        assert!(args.export_timeseries.is_none());
780    }
781
782    #[test]
783    fn test_export_timeseries_flag_set() {
784        let args = Args::parse_from(["eot", "prompt", "--export-timeseries", "out.csv"]);
785        assert_eq!(args.export_timeseries.as_deref(), Some("out.csv"));
786    }
787}