use std::io::Read;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
use anyhow::{bail, Context, Result};
pub fn build_argv(template: &str, path: &Path, out: &Path) -> Vec<String> {
let mut argv: Vec<String> = template.split_whitespace().map(str::to_string).collect();
if argv.is_empty() {
return argv;
}
let p = path.to_string_lossy().to_string();
let o = out.to_string_lossy().to_string();
let mut path_sub = false;
for a in argv.iter_mut() {
if a.contains("{path}") {
*a = a.replace("{path}", &p);
path_sub = true;
}
if a.contains("{out}") {
*a = a.replace("{out}", &o);
}
}
if !path_sub {
argv.push(p);
}
argv
}
#[cfg(test)]
thread_local! {
static CALLS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
}
#[cfg(test)]
pub(crate) fn calls_for_test() -> u64 {
CALLS.with(|c| c.get())
}
fn private_temp_dir() -> PathBuf {
static DIR: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
DIR.get_or_init(|| {
let dir = std::env::temp_dir().join(format!("konoma-cmd-{}", std::process::id()));
#[cfg(unix)]
{
use std::os::unix::fs::{DirBuilderExt, PermissionsExt};
let _ = std::fs::DirBuilder::new().mode(0o700).create(&dir);
let _ = std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700));
}
#[cfg(not(unix))]
{
let _ = std::fs::create_dir(&dir);
}
dir
})
.clone()
}
pub fn temp_out_path() -> PathBuf {
#[cfg(test)]
CALLS.with(|c| c.set(c.get() + 1));
static N: AtomicU64 = AtomicU64::new(0);
let n = N.fetch_add(1, Ordering::Relaxed);
private_temp_dir().join(format!("out-{n}"))
}
#[cfg(unix)]
fn write_private(path: &Path, data: &[u8]) -> std::io::Result<()> {
use std::io::Write as _;
use std::os::unix::fs::OpenOptionsExt;
let mut f = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(path)?;
f.write_all(data)
}
#[cfg(not(unix))]
fn write_private(path: &Path, data: &[u8]) -> std::io::Result<()> {
std::fs::write(path, data)
}
pub fn run_detached(argv: &[String]) -> Result<()> {
let (prog, rest) = argv.split_first().context("empty command template")?;
Command::new(prog)
.args(rest)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.with_context(|| format!("failed to launch `{prog}`"))?;
Ok(())
}
pub fn run_capture(argv: &[String], out: &Path, uses_out: bool) -> Result<PathBuf> {
run_capture_impl(argv, out, uses_out, RUN_TIMEOUT)
}
const RUN_TIMEOUT: Duration = Duration::from_secs(30);
const POLL_INTERVAL: Duration = Duration::from_millis(20);
fn run_capture_impl(
argv: &[String],
out: &Path,
uses_out: bool,
timeout: Duration,
) -> Result<PathBuf> {
let (prog, rest) = argv.split_first().context("empty command template")?;
let mut child = Command::new(prog)
.args(rest)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.with_context(|| format!("failed to launch `{prog}`"))?;
let mut stdout = child.stdout.take().expect("stdout was piped above");
let mut stderr = child.stderr.take().expect("stderr was piped above");
let cap = crate::preview::text::MAX_BYTES;
let stdout_capped = Arc::new(AtomicBool::new(false));
let stdout_capped_writer = Arc::clone(&stdout_capped);
let stdout_handle = std::thread::spawn(move || {
let (buf, truncated) = capped_read(&mut stdout, cap);
if truncated {
stdout_capped_writer.store(true, Ordering::Release);
}
(buf, truncated)
});
let stderr_handle = std::thread::spawn(move || capped_read(&mut stderr, cap));
let deadline = Instant::now() + timeout;
let mut timed_out = false;
let status = loop {
match child.try_wait() {
Ok(Some(status)) => break status,
Ok(None) => {
if stdout_capped.load(Ordering::Acquire) {
let _ = child.kill();
break child
.wait()
.with_context(|| format!("failed to wait for `{prog}`"))?;
}
if Instant::now() >= deadline {
timed_out = true;
let _ = child.kill();
break child
.wait()
.with_context(|| format!("failed to wait for `{prog}`"))?;
}
std::thread::sleep(POLL_INTERVAL);
}
Err(e) => return Err(e).context(format!("failed to wait for `{prog}`")),
}
};
let (stdout_buf, stdout_truncated) =
stdout_handle.join().unwrap_or_else(|_| (Vec::new(), false));
let (stderr_buf, _) = stderr_handle.join().unwrap_or_else(|_| (Vec::new(), false));
if timed_out {
bail!("timed out after {}s without exiting", timeout.as_secs());
}
if !stdout_truncated && !status.success() {
let msg = first_nonempty_line(&stderr_buf).unwrap_or_else(|| status.to_string());
bail!(msg);
}
if uses_out {
resolve_produced_out(out).with_context(|| format!("{} was not produced", out.display()))
} else {
write_private(out, &stdout_buf)
.with_context(|| format!("failed to write {}", out.display()))?;
Ok(out.to_path_buf())
}
}
fn resolve_produced_out(out: &Path) -> Option<PathBuf> {
if is_nonempty_file(out) {
return Some(out.to_path_buf());
}
find_suffixed_out(out)
}
fn is_nonempty_file(p: &Path) -> bool {
std::fs::metadata(p).map(|m| m.len() > 0).unwrap_or(false)
}
fn find_suffixed_out(out: &Path) -> Option<PathBuf> {
let dir = out.parent()?;
let base = out.file_name()?.to_str()?;
let prefix = format!("{base}.");
let mut best: Option<(std::time::SystemTime, PathBuf)> = None;
for entry in std::fs::read_dir(dir).ok()?.flatten() {
let name = entry.file_name();
let Some(name) = name.to_str() else { continue };
if !name.starts_with(&prefix) {
continue;
}
let path = entry.path();
let Ok(meta) = std::fs::metadata(&path) else {
continue;
};
if meta.len() == 0 {
continue;
}
let mtime = meta.modified().unwrap_or(std::time::SystemTime::UNIX_EPOCH);
let is_newer = match &best {
Some((t, _)) => mtime > *t,
None => true,
};
if is_newer {
best = Some((mtime, path));
}
}
best.map(|(_, p)| p)
}
fn capped_read(r: &mut impl Read, cap: usize) -> (Vec<u8>, bool) {
let mut buf = Vec::new();
let mut chunk = [0u8; 8192];
loop {
if buf.len() >= cap {
return (buf, true);
}
match r.read(&mut chunk) {
Ok(0) => return (buf, false), Ok(n) => {
let room = cap - buf.len();
let take = n.min(room);
buf.extend_from_slice(&chunk[..take]);
if take < n {
return (buf, true); }
}
Err(_) => return (buf, false), }
}
}
fn first_nonempty_line(bytes: &[u8]) -> Option<String> {
String::from_utf8_lossy(bytes)
.lines()
.map(str::trim)
.find(|l| !l.is_empty())
.map(str::to_string)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_support::unique_tmp;
#[test]
fn build_argv_substitutes_path_only() {
assert_eq!(
build_argv("cat {path}", Path::new("/a/b.txt"), Path::new("/tmp/o")),
vec!["cat", "/a/b.txt"]
);
}
#[test]
fn build_argv_substitutes_out_only_and_still_appends_path() {
assert_eq!(
build_argv("tool -o {out}", Path::new("/a/b.txt"), Path::new("/tmp/o")),
vec!["tool", "-o", "/tmp/o", "/a/b.txt"]
);
}
#[test]
fn build_argv_substitutes_both() {
assert_eq!(
build_argv(
"conv {path} {out}",
Path::new("/a/b.txt"),
Path::new("/tmp/o")
),
vec!["conv", "/a/b.txt", "/tmp/o"]
);
}
#[test]
fn build_argv_neither_token_appends_path_at_end() {
assert_eq!(
build_argv("cat", Path::new("/a/b.txt"), Path::new("/tmp/o")),
vec!["cat", "/a/b.txt"]
);
}
#[test]
fn build_argv_keeps_a_space_containing_path_as_one_argv_element() {
let argv = build_argv(
"cat {path}",
Path::new("/a/my file.txt"),
Path::new("/tmp/o"),
);
assert_eq!(argv, vec!["cat", "/a/my file.txt"]);
assert_eq!(argv.len(), 2, "空白入りパスが2要素に割れていないこと");
}
#[test]
fn build_argv_handles_cjk_path() {
assert_eq!(
build_argv(
"cat {path}",
Path::new("/a/日本語.txt"),
Path::new("/tmp/o")
),
vec!["cat", "/a/日本語.txt"]
);
}
#[test]
fn build_argv_substitutes_repeated_token_in_separate_args() {
assert_eq!(
build_argv(
"diff {path} {path}",
Path::new("/a/b.txt"),
Path::new("/tmp/o")
),
vec!["diff", "/a/b.txt", "/a/b.txt"]
);
}
#[test]
fn build_argv_substitutes_repeated_token_within_one_arg() {
assert_eq!(
build_argv("cat {path}{path}", Path::new("/a"), Path::new("/tmp/o")),
vec!["cat", "/a/a"]
);
}
#[test]
fn build_argv_empty_or_whitespace_template_produces_no_argv() {
assert_eq!(
build_argv("", Path::new("/a/b.sh"), Path::new("/tmp/o")),
Vec::<String>::new(),
"空テンプレートが argv=[プレビュー対象のパス] になっている(実行されてしまう)"
);
assert_eq!(
build_argv(" ", Path::new("/a/b.sh"), Path::new("/tmp/o")),
Vec::<String>::new(),
"空白のみのテンプレートも同様に実行対象になってしまっている"
);
assert_eq!(
build_argv("cat", Path::new("/a/b.sh"), Path::new("/tmp/o")),
vec!["cat", "/a/b.sh"]
);
}
#[cfg(unix)]
fn tmp(name: &str) -> PathBuf {
unique_tmp(&format!("konoma_command_test_{name}"))
}
#[cfg(unix)]
#[test]
fn run_capture_uses_out_when_the_command_writes_it() {
let src = tmp("uses_out_src.txt");
std::fs::write(&src, "hello from source\n").unwrap();
let out = tmp("uses_out_dst.txt");
let _ = std::fs::remove_file(&out);
let argv = build_argv("cp {path} {out}", &src, &out);
let result = run_capture(&argv, &out, true).expect("cp should succeed");
assert_eq!(result, out);
let content = std::fs::read_to_string(&out).unwrap();
assert_eq!(content, "hello from source\n");
std::fs::remove_file(&src).ok();
std::fs::remove_file(&out).ok();
}
#[cfg(unix)]
#[test]
fn run_capture_writes_captured_stdout_when_out_is_unused() {
let src = tmp("stdout_src.txt");
std::fs::write(&src, "irrelevant").unwrap();
let out = tmp("stdout_dst.txt");
let _ = std::fs::remove_file(&out);
let argv = build_argv("echo {path}", &src, &out);
let result = run_capture(&argv, &out, false).expect("echo should succeed");
assert_eq!(result, out);
let content = std::fs::read_to_string(&out).unwrap();
assert_eq!(content.trim_end(), src.to_string_lossy());
std::fs::remove_file(&src).ok();
std::fs::remove_file(&out).ok();
}
#[cfg(unix)]
#[test]
fn run_capture_reports_missing_command() {
let out = tmp("missing_cmd_dst.txt");
let argv = vec!["konoma-definitely-nonexistent-command-xyz123".to_string()];
let err = run_capture(&argv, &out, false).unwrap_err();
assert!(!err.to_string().is_empty());
}
#[cfg(unix)]
#[test]
fn run_capture_reports_stderr_first_line_on_nonzero_exit() {
let out = tmp("nonzero_dst.txt");
let argv = vec!["cat".to_string(), "/no/such/path/konoma-test".to_string()];
let err = run_capture(&argv, &out, false).unwrap_err();
assert!(
err.to_string().contains("konoma-test") || err.to_string().contains("No such"),
"stderr の1行目が理由として出ていない: {err}"
);
}
#[cfg(unix)]
#[test]
fn run_capture_reports_out_not_produced() {
let out = tmp("not_produced_dst.txt");
let _ = std::fs::remove_file(&out);
let argv = vec!["true".to_string()];
let err = run_capture(&argv, &out, true).unwrap_err();
assert!(err.to_string().contains(&out.to_string_lossy().to_string()));
assert!(!out.exists());
}
#[cfg(unix)]
#[test]
fn empty_template_end_to_end_never_executes_the_previewed_file() {
use std::os::unix::fs::PermissionsExt;
let marker = tmp("empty_template_marker.txt");
let _ = std::fs::remove_file(&marker);
let script = tmp("empty_template_script.sh");
std::fs::write(&script, format!("#!/bin/sh\ntouch {}\n", marker.display())).unwrap();
let mut perms = std::fs::metadata(&script).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&script, perms).unwrap();
let out = tmp("empty_template_out.txt");
let _ = std::fs::remove_file(&out);
let argv = build_argv("", &script, &out);
let result = run_capture(&argv, &out, false);
assert!(
result.is_err(),
"空テンプレートは「委譲するコマンドが無い」エラーになるべき: {result:?}"
);
assert!(
!marker.exists(),
"空テンプレートでプレビュー対象自身(スクリプト)が実際に実行されてしまった"
);
std::fs::remove_file(&script).ok();
std::fs::remove_file(&marker).ok();
std::fs::remove_file(&out).ok();
}
#[cfg(unix)]
#[test]
fn run_capture_finds_a_suffixed_out_when_the_literal_path_is_unused() {
let src = tmp("suffix_src.txt");
std::fs::write(&src, "suffixed output\n").unwrap();
let out = tmp("suffix_dst");
let suffixed = out.with_extension("png");
let _ = std::fs::remove_file(&out);
let _ = std::fs::remove_file(&suffixed);
let argv = build_argv("cp {path} {out}.png", &src, &out);
let result = run_capture(&argv, &out, true).expect("cp should succeed");
assert_eq!(result, suffixed, "接尾辞つき成果物のパスを返していない");
let content = std::fs::read_to_string(&result).unwrap();
assert_eq!(content, "suffixed output\n");
std::fs::remove_file(&src).ok();
std::fs::remove_file(&suffixed).ok();
}
#[cfg(unix)]
#[test]
fn run_capture_prefers_the_literal_out_over_a_suffixed_sibling() {
let src = tmp("suffix_priority_src.txt");
std::fs::write(&src, "literal wins\n").unwrap();
let out = tmp("suffix_priority_dst");
let suffixed = out.with_extension("png");
let _ = std::fs::remove_file(&out);
std::fs::write(&suffixed, "stale leftover, must be ignored\n").unwrap();
let argv = build_argv("cp {path} {out}", &src, &out);
let result = run_capture(&argv, &out, true).expect("cp should succeed");
assert_eq!(result, out, "リテラル out より接尾辞つきを優先してしまった");
let content = std::fs::read_to_string(&result).unwrap();
assert_eq!(content, "literal wins\n");
std::fs::remove_file(&src).ok();
std::fs::remove_file(&out).ok();
std::fs::remove_file(&suffixed).ok();
}
#[cfg(unix)]
#[test]
fn run_capture_out_suffix_match_requires_a_dot_separator() {
let out = {
let mut s = unique_tmp("konoma_command_test_suffix_dot").into_os_string();
s.push("-2");
PathBuf::from(s)
};
let decoy = {
let mut s = out.clone().into_os_string();
s.push("0.png");
PathBuf::from(s)
};
let _ = std::fs::remove_file(&out);
std::fs::write(
&decoy,
b"decoy belonging to a different call, must never match",
)
.unwrap();
let argv = vec!["true".to_string()];
let err = run_capture(&argv, &out, true).unwrap_err();
assert!(
err.to_string().contains(&out.to_string_lossy().to_string()),
"'.' 区切りを要求せず decoy を成果物として誤採用した: {err}"
);
assert!(!out.exists());
std::fs::remove_file(&decoy).ok();
}
#[cfg(unix)]
#[test]
fn run_capture_ignores_an_empty_suffixed_candidate() {
let out = tmp("suffix_empty_dst");
let suffixed = out.with_extension("png");
let _ = std::fs::remove_file(&out);
std::fs::write(&suffixed, b"").unwrap();
let argv = vec!["true".to_string()];
let err = run_capture(&argv, &out, true).unwrap_err();
assert!(err.to_string().contains(&out.to_string_lossy().to_string()));
assert!(!out.exists());
std::fs::remove_file(&suffixed).ok();
}
#[cfg(unix)]
#[test]
fn run_capture_picks_a_suffixed_candidate_when_more_than_one_matches() {
let out = tmp("suffix_multi_dst");
let png = out.with_extension("png");
let jpg = out.with_extension("jpg");
let _ = std::fs::remove_file(&out);
std::fs::write(&png, b"png candidate").unwrap();
std::fs::write(&jpg, b"jpg candidate").unwrap();
let argv = vec!["true".to_string()];
let result = run_capture(&argv, &out, true).expect("a suffixed candidate should be found");
assert!(
result == png || result == jpg,
"どちらの接尾辞候補でもないパスを返した: {}",
result.display()
);
assert!(
std::fs::metadata(&result)
.map(|m| m.len() > 0)
.unwrap_or(false),
"空の候補を採用した"
);
std::fs::remove_file(&png).ok();
std::fs::remove_file(&jpg).ok();
}
#[cfg(unix)]
#[test]
fn run_capture_truncates_runaway_output() {
let out = tmp("runaway_dst.txt");
let _ = std::fs::remove_file(&out);
let argv = vec!["yes".to_string()];
let result = run_capture(&argv, &out, false).expect("capped read still succeeds");
assert_eq!(result, out);
let len = std::fs::metadata(&out).unwrap().len() as usize;
assert!(len > 0, "何も書き出せていない");
assert!(
len <= crate::preview::text::MAX_BYTES,
"上限を超えて書き出された: {len}"
);
std::fs::remove_file(&out).ok();
}
#[cfg(unix)]
#[test]
fn temp_out_path_lives_in_an_owner_only_directory() {
use std::os::unix::fs::PermissionsExt;
let path = temp_out_path();
let dir = path.parent().expect("temp_out_path has a parent dir");
assert_ne!(
dir,
std::env::temp_dir(),
"システム共有の一時ディレクトリ直下に出力している(専用サブディレクトリを持っていない)"
);
let meta = std::fs::metadata(dir).expect("private temp dir should exist by now");
assert!(meta.is_dir());
let mode = meta.permissions().mode() & 0o777;
assert_eq!(
mode, 0o700,
"委譲コマンドの一時出力先が owner-only(0700) になっていない: {dir:?} mode={mode:#o}"
);
}
#[cfg(unix)]
#[test]
fn captured_stdout_output_file_is_owner_only() {
use std::os::unix::fs::PermissionsExt;
let out = tmp("stdout_perm_dst.txt");
let _ = std::fs::remove_file(&out);
let argv = vec!["echo".to_string(), "hi".to_string()];
run_capture(&argv, &out, false).expect("echo should succeed");
let mode = std::fs::metadata(&out).unwrap().permissions().mode() & 0o777;
assert_eq!(
mode, 0o600,
"captured stdout の書き出し先が owner-only(0600) になっていない: mode={mode:#o}"
);
std::fs::remove_file(&out).ok();
}
#[cfg(unix)]
#[test]
fn run_capture_returns_promptly_for_a_hanging_command() {
let out = tmp("hang_dst.txt");
let _ = std::fs::remove_file(&out);
let argv = vec![
"tail".to_string(),
"-f".to_string(),
"/dev/null".to_string(),
];
let started = std::time::Instant::now();
let result = run_capture_impl(&argv, &out, false, Duration::from_millis(200));
let elapsed = started.elapsed();
assert!(
result.is_err(),
"ハングするコマンドは失敗として返るべき: {result:?}"
);
assert!(
elapsed < Duration::from_secs(10),
"タイムアウトが機能せずブロックし続けた: elapsed={elapsed:?}"
);
let _ = std::fs::remove_file(&out);
}
}