1use std::io::{IsTerminal, Read};
19use std::path::Path;
20
21use rpi_ai::types::{ImageContent, ImageContentType};
22
23use crate::args::{parse_args, print_help, print_version, resolve_mode, Args, RunMode};
24use crate::provider::{resolve_with_extensions, ResolveError};
25use crate::session::{build_prepared, prepare_extensions, BuildError};
26
27pub const EXIT_USAGE: i32 = 2;
31pub const EXIT_RUNTIME: i32 = 1;
34pub const EXIT_VETOED: i32 = 3;
39
40fn take_flag_value(argv: &[String], flag: &str) -> Option<String> {
44 let prefix = format!("{flag}=");
45 let mut iter = argv.iter();
46 while let Some(arg) = iter.next() {
47 if let Some(value) = arg.strip_prefix(&prefix) {
48 return Some(value.to_string());
49 }
50 if arg == flag {
51 return iter.next().cloned();
52 }
53 }
54 None
55}
56
57fn resolve_connect_token(argv: &[String]) -> Option<String> {
60 take_flag_value(argv, "--token").or_else(|| {
61 std::env::var("RPI_SERVER_TOKEN")
62 .ok()
63 .map(|value| value.trim().to_string())
64 .filter(|value| !value.is_empty())
65 })
66}
67
68pub async fn run() -> i32 {
74 crate::timings::reset(crate::timings::TimingNamespace::Main);
77 let code = run_inner().await;
78 crate::timings::print_timings();
79 code
80}
81
82async fn run_inner() -> i32 {
83 let mut argv: Vec<String> = std::env::args().skip(1).collect();
86
87 if argv.first().map(String::as_str) == Some("__rpi_dev_cleanup") {
88 return crate::dev_extension::run_cleanup_helper(&argv[1..]);
89 }
90
91 if let Some(addr) = take_flag_value(&argv, "--connect") {
96 let token = resolve_connect_token(&argv);
97 return crate::remote::tui::run(&addr, token.as_deref()).await;
98 }
99
100 crate::args::normalize_offline_mode(&argv);
103
104 let dev_command = argv.first().map(String::as_str);
107 let dev_options = if matches!(dev_command, Some("dev" | "dev-local")) {
108 match crate::dev_extension::parse_args(&argv[1..]) {
109 Ok(options) if options.help => {
110 crate::dev_extension::print_help();
111 return 0;
112 }
113 Ok(mut options) => {
114 if dev_command == Some("dev-local") {
115 options.local_only = true;
116 }
117 argv = options.passthrough.clone();
118 Some(options)
119 }
120 Err(error) => {
121 eprintln!("error: {error}");
122 crate::dev_extension::print_help();
123 return EXIT_USAGE;
124 }
125 }
126 } else {
127 None
128 };
129
130 match argv.first().map(String::as_str) {
139 Some("auth") => {
140 let cwd = std::env::current_dir().unwrap_or_else(|_| Path::new(".").to_path_buf());
141 let args = crate::args::parse_args(&argv[1..]);
142 if !args.errors.is_empty() {
143 for error in &args.errors {
144 eprintln!("error: {error}");
145 }
146 return EXIT_USAGE;
147 }
148 let trusted = crate::session::resolve_project_trust(&args, &cwd);
149 let extension_session = crate::session::load_extensions(&args, &cwd, trusted, None);
150 return crate::auth::run_with_extensions(&argv[1..], extension_session.snapshot_arc())
151 .await;
152 }
153 Some("events") => return crate::events::run(&argv[1..]).await,
154 Some("update") => return crate::updates::run_self_update(&argv[1..]),
155 Some("install") => return crate::install::run_async(&argv[1..]).await,
156 Some("uninstall") => return crate::install::uninstall(&argv[1..]),
157 Some("package") => return crate::packages::run(&argv[1..]),
158 Some(removed @ ("pi-package" | "pi-update" | "install-pi" | "uninstall-pi")) => {
161 eprintln!(
162 "error: `rpi {removed}` is not available; use `rpi package`, `rpi install`, or `rpi update`."
163 );
164 return EXIT_USAGE;
165 }
166 _ => {}
172 }
173
174 let mut parsed = parse_args(&argv);
175 crate::timings::time("args parsed", crate::timings::TimingNamespace::Main);
176
177 if let Some(code) = handle_parse_short_circuits(&parsed) {
179 return code;
180 }
181
182 let cwd = match resolve_cwd() {
183 Ok(cwd) => cwd,
184 Err(code) => return code,
185 };
186
187 let _ = crate::config::migrate_legacy_layout();
191 crate::timings::time("config migrated", crate::timings::TimingNamespace::Main);
192
193 if let Some(code) = export_if_requested(&parsed) {
194 return code;
195 }
196
197 if let Some(search) = parsed.list_models.as_deref() {
202 return list_models(search, &parsed, &cwd).await;
203 }
204
205 let dev_extension = match prepare_dev_extension(dev_options.as_ref(), &cwd, &mut parsed) {
208 Ok(extension) => extension,
209 Err(code) => return code,
210 };
211
212 if let Some(code) = resolve_resume(&mut parsed, &cwd).await {
216 return code;
217 }
218
219 emit_startup_warnings(&parsed);
220
221 let wants_rpc_channel =
235 parsed.mode == crate::args::Mode::Rpc || parsed.unknown_flags.contains_key("server");
236
237 let (file_text, file_images) = match process_file_args(&parsed.file_args, &cwd) {
239 Ok(t) => t,
240 Err(msg) => {
241 eprintln!("error: {msg}");
242 return EXIT_USAGE;
243 }
244 };
245
246 let project_trusted = crate::session::resolve_project_trust(&parsed, &cwd);
248 let prepared = match prepare_extensions(&parsed, &cwd, project_trusted) {
249 Ok(prepared) => prepared,
250 Err(error) => {
251 print_build_error(&error);
252 return EXIT_RUNTIME;
253 }
254 };
255 let resolved = match resolve_with_extensions(
256 &parsed,
257 &cwd,
258 project_trusted,
259 &prepared.extension_session,
260 ) {
261 Ok(r) => r,
262 Err(e) => {
263 print_resolve_error(&e);
264 return match e {
265 ResolveError::NoApiKey { .. } | ResolveError::Config(_) => EXIT_USAGE,
266 _ => EXIT_RUNTIME,
267 };
268 }
269 };
270
271 let model_catalog = crate::provider::authenticated_catalog(&resolved);
275
276 if let Some(patterns) = &parsed.models {
281 let mut matched: Vec<String> = Vec::new();
282 for p in patterns {
283 let hits: Vec<String> = model_catalog
284 .iter()
285 .filter(|m| m.id.eq_ignore_ascii_case(p))
286 .map(|m| m.id.clone())
287 .collect();
288 if hits.is_empty() {
289 eprintln!("warning: --models pattern \"{p}\" matched no model");
290 }
291 matched.extend(hits);
292 }
293 let mut settings = crate::settings::load_settings().unwrap_or_default();
294 settings.scoped_models = if matched.is_empty() {
295 None
296 } else {
297 Some(matched)
298 };
299 if let Err(e) = crate::settings::save_settings(&settings) {
300 eprintln!("warning: could not save --models scope: {e}");
301 }
302 }
303
304 let (harness, event_rx, mut reload_context) =
306 match build_prepared(&resolved, &parsed, &cwd, project_trusted, prepared).await {
307 Ok(triple) => triple,
308 Err(e) => {
309 print_build_error(&e);
310 return EXIT_RUNTIME;
311 }
312 };
313 reload_context.dev_extension = dev_extension;
314
315 let stdio_claimed = reload_context
322 .extension_session
323 .lock()
324 .map(|session| session.owns_stdio())
325 .unwrap_or(false);
326 let stdio_lifecycle = reload_context
327 .extension_session
328 .lock()
329 .ok()
330 .and_then(|session| session.action_bridge())
331 .map(|bridge| bridge.stdio_lifecycle());
332 if let Some(lifecycle) = &stdio_lifecycle {
333 if let Err(error) = lifecycle.validate() {
334 eprintln!("error: {error}");
335 return EXIT_RUNTIME;
336 }
337 }
338 let stdin_text = if wants_rpc_channel || stdio_claimed {
339 None
340 } else {
341 read_piped_stdin()
342 };
343
344 let file_text_opt = if file_text.is_empty() {
346 None
347 } else {
348 Some(file_text.as_str())
349 };
350 let (initial, extra) = build_initial_message(&parsed, stdin_text.as_deref(), file_text_opt);
351
352 let stdin_is_tty = std::io::stdin().is_terminal();
354 let stdout_is_tty = std::io::stdout().is_terminal();
355 let mode = resolve_mode(&parsed, stdin_is_tty, stdout_is_tty);
356
357 let mode = if matches!(mode, RunMode::Interactive) && stdin_text.is_some() {
359 RunMode::Print
360 } else {
361 mode
362 };
363
364 let mode = if std::env::var("RPI_FORCE_TUI")
367 .map(|v| v == "1")
368 .unwrap_or(false)
369 {
370 RunMode::Interactive
371 } else {
372 mode
373 };
374
375 let mode = if stdio_claimed { RunMode::Rpc } else { mode };
382
383 let dev_cleanup = reload_context.dev_extension.clone();
384 let dev_watcher = if matches!(mode, RunMode::Interactive) {
385 reload_context
386 .dev_extension
387 .as_ref()
388 .and_then(|extension| extension.start_watcher(reload_context.mailbox.clone()))
389 } else {
390 None
391 };
392
393 let exit_code = match mode {
394 RunMode::Print => {
395 crate::modes::print(
396 &harness,
397 &parsed,
398 initial.clone(),
399 &extra,
400 file_images.clone(),
401 )
402 .await
403 }
404 RunMode::Json => {
405 crate::modes::json(
406 &harness,
407 &parsed,
408 initial.clone(),
409 &extra,
410 file_images.clone(),
411 Some(event_rx),
412 )
413 .await
414 }
415 RunMode::Interactive => {
416 crate::modes::interactive(
417 &harness,
418 Some(event_rx),
419 &parsed,
420 model_catalog,
421 initial.clone(),
422 &extra,
423 file_images.clone(),
424 if parsed.no_themes {
425 None
426 } else {
427 parsed.theme.as_deref().or(resolved.theme.as_deref())
428 },
429 parsed.no_themes,
430 &reload_context,
431 )
432 .await
433 }
434 RunMode::Rpc => {
435 let veto = crate::session::dispatch_session_event_async(
441 &reload_context,
442 rpi_plugin_sdk::EventTag::BeforeTuiStart,
443 )
444 .await;
445 if let Some(reason) = veto {
446 eprintln!("[rpi] startup vetoed by extension: {reason}");
447 EXIT_VETOED
448 } else {
449 if let Some(reason) = crate::session::dispatch_session_event_async(
450 &reload_context,
451 rpi_plugin_sdk::EventTag::SessionStart,
452 )
453 .await
454 {
455 tracing::warn!(
456 "[rpi] extension vetoed SessionStart (headless, advisory): {reason}"
457 );
458 }
459
460 let code = if parsed.unknown_flags.contains_key("server") || stdio_claimed {
461 eprintln!(
465 "[rpi] running in headless mode (no TUI); extension owns stdio; Ctrl+C to stop"
466 );
467 if stdio_claimed {
468 let lifecycle = stdio_lifecycle
469 .as_ref()
470 .expect("stdio owner requires an action bridge");
471 tokio::select! {
472 code = lifecycle.wait() => code,
473 _ = tokio::signal::ctrl_c() => 0,
474 }
475 } else {
476 let _ = tokio::signal::ctrl_c().await;
477 0
478 }
479 } else {
480 crate::modes::rpc(
486 &harness,
487 Some(event_rx),
488 model_catalog,
489 reload_context.ui_dialog.clone(),
490 reload_context
491 .extension_session
492 .lock()
493 .unwrap()
494 .snapshot_arc(),
495 )
496 .await
497 };
498
499 if let Some(reason) = crate::session::dispatch_session_event_async(
500 &reload_context,
501 rpi_plugin_sdk::EventTag::SessionShutdown,
502 )
503 .await
504 {
505 tracing::warn!(
506 "[rpi] extension vetoed SessionShutdown (ignored, closing): {reason}"
507 );
508 }
509 code
510 }
511 }
512 };
513
514 if let Some(dev) = &dev_cleanup {
515 dev.stop_watcher();
516 }
517 if let Some(watcher) = dev_watcher {
518 let _ = watcher.join();
519 }
520 drop(reload_context);
521 drop(harness);
522 if let Some(dev) = dev_cleanup {
523 dev.cleanup();
524 }
525 exit_code
526}
527
528fn handle_parse_short_circuits(parsed: &Args) -> Option<i32> {
529 if parsed.help {
531 print_help();
532 return Some(0);
533 }
534 if parsed.version {
535 print_version();
536 return Some(0);
537 }
538
539 if !parsed.errors.is_empty() {
541 for err in &parsed.errors {
542 eprintln!("error: {err}");
543 }
544 eprintln!();
545 print_help();
546 return Some(EXIT_USAGE);
547 }
548
549 None
550}
551
552fn resolve_cwd() -> Result<std::path::PathBuf, i32> {
553 std::env::current_dir().map_err(|error| {
554 eprintln!("error: could not determine the current directory: {error}");
555 EXIT_USAGE
556 })
557}
558
559fn export_if_requested(parsed: &Args) -> Option<i32> {
560 let input = parsed.export.as_deref()?;
561 let output = parsed
562 .messages
563 .first()
564 .map(Path::new)
565 .map(Path::to_path_buf)
566 .unwrap_or_else(|| {
567 let stem = input
568 .file_stem()
569 .and_then(|value| value.to_str())
570 .unwrap_or("session");
571 Path::new(&format!("rpi-session-{stem}.html")).to_path_buf()
572 });
573
574 match crate::export::export_file(input, &output) {
575 Ok(()) => {
576 println!("Exported to: {}", output.display());
577 Some(0)
578 }
579 Err(error) => {
580 eprintln!("error: {error}");
581 Some(EXIT_RUNTIME)
582 }
583 }
584}
585
586fn prepare_dev_extension(
587 options: Option<&crate::dev_extension::DevOptions>,
588 cwd: &Path,
589 parsed: &mut Args,
590) -> Result<Option<std::sync::Arc<crate::dev_extension::DevExtension>>, i32> {
591 let Some(options) = options else {
592 return Ok(None);
593 };
594
595 match crate::dev_extension::DevExtension::detect(cwd, options) {
596 Ok(extension) => {
597 if let Err(error) = extension.rebuild() {
598 eprintln!("error: initial extension build failed: {error}");
599 return Err(EXIT_RUNTIME);
600 }
601 if let Err(error) = extension.apply_to_args(parsed) {
602 eprintln!("error: {error}");
603 return Err(EXIT_RUNTIME);
604 }
605 Ok(Some(extension))
606 }
607 Err(error) => {
608 eprintln!("dev: {error}");
610 eprintln!("dev: no Cargo cdylib found; running in skills-only mode");
611 parsed.no_extensions = true;
612 parsed.extensions_dir.clear();
613 parsed.extension.clear();
614 if options.local_only {
615 parsed.dev_local_only = true;
616 }
617 Ok(None)
618 }
619 }
620}
621
622async fn resolve_resume(parsed: &mut Args, cwd: &Path) -> Option<i32> {
623 if !parsed.resume {
624 return None;
625 }
626 if !std::io::stdin().is_terminal() || !std::io::stdout().is_terminal() {
627 eprintln!("error: --resume requires an interactive terminal");
628 return Some(EXIT_USAGE);
629 }
630
631 match crate::resume_picker::select(cwd).await {
632 Ok(Some(id)) => {
633 parsed.resume = false;
634 parsed.session = Some(id);
635 None
636 }
637 Ok(None) => Some(0),
638 Err(error) => {
639 eprintln!("error: {error}");
640 Some(EXIT_RUNTIME)
641 }
642 }
643}
644
645fn emit_startup_warnings(parsed: &Args) {
646 if parsed.verbose {
648 for warn in &parsed.ignored {
649 eprintln!("warning: {warn}");
650 }
651 }
652 if parsed.no_themes && parsed.theme.is_some() {
653 eprintln!("warning: --no-themes overrides --theme; using the built-in default theme");
654 }
655}
656
657async fn list_models(search: &str, args: &Args, cwd: &Path) -> i32 {
660 let trusted = crate::session::resolve_project_trust(args, cwd);
661 let prepared = match prepare_extensions(args, cwd, trusted) {
662 Ok(prepared) => prepared,
663 Err(error) => {
664 print_build_error(&error);
665 return EXIT_RUNTIME;
666 }
667 };
668 let manifests = crate::provider::manifests_from_session(&prepared.extension_session);
669 let catalog = match crate::provider::catalog_with_manifests(&manifests) {
670 Ok(models) => models,
671 Err(error) => {
672 eprintln!("warning: could not load models.json: {error}");
673 Vec::new()
674 }
675 };
676 let needle = search.trim().to_ascii_lowercase();
677 let mut models: Vec<_> = catalog
678 .into_iter()
679 .filter(|model| {
680 needle.is_empty()
681 || format!("{} {} {}", model.provider, model.id, model.name)
682 .to_ascii_lowercase()
683 .contains(&needle)
684 })
685 .collect();
686 if models.is_empty() {
687 if needle.is_empty() {
688 println!("{}", crate::auth_guidance::no_models_available_message());
689 } else {
690 println!("No models matching \"{search}\"");
691 }
692 return 0;
693 }
694
695 fn format_tokens(value: u64) -> String {
696 if value >= 1_000_000 {
697 let whole = value % 1_000_000 == 0;
698 if whole {
699 format!("{}M", value / 1_000_000)
700 } else {
701 format!("{:.1}M", value as f64 / 1_000_000.0)
702 }
703 } else if value >= 1_000 {
704 let whole = value % 1_000 == 0;
705 if whole {
706 format!("{}K", value / 1_000)
707 } else {
708 format!("{:.1}K", value as f64 / 1_000.0)
709 }
710 } else {
711 value.to_string()
712 }
713 }
714
715 let rows: Vec<_> = models
716 .drain(..)
717 .map(|model| {
718 let images = model
719 .input
720 .iter()
721 .any(|input| matches!(input, rpi_ai::InputModality::Image));
722 (
723 model.provider,
724 model.id,
725 format_tokens(model.context_window),
726 format_tokens(model.max_tokens),
727 if model.reasoning { "yes" } else { "no" }.to_string(),
728 if images { "yes" } else { "no" }.to_string(),
729 )
730 })
731 .collect();
732 let widths = (
733 rows.iter().map(|r| r.0.len()).max().unwrap_or(8).max(8),
734 rows.iter().map(|r| r.1.len()).max().unwrap_or(5).max(5),
735 rows.iter().map(|r| r.2.len()).max().unwrap_or(7).max(7),
736 rows.iter().map(|r| r.3.len()).max().unwrap_or(7).max(7),
737 rows.iter().map(|r| r.4.len()).max().unwrap_or(8).max(8),
738 rows.iter().map(|r| r.5.len()).max().unwrap_or(6).max(6),
739 );
740 println!(
741 "{:provider$} {:model$} {:context$} {:max_out$} {:thinking$} {:images$}",
742 "provider",
743 "model",
744 "context",
745 "max-out",
746 "thinking",
747 "images",
748 provider = widths.0,
749 model = widths.1,
750 context = widths.2,
751 max_out = widths.3,
752 thinking = widths.4,
753 images = widths.5,
754 );
755 for row in rows {
756 println!(
757 "{:provider$} {:model$} {:context$} {:max_out$} {:thinking$} {:images$}",
758 row.0,
759 row.1,
760 row.2,
761 row.3,
762 row.4,
763 row.5,
764 provider = widths.0,
765 model = widths.1,
766 context = widths.2,
767 max_out = widths.3,
768 thinking = widths.4,
769 images = widths.5,
770 );
771 }
772 0
773}
774
775fn read_piped_stdin() -> Option<String> {
782 if std::env::var("RPI_SKIP_STDIN")
785 .map(|v| v == "1")
786 .unwrap_or(false)
787 {
788 return None;
789 }
790 if std::io::stdin().is_terminal() {
791 return None;
792 }
793 let mut buf = String::new();
794 match std::io::stdin().read_to_string(&mut buf) {
795 Ok(_) => {
796 let trimmed = buf.trim();
797 if trimmed.is_empty() {
798 None
799 } else {
800 Some(trimmed.to_string())
801 }
802 }
803 Err(_) => None,
804 }
805}
806
807fn process_file_args(
813 file_args: &[std::path::PathBuf],
814 cwd: &Path,
815) -> Result<(String, Vec<ImageContent>), String> {
816 let mut text = String::new();
817 let mut images = Vec::new();
818 for rel in file_args {
819 let abs = if rel.is_absolute() {
820 rel.clone()
821 } else {
822 cwd.join(rel)
823 };
824 if !abs.exists() {
825 return Err(format!("file not found: {}", abs.display()));
826 }
827 let bytes = std::fs::read(&abs)
828 .map_err(|e| format!("could not read file {}: {e}", abs.display()))?;
829 if let Some(image) = image_content_from_bytes(&bytes) {
830 images.push(image);
831 } else {
832 let content = String::from_utf8(bytes).map_err(|_| {
833 format!(
834 "file is not valid UTF-8 text or a supported image: {}",
835 abs.display()
836 )
837 })?;
838 text.push_str(&format!(
839 "<file name=\"{}\">\n{}\n</file>\n",
840 abs.display(),
841 content
842 ));
843 }
844 }
845 Ok((text, images))
846}
847
848pub(crate) fn image_content_from_path(path: &Path) -> Result<Option<ImageContent>, String> {
849 let bytes =
850 std::fs::read(path).map_err(|e| format!("could not read file {}: {e}", path.display()))?;
851 Ok(image_content_from_bytes(&bytes))
852}
853
854fn image_content_from_bytes(bytes: &[u8]) -> Option<ImageContent> {
855 let mime_type = rpi_tools::detect_supported_image_mime_type(bytes)?;
856 Some(ImageContent {
857 kind: ImageContentType,
858 data: rpi_tools::encode_base64(bytes),
859 mime_type: mime_type.to_string(),
860 })
861}
862
863fn build_initial_message(
869 parsed: &Args,
870 stdin: Option<&str>,
871 file_text: Option<&str>,
872) -> (Option<String>, Vec<String>) {
873 let mut extra = parsed.messages.clone();
874 let mut parts: Vec<String> = Vec::new();
875 if let Some(s) = stdin {
876 parts.push(s.to_string());
877 }
878 if let Some(t) = file_text {
879 parts.push(t.to_string());
880 }
881 if !extra.is_empty() {
883 parts.push(extra.remove(0));
884 }
885 let initial = if parts.is_empty() {
886 None
887 } else {
888 Some(parts.join(""))
889 };
890 (initial, extra)
891}
892
893fn print_resolve_error(e: &ResolveError) {
896 match e {
897 ResolveError::NoApiKey { hint } => {
898 eprintln!("error: {e}");
899 eprintln!();
900 eprintln!("Provide credentials via one of: {hint}.");
901 }
902 ResolveError::Config(_) => {
903 eprintln!("error: {e}");
904 eprintln!();
905 eprintln!("Check ~/.rpi/auth.json / ~/.rpi/models.json (set RPI_CODING_AGENT_DIR to relocate).");
906 }
907 _ => eprintln!("error: {e}"),
908 }
909}
910
911fn print_build_error(e: &BuildError) {
913 match e {
914 BuildError::SessionNotFound { .. } => {
915 eprintln!("error: {e}");
916 eprintln!();
917 eprintln!("List saved sessions with the /session command in interactive mode.");
918 }
919 _ => eprintln!("error: {e}"),
920 }
921}
922
923#[cfg(test)]
924mod tests {
925 use super::*;
926 use crate::args::Args;
927
928 #[test]
929 fn take_flag_value_supports_both_spellings() {
930 let argv = vec![
931 "--connect".to_string(),
932 "127.0.0.1:9899".to_string(),
933 "--token".to_string(),
934 "abc".to_string(),
935 ];
936 assert_eq!(
937 take_flag_value(&argv, "--connect").as_deref(),
938 Some("127.0.0.1:9899")
939 );
940 assert_eq!(take_flag_value(&argv, "--token").as_deref(), Some("abc"));
941 assert_eq!(take_flag_value(&argv, "--missing"), None);
942
943 let inline = vec!["--connect=1.2.3.4:1".to_string()];
944 assert_eq!(
945 take_flag_value(&inline, "--connect").as_deref(),
946 Some("1.2.3.4:1")
947 );
948 }
949
950 #[test]
951 fn connect_token_prefers_the_flag_over_the_env_var() {
952 let argv = vec!["--token".to_string(), "flag-token".to_string()];
954 assert_eq!(resolve_connect_token(&argv).as_deref(), Some("flag-token"));
955 }
956
957 #[test]
958 fn build_initial_combines_stdin_file_and_first_message() {
959 let mut args = Args::default();
960 args.messages = vec!["first".into(), "second".into(), "third".into()];
961 let (initial, extra) =
962 build_initial_message(&args, Some("stdin-text"), Some("<file>...</file>"));
963 assert_eq!(initial.as_deref(), Some("stdin-text<file>...</file>first"));
964 assert_eq!(extra, vec!["second".to_string(), "third".to_string()]);
965 }
966
967 #[test]
968 fn build_initial_with_no_messages_uses_stdin_and_file_only() {
969 let args = Args::default();
970 let (initial, extra) =
971 build_initial_message(&args, Some("only-stdin"), Some("<file>x</file>"));
972 assert_eq!(initial.as_deref(), Some("only-stdin<file>x</file>"));
973 assert!(extra.is_empty());
974 }
975
976 #[test]
977 fn build_initial_none_when_all_empty() {
978 let args = Args::default();
979 let (initial, extra) = build_initial_message(&args, None, None);
980 assert!(initial.is_none());
981 assert!(extra.is_empty());
982 }
983
984 #[test]
985 fn build_initial_shifts_only_first_message() {
986 let mut args = Args::default();
987 args.messages = vec!["a".into(), "b".into()];
988 let (initial, extra) = build_initial_message(&args, None, None);
989 assert_eq!(initial.as_deref(), Some("a"));
990 assert_eq!(extra, vec!["b".to_string()]);
991 }
992
993 #[test]
994 fn process_file_args_attaches_supported_images() {
995 let dir = tempfile::tempdir().unwrap();
996 let path = dir.path().join("image.bin");
997 let mut png = vec![137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13];
998 png.extend_from_slice(b"IHDR");
999 png.extend_from_slice(&[0; 13]);
1000 std::fs::write(&path, png).unwrap();
1001 let (text, images) = process_file_args(&[path], dir.path()).unwrap();
1002 assert!(text.is_empty());
1003 assert_eq!(images.len(), 1);
1004 assert_eq!(images[0].mime_type, "image/png");
1005 assert!(!images[0].data.is_empty());
1006 }
1007
1008 #[test]
1009 fn image_content_from_path_reports_supported_mime() {
1010 let dir = tempfile::tempdir().unwrap();
1011 let path = dir.path().join("drop.png");
1012 let mut png = vec![137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13];
1013 png.extend_from_slice(b"IHDR");
1014 png.extend_from_slice(&[0; 13]);
1015 std::fs::write(&path, png).unwrap();
1016 let image = image_content_from_path(&path).unwrap().unwrap();
1017 assert_eq!(image.mime_type, "image/png");
1018 }
1019}