Skip to main content

rpi_cli/
app.rs

1//! CLI entry orchestrator. Mirrors the v1-relevant slice of the TS
2//! `packages/coding-agent/src/main.ts` — the `main(args)` function that:
3//!
4//! 1. Parses argv ([`crate::args::parse_args`]).
5//! 2. Handles `--help`/`--version` + parse errors + startup warnings.
6//! 3. Reads piped stdin (non-TTY ⇒ treat as the initial prompt text — TS
7//!    `readPipedStdin`).
8//! 4. Expands `@file` attachments into an initial-message text block (TS
9//!    `processFileArguments` + [`build_initial_message`] — the port of TS
10//!    `buildInitialMessage`).
11//! 5. Resolves the provider + model + thinking level ([`crate::provider::resolve`]).
12//! 6. Builds the harness ([`crate::session::build`]).
13//! 7. Resolves the effective run mode ([`crate::args::resolve_mode`]) and
14//!    dispatches to [`crate::modes`] (`print`/`json`/`interactive`), mapping the
15//!    outcome to an exit code.
16//!
17//! # v1 scope cuts vs TS `main.ts` (in `docs/m6-cli-open-questions.md`)
18//!
19//! The TS `main` is enormous: HTTP proxy config, project-trust handling,
20//! first-time setup, migrations, and full npm package management remain
21//! outside this port. rpi does support local static package management via
22//! `rpi package` and Rust cdylib extension installation. The regular agent path
23//! remains a straight parse → resolve → build → run pipeline. The `@file`
24//! expansion ports *only* the text-file branch (images are detected
25//! but not attached to the prompt — the harness `prompt_text` accepts images,
26//! but v1 does not yet wire an image processor; binary/non-UTF-8 files error).
27
28use std::io::{IsTerminal, Read};
29use std::path::Path;
30
31use rpi_ai::types::{ImageContent, ImageContentType};
32
33use crate::args::{parse_args, print_help, print_version, resolve_mode, Args, RunMode};
34use crate::provider::{resolve_for_cwd, ResolveError};
35use crate::session::{build, BuildError};
36
37/// The exit code for a usage/parse error. (TS `main.ts` uses `process.exit(1)`
38/// for most error paths; v1 distinguishes usage errors with the conventional
39/// `2` so scripts can tell "bad invocation" from "run failed".)
40pub const EXIT_USAGE: i32 = 2;
41/// The exit code for a runtime failure (model-resolution, harness-build, or
42/// run failure). Mirrors TS `process.exitCode` set from `runPrintMode`.
43pub const EXIT_RUNTIME: i32 = 1;
44
45/// The v1 CLI entry point. Mirrors TS `export async function main(args)`.
46///
47/// Returns the process exit code (0 = success). The binary wrapper
48/// ([`crate::bin`] / `src/bin/pi.rs`) calls this under a tokio runtime and
49/// `std::process::exit`s with the returned code.
50pub async fn run() -> i32 {
51    // argv[0] is the program name; skip it (TS `main(args)` receives the same,
52    // already sliced by the Node CLI entry).
53    let mut argv: Vec<String> = std::env::args().skip(1).collect();
54
55    if argv.first().map(String::as_str) == Some("__rpi_dev_cleanup") {
56        return crate::dev_extension::run_cleanup_helper(&argv[1..]);
57    }
58
59    // Native Pi resolves offline mode before dispatching top-level commands.
60    // Normalize the CLI flag into PI_OFFLINE so early package/update commands
61    // and the regular parsed path all observe the same process-wide gate.
62    crate::args::normalize_offline_mode(&argv);
63
64    // `rpi dev` wraps the normal CLI: consume only development-specific
65    // options, then pass every remaining argument through the regular parser.
66    let dev_command = argv.first().map(String::as_str);
67    let dev_options = if matches!(dev_command, Some("dev" | "dev-local")) {
68        match crate::dev_extension::parse_args(&argv[1..]) {
69            Ok(options) if options.help => {
70                crate::dev_extension::print_help();
71                return 0;
72            }
73            Ok(mut options) => {
74                if dev_command == Some("dev-local") {
75                    options.local_only = true;
76                }
77                argv = options.passthrough.clone();
78                Some(options)
79            }
80            Err(error) => {
81                eprintln!("error: {error}");
82                crate::dev_extension::print_help();
83                return EXIT_USAGE;
84            }
85        }
86    } else {
87        None
88    };
89
90    // ---- `rpi auth …` subcommand dispatch (before flag parsing) ----
91    // `auth` is a top-level subcommand (mirrors TS `runAuthCommand` routing in
92    // `main.ts`); dispatching it here avoids it being misparsed as a prompt.
93    if argv.first().map(|s| s.as_str()) == Some("auth") {
94        return crate::auth::run(&argv[1..]).await;
95    }
96    if argv.first().map(|s| s.as_str()) == Some("package") {
97        return crate::packages::run_cli(&argv[1..]);
98    }
99    if argv.first().map(|s| s.as_str()) == Some("update") {
100        return crate::updates::run_self_update(&argv[1..]);
101    }
102    if argv.first().map(String::as_str) == Some("pi-package") {
103        let subcommand = argv.get(1).map(String::as_str);
104        if matches!(subcommand, Some("--help" | "-h")) {
105            return crate::packages::run_pi_package_update(&argv[1..]);
106        }
107        if subcommand != Some("update") {
108            eprintln!("error: usage is `rpi pi-package update`");
109            return EXIT_USAGE;
110        }
111        return crate::packages::run_pi_package_update(&argv[1..]);
112    }
113    if argv.first().map(String::as_str) == Some("pi-update") {
114        eprintln!("error: unknown command `pi-update`; use `rpi pi-package update`");
115        return EXIT_USAGE;
116    }
117    if argv.first().map(String::as_str) == Some("self-update") {
118        eprintln!("error: unknown command `self-update`; use `rpi update`");
119        return EXIT_USAGE;
120    }
121    if argv.first().map(|s| s.as_str()) == Some("install") {
122        return crate::install::run(&argv[1..]);
123    }
124    if argv.first().map(|s| s.as_str()) == Some("install-pi") {
125        return crate::install_pi::run(&argv[1..]);
126    }
127    if argv.first().map(|s| s.as_str()) == Some("uninstall") {
128        if argv.get(1).map(String::as_str) == Some("pi") {
129            return crate::install_pi::uninstall(&argv[2..]);
130        }
131        return crate::install::uninstall(&argv[1..]);
132    }
133    if argv.first().map(|s| s.as_str()) == Some("uninstall-pi") {
134        return crate::install_pi::uninstall(&argv[1..]);
135    }
136
137    let mut parsed = parse_args(&argv);
138
139    // ---- --help / --version short-circuit (before any heavy work) ----
140    if parsed.help {
141        print_help();
142        return 0;
143    }
144    if parsed.version {
145        print_version();
146        return 0;
147    }
148
149    // ---- Parse errors → help + usage exit ----
150    if !parsed.errors.is_empty() {
151        for err in &parsed.errors {
152            eprintln!("error: {err}");
153        }
154        eprintln!();
155        print_help();
156        return EXIT_USAGE;
157    }
158
159    // ---- cwd ----
160    let cwd = match std::env::current_dir() {
161        Ok(c) => c,
162        Err(e) => {
163            eprintln!("error: could not determine the current directory: {e}");
164            return EXIT_USAGE;
165        }
166    };
167
168    // ---- Legacy-layout migration (flat ~/.rpi → ~/.rpi/agent/) ----
169    // Best-effort; never blocks startup. Skipped when RPI_CODING_AGENT_DIR is
170    // set (an explicit override is its own layout).
171    let _ = crate::config::migrate_legacy_layout();
172
173    if let Some(input) = parsed.export.as_deref() {
174        let output = parsed
175            .messages
176            .first()
177            .map(Path::new)
178            .map(Path::to_path_buf)
179            .unwrap_or_else(|| {
180                let stem = input
181                    .file_stem()
182                    .and_then(|value| value.to_str())
183                    .unwrap_or("session");
184                Path::new(&format!("rpi-session-{stem}.html")).to_path_buf()
185            });
186        match crate::export::export_file(input, &output) {
187            Ok(()) => {
188                println!("Exported to: {}", output.display());
189                return 0;
190            }
191            Err(error) => {
192                eprintln!("error: {error}");
193                return EXIT_RUNTIME;
194            }
195        }
196    }
197
198    // `--list-models` is intentionally handled before credentials, session
199    // restoration, and harness construction. Native Pi exposes this as a
200    // catalog inspection command, so it must work for a newly installed user
201    // who has not authenticated yet.
202    if let Some(search) = parsed.list_models.as_deref() {
203        return list_models(search).await;
204    }
205
206    // Build before provider resolution so compiler errors do not require
207    // valid model credentials. The staged directory joins normal discovery.
208    let dev_extension = if let Some(options) = &dev_options {
209        let extension = match crate::dev_extension::DevExtension::detect(&cwd, options) {
210            Ok(extension) => extension,
211            Err(error) => {
212                eprintln!("error: {error}");
213                return EXIT_USAGE;
214            }
215        };
216        if let Err(error) = extension.rebuild() {
217            eprintln!("error: initial extension build failed: {error}");
218            return EXIT_RUNTIME;
219        }
220        if let Err(error) = extension.apply_to_args(&mut parsed) {
221            eprintln!("error: {error}");
222            return EXIT_RUNTIME;
223        }
224        Some(extension)
225    } else {
226        None
227    };
228
229    // `-r/--resume` is an interactive picker, unlike `-c/--continue` which
230    // immediately opens the latest session. Resolve the picker result before
231    // building the harness so cancelling does not create or modify a session.
232    if parsed.resume {
233        if !std::io::stdin().is_terminal() || !std::io::stdout().is_terminal() {
234            eprintln!("error: --resume requires an interactive terminal");
235            return EXIT_USAGE;
236        }
237        match crate::resume_picker::select(&cwd).await {
238            Ok(Some(id)) => {
239                parsed.resume = false;
240                parsed.session = Some(id);
241            }
242            Ok(None) => return 0,
243            Err(e) => {
244                eprintln!("error: {e}");
245                return EXIT_RUNTIME;
246            }
247        }
248    }
249
250    // ---- Startup warnings (ignored-but-recognized flags) ----
251    if parsed.verbose {
252        for warn in &parsed.ignored {
253            eprintln!("warning: {warn}");
254        }
255    }
256    if parsed.no_themes && parsed.theme.is_some() {
257        eprintln!("warning: --no-themes overrides --theme; using the built-in default theme");
258    }
259
260    // ---- stdin (TS readPipedStdin: non-TTY stdin becomes initial prompt text) ----
261    let stdin_text = read_piped_stdin();
262
263    // ---- @file attachments → text (TS processFileArguments, text branch only) ----
264    let (file_text, file_images) = match process_file_args(&parsed.file_args, &cwd) {
265        Ok(t) => t,
266        Err(msg) => {
267            eprintln!("error: {msg}");
268            return EXIT_USAGE;
269        }
270    };
271
272    // ---- initial message + extra messages (TS buildInitialMessage) ----
273    let file_text_opt = if file_text.is_empty() {
274        None
275    } else {
276        Some(file_text.as_str())
277    };
278    let (initial, extra) = build_initial_message(&parsed, stdin_text.as_deref(), file_text_opt);
279
280    // ---- provider + model resolution ----
281    let project_trusted = crate::session::resolve_project_trust(&parsed, &cwd);
282    let resolved = match resolve_for_cwd(
283        parsed.provider.as_deref(),
284        parsed.model.as_deref(),
285        parsed.thinking,
286        parsed.api_key.as_deref(),
287        parsed.base_url.as_deref(),
288        &cwd,
289        project_trusted,
290    ) {
291        Ok(r) => r,
292        Err(e) => {
293            print_resolve_error(&e);
294            return match e {
295                ResolveError::NoApiKey { .. } | ResolveError::Config(_) => EXIT_USAGE,
296                _ => EXIT_RUNTIME,
297            };
298        }
299    };
300
301    // The full authenticated catalog (read-only) for the TUI's `/model` selector.
302    // v1 does not switch models mid-session, so this is display-only.
303    let model_catalog = crate::provider::available_catalog(&resolved);
304
305    // `--models <patterns>`: persist the Ctrl+M cycle scope to settings.json
306    // (the same set `/scoped-models` edits). Each pattern matches catalog ids
307    // case-insensitively; unmatched patterns are reported so a typo doesn't
308    // silently empty the cycle.
309    if let Some(patterns) = &parsed.models {
310        let mut matched: Vec<String> = Vec::new();
311        for p in patterns {
312            let hits: Vec<String> = model_catalog
313                .iter()
314                .filter(|m| m.id.eq_ignore_ascii_case(p))
315                .map(|m| m.id.clone())
316                .collect();
317            if hits.is_empty() {
318                eprintln!("warning: --models pattern \"{p}\" matched no model");
319            }
320            matched.extend(hits);
321        }
322        let mut settings = crate::settings::load_settings().unwrap_or_default();
323        settings.scoped_models = if matched.is_empty() {
324            None
325        } else {
326            Some(matched)
327        };
328        if let Err(e) = crate::settings::save_settings(&settings) {
329            eprintln!("warning: could not save --models scope: {e}");
330        }
331    }
332
333    // ---- harness build ----
334    let (harness, event_rx, mut reload_context) =
335        match build(&resolved, &parsed, &cwd, project_trusted).await {
336            Ok(triple) => triple,
337            Err(e) => {
338                print_build_error(&e);
339                return EXIT_RUNTIME;
340            }
341        };
342    reload_context.dev_extension = dev_extension;
343
344    // ---- mode dispatch (TS resolveAppMode → runPrintMode / InteractiveMode / runRpcMode) ----
345    let stdin_is_tty = std::io::stdin().is_terminal();
346    let stdout_is_tty = std::io::stdout().is_terminal();
347    let mode = resolve_mode(&parsed, stdin_is_tty, stdout_is_tty);
348
349    // TS downgrades interactive → print when piped stdin is present.
350    let mode = if matches!(mode, RunMode::Interactive) && stdin_text.is_some() {
351        RunMode::Print
352    } else {
353        mode
354    };
355
356    // Debug/testing escape hatch: RPI_FORCE_TUI=1 forces interactive mode
357    // (for testing the TUI in non-TTY environments).
358    let mode = if std::env::var("RPI_FORCE_TUI")
359        .map(|v| v == "1")
360        .unwrap_or(false)
361    {
362        RunMode::Interactive
363    } else {
364        mode
365    };
366
367    let dev_cleanup = reload_context.dev_extension.clone();
368    let dev_watcher = if matches!(mode, RunMode::Interactive) {
369        reload_context
370            .dev_extension
371            .as_ref()
372            .and_then(|extension| extension.start_watcher(reload_context.mailbox.clone()))
373    } else {
374        None
375    };
376
377    let exit_code = match mode {
378        RunMode::Print => {
379            crate::modes::print(
380                &harness,
381                &parsed,
382                initial.clone(),
383                &extra,
384                file_images.clone(),
385            )
386            .await
387        }
388        RunMode::Json => {
389            crate::modes::json(
390                &harness,
391                &parsed,
392                initial.clone(),
393                &extra,
394                file_images.clone(),
395                Some(event_rx),
396            )
397            .await
398        }
399        RunMode::Interactive => {
400            crate::modes::interactive(
401                &harness,
402                Some(event_rx),
403                &parsed,
404                model_catalog,
405                initial.clone(),
406                &extra,
407                file_images.clone(),
408                if parsed.no_themes {
409                    None
410                } else {
411                    parsed.theme.as_deref().or(resolved.theme.as_deref())
412                },
413                parsed.no_themes,
414                &reload_context,
415            )
416            .await
417        }
418        RunMode::Rpc => {
419            // `--mode rpc` is parsed (so it doesn't hard-error) but not
420            // implemented in v1 — the JSON-RPC session protocol the TS
421            // `runRpcMode` drives is deferred.
422            eprintln!("error: rpc mode is not implemented in v1 (use --mode text or --mode json)");
423            EXIT_USAGE
424        }
425    };
426
427    if let Some(dev) = &dev_cleanup {
428        dev.stop_watcher();
429    }
430    if let Some(watcher) = dev_watcher {
431        let _ = watcher.join();
432    }
433    drop(reload_context);
434    drop(harness);
435    if let Some(dev) = dev_cleanup {
436        dev.cleanup();
437    }
438    exit_code
439}
440
441/// Print the merged model catalog, optionally filtered by a case-insensitive
442/// fuzzy-ish substring over provider, id, and display name.
443async fn list_models(search: &str) -> i32 {
444    let catalog = match crate::provider::catalog_all() {
445        Ok(models) => models,
446        Err(error) => {
447            eprintln!("warning: could not load models.json: {error}");
448            Vec::new()
449        }
450    };
451    let needle = search.trim().to_ascii_lowercase();
452    let mut models: Vec<_> = catalog
453        .into_iter()
454        .filter(|model| {
455            needle.is_empty()
456                || format!("{} {} {}", model.provider, model.id, model.name)
457                    .to_ascii_lowercase()
458                    .contains(&needle)
459        })
460        .collect();
461    if models.is_empty() {
462        if needle.is_empty() {
463            println!("No models available");
464        } else {
465            println!("No models matching \"{search}\"");
466        }
467        return 0;
468    }
469
470    fn format_tokens(value: u64) -> String {
471        if value >= 1_000_000 {
472            let whole = value % 1_000_000 == 0;
473            if whole {
474                format!("{}M", value / 1_000_000)
475            } else {
476                format!("{:.1}M", value as f64 / 1_000_000.0)
477            }
478        } else if value >= 1_000 {
479            let whole = value % 1_000 == 0;
480            if whole {
481                format!("{}K", value / 1_000)
482            } else {
483                format!("{:.1}K", value as f64 / 1_000.0)
484            }
485        } else {
486            value.to_string()
487        }
488    }
489
490    let rows: Vec<_> = models
491        .drain(..)
492        .map(|model| {
493            let images = model
494                .input
495                .iter()
496                .any(|input| matches!(input, rpi_ai::InputModality::Image));
497            (
498                model.provider,
499                model.id,
500                format_tokens(model.context_window),
501                format_tokens(model.max_tokens),
502                if model.reasoning { "yes" } else { "no" }.to_string(),
503                if images { "yes" } else { "no" }.to_string(),
504            )
505        })
506        .collect();
507    let widths = (
508        rows.iter().map(|r| r.0.len()).max().unwrap_or(8).max(8),
509        rows.iter().map(|r| r.1.len()).max().unwrap_or(5).max(5),
510        rows.iter().map(|r| r.2.len()).max().unwrap_or(7).max(7),
511        rows.iter().map(|r| r.3.len()).max().unwrap_or(7).max(7),
512        rows.iter().map(|r| r.4.len()).max().unwrap_or(8).max(8),
513        rows.iter().map(|r| r.5.len()).max().unwrap_or(6).max(6),
514    );
515    println!(
516        "{:provider$}  {:model$}  {:context$}  {:max_out$}  {:thinking$}  {:images$}",
517        "provider",
518        "model",
519        "context",
520        "max-out",
521        "thinking",
522        "images",
523        provider = widths.0,
524        model = widths.1,
525        context = widths.2,
526        max_out = widths.3,
527        thinking = widths.4,
528        images = widths.5,
529    );
530    for row in rows {
531        println!(
532            "{:provider$}  {:model$}  {:context$}  {:max_out$}  {:thinking$}  {:images$}",
533            row.0,
534            row.1,
535            row.2,
536            row.3,
537            row.4,
538            row.5,
539            provider = widths.0,
540            model = widths.1,
541            context = widths.2,
542            max_out = widths.3,
543            thinking = widths.4,
544            images = widths.5,
545        );
546    }
547    0
548}
549
550/// Read piped stdin into a string. Mirrors TS `readPipedStdin`: returns `None`
551/// when stdin is a TTY (interactive), else the trimmed stdin text (empty ⇒
552/// `None`).
553///
554/// NOTE: if stdin is *not* a TTY but no bytes arrive (e.g. `pi < /dev/null`),
555/// this returns `None` (empty), which is what TS does too (`data.trim() || undefined`).
556fn read_piped_stdin() -> Option<String> {
557    // Debug/testing escape hatch: RPI_SKIP_STDIN=1 skips reading piped stdin
558    // (avoids blocking on non-TTY stdin in automated environments).
559    if std::env::var("RPI_SKIP_STDIN")
560        .map(|v| v == "1")
561        .unwrap_or(false)
562    {
563        return None;
564    }
565    if std::io::stdin().is_terminal() {
566        return None;
567    }
568    let mut buf = String::new();
569    match std::io::stdin().read_to_string(&mut buf) {
570        Ok(_) => {
571            let trimmed = buf.trim();
572            if trimmed.is_empty() {
573                None
574            } else {
575                Some(trimmed.to_string())
576            }
577        }
578        Err(_) => None,
579    }
580}
581
582/// Expand `@file` attachments into prompt text. Mirrors the *text* branch of
583/// TS `processFileArguments`: each readable text file is wrapped in
584/// `<file name="...">\n<contents>\n</file>\n` and concatenated.
585///
586/// Paths are resolved relative to `cwd` (the TS uses `resolve(readPath, cwd)`).
587fn process_file_args(
588    file_args: &[std::path::PathBuf],
589    cwd: &Path,
590) -> Result<(String, Vec<ImageContent>), String> {
591    let mut text = String::new();
592    let mut images = Vec::new();
593    for rel in file_args {
594        let abs = if rel.is_absolute() {
595            rel.clone()
596        } else {
597            cwd.join(rel)
598        };
599        if !abs.exists() {
600            return Err(format!("file not found: {}", abs.display()));
601        }
602        let bytes = std::fs::read(&abs)
603            .map_err(|e| format!("could not read file {}: {e}", abs.display()))?;
604        if let Some(image) = image_content_from_bytes(&bytes) {
605            images.push(image);
606        } else {
607            let content = String::from_utf8(bytes).map_err(|_| {
608                format!(
609                    "file is not valid UTF-8 text or a supported image: {}",
610                    abs.display()
611                )
612            })?;
613            text.push_str(&format!(
614                "<file name=\"{}\">\n{}\n</file>\n",
615                abs.display(),
616                content
617            ));
618        }
619    }
620    Ok((text, images))
621}
622
623pub(crate) fn image_content_from_path(path: &Path) -> Result<Option<ImageContent>, String> {
624    let bytes =
625        std::fs::read(path).map_err(|e| format!("could not read file {}: {e}", path.display()))?;
626    Ok(image_content_from_bytes(&bytes))
627}
628
629fn image_content_from_bytes(bytes: &[u8]) -> Option<ImageContent> {
630    let mime_type = rpi_tools::detect_supported_image_mime_type(bytes)?;
631    Some(ImageContent {
632        kind: ImageContentType,
633        data: rpi_tools::encode_base64(bytes),
634        mime_type: mime_type.to_string(),
635    })
636}
637
638/// Build the initial prompt + the remaining extra messages. Mirrors TS
639/// `buildInitialMessage`: `[stdinContent, fileText, messages[0]].join("")` is
640/// the initial message; `messages[1..]` are the follow-up prompts.
641///
642/// Returns `(initial: Option<String>, extra: Vec<String>)`.
643fn build_initial_message(
644    parsed: &Args,
645    stdin: Option<&str>,
646    file_text: Option<&str>,
647) -> (Option<String>, Vec<String>) {
648    let mut extra = parsed.messages.clone();
649    let mut parts: Vec<String> = Vec::new();
650    if let Some(s) = stdin {
651        parts.push(s.to_string());
652    }
653    if let Some(t) = file_text {
654        parts.push(t.to_string());
655    }
656    // Pull the first positional message into the initial prompt (TS `.shift()`).
657    if !extra.is_empty() {
658        parts.push(extra.remove(0));
659    }
660    let initial = if parts.is_empty() {
661        None
662    } else {
663        Some(parts.join(""))
664    };
665    (initial, extra)
666}
667
668/// Print a model-resolution error with env-specific guidance. Mirrors the TS
669/// auth-guidance / model-resolver error formatting (condensed to stderr lines).
670fn print_resolve_error(e: &ResolveError) {
671    match e {
672        ResolveError::NoApiKey { hint } => {
673            eprintln!("error: {e}");
674            eprintln!();
675            eprintln!("Provide credentials via one of: {hint}.");
676        }
677        ResolveError::Config(_) => {
678            eprintln!("error: {e}");
679            eprintln!();
680            eprintln!("Check ~/.rpi/auth.json / ~/.rpi/models.json (set RPI_CODING_AGENT_DIR to relocate).");
681        }
682        _ => eprintln!("error: {e}"),
683    }
684}
685
686/// Print a harness-build error with flag-specific guidance for restore requests.
687fn print_build_error(e: &BuildError) {
688    match e {
689        BuildError::SessionNotFound { .. } => {
690            eprintln!("error: {e}");
691            eprintln!();
692            eprintln!("List saved sessions with the /session command in interactive mode.");
693        }
694        _ => eprintln!("error: {e}"),
695    }
696}
697
698#[cfg(test)]
699mod tests {
700    use super::*;
701    use crate::args::Args;
702
703    #[test]
704    fn build_initial_combines_stdin_file_and_first_message() {
705        let mut args = Args::default();
706        args.messages = vec!["first".into(), "second".into(), "third".into()];
707        let (initial, extra) =
708            build_initial_message(&args, Some("stdin-text"), Some("<file>...</file>"));
709        assert_eq!(initial.as_deref(), Some("stdin-text<file>...</file>first"));
710        assert_eq!(extra, vec!["second".to_string(), "third".to_string()]);
711    }
712
713    #[test]
714    fn build_initial_with_no_messages_uses_stdin_and_file_only() {
715        let args = Args::default();
716        let (initial, extra) =
717            build_initial_message(&args, Some("only-stdin"), Some("<file>x</file>"));
718        assert_eq!(initial.as_deref(), Some("only-stdin<file>x</file>"));
719        assert!(extra.is_empty());
720    }
721
722    #[test]
723    fn build_initial_none_when_all_empty() {
724        let args = Args::default();
725        let (initial, extra) = build_initial_message(&args, None, None);
726        assert!(initial.is_none());
727        assert!(extra.is_empty());
728    }
729
730    #[test]
731    fn build_initial_shifts_only_first_message() {
732        let mut args = Args::default();
733        args.messages = vec!["a".into(), "b".into()];
734        let (initial, extra) = build_initial_message(&args, None, None);
735        assert_eq!(initial.as_deref(), Some("a"));
736        assert_eq!(extra, vec!["b".to_string()]);
737    }
738
739    #[test]
740    fn process_file_args_attaches_supported_images() {
741        let dir = tempfile::tempdir().unwrap();
742        let path = dir.path().join("image.bin");
743        let mut png = vec![137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13];
744        png.extend_from_slice(b"IHDR");
745        png.extend_from_slice(&[0; 13]);
746        std::fs::write(&path, png).unwrap();
747        let (text, images) = process_file_args(&[path], dir.path()).unwrap();
748        assert!(text.is_empty());
749        assert_eq!(images.len(), 1);
750        assert_eq!(images[0].mime_type, "image/png");
751        assert!(!images[0].data.is_empty());
752    }
753
754    #[test]
755    fn image_content_from_path_reports_supported_mime() {
756        let dir = tempfile::tempdir().unwrap();
757        let path = dir.path().join("drop.png");
758        let mut png = vec![137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13];
759        png.extend_from_slice(b"IHDR");
760        png.extend_from_slice(&[0; 13]);
761        std::fs::write(&path, png).unwrap();
762        let image = image_content_from_path(&path).unwrap().unwrap();
763        assert_eq!(image.mime_type, "image/png");
764    }
765}