use std::io::Read;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicU64, Ordering};
use anyhow::{bail, Context, Result};
pub fn build_argv(template: &str, path: &Path, out: &Path) -> Vec<String> {
let p = path.to_string_lossy().to_string();
let o = out.to_string_lossy().to_string();
let mut argv: Vec<String> = template.split_whitespace().map(str::to_string).collect();
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())
}
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);
std::env::temp_dir().join(format!("konoma-cmd-{}-{}", std::process::id(), n))
}
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> {
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 stderr_handle = std::thread::spawn(move || capped_read(&mut stderr, cap));
let (stdout_buf, stdout_truncated) = capped_read(&mut stdout, cap);
if stdout_truncated {
let _ = child.kill();
}
let status = child
.wait()
.with_context(|| format!("failed to wait for `{prog}`"))?;
let (stderr_buf, _) = stderr_handle.join().unwrap_or_else(|_| (Vec::new(), false));
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 {
std::fs::write(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"]
);
}
#[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 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();
}
}