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