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) {
242 Ok(t) => t,
243 Err(msg) => {
244 eprintln!("error: {msg}");
245 return EXIT_USAGE;
246 }
247 };
248
249 let session_root = crate::session::configured_session_dir(&parsed, &cwd);
262 parsed.session_dir = Some(session_root.clone());
263 let mut startup_notices: Vec<String> = Vec::new();
264 let selection = crate::session::select_session_in_dir(&parsed, session_root);
267 let restored_cwd = crate::session::peek_restored_session_cwd(&selection, &cwd).await;
268 if let Some(restored) = &restored_cwd {
274 crate::session::pin_restored_selection(&mut parsed, restored);
275 }
276 let launch_cwd = cwd;
277 let cwd = match &restored_cwd {
278 Some(restored) => match &restored.adopted {
279 Some(adopted) => {
280 startup_notices.push(crate::session_cwd::adopted_session_cwd_note(
283 &launch_cwd,
284 adopted,
285 ));
286 adopted.clone()
287 }
288 None => {
289 startup_notices.push(crate::session_cwd::missing_session_cwd_note(
290 &restored.recorded,
291 &launch_cwd,
292 ));
293 launch_cwd.clone()
294 }
295 },
296 None => launch_cwd.clone(),
297 };
298
299 let project_trusted = crate::session::resolve_project_trust(&parsed, &cwd);
303 if !project_trusted && cwd != launch_cwd {
304 startup_notices.push(crate::session_cwd::untrusted_session_cwd_note(&cwd));
305 }
306 let prepared = match prepare_extensions(&parsed, &cwd, project_trusted) {
307 Ok(prepared) => prepared,
308 Err(error) => {
309 print_build_error(&error);
310 return EXIT_RUNTIME;
311 }
312 };
313 let resolved = match resolve_with_extensions(
314 &parsed,
315 &cwd,
316 project_trusted,
317 &prepared.extension_session,
318 ) {
319 Ok(r) => r,
320 Err(e) => {
321 print_resolve_error(&e);
322 return match e {
323 ResolveError::NoApiKey { .. } | ResolveError::Config(_) => EXIT_USAGE,
324 _ => EXIT_RUNTIME,
325 };
326 }
327 };
328
329 for warning in &resolved.compat_warnings {
330 eprintln!("warning: models.json: {warning}");
331 }
332
333 let model_catalog = crate::provider::authenticated_catalog(&resolved);
337
338 if let Some(patterns) = &parsed.models {
343 let mut matched: Vec<String> = Vec::new();
344 for p in patterns {
345 let hits: Vec<String> = model_catalog
346 .iter()
347 .filter(|m| m.id.eq_ignore_ascii_case(p))
348 .map(|m| m.id.clone())
349 .collect();
350 if hits.is_empty() {
351 eprintln!("warning: --models pattern \"{p}\" matched no model");
352 }
353 matched.extend(hits);
354 }
355 let mut settings = crate::settings::load_settings().unwrap_or_default();
356 settings.scoped_models = if matched.is_empty() {
357 None
358 } else {
359 Some(matched)
360 };
361 if let Err(e) = crate::settings::save_settings(&settings) {
362 eprintln!("warning: could not save --models scope: {e}");
363 }
364 }
365
366 let (harness, event_rx, mut reload_context) =
368 match build_prepared(&resolved, &parsed, &cwd, project_trusted, prepared).await {
369 Ok(triple) => triple,
370 Err(e) => {
371 print_build_error(&e);
372 return EXIT_RUNTIME;
373 }
374 };
375 reload_context.dev_extension = dev_extension;
376
377 let stdio_claimed = reload_context
384 .extension_session
385 .lock()
386 .map(|session| session.owns_stdio())
387 .unwrap_or(false);
388 let web_requested = match parsed.unknown_flags.get("web") {
389 Some(serde_json::Value::Bool(value)) => *value,
390 Some(serde_json::Value::String(value)) => {
391 !value.is_empty() && !value.eq_ignore_ascii_case("false")
392 }
393 _ => false,
394 };
395 let web_registered = reload_context
396 .extension_session
397 .lock()
398 .ok()
399 .and_then(|session| {
400 session
401 .snapshot()
402 .map(|snapshot| snapshot.flags().iter().any(|flag| flag.name == "web"))
403 })
404 .unwrap_or(false);
405 if web_requested && (!web_registered || !stdio_claimed) {
406 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");
407 return EXIT_USAGE;
408 }
409 let stdio_lifecycle = reload_context
410 .extension_session
411 .lock()
412 .ok()
413 .and_then(|session| session.action_bridge())
414 .map(|bridge| bridge.stdio_lifecycle());
415 if let Some(lifecycle) = &stdio_lifecycle {
416 if let Err(error) = lifecycle.validate() {
417 eprintln!("error: {error}");
418 return EXIT_RUNTIME;
419 }
420 }
421 let stdin_text = if wants_rpc_channel || stdio_claimed {
422 None
423 } else {
424 read_piped_stdin()
425 };
426
427 let file_text_opt = if file_text.is_empty() {
429 None
430 } else {
431 Some(file_text.as_str())
432 };
433 let (initial, extra) = build_initial_message(&parsed, stdin_text.as_deref(), file_text_opt);
434
435 let stdin_is_tty = std::io::stdin().is_terminal();
437 let stdout_is_tty = std::io::stdout().is_terminal();
438 let mode = resolve_mode(&parsed, stdin_is_tty, stdout_is_tty);
439
440 let mode = if matches!(mode, RunMode::Interactive) && stdin_text.is_some() {
442 RunMode::Print
443 } else {
444 mode
445 };
446
447 let mode = if std::env::var("RPI_FORCE_TUI")
450 .map(|v| v == "1")
451 .unwrap_or(false)
452 {
453 RunMode::Interactive
454 } else {
455 mode
456 };
457
458 let mode = if stdio_claimed { RunMode::Rpc } else { mode };
465
466 if matches!(mode, RunMode::Interactive) {
471 reload_context.startup_notices = startup_notices;
472 } else {
473 for notice in &startup_notices {
474 eprintln!("warning: {notice}");
475 }
476 }
477
478 let dev_cleanup = reload_context.dev_extension.clone();
479 let dev_watcher = if matches!(mode, RunMode::Interactive) {
480 reload_context
481 .dev_extension
482 .as_ref()
483 .and_then(|extension| extension.start_watcher(reload_context.mailbox.clone()))
484 } else {
485 None
486 };
487
488 let exit_code = match mode {
489 RunMode::Print => {
490 crate::modes::print(
491 &harness,
492 &parsed,
493 initial.clone(),
494 &extra,
495 file_images.clone(),
496 )
497 .await
498 }
499 RunMode::Json => {
500 crate::modes::json(
501 &harness,
502 &parsed,
503 initial.clone(),
504 &extra,
505 file_images.clone(),
506 Some(event_rx),
507 )
508 .await
509 }
510 RunMode::Interactive => {
511 crate::modes::interactive(
512 &harness,
513 Some(event_rx),
514 &parsed,
515 model_catalog,
516 initial.clone(),
517 &extra,
518 file_images.clone(),
519 if parsed.no_themes {
520 None
521 } else {
522 parsed.theme.as_deref().or(resolved.theme.as_deref())
523 },
524 parsed.no_themes,
525 &reload_context,
526 )
527 .await
528 }
529 RunMode::Rpc => {
530 let veto = crate::session::dispatch_session_event_async(
536 &reload_context,
537 rpi_plugin_sdk::EventTag::BeforeTuiStart,
538 )
539 .await;
540 if let Some(reason) = veto {
541 eprintln!("[rpi] startup vetoed by extension: {reason}");
542 EXIT_VETOED
543 } else {
544 if let Some(reason) = crate::session::dispatch_session_event_async(
545 &reload_context,
546 rpi_plugin_sdk::EventTag::SessionStart,
547 )
548 .await
549 {
550 tracing::warn!(
551 "[rpi] extension vetoed SessionStart (headless, advisory): {reason}"
552 );
553 }
554
555 let code = if parsed.unknown_flags.contains_key("server") || stdio_claimed {
556 eprintln!(
560 "[rpi] running in headless mode (no TUI); extension owns stdio; Ctrl+C to stop"
561 );
562 if stdio_claimed {
563 let lifecycle = stdio_lifecycle
564 .as_ref()
565 .expect("stdio owner requires an action bridge");
566 tokio::select! {
567 code = lifecycle.wait() => code,
568 _ = tokio::signal::ctrl_c() => 0,
569 }
570 } else {
571 let _ = tokio::signal::ctrl_c().await;
572 0
573 }
574 } else {
575 let oauth_snapshot = {
583 let session = reload_context.extension_session.lock().unwrap();
584 session.snapshot_arc()
585 };
586 crate::modes::rpc(
587 &harness,
588 Some(event_rx),
589 model_catalog,
590 reload_context.ui_dialog.clone(),
591 oauth_snapshot,
592 parsed.rpc_message_updates,
593 )
594 .await
595 };
596
597 if let Some(reason) = crate::session::dispatch_session_event_async(
598 &reload_context,
599 rpi_plugin_sdk::EventTag::SessionShutdown,
600 )
601 .await
602 {
603 tracing::warn!(
604 "[rpi] extension vetoed SessionShutdown (ignored, closing): {reason}"
605 );
606 }
607 code
608 }
609 }
610 };
611
612 if let Some(dev) = &dev_cleanup {
613 dev.stop_watcher();
614 }
615 if let Some(watcher) = dev_watcher {
616 let _ = watcher.join();
617 }
618 drop(reload_context);
619 drop(harness);
620 if let Some(dev) = dev_cleanup {
621 dev.cleanup();
622 }
623 exit_code
624}
625
626fn handle_parse_short_circuits(parsed: &Args) -> Option<i32> {
627 if parsed.help {
629 print_help();
630 return Some(0);
631 }
632 if parsed.version {
633 print_version();
634 return Some(0);
635 }
636
637 if !parsed.errors.is_empty() {
639 for err in &parsed.errors {
640 eprintln!("error: {err}");
641 }
642 eprintln!();
643 print_help();
644 return Some(EXIT_USAGE);
645 }
646
647 None
648}
649
650fn resolve_cwd() -> Result<std::path::PathBuf, i32> {
651 std::env::current_dir().map_err(|error| {
652 eprintln!("error: could not determine the current directory: {error}");
653 EXIT_USAGE
654 })
655}
656
657fn export_if_requested(parsed: &Args) -> Option<i32> {
658 let input = parsed.export.as_deref()?;
659 let output = parsed
660 .messages
661 .first()
662 .map(Path::new)
663 .map(Path::to_path_buf)
664 .unwrap_or_else(|| {
665 let stem = input
666 .file_stem()
667 .and_then(|value| value.to_str())
668 .unwrap_or("session");
669 Path::new(&format!("rpi-session-{stem}.html")).to_path_buf()
670 });
671
672 match crate::export::export_file(input, &output) {
673 Ok(()) => {
674 println!("Exported to: {}", output.display());
675 Some(0)
676 }
677 Err(error) => {
678 eprintln!("error: {error}");
679 Some(EXIT_RUNTIME)
680 }
681 }
682}
683
684fn prepare_dev_extension(
685 options: Option<&crate::dev_extension::DevOptions>,
686 cwd: &Path,
687 parsed: &mut Args,
688) -> Result<Option<std::sync::Arc<crate::dev_extension::DevExtension>>, i32> {
689 let Some(options) = options else {
690 return Ok(None);
691 };
692
693 match crate::dev_extension::DevExtension::detect(cwd, options) {
694 Ok(extension) => {
695 if let Err(error) = extension.rebuild() {
696 eprintln!("error: initial extension build failed: {error}");
697 return Err(EXIT_RUNTIME);
698 }
699 if let Err(error) = extension.apply_to_args(parsed) {
700 eprintln!("error: {error}");
701 return Err(EXIT_RUNTIME);
702 }
703 Ok(Some(extension))
704 }
705 Err(error) => {
706 eprintln!("dev: {error}");
708 eprintln!("dev: no Cargo cdylib found; running in skills-only mode");
709 parsed.no_extensions = true;
710 parsed.extensions_dir.clear();
711 parsed.extension.clear();
712 if options.local_only {
713 parsed.dev_local_only = true;
714 }
715 Ok(None)
716 }
717 }
718}
719
720async fn resolve_resume(parsed: &mut Args, cwd: &Path) -> Option<i32> {
721 if !parsed.resume {
722 return None;
723 }
724 if !std::io::stdin().is_terminal() || !std::io::stdout().is_terminal() {
725 eprintln!("error: --resume requires an interactive terminal");
726 return Some(EXIT_USAGE);
727 }
728
729 let session_dir = crate::session::configured_session_dir(parsed, cwd);
730 match crate::resume_picker::select_in_dir(cwd, &session_dir).await {
731 Ok(Some(id)) => {
732 parsed.resume = false;
733 parsed.session = Some(id);
734 parsed.session_dir = Some(session_dir);
735 None
736 }
737 Ok(None) => Some(0),
738 Err(error) => {
739 eprintln!("error: {error}");
740 Some(EXIT_RUNTIME)
741 }
742 }
743}
744
745fn emit_startup_warnings(parsed: &Args) {
746 if parsed.verbose {
748 for warn in &parsed.ignored {
749 eprintln!("warning: {warn}");
750 }
751 }
752 if parsed.no_themes && parsed.theme.is_some() {
753 eprintln!("warning: --no-themes overrides --theme; using the built-in default theme");
754 }
755}
756
757async fn list_models(search: &str, args: &Args, cwd: &Path) -> i32 {
760 let trusted = crate::session::resolve_project_trust(args, cwd);
761 let prepared = match prepare_extensions(args, cwd, trusted) {
762 Ok(prepared) => prepared,
763 Err(error) => {
764 print_build_error(&error);
765 return EXIT_RUNTIME;
766 }
767 };
768 let manifests = crate::provider::manifests_from_session(&prepared.extension_session);
769 let catalog = match crate::provider::catalog_with_diagnostics(&manifests) {
770 Ok((models, warnings)) => {
771 for warning in warnings {
772 eprintln!("warning: models.json: {warning}");
773 }
774 models
775 }
776 Err(error) => {
777 eprintln!("warning: could not load models.json: {error}");
778 Vec::new()
779 }
780 };
781 let needle = search.trim().to_ascii_lowercase();
782 let mut models: Vec<_> = catalog
783 .into_iter()
784 .filter(|model| {
785 needle.is_empty()
786 || format!("{} {} {}", model.provider, model.id, model.name)
787 .to_ascii_lowercase()
788 .contains(&needle)
789 })
790 .collect();
791 if models.is_empty() {
792 if needle.is_empty() {
793 println!("{}", crate::auth_guidance::no_models_available_message());
794 } else {
795 println!("No models matching \"{search}\"");
796 }
797 return 0;
798 }
799
800 fn format_tokens(value: u64) -> String {
801 if value >= 1_000_000 {
802 let whole = value % 1_000_000 == 0;
803 if whole {
804 format!("{}M", value / 1_000_000)
805 } else {
806 format!("{:.1}M", value as f64 / 1_000_000.0)
807 }
808 } else if value >= 1_000 {
809 let whole = value % 1_000 == 0;
810 if whole {
811 format!("{}K", value / 1_000)
812 } else {
813 format!("{:.1}K", value as f64 / 1_000.0)
814 }
815 } else {
816 value.to_string()
817 }
818 }
819
820 let rows: Vec<_> = models
821 .drain(..)
822 .map(|model| {
823 let images = model
824 .input
825 .iter()
826 .any(|input| matches!(input, rpi_ai::InputModality::Image));
827 (
828 model.provider,
829 model.id,
830 format_tokens(model.context_window),
831 format_tokens(model.max_tokens),
832 if model.reasoning { "yes" } else { "no" }.to_string(),
833 if images { "yes" } else { "no" }.to_string(),
834 )
835 })
836 .collect();
837 let widths = (
838 rows.iter().map(|r| r.0.len()).max().unwrap_or(8).max(8),
839 rows.iter().map(|r| r.1.len()).max().unwrap_or(5).max(5),
840 rows.iter().map(|r| r.2.len()).max().unwrap_or(7).max(7),
841 rows.iter().map(|r| r.3.len()).max().unwrap_or(7).max(7),
842 rows.iter().map(|r| r.4.len()).max().unwrap_or(8).max(8),
843 rows.iter().map(|r| r.5.len()).max().unwrap_or(6).max(6),
844 );
845 println!(
846 "{:provider$} {:model$} {:context$} {:max_out$} {:thinking$} {:images$}",
847 "provider",
848 "model",
849 "context",
850 "max-out",
851 "thinking",
852 "images",
853 provider = widths.0,
854 model = widths.1,
855 context = widths.2,
856 max_out = widths.3,
857 thinking = widths.4,
858 images = widths.5,
859 );
860 for row in rows {
861 println!(
862 "{:provider$} {:model$} {:context$} {:max_out$} {:thinking$} {:images$}",
863 row.0,
864 row.1,
865 row.2,
866 row.3,
867 row.4,
868 row.5,
869 provider = widths.0,
870 model = widths.1,
871 context = widths.2,
872 max_out = widths.3,
873 thinking = widths.4,
874 images = widths.5,
875 );
876 }
877 0
878}
879
880fn read_piped_stdin() -> Option<String> {
887 if std::env::var("RPI_SKIP_STDIN")
890 .map(|v| v == "1")
891 .unwrap_or(false)
892 {
893 return None;
894 }
895 if std::io::stdin().is_terminal() {
896 return None;
897 }
898 let mut buf = String::new();
899 match std::io::stdin().read_to_string(&mut buf) {
900 Ok(_) => {
901 let trimmed = buf.trim();
902 if trimmed.is_empty() {
903 None
904 } else {
905 Some(trimmed.to_string())
906 }
907 }
908 Err(_) => None,
909 }
910}
911
912fn process_file_args(
918 file_args: &[std::path::PathBuf],
919 cwd: &Path,
920) -> Result<(String, Vec<ImageContent>), String> {
921 let mut text = String::new();
922 let mut images = Vec::new();
923 for rel in file_args {
924 let abs = if rel.is_absolute() {
925 rel.clone()
926 } else {
927 cwd.join(rel)
928 };
929 if !abs.exists() {
930 return Err(format!("file not found: {}", abs.display()));
931 }
932 let bytes = std::fs::read(&abs)
933 .map_err(|e| format!("could not read file {}: {e}", abs.display()))?;
934 if let Some(image) = image_content_from_bytes(&bytes) {
935 images.push(image);
936 } else {
937 let content = String::from_utf8(bytes).map_err(|_| {
938 format!(
939 "file is not valid UTF-8 text or a supported image: {}",
940 abs.display()
941 )
942 })?;
943 text.push_str(&format!(
944 "<file name=\"{}\">\n{}\n</file>\n",
945 abs.display(),
946 content
947 ));
948 }
949 }
950 Ok((text, images))
951}
952
953pub(crate) fn image_content_from_path(path: &Path) -> Result<Option<ImageContent>, String> {
954 let bytes =
955 std::fs::read(path).map_err(|e| format!("could not read file {}: {e}", path.display()))?;
956 Ok(image_content_from_bytes(&bytes))
957}
958
959fn image_content_from_bytes(bytes: &[u8]) -> Option<ImageContent> {
960 let mime_type = rpi_tools::detect_supported_image_mime_type(bytes)?;
961 Some(ImageContent {
962 kind: ImageContentType,
963 data: rpi_tools::encode_base64(bytes),
964 mime_type: mime_type.to_string(),
965 })
966}
967
968fn build_initial_message(
974 parsed: &Args,
975 stdin: Option<&str>,
976 file_text: Option<&str>,
977) -> (Option<String>, Vec<String>) {
978 let mut extra = parsed.messages.clone();
979 let mut parts: Vec<String> = Vec::new();
980 if let Some(s) = stdin {
981 parts.push(s.to_string());
982 }
983 if let Some(t) = file_text {
984 parts.push(t.to_string());
985 }
986 if !extra.is_empty() {
988 parts.push(extra.remove(0));
989 }
990 let initial = if parts.is_empty() {
991 None
992 } else {
993 Some(parts.join(""))
994 };
995 (initial, extra)
996}
997
998fn print_resolve_error(e: &ResolveError) {
1001 match e {
1002 ResolveError::NoApiKey { hint } => {
1003 eprintln!("error: {e}");
1004 eprintln!();
1005 eprintln!("Provide credentials via one of: {hint}.");
1006 }
1007 ResolveError::Config(_) => {
1008 eprintln!("error: {e}");
1009 eprintln!();
1010 eprintln!("Check ~/.rpi/auth.json / ~/.rpi/models.json (set RPI_CODING_AGENT_DIR to relocate).");
1011 }
1012 _ => eprintln!("error: {e}"),
1013 }
1014}
1015
1016fn print_build_error(e: &BuildError) {
1018 match e {
1019 BuildError::SessionNotFound { .. } => {
1020 eprintln!("error: {e}");
1021 eprintln!();
1022 eprintln!("List saved sessions with the /session command in interactive mode.");
1023 }
1024 _ => eprintln!("error: {e}"),
1025 }
1026}
1027
1028#[cfg(test)]
1029mod tests {
1030 use super::*;
1031 use crate::args::Args;
1032
1033 #[test]
1034 fn take_flag_value_supports_both_spellings() {
1035 let argv = vec![
1036 "--connect".to_string(),
1037 "127.0.0.1:9899".to_string(),
1038 "--token".to_string(),
1039 "abc".to_string(),
1040 ];
1041 assert_eq!(
1042 take_flag_value(&argv, "--connect").as_deref(),
1043 Some("127.0.0.1:9899")
1044 );
1045 assert_eq!(take_flag_value(&argv, "--token").as_deref(), Some("abc"));
1046 assert_eq!(take_flag_value(&argv, "--missing"), None);
1047
1048 let inline = vec!["--connect=1.2.3.4:1".to_string()];
1049 assert_eq!(
1050 take_flag_value(&inline, "--connect").as_deref(),
1051 Some("1.2.3.4:1")
1052 );
1053 }
1054
1055 #[test]
1056 fn connect_token_prefers_the_flag_over_the_env_var() {
1057 let argv = vec!["--token".to_string(), "flag-token".to_string()];
1059 assert_eq!(resolve_connect_token(&argv).as_deref(), Some("flag-token"));
1060 }
1061
1062 #[test]
1063 fn build_initial_combines_stdin_file_and_first_message() {
1064 let mut args = Args::default();
1065 args.messages = vec!["first".into(), "second".into(), "third".into()];
1066 let (initial, extra) =
1067 build_initial_message(&args, Some("stdin-text"), Some("<file>...</file>"));
1068 assert_eq!(initial.as_deref(), Some("stdin-text<file>...</file>first"));
1069 assert_eq!(extra, vec!["second".to_string(), "third".to_string()]);
1070 }
1071
1072 #[test]
1073 fn build_initial_with_no_messages_uses_stdin_and_file_only() {
1074 let args = Args::default();
1075 let (initial, extra) =
1076 build_initial_message(&args, Some("only-stdin"), Some("<file>x</file>"));
1077 assert_eq!(initial.as_deref(), Some("only-stdin<file>x</file>"));
1078 assert!(extra.is_empty());
1079 }
1080
1081 #[test]
1082 fn build_initial_none_when_all_empty() {
1083 let args = Args::default();
1084 let (initial, extra) = build_initial_message(&args, None, None);
1085 assert!(initial.is_none());
1086 assert!(extra.is_empty());
1087 }
1088
1089 #[test]
1090 fn build_initial_shifts_only_first_message() {
1091 let mut args = Args::default();
1092 args.messages = vec!["a".into(), "b".into()];
1093 let (initial, extra) = build_initial_message(&args, None, None);
1094 assert_eq!(initial.as_deref(), Some("a"));
1095 assert_eq!(extra, vec!["b".to_string()]);
1096 }
1097
1098 #[test]
1099 fn process_file_args_attaches_supported_images() {
1100 let dir = tempfile::tempdir().unwrap();
1101 let path = dir.path().join("image.bin");
1102 let mut png = vec![137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13];
1103 png.extend_from_slice(b"IHDR");
1104 png.extend_from_slice(&[0; 13]);
1105 std::fs::write(&path, png).unwrap();
1106 let (text, images) = process_file_args(&[path], dir.path()).unwrap();
1107 assert!(text.is_empty());
1108 assert_eq!(images.len(), 1);
1109 assert_eq!(images[0].mime_type, "image/png");
1110 assert!(!images[0].data.is_empty());
1111 }
1112
1113 #[test]
1114 fn image_content_from_path_reports_supported_mime() {
1115 let dir = tempfile::tempdir().unwrap();
1116 let path = dir.path().join("drop.png");
1117 let mut png = vec![137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13];
1118 png.extend_from_slice(b"IHDR");
1119 png.extend_from_slice(&[0; 13]);
1120 std::fs::write(&path, png).unwrap();
1121 let image = image_content_from_path(&path).unwrap().unwrap();
1122 assert_eq!(image.mime_type, "image/png");
1123 }
1124}