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