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_for_cwd, ResolveError};
25use crate::session::{build, 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 dirs = crate::resource_dirs::extension_dirs(&cwd);
142 let diagnostics: std::sync::Arc<dyn rpi_extensions::PluginDiagnostics> =
143 std::sync::Arc::new(rpi_extensions::NullDiagnostics);
144 let extension_session = rpi_extensions::load_session(&dirs, diagnostics, None);
145 return crate::auth::run_with_extensions(&argv[1..], extension_session.snapshot_arc())
146 .await;
147 }
148 Some("events") => return crate::events::run(&argv[1..]).await,
149 Some("update") => return crate::updates::run_self_update(&argv[1..]),
150 Some("install") => return crate::install::run(&argv[1..]),
151 Some("uninstall") => return crate::install::uninstall(&argv[1..]),
152 Some("package") => return crate::packages::run(&argv[1..]),
153 Some(removed @ ("pi-package" | "pi-update" | "install-pi" | "uninstall-pi")) => {
156 eprintln!(
157 "error: `rpi {removed}` is not available; use `rpi package`, `rpi install`, or `rpi update`."
158 );
159 return EXIT_USAGE;
160 }
161 _ => {}
167 }
168
169 let mut parsed = parse_args(&argv);
170 crate::timings::time("args parsed", crate::timings::TimingNamespace::Main);
171
172 if let Some(code) = handle_parse_short_circuits(&parsed) {
174 return code;
175 }
176
177 let cwd = match resolve_cwd() {
178 Ok(cwd) => cwd,
179 Err(code) => return code,
180 };
181
182 let _ = crate::config::migrate_legacy_layout();
186 crate::timings::time("config migrated", crate::timings::TimingNamespace::Main);
187
188 if let Some(code) = export_if_requested(&parsed) {
189 return code;
190 }
191
192 if let Some(search) = parsed.list_models.as_deref() {
197 return list_models(search).await;
198 }
199
200 let dev_extension = match prepare_dev_extension(dev_options.as_ref(), &cwd, &mut parsed) {
203 Ok(extension) => extension,
204 Err(code) => return code,
205 };
206
207 if let Some(code) = resolve_resume(&mut parsed, &cwd).await {
211 return code;
212 }
213
214 emit_startup_warnings(&parsed);
215
216 let wants_rpc_channel =
230 parsed.mode == crate::args::Mode::Rpc || parsed.unknown_flags.contains_key("server");
231
232 let (file_text, file_images) = match process_file_args(&parsed.file_args, &cwd) {
234 Ok(t) => t,
235 Err(msg) => {
236 eprintln!("error: {msg}");
237 return EXIT_USAGE;
238 }
239 };
240
241 let project_trusted = crate::session::resolve_project_trust(&parsed, &cwd);
243 let resolved = match resolve_for_cwd(
244 parsed.provider.as_deref(),
245 parsed.model.as_deref(),
246 parsed.thinking,
247 parsed.api_key.as_deref(),
248 parsed.base_url.as_deref(),
249 &cwd,
250 project_trusted,
251 ) {
252 Ok(r) => r,
253 Err(e) => {
254 print_resolve_error(&e);
255 return match e {
256 ResolveError::NoApiKey { .. } | ResolveError::Config(_) => EXIT_USAGE,
257 _ => EXIT_RUNTIME,
258 };
259 }
260 };
261
262 let model_catalog = crate::provider::authenticated_catalog(&resolved);
266
267 if let Some(patterns) = &parsed.models {
272 let mut matched: Vec<String> = Vec::new();
273 for p in patterns {
274 let hits: Vec<String> = model_catalog
275 .iter()
276 .filter(|m| m.id.eq_ignore_ascii_case(p))
277 .map(|m| m.id.clone())
278 .collect();
279 if hits.is_empty() {
280 eprintln!("warning: --models pattern \"{p}\" matched no model");
281 }
282 matched.extend(hits);
283 }
284 let mut settings = crate::settings::load_settings().unwrap_or_default();
285 settings.scoped_models = if matched.is_empty() {
286 None
287 } else {
288 Some(matched)
289 };
290 if let Err(e) = crate::settings::save_settings(&settings) {
291 eprintln!("warning: could not save --models scope: {e}");
292 }
293 }
294
295 let (harness, event_rx, mut reload_context) =
297 match build(&resolved, &parsed, &cwd, project_trusted).await {
298 Ok(triple) => triple,
299 Err(e) => {
300 print_build_error(&e);
301 return EXIT_RUNTIME;
302 }
303 };
304 reload_context.dev_extension = dev_extension;
305
306 let stdio_claimed = reload_context
313 .extension_session
314 .lock()
315 .map(|session| session.owns_stdio())
316 .unwrap_or(false);
317 let stdio_lifecycle = reload_context
318 .extension_session
319 .lock()
320 .ok()
321 .and_then(|session| session.action_bridge())
322 .map(|bridge| bridge.stdio_lifecycle());
323 if let Some(lifecycle) = &stdio_lifecycle {
324 if let Err(error) = lifecycle.validate() {
325 eprintln!("error: {error}");
326 return EXIT_RUNTIME;
327 }
328 }
329 let stdin_text = if wants_rpc_channel || stdio_claimed {
330 None
331 } else {
332 read_piped_stdin()
333 };
334
335 let file_text_opt = if file_text.is_empty() {
337 None
338 } else {
339 Some(file_text.as_str())
340 };
341 let (initial, extra) = build_initial_message(&parsed, stdin_text.as_deref(), file_text_opt);
342
343 let stdin_is_tty = std::io::stdin().is_terminal();
345 let stdout_is_tty = std::io::stdout().is_terminal();
346 let mode = resolve_mode(&parsed, stdin_is_tty, stdout_is_tty);
347
348 let mode = if matches!(mode, RunMode::Interactive) && stdin_text.is_some() {
350 RunMode::Print
351 } else {
352 mode
353 };
354
355 let mode = if std::env::var("RPI_FORCE_TUI")
358 .map(|v| v == "1")
359 .unwrap_or(false)
360 {
361 RunMode::Interactive
362 } else {
363 mode
364 };
365
366 let mode = if stdio_claimed { RunMode::Rpc } else { mode };
373
374 let dev_cleanup = reload_context.dev_extension.clone();
375 let dev_watcher = if matches!(mode, RunMode::Interactive) {
376 reload_context
377 .dev_extension
378 .as_ref()
379 .and_then(|extension| extension.start_watcher(reload_context.mailbox.clone()))
380 } else {
381 None
382 };
383
384 let exit_code = match mode {
385 RunMode::Print => {
386 crate::modes::print(
387 &harness,
388 &parsed,
389 initial.clone(),
390 &extra,
391 file_images.clone(),
392 )
393 .await
394 }
395 RunMode::Json => {
396 crate::modes::json(
397 &harness,
398 &parsed,
399 initial.clone(),
400 &extra,
401 file_images.clone(),
402 Some(event_rx),
403 )
404 .await
405 }
406 RunMode::Interactive => {
407 crate::modes::interactive(
408 &harness,
409 Some(event_rx),
410 &parsed,
411 model_catalog,
412 initial.clone(),
413 &extra,
414 file_images.clone(),
415 if parsed.no_themes {
416 None
417 } else {
418 parsed.theme.as_deref().or(resolved.theme.as_deref())
419 },
420 parsed.no_themes,
421 &reload_context,
422 )
423 .await
424 }
425 RunMode::Rpc => {
426 let veto = crate::session::dispatch_session_event_async(
432 &reload_context,
433 rpi_plugin_sdk::EventTag::BeforeTuiStart,
434 )
435 .await;
436 if let Some(reason) = veto {
437 eprintln!("[rpi] startup vetoed by extension: {reason}");
438 EXIT_VETOED
439 } else {
440 if let Some(reason) = crate::session::dispatch_session_event_async(
441 &reload_context,
442 rpi_plugin_sdk::EventTag::SessionStart,
443 )
444 .await
445 {
446 tracing::warn!(
447 "[rpi] extension vetoed SessionStart (headless, advisory): {reason}"
448 );
449 }
450
451 let code = if parsed.unknown_flags.contains_key("server") || stdio_claimed {
452 eprintln!(
456 "[rpi] running in headless mode (no TUI); extension owns stdio; Ctrl+C to stop"
457 );
458 if stdio_claimed {
459 let lifecycle = stdio_lifecycle
460 .as_ref()
461 .expect("stdio owner requires an action bridge");
462 tokio::select! {
463 code = lifecycle.wait() => code,
464 _ = tokio::signal::ctrl_c() => 0,
465 }
466 } else {
467 let _ = tokio::signal::ctrl_c().await;
468 0
469 }
470 } else {
471 crate::modes::rpc(
477 &harness,
478 Some(event_rx),
479 model_catalog,
480 reload_context.ui_dialog.clone(),
481 )
482 .await
483 };
484
485 if let Some(reason) = crate::session::dispatch_session_event_async(
486 &reload_context,
487 rpi_plugin_sdk::EventTag::SessionShutdown,
488 )
489 .await
490 {
491 tracing::warn!(
492 "[rpi] extension vetoed SessionShutdown (ignored, closing): {reason}"
493 );
494 }
495 code
496 }
497 }
498 };
499
500 if let Some(dev) = &dev_cleanup {
501 dev.stop_watcher();
502 }
503 if let Some(watcher) = dev_watcher {
504 let _ = watcher.join();
505 }
506 drop(reload_context);
507 drop(harness);
508 if let Some(dev) = dev_cleanup {
509 dev.cleanup();
510 }
511 exit_code
512}
513
514fn handle_parse_short_circuits(parsed: &Args) -> Option<i32> {
515 if parsed.help {
517 print_help();
518 return Some(0);
519 }
520 if parsed.version {
521 print_version();
522 return Some(0);
523 }
524
525 if !parsed.errors.is_empty() {
527 for err in &parsed.errors {
528 eprintln!("error: {err}");
529 }
530 eprintln!();
531 print_help();
532 return Some(EXIT_USAGE);
533 }
534
535 None
536}
537
538fn resolve_cwd() -> Result<std::path::PathBuf, i32> {
539 std::env::current_dir().map_err(|error| {
540 eprintln!("error: could not determine the current directory: {error}");
541 EXIT_USAGE
542 })
543}
544
545fn export_if_requested(parsed: &Args) -> Option<i32> {
546 let input = parsed.export.as_deref()?;
547 let output = parsed
548 .messages
549 .first()
550 .map(Path::new)
551 .map(Path::to_path_buf)
552 .unwrap_or_else(|| {
553 let stem = input
554 .file_stem()
555 .and_then(|value| value.to_str())
556 .unwrap_or("session");
557 Path::new(&format!("rpi-session-{stem}.html")).to_path_buf()
558 });
559
560 match crate::export::export_file(input, &output) {
561 Ok(()) => {
562 println!("Exported to: {}", output.display());
563 Some(0)
564 }
565 Err(error) => {
566 eprintln!("error: {error}");
567 Some(EXIT_RUNTIME)
568 }
569 }
570}
571
572fn prepare_dev_extension(
573 options: Option<&crate::dev_extension::DevOptions>,
574 cwd: &Path,
575 parsed: &mut Args,
576) -> Result<Option<std::sync::Arc<crate::dev_extension::DevExtension>>, i32> {
577 let Some(options) = options else {
578 return Ok(None);
579 };
580
581 match crate::dev_extension::DevExtension::detect(cwd, options) {
582 Ok(extension) => {
583 if let Err(error) = extension.rebuild() {
584 eprintln!("error: initial extension build failed: {error}");
585 return Err(EXIT_RUNTIME);
586 }
587 if let Err(error) = extension.apply_to_args(parsed) {
588 eprintln!("error: {error}");
589 return Err(EXIT_RUNTIME);
590 }
591 Ok(Some(extension))
592 }
593 Err(error) => {
594 eprintln!("dev: {error}");
596 eprintln!("dev: no Cargo cdylib found; running in skills-only mode");
597 parsed.no_extensions = true;
598 parsed.extensions_dir.clear();
599 parsed.extension.clear();
600 if options.local_only {
601 parsed.dev_local_only = true;
602 }
603 Ok(None)
604 }
605 }
606}
607
608async fn resolve_resume(parsed: &mut Args, cwd: &Path) -> Option<i32> {
609 if !parsed.resume {
610 return None;
611 }
612 if !std::io::stdin().is_terminal() || !std::io::stdout().is_terminal() {
613 eprintln!("error: --resume requires an interactive terminal");
614 return Some(EXIT_USAGE);
615 }
616
617 match crate::resume_picker::select(cwd).await {
618 Ok(Some(id)) => {
619 parsed.resume = false;
620 parsed.session = Some(id);
621 None
622 }
623 Ok(None) => Some(0),
624 Err(error) => {
625 eprintln!("error: {error}");
626 Some(EXIT_RUNTIME)
627 }
628 }
629}
630
631fn emit_startup_warnings(parsed: &Args) {
632 if parsed.verbose {
634 for warn in &parsed.ignored {
635 eprintln!("warning: {warn}");
636 }
637 }
638 if parsed.no_themes && parsed.theme.is_some() {
639 eprintln!("warning: --no-themes overrides --theme; using the built-in default theme");
640 }
641}
642
643async fn list_models(search: &str) -> i32 {
646 let catalog = match crate::provider::catalog_all() {
647 Ok(models) => models,
648 Err(error) => {
649 eprintln!("warning: could not load models.json: {error}");
650 Vec::new()
651 }
652 };
653 let needle = search.trim().to_ascii_lowercase();
654 let mut models: Vec<_> = catalog
655 .into_iter()
656 .filter(|model| {
657 needle.is_empty()
658 || format!("{} {} {}", model.provider, model.id, model.name)
659 .to_ascii_lowercase()
660 .contains(&needle)
661 })
662 .collect();
663 if models.is_empty() {
664 if needle.is_empty() {
665 println!("{}", crate::auth_guidance::no_models_available_message());
666 } else {
667 println!("No models matching \"{search}\"");
668 }
669 return 0;
670 }
671
672 fn format_tokens(value: u64) -> String {
673 if value >= 1_000_000 {
674 let whole = value % 1_000_000 == 0;
675 if whole {
676 format!("{}M", value / 1_000_000)
677 } else {
678 format!("{:.1}M", value as f64 / 1_000_000.0)
679 }
680 } else if value >= 1_000 {
681 let whole = value % 1_000 == 0;
682 if whole {
683 format!("{}K", value / 1_000)
684 } else {
685 format!("{:.1}K", value as f64 / 1_000.0)
686 }
687 } else {
688 value.to_string()
689 }
690 }
691
692 let rows: Vec<_> = models
693 .drain(..)
694 .map(|model| {
695 let images = model
696 .input
697 .iter()
698 .any(|input| matches!(input, rpi_ai::InputModality::Image));
699 (
700 model.provider,
701 model.id,
702 format_tokens(model.context_window),
703 format_tokens(model.max_tokens),
704 if model.reasoning { "yes" } else { "no" }.to_string(),
705 if images { "yes" } else { "no" }.to_string(),
706 )
707 })
708 .collect();
709 let widths = (
710 rows.iter().map(|r| r.0.len()).max().unwrap_or(8).max(8),
711 rows.iter().map(|r| r.1.len()).max().unwrap_or(5).max(5),
712 rows.iter().map(|r| r.2.len()).max().unwrap_or(7).max(7),
713 rows.iter().map(|r| r.3.len()).max().unwrap_or(7).max(7),
714 rows.iter().map(|r| r.4.len()).max().unwrap_or(8).max(8),
715 rows.iter().map(|r| r.5.len()).max().unwrap_or(6).max(6),
716 );
717 println!(
718 "{:provider$} {:model$} {:context$} {:max_out$} {:thinking$} {:images$}",
719 "provider",
720 "model",
721 "context",
722 "max-out",
723 "thinking",
724 "images",
725 provider = widths.0,
726 model = widths.1,
727 context = widths.2,
728 max_out = widths.3,
729 thinking = widths.4,
730 images = widths.5,
731 );
732 for row in rows {
733 println!(
734 "{:provider$} {:model$} {:context$} {:max_out$} {:thinking$} {:images$}",
735 row.0,
736 row.1,
737 row.2,
738 row.3,
739 row.4,
740 row.5,
741 provider = widths.0,
742 model = widths.1,
743 context = widths.2,
744 max_out = widths.3,
745 thinking = widths.4,
746 images = widths.5,
747 );
748 }
749 0
750}
751
752fn read_piped_stdin() -> Option<String> {
759 if std::env::var("RPI_SKIP_STDIN")
762 .map(|v| v == "1")
763 .unwrap_or(false)
764 {
765 return None;
766 }
767 if std::io::stdin().is_terminal() {
768 return None;
769 }
770 let mut buf = String::new();
771 match std::io::stdin().read_to_string(&mut buf) {
772 Ok(_) => {
773 let trimmed = buf.trim();
774 if trimmed.is_empty() {
775 None
776 } else {
777 Some(trimmed.to_string())
778 }
779 }
780 Err(_) => None,
781 }
782}
783
784fn process_file_args(
790 file_args: &[std::path::PathBuf],
791 cwd: &Path,
792) -> Result<(String, Vec<ImageContent>), String> {
793 let mut text = String::new();
794 let mut images = Vec::new();
795 for rel in file_args {
796 let abs = if rel.is_absolute() {
797 rel.clone()
798 } else {
799 cwd.join(rel)
800 };
801 if !abs.exists() {
802 return Err(format!("file not found: {}", abs.display()));
803 }
804 let bytes = std::fs::read(&abs)
805 .map_err(|e| format!("could not read file {}: {e}", abs.display()))?;
806 if let Some(image) = image_content_from_bytes(&bytes) {
807 images.push(image);
808 } else {
809 let content = String::from_utf8(bytes).map_err(|_| {
810 format!(
811 "file is not valid UTF-8 text or a supported image: {}",
812 abs.display()
813 )
814 })?;
815 text.push_str(&format!(
816 "<file name=\"{}\">\n{}\n</file>\n",
817 abs.display(),
818 content
819 ));
820 }
821 }
822 Ok((text, images))
823}
824
825pub(crate) fn image_content_from_path(path: &Path) -> Result<Option<ImageContent>, String> {
826 let bytes =
827 std::fs::read(path).map_err(|e| format!("could not read file {}: {e}", path.display()))?;
828 Ok(image_content_from_bytes(&bytes))
829}
830
831fn image_content_from_bytes(bytes: &[u8]) -> Option<ImageContent> {
832 let mime_type = rpi_tools::detect_supported_image_mime_type(bytes)?;
833 Some(ImageContent {
834 kind: ImageContentType,
835 data: rpi_tools::encode_base64(bytes),
836 mime_type: mime_type.to_string(),
837 })
838}
839
840fn build_initial_message(
846 parsed: &Args,
847 stdin: Option<&str>,
848 file_text: Option<&str>,
849) -> (Option<String>, Vec<String>) {
850 let mut extra = parsed.messages.clone();
851 let mut parts: Vec<String> = Vec::new();
852 if let Some(s) = stdin {
853 parts.push(s.to_string());
854 }
855 if let Some(t) = file_text {
856 parts.push(t.to_string());
857 }
858 if !extra.is_empty() {
860 parts.push(extra.remove(0));
861 }
862 let initial = if parts.is_empty() {
863 None
864 } else {
865 Some(parts.join(""))
866 };
867 (initial, extra)
868}
869
870fn print_resolve_error(e: &ResolveError) {
873 match e {
874 ResolveError::NoApiKey { hint } => {
875 eprintln!("error: {e}");
876 eprintln!();
877 eprintln!("Provide credentials via one of: {hint}.");
878 }
879 ResolveError::Config(_) => {
880 eprintln!("error: {e}");
881 eprintln!();
882 eprintln!("Check ~/.rpi/auth.json / ~/.rpi/models.json (set RPI_CODING_AGENT_DIR to relocate).");
883 }
884 _ => eprintln!("error: {e}"),
885 }
886}
887
888fn print_build_error(e: &BuildError) {
890 match e {
891 BuildError::SessionNotFound { .. } => {
892 eprintln!("error: {e}");
893 eprintln!();
894 eprintln!("List saved sessions with the /session command in interactive mode.");
895 }
896 _ => eprintln!("error: {e}"),
897 }
898}
899
900#[cfg(test)]
901mod tests {
902 use super::*;
903 use crate::args::Args;
904
905 #[test]
906 fn take_flag_value_supports_both_spellings() {
907 let argv = vec![
908 "--connect".to_string(),
909 "127.0.0.1:9899".to_string(),
910 "--token".to_string(),
911 "abc".to_string(),
912 ];
913 assert_eq!(
914 take_flag_value(&argv, "--connect").as_deref(),
915 Some("127.0.0.1:9899")
916 );
917 assert_eq!(take_flag_value(&argv, "--token").as_deref(), Some("abc"));
918 assert_eq!(take_flag_value(&argv, "--missing"), None);
919
920 let inline = vec!["--connect=1.2.3.4:1".to_string()];
921 assert_eq!(
922 take_flag_value(&inline, "--connect").as_deref(),
923 Some("1.2.3.4:1")
924 );
925 }
926
927 #[test]
928 fn connect_token_prefers_the_flag_over_the_env_var() {
929 let argv = vec!["--token".to_string(), "flag-token".to_string()];
931 assert_eq!(resolve_connect_token(&argv).as_deref(), Some("flag-token"));
932 }
933
934 #[test]
935 fn build_initial_combines_stdin_file_and_first_message() {
936 let mut args = Args::default();
937 args.messages = vec!["first".into(), "second".into(), "third".into()];
938 let (initial, extra) =
939 build_initial_message(&args, Some("stdin-text"), Some("<file>...</file>"));
940 assert_eq!(initial.as_deref(), Some("stdin-text<file>...</file>first"));
941 assert_eq!(extra, vec!["second".to_string(), "third".to_string()]);
942 }
943
944 #[test]
945 fn build_initial_with_no_messages_uses_stdin_and_file_only() {
946 let args = Args::default();
947 let (initial, extra) =
948 build_initial_message(&args, Some("only-stdin"), Some("<file>x</file>"));
949 assert_eq!(initial.as_deref(), Some("only-stdin<file>x</file>"));
950 assert!(extra.is_empty());
951 }
952
953 #[test]
954 fn build_initial_none_when_all_empty() {
955 let args = Args::default();
956 let (initial, extra) = build_initial_message(&args, None, None);
957 assert!(initial.is_none());
958 assert!(extra.is_empty());
959 }
960
961 #[test]
962 fn build_initial_shifts_only_first_message() {
963 let mut args = Args::default();
964 args.messages = vec!["a".into(), "b".into()];
965 let (initial, extra) = build_initial_message(&args, None, None);
966 assert_eq!(initial.as_deref(), Some("a"));
967 assert_eq!(extra, vec!["b".to_string()]);
968 }
969
970 #[test]
971 fn process_file_args_attaches_supported_images() {
972 let dir = tempfile::tempdir().unwrap();
973 let path = dir.path().join("image.bin");
974 let mut png = vec![137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13];
975 png.extend_from_slice(b"IHDR");
976 png.extend_from_slice(&[0; 13]);
977 std::fs::write(&path, png).unwrap();
978 let (text, images) = process_file_args(&[path], dir.path()).unwrap();
979 assert!(text.is_empty());
980 assert_eq!(images.len(), 1);
981 assert_eq!(images[0].mime_type, "image/png");
982 assert!(!images[0].data.is_empty());
983 }
984
985 #[test]
986 fn image_content_from_path_reports_supported_mime() {
987 let dir = tempfile::tempdir().unwrap();
988 let path = dir.path().join("drop.png");
989 let mut png = vec![137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13];
990 png.extend_from_slice(b"IHDR");
991 png.extend_from_slice(&[0; 13]);
992 std::fs::write(&path, png).unwrap();
993 let image = image_content_from_path(&path).unwrap().unwrap();
994 assert_eq!(image.mime_type, "image/png");
995 }
996}