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 =
222 parsed.mode == crate::args::Mode::Rpc || parsed.unknown_flags.contains_key("server");
223 let stdin_text = if wants_rpc_channel {
224 None
225 } else {
226 read_piped_stdin()
227 };
228
229 let (file_text, file_images) = match process_file_args(&parsed.file_args, &cwd) {
231 Ok(t) => t,
232 Err(msg) => {
233 eprintln!("error: {msg}");
234 return EXIT_USAGE;
235 }
236 };
237
238 let file_text_opt = if file_text.is_empty() {
240 None
241 } else {
242 Some(file_text.as_str())
243 };
244 let (initial, extra) = build_initial_message(&parsed, stdin_text.as_deref(), file_text_opt);
245
246 let project_trusted = crate::session::resolve_project_trust(&parsed, &cwd);
248 let resolved = match resolve_for_cwd(
249 parsed.provider.as_deref(),
250 parsed.model.as_deref(),
251 parsed.thinking,
252 parsed.api_key.as_deref(),
253 parsed.base_url.as_deref(),
254 &cwd,
255 project_trusted,
256 ) {
257 Ok(r) => r,
258 Err(e) => {
259 print_resolve_error(&e);
260 return match e {
261 ResolveError::NoApiKey { .. } | ResolveError::Config(_) => EXIT_USAGE,
262 _ => EXIT_RUNTIME,
263 };
264 }
265 };
266
267 let model_catalog = crate::provider::authenticated_catalog(&resolved);
271
272 if let Some(patterns) = &parsed.models {
277 let mut matched: Vec<String> = Vec::new();
278 for p in patterns {
279 let hits: Vec<String> = model_catalog
280 .iter()
281 .filter(|m| m.id.eq_ignore_ascii_case(p))
282 .map(|m| m.id.clone())
283 .collect();
284 if hits.is_empty() {
285 eprintln!("warning: --models pattern \"{p}\" matched no model");
286 }
287 matched.extend(hits);
288 }
289 let mut settings = crate::settings::load_settings().unwrap_or_default();
290 settings.scoped_models = if matched.is_empty() {
291 None
292 } else {
293 Some(matched)
294 };
295 if let Err(e) = crate::settings::save_settings(&settings) {
296 eprintln!("warning: could not save --models scope: {e}");
297 }
298 }
299
300 let (harness, event_rx, mut reload_context) =
302 match build(&resolved, &parsed, &cwd, project_trusted).await {
303 Ok(triple) => triple,
304 Err(e) => {
305 print_build_error(&e);
306 return EXIT_RUNTIME;
307 }
308 };
309 reload_context.dev_extension = dev_extension;
310
311 let stdin_is_tty = std::io::stdin().is_terminal();
313 let stdout_is_tty = std::io::stdout().is_terminal();
314 let mode = resolve_mode(&parsed, stdin_is_tty, stdout_is_tty);
315
316 let mode = if matches!(mode, RunMode::Interactive) && stdin_text.is_some() {
318 RunMode::Print
319 } else {
320 mode
321 };
322
323 let mode = if std::env::var("RPI_FORCE_TUI")
326 .map(|v| v == "1")
327 .unwrap_or(false)
328 {
329 RunMode::Interactive
330 } else {
331 mode
332 };
333
334 let dev_cleanup = reload_context.dev_extension.clone();
335 let dev_watcher = if matches!(mode, RunMode::Interactive) {
336 reload_context
337 .dev_extension
338 .as_ref()
339 .and_then(|extension| extension.start_watcher(reload_context.mailbox.clone()))
340 } else {
341 None
342 };
343
344 let exit_code = match mode {
345 RunMode::Print => {
346 crate::modes::print(
347 &harness,
348 &parsed,
349 initial.clone(),
350 &extra,
351 file_images.clone(),
352 )
353 .await
354 }
355 RunMode::Json => {
356 crate::modes::json(
357 &harness,
358 &parsed,
359 initial.clone(),
360 &extra,
361 file_images.clone(),
362 Some(event_rx),
363 )
364 .await
365 }
366 RunMode::Interactive => {
367 crate::modes::interactive(
368 &harness,
369 Some(event_rx),
370 &parsed,
371 model_catalog,
372 initial.clone(),
373 &extra,
374 file_images.clone(),
375 if parsed.no_themes {
376 None
377 } else {
378 parsed.theme.as_deref().or(resolved.theme.as_deref())
379 },
380 parsed.no_themes,
381 &reload_context,
382 )
383 .await
384 }
385 RunMode::Rpc => {
386 let veto = crate::session::dispatch_session_event_async(
392 &reload_context,
393 rpi_plugin_sdk::EventTag::BeforeTuiStart,
394 )
395 .await;
396 if let Some(reason) = veto {
397 eprintln!("[rpi] startup vetoed by extension: {reason}");
398 EXIT_VETOED
399 } else {
400 if let Some(reason) = crate::session::dispatch_session_event_async(
401 &reload_context,
402 rpi_plugin_sdk::EventTag::SessionStart,
403 )
404 .await
405 {
406 tracing::warn!(
407 "[rpi] extension vetoed SessionStart (headless, advisory): {reason}"
408 );
409 }
410
411 let code = if parsed.unknown_flags.contains_key("server") {
412 eprintln!("[rpi] running in headless server mode (no TUI); Ctrl+C to stop");
416 let _ = tokio::signal::ctrl_c().await;
417 0
418 } else {
419 crate::modes::rpc(&harness, Some(event_rx), model_catalog).await
423 };
424
425 if let Some(reason) = crate::session::dispatch_session_event_async(
426 &reload_context,
427 rpi_plugin_sdk::EventTag::SessionShutdown,
428 )
429 .await
430 {
431 tracing::warn!(
432 "[rpi] extension vetoed SessionShutdown (ignored, closing): {reason}"
433 );
434 }
435 code
436 }
437 }
438 };
439
440 if let Some(dev) = &dev_cleanup {
441 dev.stop_watcher();
442 }
443 if let Some(watcher) = dev_watcher {
444 let _ = watcher.join();
445 }
446 drop(reload_context);
447 drop(harness);
448 if let Some(dev) = dev_cleanup {
449 dev.cleanup();
450 }
451 exit_code
452}
453
454fn handle_parse_short_circuits(parsed: &Args) -> Option<i32> {
455 if parsed.help {
457 print_help();
458 return Some(0);
459 }
460 if parsed.version {
461 print_version();
462 return Some(0);
463 }
464
465 if !parsed.errors.is_empty() {
467 for err in &parsed.errors {
468 eprintln!("error: {err}");
469 }
470 eprintln!();
471 print_help();
472 return Some(EXIT_USAGE);
473 }
474
475 None
476}
477
478fn resolve_cwd() -> Result<std::path::PathBuf, i32> {
479 std::env::current_dir().map_err(|error| {
480 eprintln!("error: could not determine the current directory: {error}");
481 EXIT_USAGE
482 })
483}
484
485fn export_if_requested(parsed: &Args) -> Option<i32> {
486 let input = parsed.export.as_deref()?;
487 let output = parsed
488 .messages
489 .first()
490 .map(Path::new)
491 .map(Path::to_path_buf)
492 .unwrap_or_else(|| {
493 let stem = input
494 .file_stem()
495 .and_then(|value| value.to_str())
496 .unwrap_or("session");
497 Path::new(&format!("rpi-session-{stem}.html")).to_path_buf()
498 });
499
500 match crate::export::export_file(input, &output) {
501 Ok(()) => {
502 println!("Exported to: {}", output.display());
503 Some(0)
504 }
505 Err(error) => {
506 eprintln!("error: {error}");
507 Some(EXIT_RUNTIME)
508 }
509 }
510}
511
512fn prepare_dev_extension(
513 options: Option<&crate::dev_extension::DevOptions>,
514 cwd: &Path,
515 parsed: &mut Args,
516) -> Result<Option<std::sync::Arc<crate::dev_extension::DevExtension>>, i32> {
517 let Some(options) = options else {
518 return Ok(None);
519 };
520
521 match crate::dev_extension::DevExtension::detect(cwd, options) {
522 Ok(extension) => {
523 if let Err(error) = extension.rebuild() {
524 eprintln!("error: initial extension build failed: {error}");
525 return Err(EXIT_RUNTIME);
526 }
527 if let Err(error) = extension.apply_to_args(parsed) {
528 eprintln!("error: {error}");
529 return Err(EXIT_RUNTIME);
530 }
531 Ok(Some(extension))
532 }
533 Err(error) => {
534 eprintln!("dev: {error}");
536 eprintln!("dev: no Cargo cdylib found; running in skills-only mode");
537 parsed.no_extensions = true;
538 parsed.extensions_dir.clear();
539 parsed.extension.clear();
540 if options.local_only {
541 parsed.dev_local_only = true;
542 }
543 Ok(None)
544 }
545 }
546}
547
548async fn resolve_resume(parsed: &mut Args, cwd: &Path) -> Option<i32> {
549 if !parsed.resume {
550 return None;
551 }
552 if !std::io::stdin().is_terminal() || !std::io::stdout().is_terminal() {
553 eprintln!("error: --resume requires an interactive terminal");
554 return Some(EXIT_USAGE);
555 }
556
557 match crate::resume_picker::select(cwd).await {
558 Ok(Some(id)) => {
559 parsed.resume = false;
560 parsed.session = Some(id);
561 None
562 }
563 Ok(None) => Some(0),
564 Err(error) => {
565 eprintln!("error: {error}");
566 Some(EXIT_RUNTIME)
567 }
568 }
569}
570
571fn emit_startup_warnings(parsed: &Args) {
572 if parsed.verbose {
574 for warn in &parsed.ignored {
575 eprintln!("warning: {warn}");
576 }
577 }
578 if parsed.no_themes && parsed.theme.is_some() {
579 eprintln!("warning: --no-themes overrides --theme; using the built-in default theme");
580 }
581}
582
583async fn list_models(search: &str) -> i32 {
586 let catalog = match crate::provider::catalog_all() {
587 Ok(models) => models,
588 Err(error) => {
589 eprintln!("warning: could not load models.json: {error}");
590 Vec::new()
591 }
592 };
593 let needle = search.trim().to_ascii_lowercase();
594 let mut models: Vec<_> = catalog
595 .into_iter()
596 .filter(|model| {
597 needle.is_empty()
598 || format!("{} {} {}", model.provider, model.id, model.name)
599 .to_ascii_lowercase()
600 .contains(&needle)
601 })
602 .collect();
603 if models.is_empty() {
604 if needle.is_empty() {
605 println!("{}", crate::auth_guidance::no_models_available_message());
606 } else {
607 println!("No models matching \"{search}\"");
608 }
609 return 0;
610 }
611
612 fn format_tokens(value: u64) -> String {
613 if value >= 1_000_000 {
614 let whole = value % 1_000_000 == 0;
615 if whole {
616 format!("{}M", value / 1_000_000)
617 } else {
618 format!("{:.1}M", value as f64 / 1_000_000.0)
619 }
620 } else if value >= 1_000 {
621 let whole = value % 1_000 == 0;
622 if whole {
623 format!("{}K", value / 1_000)
624 } else {
625 format!("{:.1}K", value as f64 / 1_000.0)
626 }
627 } else {
628 value.to_string()
629 }
630 }
631
632 let rows: Vec<_> = models
633 .drain(..)
634 .map(|model| {
635 let images = model
636 .input
637 .iter()
638 .any(|input| matches!(input, rpi_ai::InputModality::Image));
639 (
640 model.provider,
641 model.id,
642 format_tokens(model.context_window),
643 format_tokens(model.max_tokens),
644 if model.reasoning { "yes" } else { "no" }.to_string(),
645 if images { "yes" } else { "no" }.to_string(),
646 )
647 })
648 .collect();
649 let widths = (
650 rows.iter().map(|r| r.0.len()).max().unwrap_or(8).max(8),
651 rows.iter().map(|r| r.1.len()).max().unwrap_or(5).max(5),
652 rows.iter().map(|r| r.2.len()).max().unwrap_or(7).max(7),
653 rows.iter().map(|r| r.3.len()).max().unwrap_or(7).max(7),
654 rows.iter().map(|r| r.4.len()).max().unwrap_or(8).max(8),
655 rows.iter().map(|r| r.5.len()).max().unwrap_or(6).max(6),
656 );
657 println!(
658 "{:provider$} {:model$} {:context$} {:max_out$} {:thinking$} {:images$}",
659 "provider",
660 "model",
661 "context",
662 "max-out",
663 "thinking",
664 "images",
665 provider = widths.0,
666 model = widths.1,
667 context = widths.2,
668 max_out = widths.3,
669 thinking = widths.4,
670 images = widths.5,
671 );
672 for row in rows {
673 println!(
674 "{:provider$} {:model$} {:context$} {:max_out$} {:thinking$} {:images$}",
675 row.0,
676 row.1,
677 row.2,
678 row.3,
679 row.4,
680 row.5,
681 provider = widths.0,
682 model = widths.1,
683 context = widths.2,
684 max_out = widths.3,
685 thinking = widths.4,
686 images = widths.5,
687 );
688 }
689 0
690}
691
692fn read_piped_stdin() -> Option<String> {
699 if std::env::var("RPI_SKIP_STDIN")
702 .map(|v| v == "1")
703 .unwrap_or(false)
704 {
705 return None;
706 }
707 if std::io::stdin().is_terminal() {
708 return None;
709 }
710 let mut buf = String::new();
711 match std::io::stdin().read_to_string(&mut buf) {
712 Ok(_) => {
713 let trimmed = buf.trim();
714 if trimmed.is_empty() {
715 None
716 } else {
717 Some(trimmed.to_string())
718 }
719 }
720 Err(_) => None,
721 }
722}
723
724fn process_file_args(
730 file_args: &[std::path::PathBuf],
731 cwd: &Path,
732) -> Result<(String, Vec<ImageContent>), String> {
733 let mut text = String::new();
734 let mut images = Vec::new();
735 for rel in file_args {
736 let abs = if rel.is_absolute() {
737 rel.clone()
738 } else {
739 cwd.join(rel)
740 };
741 if !abs.exists() {
742 return Err(format!("file not found: {}", abs.display()));
743 }
744 let bytes = std::fs::read(&abs)
745 .map_err(|e| format!("could not read file {}: {e}", abs.display()))?;
746 if let Some(image) = image_content_from_bytes(&bytes) {
747 images.push(image);
748 } else {
749 let content = String::from_utf8(bytes).map_err(|_| {
750 format!(
751 "file is not valid UTF-8 text or a supported image: {}",
752 abs.display()
753 )
754 })?;
755 text.push_str(&format!(
756 "<file name=\"{}\">\n{}\n</file>\n",
757 abs.display(),
758 content
759 ));
760 }
761 }
762 Ok((text, images))
763}
764
765pub(crate) fn image_content_from_path(path: &Path) -> Result<Option<ImageContent>, String> {
766 let bytes =
767 std::fs::read(path).map_err(|e| format!("could not read file {}: {e}", path.display()))?;
768 Ok(image_content_from_bytes(&bytes))
769}
770
771fn image_content_from_bytes(bytes: &[u8]) -> Option<ImageContent> {
772 let mime_type = rpi_tools::detect_supported_image_mime_type(bytes)?;
773 Some(ImageContent {
774 kind: ImageContentType,
775 data: rpi_tools::encode_base64(bytes),
776 mime_type: mime_type.to_string(),
777 })
778}
779
780fn build_initial_message(
786 parsed: &Args,
787 stdin: Option<&str>,
788 file_text: Option<&str>,
789) -> (Option<String>, Vec<String>) {
790 let mut extra = parsed.messages.clone();
791 let mut parts: Vec<String> = Vec::new();
792 if let Some(s) = stdin {
793 parts.push(s.to_string());
794 }
795 if let Some(t) = file_text {
796 parts.push(t.to_string());
797 }
798 if !extra.is_empty() {
800 parts.push(extra.remove(0));
801 }
802 let initial = if parts.is_empty() {
803 None
804 } else {
805 Some(parts.join(""))
806 };
807 (initial, extra)
808}
809
810fn print_resolve_error(e: &ResolveError) {
813 match e {
814 ResolveError::NoApiKey { hint } => {
815 eprintln!("error: {e}");
816 eprintln!();
817 eprintln!("Provide credentials via one of: {hint}.");
818 }
819 ResolveError::Config(_) => {
820 eprintln!("error: {e}");
821 eprintln!();
822 eprintln!("Check ~/.rpi/auth.json / ~/.rpi/models.json (set RPI_CODING_AGENT_DIR to relocate).");
823 }
824 _ => eprintln!("error: {e}"),
825 }
826}
827
828fn print_build_error(e: &BuildError) {
830 match e {
831 BuildError::SessionNotFound { .. } => {
832 eprintln!("error: {e}");
833 eprintln!();
834 eprintln!("List saved sessions with the /session command in interactive mode.");
835 }
836 _ => eprintln!("error: {e}"),
837 }
838}
839
840#[cfg(test)]
841mod tests {
842 use super::*;
843 use crate::args::Args;
844
845 #[test]
846 fn take_flag_value_supports_both_spellings() {
847 let argv = vec![
848 "--connect".to_string(),
849 "127.0.0.1:9899".to_string(),
850 "--token".to_string(),
851 "abc".to_string(),
852 ];
853 assert_eq!(
854 take_flag_value(&argv, "--connect").as_deref(),
855 Some("127.0.0.1:9899")
856 );
857 assert_eq!(take_flag_value(&argv, "--token").as_deref(), Some("abc"));
858 assert_eq!(take_flag_value(&argv, "--missing"), None);
859
860 let inline = vec!["--connect=1.2.3.4:1".to_string()];
861 assert_eq!(
862 take_flag_value(&inline, "--connect").as_deref(),
863 Some("1.2.3.4:1")
864 );
865 }
866
867 #[test]
868 fn connect_token_prefers_the_flag_over_the_env_var() {
869 let argv = vec!["--token".to_string(), "flag-token".to_string()];
871 assert_eq!(resolve_connect_token(&argv).as_deref(), Some("flag-token"));
872 }
873
874 #[test]
875 fn build_initial_combines_stdin_file_and_first_message() {
876 let mut args = Args::default();
877 args.messages = vec!["first".into(), "second".into(), "third".into()];
878 let (initial, extra) =
879 build_initial_message(&args, Some("stdin-text"), Some("<file>...</file>"));
880 assert_eq!(initial.as_deref(), Some("stdin-text<file>...</file>first"));
881 assert_eq!(extra, vec!["second".to_string(), "third".to_string()]);
882 }
883
884 #[test]
885 fn build_initial_with_no_messages_uses_stdin_and_file_only() {
886 let args = Args::default();
887 let (initial, extra) =
888 build_initial_message(&args, Some("only-stdin"), Some("<file>x</file>"));
889 assert_eq!(initial.as_deref(), Some("only-stdin<file>x</file>"));
890 assert!(extra.is_empty());
891 }
892
893 #[test]
894 fn build_initial_none_when_all_empty() {
895 let args = Args::default();
896 let (initial, extra) = build_initial_message(&args, None, None);
897 assert!(initial.is_none());
898 assert!(extra.is_empty());
899 }
900
901 #[test]
902 fn build_initial_shifts_only_first_message() {
903 let mut args = Args::default();
904 args.messages = vec!["a".into(), "b".into()];
905 let (initial, extra) = build_initial_message(&args, None, None);
906 assert_eq!(initial.as_deref(), Some("a"));
907 assert_eq!(extra, vec!["b".to_string()]);
908 }
909
910 #[test]
911 fn process_file_args_attaches_supported_images() {
912 let dir = tempfile::tempdir().unwrap();
913 let path = dir.path().join("image.bin");
914 let mut png = vec![137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13];
915 png.extend_from_slice(b"IHDR");
916 png.extend_from_slice(&[0; 13]);
917 std::fs::write(&path, png).unwrap();
918 let (text, images) = process_file_args(&[path], dir.path()).unwrap();
919 assert!(text.is_empty());
920 assert_eq!(images.len(), 1);
921 assert_eq!(images[0].mime_type, "image/png");
922 assert!(!images[0].data.is_empty());
923 }
924
925 #[test]
926 fn image_content_from_path_reports_supported_mime() {
927 let dir = tempfile::tempdir().unwrap();
928 let path = dir.path().join("drop.png");
929 let mut png = vec![137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13];
930 png.extend_from_slice(b"IHDR");
931 png.extend_from_slice(&[0; 13]);
932 std::fs::write(&path, png).unwrap();
933 let image = image_content_from_path(&path).unwrap().unwrap();
934 assert_eq!(image.mime_type, "image/png");
935 }
936}