1use std::io::{IsTerminal, Read};
28use std::path::Path;
29
30use rpi_ai::types::ImageContent;
31
32use crate::args::{parse_args, print_help, print_version, resolve_mode, Args, RunMode};
33use crate::provider::{resolve, ResolveError};
34use crate::session::{build, BuildError};
35
36pub const EXIT_USAGE: i32 = 2;
40pub const EXIT_RUNTIME: i32 = 1;
43
44pub async fn run() -> i32 {
50 let argv: Vec<String> = std::env::args().skip(1).collect();
53
54 if argv.first().map(|s| s.as_str()) == Some("auth") {
58 return crate::auth::run(&argv[1..]).await;
59 }
60
61 let parsed = parse_args(&argv);
62
63 if parsed.help {
65 print_help();
66 return 0;
67 }
68 if parsed.version {
69 print_version();
70 return 0;
71 }
72
73 if !parsed.errors.is_empty() {
75 for err in &parsed.errors {
76 eprintln!("error: {err}");
77 }
78 eprintln!();
79 print_help();
80 return EXIT_USAGE;
81 }
82
83 if parsed.verbose {
85 for warn in &parsed.ignored {
86 eprintln!("warning: {warn}");
87 }
88 }
89
90 let cwd = match std::env::current_dir() {
92 Ok(c) => c,
93 Err(e) => {
94 eprintln!("error: could not determine the current directory: {e}");
95 return EXIT_USAGE;
96 }
97 };
98
99 let stdin_text = read_piped_stdin();
101
102 let (file_text, _file_images) = match process_file_args(&parsed.file_args, &cwd) {
104 Ok(t) => t,
105 Err(msg) => {
106 eprintln!("error: {msg}");
107 return EXIT_USAGE;
108 }
109 };
110
111 let file_text_opt = if file_text.is_empty() { None } else { Some(file_text.as_str()) };
113 let (initial, extra) =
114 build_initial_message(&parsed, stdin_text.as_deref(), file_text_opt);
115
116 let resolved = match resolve(
118 parsed.provider.as_deref(),
119 parsed.model.as_deref(),
120 parsed.thinking,
121 parsed.api_key.as_deref(),
122 parsed.base_url.as_deref(),
123 ) {
124 Ok(r) => r,
125 Err(e) => {
126 print_resolve_error(&e);
127 return match e {
128 ResolveError::NoApiKey { .. } | ResolveError::Config(_) => EXIT_USAGE,
129 _ => EXIT_RUNTIME,
130 };
131 }
132 };
133
134 let harness = match build(&resolved, &parsed, &cwd).await {
136 Ok(h) => h,
137 Err(e) => {
138 print_build_error(&e);
139 return EXIT_RUNTIME;
140 }
141 };
142
143 let stdin_is_tty = std::io::stdin().is_terminal();
145 let stdout_is_tty = std::io::stdout().is_terminal();
146 let mode = resolve_mode(&parsed, stdin_is_tty, stdout_is_tty);
147
148 let mode = if matches!(mode, RunMode::Interactive) && stdin_text.is_some() {
150 RunMode::Print
151 } else {
152 mode
153 };
154
155 match mode {
156 RunMode::Print => crate::modes::print(&harness, &parsed, initial.clone(), &extra).await,
157 RunMode::Json => crate::modes::json(&harness, &parsed, initial.clone(), &extra).await,
158 RunMode::Interactive => {
159 crate::modes::interactive(&harness, &parsed, initial.clone(), &extra).await
160 }
161 RunMode::Rpc => {
162 eprintln!("error: rpc mode is not implemented in v1 (use --mode text or --mode json)");
166 EXIT_USAGE
167 }
168 }
169}
170
171fn read_piped_stdin() -> Option<String> {
178 if std::io::stdin().is_terminal() {
179 return None;
180 }
181 let mut buf = String::new();
182 match std::io::stdin().read_to_string(&mut buf) {
183 Ok(_) => {
184 let trimmed = buf.trim();
185 if trimmed.is_empty() {
186 None
187 } else {
188 Some(trimmed.to_string())
189 }
190 }
191 Err(_) => None,
192 }
193}
194
195fn process_file_args(
207 file_args: &[std::path::PathBuf],
208 cwd: &Path,
209) -> Result<(String, Vec<ImageContent>), String> {
210 let mut text = String::new();
211 for rel in file_args {
212 let abs = if rel.is_absolute() {
213 rel.clone()
214 } else {
215 cwd.join(rel)
216 };
217 if !abs.exists() {
218 return Err(format!("file not found: {}", abs.display()));
219 }
220 if is_likely_image(&abs) {
222 return Err(format!(
223 "image attachments are not supported in v1: {}",
224 abs.display()
225 ));
226 }
227 match std::fs::read_to_string(&abs) {
228 Ok(content) => {
229 text.push_str(&format!(
230 "<file name=\"{}\">\n{}\n</file>\n",
231 abs.display(),
232 content
233 ));
234 }
235 Err(e) => {
236 return Err(format!(
237 "could not read file {}: {e}",
238 abs.display()
239 ));
240 }
241 }
242 }
243 Ok((text, Vec::new()))
244}
245
246fn is_likely_image(path: &Path) -> bool {
249 matches!(
250 path.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase()).as_deref(),
251 Some("png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp")
252 )
253}
254
255fn build_initial_message(
261 parsed: &Args,
262 stdin: Option<&str>,
263 file_text: Option<&str>,
264) -> (Option<String>, Vec<String>) {
265 let mut extra = parsed.messages.clone();
266 let mut parts: Vec<String> = Vec::new();
267 if let Some(s) = stdin {
268 parts.push(s.to_string());
269 }
270 if let Some(t) = file_text {
271 parts.push(t.to_string());
272 }
273 if !extra.is_empty() {
275 parts.push(extra.remove(0));
276 }
277 let initial = if parts.is_empty() { None } else { Some(parts.join("")) };
278 (initial, extra)
279}
280
281fn print_resolve_error(e: &ResolveError) {
284 match e {
285 ResolveError::NoApiKey { hint } => {
286 eprintln!("error: {e}");
287 eprintln!();
288 eprintln!("Provide credentials via one of: {hint}.");
289 }
290 ResolveError::Config(_) => {
291 eprintln!("error: {e}");
292 eprintln!();
293 eprintln!("Check ~/.rpi/auth.json / ~/.rpi/models.json (set RPI_CODING_AGENT_DIR to relocate).");
294 }
295 _ => eprintln!("error: {e}"),
296 }
297}
298
299fn print_build_error(e: &BuildError) {
301 match e {
302 BuildError::RestoreNotImplemented { requested: _, flag } => {
303 eprintln!("error: {e}");
304 eprintln!();
305 eprintln!(
306 "To start a fresh session instead, drop {flag} (and any --session argument)."
307 );
308 }
309 _ => eprintln!("error: {e}"),
310 }
311}
312
313#[cfg(test)]
314mod tests {
315 use super::*;
316 use crate::args::Args;
317
318 #[test]
319 fn build_initial_combines_stdin_file_and_first_message() {
320 let mut args = Args::default();
321 args.messages = vec!["first".into(), "second".into(), "third".into()];
322 let (initial, extra) =
323 build_initial_message(&args, Some("stdin-text"), Some("<file>...</file>"));
324 assert_eq!(initial.as_deref(), Some("stdin-text<file>...</file>first"));
325 assert_eq!(extra, vec!["second".to_string(), "third".to_string()]);
326 }
327
328 #[test]
329 fn build_initial_with_no_messages_uses_stdin_and_file_only() {
330 let args = Args::default();
331 let (initial, extra) =
332 build_initial_message(&args, Some("only-stdin"), Some("<file>x</file>"));
333 assert_eq!(initial.as_deref(), Some("only-stdin<file>x</file>"));
334 assert!(extra.is_empty());
335 }
336
337 #[test]
338 fn build_initial_none_when_all_empty() {
339 let args = Args::default();
340 let (initial, extra) = build_initial_message(&args, None, None);
341 assert!(initial.is_none());
342 assert!(extra.is_empty());
343 }
344
345 #[test]
346 fn build_initial_shifts_only_first_message() {
347 let mut args = Args::default();
348 args.messages = vec!["a".into(), "b".into()];
349 let (initial, extra) = build_initial_message(&args, None, None);
350 assert_eq!(initial.as_deref(), Some("a"));
351 assert_eq!(extra, vec!["b".to_string()]);
352 }
353
354 #[test]
355 fn is_likely_image_detects_extensions() {
356 assert!(is_likely_image(Path::new("foo.png")));
357 assert!(is_likely_image(Path::new("foo.JPG")));
358 assert!(!is_likely_image(Path::new("foo.rs")));
359 assert!(!is_likely_image(Path::new("foo")));
360 }
361}