use anyhow::{Context, Result, bail};
use std::io::{IsTerminal, Read};
use std::path::{Path, PathBuf};
use std::process::Command;
use crate::cli::AskArgs;
pub fn resolve_main_prompt(
positional: Option<&str>,
file: Option<&Path>,
editor: bool,
editor_history: Option<usize>,
) -> Result<Option<String>> {
if editor {
if !std::io::stdin().is_terminal() {
bail!("--editor requires a TTY; pipe-mode input is incompatible");
}
let n = editor_history.unwrap_or(1);
return Ok(Some(compose_in_editor(n)?));
}
if let Some(path) = file {
let content = std::fs::read_to_string(path)
.with_context(|| format!("reading prompt from {}", path.display()))?;
let trimmed = content.trim_end().to_string();
if trimmed.is_empty() {
bail!("file {} is empty", path.display());
}
return Ok(Some(trimmed));
}
match positional {
Some("-") => Ok(Some(read_stdin()?)),
Some(p) => Ok(Some(p.to_string())),
None => {
if std::io::stdin().is_terminal() {
Ok(None)
} else {
Ok(Some(read_stdin()?))
}
}
}
}
pub fn compose_prompt(
main: Option<String>,
prepend: &[PathBuf],
attachments: Option<String>,
append: &[PathBuf],
) -> Result<String> {
let mut parts: Vec<String> = Vec::new();
for path in prepend {
let content = std::fs::read_to_string(path)
.with_context(|| format!("reading --prepend {}", path.display()))?;
parts.push(content.trim_end().to_string());
}
if let Some(attach_block) = attachments {
parts.push(attach_block);
}
if let Some(m) = main {
parts.push(m);
}
for path in append {
let content = std::fs::read_to_string(path)
.with_context(|| format!("reading --append {}", path.display()))?;
parts.push(content.trim_end().to_string());
}
parts.retain(|p| !p.is_empty());
if parts.is_empty() {
bail!(
"no prompt: pass one as an argument, use -f / -e, --prepend / --append / --attach, pipe via stdin, or use `-` for stdin"
);
}
Ok(parts.join("\n\n"))
}
pub fn apply_vars(mut prompt: String, vars: &[(String, String)]) -> String {
for (k, v) in vars {
let placeholder = format!("{{{{{k}}}}}");
prompt = prompt.replace(&placeholder, v);
}
prompt
}
pub fn merge_optional(a: Option<String>, b: Option<String>) -> Option<String> {
match (a, b) {
(Some(a), Some(b)) => Some(format!("{a}\n\n{b}")),
(Some(s), None) | (None, Some(s)) => Some(s),
(None, None) => None,
}
}
pub fn collect_attachments(patterns: &[String]) -> Result<Option<String>> {
if patterns.is_empty() {
return Ok(None);
}
let mut blocks: Vec<String> = Vec::new();
for pat in patterns {
let matches = glob::glob(pat).with_context(|| format!("invalid glob: {pat}"))?;
let mut had_any = false;
for entry in matches {
let path = entry.with_context(|| format!("walking --attach {pat}"))?;
if !path.is_file() {
continue;
}
had_any = true;
let content = std::fs::read_to_string(&path)
.with_context(|| format!("reading --attach {}", path.display()))?;
blocks.push(format!(
"File: {}\n```\n{}\n```",
path.display(),
content.trim_end()
));
}
if !had_any {
eprintln!("warning: --attach {pat} matched no files");
}
}
if blocks.is_empty() {
Ok(None)
} else {
Ok(Some(blocks.join("\n\n")))
}
}
pub fn collect_git_context(args: &AskArgs) -> Result<Option<String>> {
let mut blocks: Vec<String> = Vec::new();
if args.git_diff
&& let Some(out) = run_git(&["diff"])?
{
blocks.push(format!("git diff:\n```diff\n{out}\n```"));
}
if let Some(n) = args.git_log
&& let Some(out) = run_git(&["log", "-n", &n.to_string(), "--oneline"])?
{
blocks.push(format!("git log -n {n}:\n```\n{out}\n```"));
}
if args.git_status
&& let Some(out) = run_git(&["status", "--short"])?
{
blocks.push(format!("git status:\n```\n{out}\n```"));
}
if blocks.is_empty() {
Ok(None)
} else {
Ok(Some(blocks.join("\n\n")))
}
}
pub fn run_git(args: &[&str]) -> Result<Option<String>> {
let output = std::process::Command::new("git")
.args(args)
.output()
.with_context(|| format!("running git {}", args.join(" ")))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("git {} failed: {}", args.join(" "), stderr.trim());
}
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
if stdout.is_empty() {
Ok(None)
} else {
Ok(Some(stdout))
}
}
pub fn read_stdin() -> Result<String> {
let mut buf = String::new();
std::io::stdin().read_to_string(&mut buf)?;
let trimmed = buf.trim_end().to_string();
if trimmed.is_empty() {
bail!("empty stdin");
}
Ok(trimmed)
}
const SCISSORS: &str = "// ------------------------ >8 ------------------------";
pub fn compose_in_editor(history_n: usize) -> Result<String> {
let tmp = tempfile::Builder::new()
.prefix("roba-prompt-")
.suffix(".md")
.tempfile()
.context("creating editor scratch file")?;
let path = tmp.path().to_path_buf();
let preamble = if history_n == 0 {
String::new()
} else {
let responses =
crate::history::last_n_assistant_texts_in_cwd(history_n).unwrap_or_default();
build_editor_preamble(&responses)
};
if !preamble.is_empty() {
std::fs::write(&path, &preamble).context("writing editor preamble")?;
}
let editor = editor_command();
let status =
spawn_editor(&editor, &path).with_context(|| format!("running editor `{editor}`"))?;
if !status.success() {
bail!("editor exited with {status}");
}
let content = std::fs::read_to_string(&path).context("reading editor buffer")?;
let body = strip_from_scissors(&content);
let trimmed = body.trim().to_string();
if trimmed.is_empty() {
bail!("editor returned an empty prompt");
}
Ok(trimmed)
}
pub fn build_editor_preamble(responses: &[String]) -> String {
if responses.is_empty() {
return String::new();
}
let mut out = String::new();
out.push('\n');
out.push('\n');
out.push_str(SCISSORS);
out.push('\n');
out.push_str("// Reference only -- everything from the scissors down is stripped on save.\n");
out.push_str("// Type your prompt above the scissors line.\n");
out.push('\n');
if responses.len() == 1 {
out.push_str("// --- last response from the most recent session in this dir ---\n");
out.push('\n');
out.push_str(responses[0].trim_end());
out.push('\n');
} else {
out.push_str(&format!(
"// --- last {} responses from the most recent session in this dir (oldest first) ---\n",
responses.len()
));
for (i, r) in responses.iter().enumerate() {
out.push('\n');
out.push_str(&format!("// --- {} of {} ---\n", i + 1, responses.len()));
out.push('\n');
out.push_str(r.trim_end());
out.push('\n');
}
}
out
}
pub fn strip_from_scissors(content: &str) -> String {
let mut idx: Option<usize> = None;
for (i, line) in content.lines().enumerate() {
if line == SCISSORS {
idx = Some(i);
break;
}
}
let Some(scissors_idx) = idx else {
return content.to_string();
};
content
.lines()
.take(scissors_idx)
.collect::<Vec<_>>()
.join("\n")
}
fn editor_command() -> String {
std::env::var("VISUAL")
.or_else(|_| std::env::var("EDITOR"))
.unwrap_or_else(|_| "vi".to_string())
}
fn spawn_editor(editor: &str, path: &Path) -> std::io::Result<std::process::ExitStatus> {
let mut parts = editor.split_whitespace();
let program = parts.next().expect("editor_command never returns empty");
let extra_args: Vec<&str> = parts.collect();
Command::new(program).args(&extra_args).arg(path).status()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn apply_vars_substitutes_named_placeholders() {
let prompt = "Hello {{NAME}}, ticket {{ID}}".to_string();
let vars = vec![
("NAME".to_string(), "Josh".to_string()),
("ID".to_string(), "ABC-123".to_string()),
];
assert_eq!(apply_vars(prompt, &vars), "Hello Josh, ticket ABC-123");
}
#[test]
fn apply_vars_leaves_unknown_placeholders_alone() {
let prompt = "{{KNOWN}} and {{UNKNOWN}}".to_string();
let vars = vec![("KNOWN".to_string(), "yes".to_string())];
assert_eq!(apply_vars(prompt, &vars), "yes and {{UNKNOWN}}");
}
#[test]
fn apply_vars_handles_repeated_placeholders() {
let prompt = "{{X}} and {{X}} again".to_string();
let vars = vec![("X".to_string(), "go".to_string())];
assert_eq!(apply_vars(prompt, &vars), "go and go again");
}
#[test]
fn merge_optional_combines_with_blank_line() {
assert_eq!(
merge_optional(Some("a".to_string()), Some("b".to_string())),
Some("a\n\nb".to_string())
);
}
#[test]
fn merge_optional_returns_either_when_other_is_none() {
assert_eq!(
merge_optional(Some("a".to_string()), None),
Some("a".to_string())
);
assert_eq!(
merge_optional(None, Some("b".to_string())),
Some("b".to_string())
);
}
#[test]
fn merge_optional_returns_none_when_both_none() {
assert_eq!(merge_optional(None, None), None);
}
fn write_temp(content: &str) -> tempfile::NamedTempFile {
use std::io::Write;
let mut f = tempfile::NamedTempFile::new().unwrap();
write!(f, "{content}").unwrap();
f.flush().unwrap();
f
}
#[test]
fn compose_prompt_just_main() {
let out = compose_prompt(Some("hi".to_string()), &[], None, &[]).unwrap();
assert_eq!(out, "hi");
}
#[test]
fn compose_prompt_prepend_then_main_then_append() {
let pre = write_temp("SYSTEM");
let post = write_temp("CONTEXT");
let out = compose_prompt(
Some("question".to_string()),
std::slice::from_ref(&pre.path().to_path_buf()),
None,
std::slice::from_ref(&post.path().to_path_buf()),
)
.unwrap();
assert_eq!(out, "SYSTEM\n\nquestion\n\nCONTEXT");
}
#[test]
fn compose_prompt_inserts_attachments_between_prepend_and_main() {
let pre = write_temp("PREP");
let attach = "File: foo.rs\n```\nfn x() {}\n```".to_string();
let out = compose_prompt(
Some("question".to_string()),
std::slice::from_ref(&pre.path().to_path_buf()),
Some(attach.clone()),
&[],
)
.unwrap();
assert_eq!(out, format!("PREP\n\n{attach}\n\nquestion"));
}
#[test]
fn compose_prompt_main_optional_when_prepend_present() {
let pre = write_temp("STANDALONE");
let out = compose_prompt(
None,
std::slice::from_ref(&pre.path().to_path_buf()),
None,
&[],
)
.unwrap();
assert_eq!(out, "STANDALONE");
}
#[test]
fn compose_prompt_errors_when_everything_empty() {
let err = compose_prompt(None, &[], None, &[]).expect_err("must error");
assert!(format!("{err:#}").contains("no prompt"));
}
#[test]
fn compose_prompt_drops_empty_segments() {
let empty = write_temp("");
let out = compose_prompt(
Some("only".to_string()),
std::slice::from_ref(&empty.path().to_path_buf()),
None,
&[],
)
.unwrap();
assert_eq!(out, "only");
}
#[test]
fn preamble_empty_for_no_responses() {
assert_eq!(build_editor_preamble(&[]), "");
}
#[test]
fn preamble_single_response_layout() {
let p = build_editor_preamble(&["the previous answer".to_string()]);
assert!(
p.starts_with("\n\n"),
"expected leading blank lines for cursor area, got: {p:?}"
);
assert!(p.contains(SCISSORS));
assert!(p.contains("// Type your prompt above the scissors line."));
assert!(p.contains("// --- last response"));
assert!(
p.contains("\nthe previous answer\n"),
"expected unprefixed response body, got:\n{p}"
);
}
#[test]
fn preamble_multi_response_uses_section_dividers() {
let p = build_editor_preamble(&["older one".to_string(), "newer one".to_string()]);
assert!(p.contains("// --- last 2 responses"));
assert!(p.contains("// --- 1 of 2 ---"));
assert!(p.contains("// --- 2 of 2 ---"));
assert!(p.contains("\nolder one\n"));
assert!(p.contains("\nnewer one\n"));
assert!(p.contains(SCISSORS));
}
#[test]
fn preamble_preserves_blank_lines_in_response() {
let p = build_editor_preamble(&["first\n\nsecond".to_string()]);
assert!(
p.contains("\nfirst\n\nsecond\n"),
"expected verbatim response with blank line preserved, got:\n{p}"
);
}
#[test]
fn strip_returns_content_above_scissors() {
let buf = format!("my prompt line 1\nmy prompt line 2\n\n{SCISSORS}\n// reference");
assert_eq!(
strip_from_scissors(&buf),
"my prompt line 1\nmy prompt line 2\n"
);
}
#[test]
fn strip_uses_first_scissors_when_multiple_exist() {
let buf = format!("real prompt\n{SCISSORS}\n// stuff\n{SCISSORS}\n// more");
assert_eq!(strip_from_scissors(&buf), "real prompt");
}
#[test]
fn strip_no_scissors_returns_whole_content() {
let buf = "just the prompt\nno scissors here";
assert_eq!(strip_from_scissors(buf), buf);
}
#[test]
fn strip_preserves_markdown_headers_in_prompt() {
let buf = format!("# Real heading\n## Sub\nbody\n{SCISSORS}\n// reference");
assert_eq!(strip_from_scissors(&buf), "# Real heading\n## Sub\nbody");
}
}