1use std::io::{IsTerminal, Read};
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
37pub const EXIT_USAGE: i32 = 2;
41pub const EXIT_RUNTIME: i32 = 1;
44pub const EXIT_VETOED: i32 = 3;
49
50fn take_flag_value(argv: &[String], flag: &str) -> Option<String> {
54 let prefix = format!("{flag}=");
55 let mut iter = argv.iter();
56 while let Some(arg) = iter.next() {
57 if let Some(value) = arg.strip_prefix(&prefix) {
58 return Some(value.to_string());
59 }
60 if arg == flag {
61 return iter.next().cloned();
62 }
63 }
64 None
65}
66
67fn resolve_connect_token(argv: &[String]) -> Option<String> {
70 take_flag_value(argv, "--token").or_else(|| {
71 std::env::var("RPI_SERVER_TOKEN")
72 .ok()
73 .map(|value| value.trim().to_string())
74 .filter(|value| !value.is_empty())
75 })
76}
77
78pub async fn run() -> i32 {
84 crate::timings::reset(crate::timings::TimingNamespace::Main);
87 let code = run_inner().await;
88 crate::timings::print_timings();
89 code
90}
91
92async fn run_inner() -> i32 {
93 let mut argv: Vec<String> = std::env::args().skip(1).collect();
96
97 if argv.first().map(String::as_str) == Some("__rpi_dev_cleanup") {
98 return crate::dev_extension::run_cleanup_helper(&argv[1..]);
99 }
100
101 if let Some(addr) = take_flag_value(&argv, "--connect") {
106 let token = resolve_connect_token(&argv);
107 return crate::remote::tui::run(&addr, token.as_deref()).await;
108 }
109
110 crate::args::normalize_offline_mode(&argv);
114
115 let dev_command = argv.first().map(String::as_str);
118 let dev_options = if matches!(dev_command, Some("dev" | "dev-local")) {
119 match crate::dev_extension::parse_args(&argv[1..]) {
120 Ok(options) if options.help => {
121 crate::dev_extension::print_help();
122 return 0;
123 }
124 Ok(mut options) => {
125 if dev_command == Some("dev-local") {
126 options.local_only = true;
127 }
128 argv = options.passthrough.clone();
129 Some(options)
130 }
131 Err(error) => {
132 eprintln!("error: {error}");
133 crate::dev_extension::print_help();
134 return EXIT_USAGE;
135 }
136 }
137 } else {
138 None
139 };
140
141 if argv.first().map(|s| s.as_str()) == Some("auth") {
145 return crate::auth::run(&argv[1..]).await;
146 }
147 if argv.first().map(|s| s.as_str()) == Some("events") {
148 return crate::events::run(&argv[1..]).await;
149 }
150 if argv.first().map(|s| s.as_str()) == Some("package") {
151 return crate::packages::run_cli(&argv[1..]);
152 }
153 if argv.first().map(|s| s.as_str()) == Some("update") {
154 return crate::updates::run_self_update(&argv[1..]);
155 }
156 if argv.first().map(String::as_str) == Some("pi-package") {
157 let subcommand = argv.get(1).map(String::as_str);
158 if matches!(subcommand, Some("--help" | "-h")) {
159 return crate::packages::run_pi_package_update(&argv[1..]);
160 }
161 if subcommand != Some("update") {
162 eprintln!("error: usage is `rpi pi-package update`");
163 return EXIT_USAGE;
164 }
165 return crate::packages::run_pi_package_update(&argv[1..]);
166 }
167 if argv.first().map(String::as_str) == Some("pi-update") {
168 eprintln!("error: unknown command `pi-update`; use `rpi pi-package update`");
169 return EXIT_USAGE;
170 }
171 if argv.first().map(String::as_str) == Some("self-update") {
172 eprintln!("error: unknown command `self-update`; use `rpi update`");
173 return EXIT_USAGE;
174 }
175 if argv.first().map(|s| s.as_str()) == Some("install") {
176 return crate::install::run(&argv[1..]);
177 }
178 if argv.first().map(|s| s.as_str()) == Some("install-pi") {
179 return crate::install_pi::run(&argv[1..]);
180 }
181 if argv.first().map(|s| s.as_str()) == Some("uninstall") {
182 if argv.get(1).map(String::as_str) == Some("pi") {
183 return crate::install_pi::uninstall(&argv[2..]);
184 }
185 return crate::install::uninstall(&argv[1..]);
186 }
187 if argv.first().map(|s| s.as_str()) == Some("uninstall-pi") {
188 return crate::install_pi::uninstall(&argv[1..]);
189 }
190
191 let mut parsed = parse_args(&argv);
192 crate::timings::time("args parsed", crate::timings::TimingNamespace::Main);
193
194 if parsed.help {
196 print_help();
197 return 0;
198 }
199 if parsed.version {
200 print_version();
201 return 0;
202 }
203
204 if !parsed.errors.is_empty() {
206 for err in &parsed.errors {
207 eprintln!("error: {err}");
208 }
209 eprintln!();
210 print_help();
211 return EXIT_USAGE;
212 }
213
214 let cwd = match std::env::current_dir() {
216 Ok(c) => c,
217 Err(e) => {
218 eprintln!("error: could not determine the current directory: {e}");
219 return EXIT_USAGE;
220 }
221 };
222
223 let _ = crate::config::migrate_legacy_layout();
227 crate::timings::time("config migrated", crate::timings::TimingNamespace::Main);
228
229 if let Some(input) = parsed.export.as_deref() {
230 let output = parsed
231 .messages
232 .first()
233 .map(Path::new)
234 .map(Path::to_path_buf)
235 .unwrap_or_else(|| {
236 let stem = input
237 .file_stem()
238 .and_then(|value| value.to_str())
239 .unwrap_or("session");
240 Path::new(&format!("rpi-session-{stem}.html")).to_path_buf()
241 });
242 match crate::export::export_file(input, &output) {
243 Ok(()) => {
244 println!("Exported to: {}", output.display());
245 return 0;
246 }
247 Err(error) => {
248 eprintln!("error: {error}");
249 return EXIT_RUNTIME;
250 }
251 }
252 }
253
254 if let Some(search) = parsed.list_models.as_deref() {
259 return list_models(search).await;
260 }
261 let dev_extension = if let Some(options) = &dev_options {
267 match crate::dev_extension::DevExtension::detect(&cwd, options) {
268 Ok(extension) => {
269 if let Err(error) = extension.rebuild() {
270 eprintln!("error: initial extension build failed: {error}");
271 return EXIT_RUNTIME;
272 }
273 if let Err(error) = extension.apply_to_args(&mut parsed) {
274 eprintln!("error: {error}");
275 return EXIT_RUNTIME;
276 }
277 Some(extension)
278 }
279 Err(error) => {
280 eprintln!("dev: {error}");
282 eprintln!("dev: no Cargo cdylib found; running in skills-only mode");
283 parsed.no_extensions = true;
284 parsed.extensions_dir.clear();
285 parsed.extension.clear();
286 if options.local_only {
287 parsed.dev_local_only = true;
288 }
289 None
290 }
291 }
292 } else {
293 None
294 };
295
296 if parsed.resume {
300 if !std::io::stdin().is_terminal() || !std::io::stdout().is_terminal() {
301 eprintln!("error: --resume requires an interactive terminal");
302 return EXIT_USAGE;
303 }
304 match crate::resume_picker::select(&cwd).await {
305 Ok(Some(id)) => {
306 parsed.resume = false;
307 parsed.session = Some(id);
308 }
309 Ok(None) => return 0,
310 Err(e) => {
311 eprintln!("error: {e}");
312 return EXIT_RUNTIME;
313 }
314 }
315 }
316
317 if parsed.verbose {
319 for warn in &parsed.ignored {
320 eprintln!("warning: {warn}");
321 }
322 }
323 if parsed.no_themes && parsed.theme.is_some() {
324 eprintln!("warning: --no-themes overrides --theme; using the built-in default theme");
325 }
326
327 let wants_rpc_channel = parsed.mode == crate::args::Mode::Rpc
333 || parsed.unknown_flags.contains_key("server");
334 let stdin_text = if wants_rpc_channel {
335 None
336 } else {
337 read_piped_stdin()
338 };
339
340 let (file_text, file_images) = match process_file_args(&parsed.file_args, &cwd) {
342 Ok(t) => t,
343 Err(msg) => {
344 eprintln!("error: {msg}");
345 return EXIT_USAGE;
346 }
347 };
348
349 let file_text_opt = if file_text.is_empty() {
351 None
352 } else {
353 Some(file_text.as_str())
354 };
355 let (initial, extra) = build_initial_message(&parsed, stdin_text.as_deref(), file_text_opt);
356
357 let project_trusted = crate::session::resolve_project_trust(&parsed, &cwd);
359 let resolved = match resolve_for_cwd(
360 parsed.provider.as_deref(),
361 parsed.model.as_deref(),
362 parsed.thinking,
363 parsed.api_key.as_deref(),
364 parsed.base_url.as_deref(),
365 &cwd,
366 project_trusted,
367 ) {
368 Ok(r) => r,
369 Err(e) => {
370 print_resolve_error(&e);
371 return match e {
372 ResolveError::NoApiKey { .. } | ResolveError::Config(_) => EXIT_USAGE,
373 _ => EXIT_RUNTIME,
374 };
375 }
376 };
377
378 let model_catalog = crate::provider::available_catalog(&resolved);
381
382 if let Some(patterns) = &parsed.models {
387 let mut matched: Vec<String> = Vec::new();
388 for p in patterns {
389 let hits: Vec<String> = model_catalog
390 .iter()
391 .filter(|m| m.id.eq_ignore_ascii_case(p))
392 .map(|m| m.id.clone())
393 .collect();
394 if hits.is_empty() {
395 eprintln!("warning: --models pattern \"{p}\" matched no model");
396 }
397 matched.extend(hits);
398 }
399 let mut settings = crate::settings::load_settings().unwrap_or_default();
400 settings.scoped_models = if matched.is_empty() {
401 None
402 } else {
403 Some(matched)
404 };
405 if let Err(e) = crate::settings::save_settings(&settings) {
406 eprintln!("warning: could not save --models scope: {e}");
407 }
408 }
409
410 let (harness, event_rx, mut reload_context) =
412 match build(&resolved, &parsed, &cwd, project_trusted).await {
413 Ok(triple) => triple,
414 Err(e) => {
415 print_build_error(&e);
416 return EXIT_RUNTIME;
417 }
418 };
419 reload_context.dev_extension = dev_extension;
420
421 let stdin_is_tty = std::io::stdin().is_terminal();
423 let stdout_is_tty = std::io::stdout().is_terminal();
424 let mode = resolve_mode(&parsed, stdin_is_tty, stdout_is_tty);
425
426 let mode = if matches!(mode, RunMode::Interactive) && stdin_text.is_some() {
428 RunMode::Print
429 } else {
430 mode
431 };
432
433 let mode = if std::env::var("RPI_FORCE_TUI")
436 .map(|v| v == "1")
437 .unwrap_or(false)
438 {
439 RunMode::Interactive
440 } else {
441 mode
442 };
443
444 let dev_cleanup = reload_context.dev_extension.clone();
445 let dev_watcher = if matches!(mode, RunMode::Interactive) {
446 reload_context
447 .dev_extension
448 .as_ref()
449 .and_then(|extension| extension.start_watcher(reload_context.mailbox.clone()))
450 } else {
451 None
452 };
453
454 let exit_code = match mode {
455 RunMode::Print => {
456 crate::modes::print(
457 &harness,
458 &parsed,
459 initial.clone(),
460 &extra,
461 file_images.clone(),
462 )
463 .await
464 }
465 RunMode::Json => {
466 crate::modes::json(
467 &harness,
468 &parsed,
469 initial.clone(),
470 &extra,
471 file_images.clone(),
472 Some(event_rx),
473 )
474 .await
475 }
476 RunMode::Interactive => {
477 crate::modes::interactive(
478 &harness,
479 Some(event_rx),
480 &parsed,
481 model_catalog,
482 initial.clone(),
483 &extra,
484 file_images.clone(),
485 if parsed.no_themes {
486 None
487 } else {
488 parsed.theme.as_deref().or(resolved.theme.as_deref())
489 },
490 parsed.no_themes,
491 &reload_context,
492 )
493 .await
494 }
495 RunMode::Rpc => {
496 let veto = crate::session::dispatch_session_event_async(
502 &reload_context,
503 rpi_plugin_sdk::EventTag::BeforeTuiStart,
504 )
505 .await;
506 if let Some(reason) = veto {
507 eprintln!("[rpi] startup vetoed by extension: {reason}");
508 EXIT_VETOED
509 } else {
510 if let Some(reason) = crate::session::dispatch_session_event_async(
511 &reload_context,
512 rpi_plugin_sdk::EventTag::SessionStart,
513 )
514 .await
515 {
516 tracing::warn!(
517 "[rpi] extension vetoed SessionStart (headless, advisory): {reason}"
518 );
519 }
520
521 let code = if parsed.unknown_flags.contains_key("server") {
522 eprintln!(
526 "[rpi] running in headless server mode (no TUI); Ctrl+C to stop"
527 );
528 let _ = tokio::signal::ctrl_c().await;
529 0
530 } else {
531 crate::modes::rpc(&harness, Some(event_rx), model_catalog).await
535 };
536
537 if let Some(reason) = crate::session::dispatch_session_event_async(
538 &reload_context,
539 rpi_plugin_sdk::EventTag::SessionShutdown,
540 )
541 .await
542 {
543 tracing::warn!(
544 "[rpi] extension vetoed SessionShutdown (ignored, closing): {reason}"
545 );
546 }
547 code
548 }
549 }
550 };
551
552 if let Some(dev) = &dev_cleanup {
553 dev.stop_watcher();
554 }
555 if let Some(watcher) = dev_watcher {
556 let _ = watcher.join();
557 }
558 drop(reload_context);
559 drop(harness);
560 if let Some(dev) = dev_cleanup {
561 dev.cleanup();
562 }
563 exit_code
564}
565
566async fn list_models(search: &str) -> i32 {
569 let catalog = match crate::provider::catalog_all() {
570 Ok(models) => models,
571 Err(error) => {
572 eprintln!("warning: could not load models.json: {error}");
573 Vec::new()
574 }
575 };
576 let allow_network = !crate::args::offline_mode_enabled(false);
579 let catalog = crate::remote_catalog::merge_into_catalog(catalog, allow_network, None).await;
580 let needle = search.trim().to_ascii_lowercase();
581 let mut models: Vec<_> = catalog
582 .into_iter()
583 .filter(|model| {
584 needle.is_empty()
585 || format!("{} {} {}", model.provider, model.id, model.name)
586 .to_ascii_lowercase()
587 .contains(&needle)
588 })
589 .collect();
590 if models.is_empty() {
591 if needle.is_empty() {
592 println!("{}", crate::auth_guidance::no_models_available_message());
593 } else {
594 println!("No models matching \"{search}\"");
595 }
596 return 0;
597 }
598
599 fn format_tokens(value: u64) -> String {
600 if value >= 1_000_000 {
601 let whole = value % 1_000_000 == 0;
602 if whole {
603 format!("{}M", value / 1_000_000)
604 } else {
605 format!("{:.1}M", value as f64 / 1_000_000.0)
606 }
607 } else if value >= 1_000 {
608 let whole = value % 1_000 == 0;
609 if whole {
610 format!("{}K", value / 1_000)
611 } else {
612 format!("{:.1}K", value as f64 / 1_000.0)
613 }
614 } else {
615 value.to_string()
616 }
617 }
618
619 let rows: Vec<_> = models
620 .drain(..)
621 .map(|model| {
622 let images = model
623 .input
624 .iter()
625 .any(|input| matches!(input, rpi_ai::InputModality::Image));
626 (
627 model.provider,
628 model.id,
629 format_tokens(model.context_window),
630 format_tokens(model.max_tokens),
631 if model.reasoning { "yes" } else { "no" }.to_string(),
632 if images { "yes" } else { "no" }.to_string(),
633 )
634 })
635 .collect();
636 let widths = (
637 rows.iter().map(|r| r.0.len()).max().unwrap_or(8).max(8),
638 rows.iter().map(|r| r.1.len()).max().unwrap_or(5).max(5),
639 rows.iter().map(|r| r.2.len()).max().unwrap_or(7).max(7),
640 rows.iter().map(|r| r.3.len()).max().unwrap_or(7).max(7),
641 rows.iter().map(|r| r.4.len()).max().unwrap_or(8).max(8),
642 rows.iter().map(|r| r.5.len()).max().unwrap_or(6).max(6),
643 );
644 println!(
645 "{:provider$} {:model$} {:context$} {:max_out$} {:thinking$} {:images$}",
646 "provider",
647 "model",
648 "context",
649 "max-out",
650 "thinking",
651 "images",
652 provider = widths.0,
653 model = widths.1,
654 context = widths.2,
655 max_out = widths.3,
656 thinking = widths.4,
657 images = widths.5,
658 );
659 for row in rows {
660 println!(
661 "{:provider$} {:model$} {:context$} {:max_out$} {:thinking$} {:images$}",
662 row.0,
663 row.1,
664 row.2,
665 row.3,
666 row.4,
667 row.5,
668 provider = widths.0,
669 model = widths.1,
670 context = widths.2,
671 max_out = widths.3,
672 thinking = widths.4,
673 images = widths.5,
674 );
675 }
676 0
677}
678
679fn read_piped_stdin() -> Option<String> {
686 if std::env::var("RPI_SKIP_STDIN")
689 .map(|v| v == "1")
690 .unwrap_or(false)
691 {
692 return None;
693 }
694 if std::io::stdin().is_terminal() {
695 return None;
696 }
697 let mut buf = String::new();
698 match std::io::stdin().read_to_string(&mut buf) {
699 Ok(_) => {
700 let trimmed = buf.trim();
701 if trimmed.is_empty() {
702 None
703 } else {
704 Some(trimmed.to_string())
705 }
706 }
707 Err(_) => None,
708 }
709}
710
711fn process_file_args(
717 file_args: &[std::path::PathBuf],
718 cwd: &Path,
719) -> Result<(String, Vec<ImageContent>), String> {
720 let mut text = String::new();
721 let mut images = Vec::new();
722 for rel in file_args {
723 let abs = if rel.is_absolute() {
724 rel.clone()
725 } else {
726 cwd.join(rel)
727 };
728 if !abs.exists() {
729 return Err(format!("file not found: {}", abs.display()));
730 }
731 let bytes = std::fs::read(&abs)
732 .map_err(|e| format!("could not read file {}: {e}", abs.display()))?;
733 if let Some(image) = image_content_from_bytes(&bytes) {
734 images.push(image);
735 } else {
736 let content = String::from_utf8(bytes).map_err(|_| {
737 format!(
738 "file is not valid UTF-8 text or a supported image: {}",
739 abs.display()
740 )
741 })?;
742 text.push_str(&format!(
743 "<file name=\"{}\">\n{}\n</file>\n",
744 abs.display(),
745 content
746 ));
747 }
748 }
749 Ok((text, images))
750}
751
752pub(crate) fn image_content_from_path(path: &Path) -> Result<Option<ImageContent>, String> {
753 let bytes =
754 std::fs::read(path).map_err(|e| format!("could not read file {}: {e}", path.display()))?;
755 Ok(image_content_from_bytes(&bytes))
756}
757
758fn image_content_from_bytes(bytes: &[u8]) -> Option<ImageContent> {
759 let mime_type = rpi_tools::detect_supported_image_mime_type(bytes)?;
760 Some(ImageContent {
761 kind: ImageContentType,
762 data: rpi_tools::encode_base64(bytes),
763 mime_type: mime_type.to_string(),
764 })
765}
766
767fn build_initial_message(
773 parsed: &Args,
774 stdin: Option<&str>,
775 file_text: Option<&str>,
776) -> (Option<String>, Vec<String>) {
777 let mut extra = parsed.messages.clone();
778 let mut parts: Vec<String> = Vec::new();
779 if let Some(s) = stdin {
780 parts.push(s.to_string());
781 }
782 if let Some(t) = file_text {
783 parts.push(t.to_string());
784 }
785 if !extra.is_empty() {
787 parts.push(extra.remove(0));
788 }
789 let initial = if parts.is_empty() {
790 None
791 } else {
792 Some(parts.join(""))
793 };
794 (initial, extra)
795}
796
797fn print_resolve_error(e: &ResolveError) {
800 match e {
801 ResolveError::NoApiKey { hint } => {
802 eprintln!("error: {e}");
803 eprintln!();
804 eprintln!("Provide credentials via one of: {hint}.");
805 }
806 ResolveError::Config(_) => {
807 eprintln!("error: {e}");
808 eprintln!();
809 eprintln!("Check ~/.rpi/auth.json / ~/.rpi/models.json (set RPI_CODING_AGENT_DIR to relocate).");
810 }
811 _ => eprintln!("error: {e}"),
812 }
813}
814
815fn print_build_error(e: &BuildError) {
817 match e {
818 BuildError::SessionNotFound { .. } => {
819 eprintln!("error: {e}");
820 eprintln!();
821 eprintln!("List saved sessions with the /session command in interactive mode.");
822 }
823 _ => eprintln!("error: {e}"),
824 }
825}
826
827#[cfg(test)]
828mod tests {
829 use super::*;
830 use crate::args::Args;
831
832 #[test]
833 fn take_flag_value_supports_both_spellings() {
834 let argv = vec![
835 "--connect".to_string(),
836 "127.0.0.1:9899".to_string(),
837 "--token".to_string(),
838 "abc".to_string(),
839 ];
840 assert_eq!(
841 take_flag_value(&argv, "--connect").as_deref(),
842 Some("127.0.0.1:9899")
843 );
844 assert_eq!(take_flag_value(&argv, "--token").as_deref(), Some("abc"));
845 assert_eq!(take_flag_value(&argv, "--missing"), None);
846
847 let inline = vec!["--connect=1.2.3.4:1".to_string()];
848 assert_eq!(
849 take_flag_value(&inline, "--connect").as_deref(),
850 Some("1.2.3.4:1")
851 );
852 }
853
854 #[test]
855 fn connect_token_prefers_the_flag_over_the_env_var() {
856 let argv = vec!["--token".to_string(), "flag-token".to_string()];
858 assert_eq!(resolve_connect_token(&argv).as_deref(), Some("flag-token"));
859 }
860
861 #[test]
862 fn build_initial_combines_stdin_file_and_first_message() {
863 let mut args = Args::default();
864 args.messages = vec!["first".into(), "second".into(), "third".into()];
865 let (initial, extra) =
866 build_initial_message(&args, Some("stdin-text"), Some("<file>...</file>"));
867 assert_eq!(initial.as_deref(), Some("stdin-text<file>...</file>first"));
868 assert_eq!(extra, vec!["second".to_string(), "third".to_string()]);
869 }
870
871 #[test]
872 fn build_initial_with_no_messages_uses_stdin_and_file_only() {
873 let args = Args::default();
874 let (initial, extra) =
875 build_initial_message(&args, Some("only-stdin"), Some("<file>x</file>"));
876 assert_eq!(initial.as_deref(), Some("only-stdin<file>x</file>"));
877 assert!(extra.is_empty());
878 }
879
880 #[test]
881 fn build_initial_none_when_all_empty() {
882 let args = Args::default();
883 let (initial, extra) = build_initial_message(&args, None, None);
884 assert!(initial.is_none());
885 assert!(extra.is_empty());
886 }
887
888 #[test]
889 fn build_initial_shifts_only_first_message() {
890 let mut args = Args::default();
891 args.messages = vec!["a".into(), "b".into()];
892 let (initial, extra) = build_initial_message(&args, None, None);
893 assert_eq!(initial.as_deref(), Some("a"));
894 assert_eq!(extra, vec!["b".to_string()]);
895 }
896
897 #[test]
898 fn process_file_args_attaches_supported_images() {
899 let dir = tempfile::tempdir().unwrap();
900 let path = dir.path().join("image.bin");
901 let mut png = vec![137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13];
902 png.extend_from_slice(b"IHDR");
903 png.extend_from_slice(&[0; 13]);
904 std::fs::write(&path, png).unwrap();
905 let (text, images) = process_file_args(&[path], dir.path()).unwrap();
906 assert!(text.is_empty());
907 assert_eq!(images.len(), 1);
908 assert_eq!(images[0].mime_type, "image/png");
909 assert!(!images[0].data.is_empty());
910 }
911
912 #[test]
913 fn image_content_from_path_reports_supported_mime() {
914 let dir = tempfile::tempdir().unwrap();
915 let path = dir.path().join("drop.png");
916 let mut png = vec![137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13];
917 png.extend_from_slice(b"IHDR");
918 png.extend_from_slice(&[0; 13]);
919 std::fs::write(&path, png).unwrap();
920 let image = image_content_from_path(&path).unwrap().unwrap();
921 assert_eq!(image.mime_type, "image/png");
922 }
923}