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