use std::future::Future;
use std::path::{Path, PathBuf};
use std::time::Duration;
const CHEZMOI_TIMEOUT: Duration = Duration::from_secs(2);
async fn resolve_source_path<F, Fut>(target: &Path, mut probe: F) -> Option<PathBuf>
where
F: FnMut(PathBuf) -> Fut,
Fut: Future<Output = Option<PathBuf>>,
{
if let Some(sp) = probe(target.to_path_buf()).await {
if is_tmpl(&sp) {
eprintln!(
"\u{26a0} {} is a chezmoi template (.tmpl). \
chezmoi=true requires plain files — use rvpm's Tera templates instead.",
target.display(),
);
return None;
}
return Some(sp);
}
let mut ancestor = target.parent().map(|p| p.to_path_buf());
while let Some(a) = ancestor {
if let Some(source_ancestor) = probe(a.clone()).await {
if is_tmpl(&source_ancestor) {
eprintln!(
"\u{26a0} ancestor {} resolves to a chezmoi template (.tmpl). \
chezmoi=true requires plain files — use rvpm's Tera templates instead.",
a.display(),
);
return None;
}
let relative = target.strip_prefix(&a).ok()?;
return Some(source_ancestor.join(relative));
}
ancestor = a.parent().map(|p| p.to_path_buf());
}
None
}
fn is_tmpl(p: &Path) -> bool {
p.to_string_lossy().ends_with(".tmpl")
}
async fn chezmoi_source_path(target: &Path) -> Option<PathBuf> {
let output = match tokio::process::Command::new("chezmoi")
.arg("source-path")
.arg(target)
.output()
.await
{
Ok(o) => o,
Err(e) => {
eprintln!(
"\u{26a0} chezmoi source-path {} failed: {}",
target.display(),
e,
);
return None;
}
};
if output.status.success() {
let s = String::from_utf8_lossy(&output.stdout).trim().to_string();
if s.is_empty() {
None
} else {
Some(PathBuf::from(s))
}
} else {
None
}
}
async fn is_chezmoi_available() -> bool {
matches!(
tokio::process::Command::new("chezmoi")
.arg("--version")
.output()
.await,
Ok(o) if o.status.success(),
)
}
pub async fn write_path(enabled: bool, target: &Path) -> PathBuf {
if !enabled {
return target.to_path_buf();
}
let work = async {
if !is_chezmoi_available().await {
eprintln!(
"\u{26a0} options.chezmoi = true but `chezmoi` is not in PATH. \
Writing to target directly (install chezmoi or set chezmoi = false).",
);
return None;
}
resolve_source_path(target, |p| async move { chezmoi_source_path(&p).await }).await
};
match tokio::time::timeout(CHEZMOI_TIMEOUT, work).await {
Ok(Some(sp)) => sp,
Ok(None) => target.to_path_buf(),
Err(_) => {
eprintln!(
"\u{26a0} chezmoi resolution for {} timed out after {}s; writing to target directly",
target.display(),
CHEZMOI_TIMEOUT.as_secs(),
);
target.to_path_buf()
}
}
}
pub async fn apply(wrote_to: &Path, target: &Path) {
if wrote_to == target {
return;
}
let fut = tokio::process::Command::new("chezmoi")
.arg("apply")
.arg("--force")
.arg(target)
.status();
match tokio::time::timeout(CHEZMOI_TIMEOUT, fut).await {
Ok(Ok(s)) if s.success() => {}
Ok(Ok(s)) => eprintln!(
"\u{26a0} chezmoi apply {} failed (exit {})",
target.display(),
s.code().unwrap_or(-1),
),
Ok(Err(e)) => eprintln!("\u{26a0} chezmoi apply {} failed: {}", target.display(), e),
Err(_) => eprintln!(
"\u{26a0} chezmoi apply {} timed out after {}s",
target.display(),
CHEZMOI_TIMEOUT.as_secs(),
),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
fn mock_probe(
managed: &HashMap<PathBuf, PathBuf>,
) -> impl FnMut(PathBuf) -> std::future::Ready<Option<PathBuf>> + '_ {
move |p| std::future::ready(managed.get(&p).cloned())
}
#[tokio::test]
async fn test_resolve_existing_managed_file() {
let target = PathBuf::from("/home/user/.config/rvpm/nvim/config.toml");
let source =
PathBuf::from("/home/user/.local/share/chezmoi/dot_config/rvpm/nvim/config.toml");
let managed = HashMap::from([(target.clone(), source.clone())]);
let got = resolve_source_path(&target, mock_probe(&managed)).await;
assert_eq!(got, Some(source));
}
#[tokio::test]
async fn test_resolve_new_file_via_managed_ancestor() {
let ancestor_target = PathBuf::from("/home/user/.config/rvpm/nvim/plugins");
let ancestor_source =
PathBuf::from("/home/user/.local/share/chezmoi/dot_config/rvpm/nvim/plugins");
let target = ancestor_target.join("github.com/foo/bar/init.lua");
let managed = HashMap::from([(ancestor_target, ancestor_source.clone())]);
let got = resolve_source_path(&target, mock_probe(&managed)).await;
assert_eq!(
got,
Some(ancestor_source.join("github.com/foo/bar/init.lua"))
);
}
#[tokio::test]
async fn test_resolve_tmpl_returns_none() {
let target = PathBuf::from("/home/user/.config/rvpm/nvim/config.toml");
let source =
PathBuf::from("/home/user/.local/share/chezmoi/dot_config/rvpm/nvim/config.toml.tmpl");
let managed = HashMap::from([(target.clone(), source)]);
let got = resolve_source_path(&target, mock_probe(&managed)).await;
assert_eq!(got, None);
}
#[tokio::test]
async fn test_resolve_not_managed_returns_none() {
let target = PathBuf::from("/home/user/.config/rvpm/nvim/config.toml");
let managed = HashMap::new();
let got = resolve_source_path(&target, mock_probe(&managed)).await;
assert_eq!(got, None);
}
#[tokio::test]
async fn test_resolve_new_file_no_managed_ancestor_returns_none() {
let target = PathBuf::from("/home/user/.config/rvpm/nvim/plugins/foo/init.lua");
let managed = HashMap::new();
let got = resolve_source_path(&target, mock_probe(&managed)).await;
assert_eq!(got, None);
}
#[tokio::test]
async fn test_write_path_disabled_returns_target() {
let target = Path::new("/some/target");
let got = write_path(false, target).await;
assert_eq!(got, target);
}
#[tokio::test]
async fn test_write_path_enabled_does_not_hang() {
let target = Path::new("/some/target");
let fut = write_path(true, target);
let res = tokio::time::timeout(Duration::from_secs(10), fut).await;
assert!(
res.is_ok(),
"write_path must return within 10s even when chezmoi is missing or slow"
);
}
}