use anyhow::{Context, Result};
use std::path::PathBuf;
use std::time::{Duration, SystemTime};
use std::process::Command;
#[derive(Debug, Clone)]
pub struct EditRequest {
pub file_name: String,
pub contents: String,
pub configured: Option<String>,
}
pub fn resolve_editor(configured: Option<&str>) -> (String, Vec<String>) {
if let Some(raw) = configured {
let raw = raw.trim();
if !raw.is_empty() {
let mut parts = raw.split_whitespace().map(str::to_string);
if let Some(prog) = parts.next() {
return (prog, parts.collect());
}
}
}
for var in ["VISUAL", "EDITOR"] {
if let Ok(raw) = std::env::var(var) {
let raw = raw.trim();
if !raw.is_empty() {
let mut parts = raw.split_whitespace().map(str::to_string);
if let Some(prog) = parts.next() {
return (prog, parts.collect());
}
}
}
}
let fallback = if which("less") { "less" } else { "vi" };
(fallback.to_string(), Vec::new())
}
fn which(program: &str) -> bool {
Command::new("sh")
.arg("-c")
.arg(format!("command -v {program}"))
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
fn sanitize(name: &str) -> String {
let cleaned: String = name
.chars()
.map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' { c } else { '-' })
.collect();
cleaned.trim_matches('-').to_string()
}
pub fn temp_path(file_name: &str) -> PathBuf {
let mut p = std::env::temp_dir();
p.push(format!("lazygocd-{}", sanitize(file_name)));
p
}
pub fn edit(req: &EditRequest) -> Result<()> {
let path = temp_path(&req.file_name);
std::fs::write(&path, &req.contents)
.with_context(|| format!("writing {}", path.display()))?;
let (program, args) = resolve_editor(req.configured.as_deref());
let status = Command::new(&program)
.args(&args)
.arg(&path)
.status()
.with_context(|| {
format!("launching editor {program:?} (set `editor` in config.toml or $EDITOR)")
})?;
if !status.success() {
anyhow::bail!("{program} exited with {status}");
}
Ok(())
}
pub fn cleanup_stale(max_age: Duration) {
let Ok(entries) = std::fs::read_dir(std::env::temp_dir()) else {
return;
};
let now = SystemTime::now();
for entry in entries.flatten() {
let name = entry.file_name();
let Some(name) = name.to_str() else { continue };
if !name.starts_with("lazygocd-") {
continue;
}
let stale = entry
.metadata()
.and_then(|m| m.modified())
.map(|t| now.duration_since(t).unwrap_or_default() > max_age)
.unwrap_or(false);
if stale {
let _ = std::fs::remove_file(entry.path());
}
}
}
#[cfg(test)]
mod tests {
#[test]
fn configured_editor_beats_the_environment() {
let (prog, args) = super::resolve_editor(Some("code --wait"));
assert_eq!(prog, "code");
assert_eq!(args, vec!["--wait"]);
}
#[test]
fn blank_config_falls_through_to_the_environment_or_default() {
let (prog, _) = super::resolve_editor(Some(" "));
assert!(!prog.is_empty());
assert_ne!(prog, " ");
}
#[test]
fn visual_beats_editor_and_flags_are_split() {
let raw = "code --wait --new-window";
let mut parts = raw.split_whitespace().map(str::to_string);
let prog = parts.next().unwrap();
let args: Vec<String> = parts.collect();
assert_eq!(prog, "code");
assert_eq!(args, vec!["--wait", "--new-window"]);
}
#[test]
fn sanitize_strips_path_separators_and_odd_characters() {
assert_eq!(super::sanitize("web-app/ui build#1"), "web-app-ui-build-1");
assert_eq!(super::sanitize("plain-name.log"), "plain-name.log");
for hostile in ["../../etc/passwd", "a/b/c", "..\\..\\win", "/abs/path"] {
let out = super::sanitize(hostile);
assert!(!out.contains('/'), "{out:?} still has a separator");
assert!(!out.contains('\\'), "{out:?} still has a separator");
}
}
#[test]
fn temp_path_stays_inside_the_temp_dir() {
let p = super::temp_path("../../escape.log");
assert_eq!(p.parent().unwrap(), std::env::temp_dir());
assert!(p.file_name().unwrap().to_string_lossy().starts_with("lazygocd-"));
}
}