use anyhow::Result;
use ratatui::text::Text;
use std::io::Write;
use std::process::{Command, Stdio};
use std::time::Duration;
const RENDER_TIMEOUT: Duration = Duration::from_secs(3);
pub fn render(
command: &[String],
markdown: &str,
width: u16,
height: u16,
) -> Result<Option<Text<'static>>> {
if command.is_empty() {
return Ok(None);
}
let needs_file = command.iter().any(|a| uses_file_placeholder(a));
let tempfile_holder = if needs_file {
let mut f = tempfile::Builder::new()
.prefix("rvpm-browse-readme-")
.suffix(".md")
.tempfile()?;
f.write_all(markdown.as_bytes())?;
f.flush()?;
Some(f)
} else {
None
};
let (args, use_stdin) = expand_args(command, width, height, tempfile_holder.as_ref());
let Some((program, rest)) = args.split_first() else {
return Ok(None);
};
let mut cmd = Command::new(program);
cmd.args(rest);
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::null());
cmd.stdin(if use_stdin {
Stdio::piped()
} else {
Stdio::null()
});
let mut child = cmd.spawn()?;
let stdin_writer = if use_stdin && let Some(mut stdin) = child.stdin.take() {
let data = markdown.as_bytes().to_vec();
Some(std::thread::spawn(move || {
let _ = stdin.write_all(&data);
}))
} else {
None
};
let stdout_reader = child.stdout.take().map(|mut s| {
std::thread::spawn(move || {
use std::io::Read;
let mut buf = Vec::new();
let _ = s.read_to_end(&mut buf);
buf
})
});
use wait_timeout::ChildExt;
let status = match child.wait_timeout(RENDER_TIMEOUT)? {
Some(s) => s,
None => {
let _ = child.kill();
let _ = child.wait();
if let Some(h) = stdin_writer {
let _ = h.join();
}
if let Some(h) = stdout_reader {
let _ = h.join();
}
return Ok(None);
}
};
if let Some(h) = stdin_writer {
let _ = h.join();
}
let buf = stdout_reader
.and_then(|h| h.join().ok())
.unwrap_or_default();
if !status.success() {
return Ok(None);
}
if buf.is_empty() {
return Ok(None);
}
use ansi_to_tui::IntoText;
let text = buf.into_text().map_err(anyhow::Error::from)?;
Ok(Some(text_to_owned(text)))
}
fn expand_args(
command: &[String],
width: u16,
height: u16,
tempfile: Option<&tempfile::NamedTempFile>,
) -> (Vec<String>, bool) {
use std::path::Path;
let (file_path, file_dir, file_name, file_stem, file_ext) = match tempfile {
Some(f) => {
let p: &Path = f.path();
let s = |o: Option<&std::ffi::OsStr>| {
o.map(|s| s.to_string_lossy().to_string())
.unwrap_or_default()
};
let sp = |o: Option<&Path>| {
o.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default()
};
(
p.to_string_lossy().to_string(),
sp(p.parent()),
s(p.file_name()),
s(p.file_stem()),
s(p.extension()),
)
}
None => (
String::new(),
String::new(),
String::new(),
String::new(),
String::new(),
),
};
let use_stdin = tempfile.is_none();
let vars: [(&str, &str); 7] = [
("width", &width_str(width)),
("height", &height_str(height)),
("file_path", &file_path),
("file_dir", &file_dir),
("file_name", &file_name),
("file_stem", &file_stem),
("file_ext", &file_ext),
];
let expanded: Vec<String> = command.iter().map(|a| substitute(a, &vars)).collect();
(expanded, use_stdin)
}
fn width_str(w: u16) -> String {
w.to_string()
}
fn height_str(h: u16) -> String {
h.to_string()
}
fn substitute(s: &str, vars: &[(&str, &str)]) -> String {
let mut out = String::with_capacity(s.len());
let mut last = 0;
let bytes = s.as_bytes();
let mut i = 0;
while i + 1 < bytes.len() {
if bytes[i] == b'{'
&& bytes[i + 1] == b'{'
&& let Some(end_rel) = s[i + 2..].find("}}")
{
let inner_start = i + 2;
let inner_end = inner_start + end_rel;
let key = s[inner_start..inner_end].trim();
if let Some((_, val)) = vars.iter().find(|(k, _)| *k == key) {
out.push_str(&s[last..i]);
out.push_str(val);
i = inner_end + 2;
last = i;
continue;
}
}
i += 1;
}
out.push_str(&s[last..]);
out
}
fn uses_file_placeholder(arg: &str) -> bool {
const FILE_KEYS: &[&str] = &[
"file_path",
"file_dir",
"file_name",
"file_stem",
"file_ext",
];
let bytes = arg.as_bytes();
let mut i = 0;
while i + 1 < bytes.len() {
if bytes[i] == b'{'
&& bytes[i + 1] == b'{'
&& let Some(end_rel) = arg[i + 2..].find("}}")
{
let key = arg[i + 2..i + 2 + end_rel].trim();
if FILE_KEYS.contains(&key) {
return true;
}
i = i + 2 + end_rel + 2;
continue;
}
i += 1;
}
false
}
fn text_to_owned(text: Text<'_>) -> Text<'static> {
use ratatui::text::{Line, Span};
let lines: Vec<Line<'static>> = text
.lines
.into_iter()
.map(|line| {
let spans: Vec<Span<'static>> = line
.spans
.into_iter()
.map(|span| Span::styled(span.content.into_owned(), span.style))
.collect();
let mut l = Line::from(spans);
l.style = line.style;
l.alignment = line.alignment;
l
})
.collect();
let mut out = Text::from(lines);
out.style = text.style;
out.alignment = text.alignment;
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_empty_command_returns_none() {
let result = render(&[], "# hi", 80, 24).unwrap();
assert!(result.is_none());
}
#[test]
fn test_expand_args_substitutes_width_height() {
let cmd = vec![
"x".to_string(),
"--cols={{ width }}".to_string(),
"--rows={{height}}".to_string(), ];
let (expanded, use_stdin) = expand_args(&cmd, 120, 40, None);
assert_eq!(
expanded,
vec![
"x".to_string(),
"--cols=120".to_string(),
"--rows=40".to_string(),
]
);
assert!(use_stdin);
}
#[test]
fn test_expand_args_file_path_uses_tempfile_and_no_stdin() {
let tmp = tempfile::Builder::new()
.prefix("test-")
.suffix(".md")
.tempfile()
.unwrap();
let cmd = vec!["x".to_string(), "{{ file_path }}".to_string()];
let (expanded, use_stdin) = expand_args(&cmd, 80, 24, Some(&tmp));
assert_eq!(expanded[0], "x");
assert_eq!(expanded[1], tmp.path().to_string_lossy());
assert!(!use_stdin);
}
#[test]
fn test_expand_args_all_file_placeholders() {
let tmp = tempfile::Builder::new()
.prefix("readme-")
.suffix(".md")
.tempfile()
.unwrap();
let cmd = vec![
"--path={{ file_path }}".to_string(),
"--dir={{ file_dir }}".to_string(),
"--name={{ file_name }}".to_string(),
"--stem={{ file_stem }}".to_string(),
"--ext={{ file_ext }}".to_string(),
];
let (expanded, use_stdin) = expand_args(&cmd, 80, 24, Some(&tmp));
let name = tmp
.path()
.file_name()
.unwrap()
.to_string_lossy()
.to_string();
let stem = tmp
.path()
.file_stem()
.unwrap()
.to_string_lossy()
.to_string();
assert_eq!(expanded[0], format!("--path={}", tmp.path().display()));
assert!(expanded[1].starts_with("--dir="));
assert_eq!(expanded[2], format!("--name={}", name));
assert_eq!(expanded[3], format!("--stem={}", stem));
assert_eq!(expanded[4], "--ext=md");
assert!(!use_stdin);
}
#[test]
fn test_expand_args_unknown_placeholder_left_as_is() {
let cmd = vec!["{{ nonexistent }}".to_string(), "{{ width }}".to_string()];
let (expanded, _) = expand_args(&cmd, 100, 20, None);
assert_eq!(expanded[0], "{{ nonexistent }}");
assert_eq!(expanded[1], "100");
}
#[test]
fn test_substitute_multiple_occurrences_in_one_arg() {
let vars = [("a", "foo"), ("b", "bar")];
assert_eq!(substitute("{{a}}/{{b}}/{{a}}", &vars), "foo/bar/foo");
assert_eq!(substitute("{{ a }} and {{ b }}", &vars), "foo and bar");
}
#[test]
fn test_uses_file_placeholder_detects_any_variant() {
assert!(uses_file_placeholder("--in={{ file_path }}"));
assert!(uses_file_placeholder("{{file_dir}}"));
assert!(uses_file_placeholder("--out={{file_name}}"));
assert!(uses_file_placeholder("{{ file_stem }}"));
assert!(uses_file_placeholder("{{ file_ext }}"));
assert!(!uses_file_placeholder("{{ width }}"));
assert!(!uses_file_placeholder("no placeholders here"));
}
#[test]
#[cfg(unix)]
fn test_render_with_echo_smoke() {
let cmd = vec!["echo".to_string(), "hello".to_string()];
let text = render(&cmd, "", 80, 24).unwrap();
let text = text.expect("echo should produce output");
let joined: String = text
.lines
.iter()
.flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
.collect();
assert!(joined.contains("hello"));
}
}