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