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 let parsed = parse_args(&argv);
54
55 if parsed.help {
57 print_help();
58 return 0;
59 }
60 if parsed.version {
61 print_version();
62 return 0;
63 }
64
65 if !parsed.errors.is_empty() {
67 for err in &parsed.errors {
68 eprintln!("error: {err}");
69 }
70 eprintln!();
71 print_help();
72 return EXIT_USAGE;
73 }
74
75 if parsed.verbose {
77 for warn in &parsed.ignored {
78 eprintln!("warning: {warn}");
79 }
80 }
81
82 let cwd = match std::env::current_dir() {
84 Ok(c) => c,
85 Err(e) => {
86 eprintln!("error: could not determine the current directory: {e}");
87 return EXIT_USAGE;
88 }
89 };
90
91 let stdin_text = read_piped_stdin();
93
94 let (file_text, _file_images) = match process_file_args(&parsed.file_args, &cwd) {
96 Ok(t) => t,
97 Err(msg) => {
98 eprintln!("error: {msg}");
99 return EXIT_USAGE;
100 }
101 };
102
103 let file_text_opt = if file_text.is_empty() { None } else { Some(file_text.as_str()) };
105 let (initial, extra) =
106 build_initial_message(&parsed, stdin_text.as_deref(), file_text_opt);
107
108 let resolved = match resolve(
110 parsed.provider.as_deref(),
111 parsed.model.as_deref(),
112 parsed.thinking,
113 parsed.api_key.as_deref(),
114 ) {
115 Ok(r) => r,
116 Err(e) => {
117 print_resolve_error(&e);
118 return match e {
119 ResolveError::NoApiKey { .. } => EXIT_USAGE,
120 _ => EXIT_RUNTIME,
121 };
122 }
123 };
124
125 let harness = match build(&resolved, &parsed, &cwd).await {
127 Ok(h) => h,
128 Err(e) => {
129 print_build_error(&e);
130 return EXIT_RUNTIME;
131 }
132 };
133
134 let stdin_is_tty = std::io::stdin().is_terminal();
136 let stdout_is_tty = std::io::stdout().is_terminal();
137 let mode = resolve_mode(&parsed, stdin_is_tty, stdout_is_tty);
138
139 let mode = if matches!(mode, RunMode::Interactive) && stdin_text.is_some() {
141 RunMode::Print
142 } else {
143 mode
144 };
145
146 match mode {
147 RunMode::Print => crate::modes::print(&harness, &parsed, initial.clone(), &extra).await,
148 RunMode::Json => crate::modes::json(&harness, &parsed, initial.clone(), &extra).await,
149 RunMode::Interactive => {
150 crate::modes::interactive(&harness, &parsed, initial.clone(), &extra).await
151 }
152 RunMode::Rpc => {
153 eprintln!("error: rpc mode is not implemented in v1 (use --mode text or --mode json)");
157 EXIT_USAGE
158 }
159 }
160}
161
162fn read_piped_stdin() -> Option<String> {
169 if std::io::stdin().is_terminal() {
170 return None;
171 }
172 let mut buf = String::new();
173 match std::io::stdin().read_to_string(&mut buf) {
174 Ok(_) => {
175 let trimmed = buf.trim();
176 if trimmed.is_empty() {
177 None
178 } else {
179 Some(trimmed.to_string())
180 }
181 }
182 Err(_) => None,
183 }
184}
185
186fn process_file_args(
198 file_args: &[std::path::PathBuf],
199 cwd: &Path,
200) -> Result<(String, Vec<ImageContent>), String> {
201 let mut text = String::new();
202 for rel in file_args {
203 let abs = if rel.is_absolute() {
204 rel.clone()
205 } else {
206 cwd.join(rel)
207 };
208 if !abs.exists() {
209 return Err(format!("file not found: {}", abs.display()));
210 }
211 if is_likely_image(&abs) {
213 return Err(format!(
214 "image attachments are not supported in v1: {}",
215 abs.display()
216 ));
217 }
218 match std::fs::read_to_string(&abs) {
219 Ok(content) => {
220 text.push_str(&format!(
221 "<file name=\"{}\">\n{}\n</file>\n",
222 abs.display(),
223 content
224 ));
225 }
226 Err(e) => {
227 return Err(format!(
228 "could not read file {}: {e}",
229 abs.display()
230 ));
231 }
232 }
233 }
234 Ok((text, Vec::new()))
235}
236
237fn is_likely_image(path: &Path) -> bool {
240 matches!(
241 path.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase()).as_deref(),
242 Some("png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp")
243 )
244}
245
246fn build_initial_message(
252 parsed: &Args,
253 stdin: Option<&str>,
254 file_text: Option<&str>,
255) -> (Option<String>, Vec<String>) {
256 let mut extra = parsed.messages.clone();
257 let mut parts: Vec<String> = Vec::new();
258 if let Some(s) = stdin {
259 parts.push(s.to_string());
260 }
261 if let Some(t) = file_text {
262 parts.push(t.to_string());
263 }
264 if !extra.is_empty() {
266 parts.push(extra.remove(0));
267 }
268 let initial = if parts.is_empty() { None } else { Some(parts.join("")) };
269 (initial, extra)
270}
271
272fn print_resolve_error(e: &ResolveError) {
275 match e {
276 ResolveError::NoApiKey { env } => {
277 eprintln!("error: {e}");
278 eprintln!();
279 eprintln!("Set the {env} environment variable, or pass --api-key <key>.");
280 }
281 _ => eprintln!("error: {e}"),
282 }
283}
284
285fn print_build_error(e: &BuildError) {
287 match e {
288 BuildError::RestoreNotImplemented { requested: _, flag } => {
289 eprintln!("error: {e}");
290 eprintln!();
291 eprintln!(
292 "To start a fresh session instead, drop {flag} (and any --session argument)."
293 );
294 }
295 _ => eprintln!("error: {e}"),
296 }
297}
298
299#[cfg(test)]
300mod tests {
301 use super::*;
302 use crate::args::Args;
303
304 #[test]
305 fn build_initial_combines_stdin_file_and_first_message() {
306 let mut args = Args::default();
307 args.messages = vec!["first".into(), "second".into(), "third".into()];
308 let (initial, extra) =
309 build_initial_message(&args, Some("stdin-text"), Some("<file>...</file>"));
310 assert_eq!(initial.as_deref(), Some("stdin-text<file>...</file>first"));
311 assert_eq!(extra, vec!["second".to_string(), "third".to_string()]);
312 }
313
314 #[test]
315 fn build_initial_with_no_messages_uses_stdin_and_file_only() {
316 let args = Args::default();
317 let (initial, extra) =
318 build_initial_message(&args, Some("only-stdin"), Some("<file>x</file>"));
319 assert_eq!(initial.as_deref(), Some("only-stdin<file>x</file>"));
320 assert!(extra.is_empty());
321 }
322
323 #[test]
324 fn build_initial_none_when_all_empty() {
325 let args = Args::default();
326 let (initial, extra) = build_initial_message(&args, None, None);
327 assert!(initial.is_none());
328 assert!(extra.is_empty());
329 }
330
331 #[test]
332 fn build_initial_shifts_only_first_message() {
333 let mut args = Args::default();
334 args.messages = vec!["a".into(), "b".into()];
335 let (initial, extra) = build_initial_message(&args, None, None);
336 assert_eq!(initial.as_deref(), Some("a"));
337 assert_eq!(extra, vec!["b".to_string()]);
338 }
339
340 #[test]
341 fn is_likely_image_detects_extensions() {
342 assert!(is_likely_image(Path::new("foo.png")));
343 assert!(is_likely_image(Path::new("foo.JPG")));
344 assert!(!is_likely_image(Path::new("foo.rs")));
345 assert!(!is_likely_image(Path::new("foo")));
346 }
347}