use crate::error::{Error, Result};
use std::path::Path;
use std::process::Command;
pub const WHY_TEMPLATE: &str = "\n\
# Write the WHY for this decision above — the reason you chose this design.\n\
# Lines starting with '#' are ignored. An empty message aborts the entry.\n";
pub fn resolve_editor() -> Result<String> {
for var in ["EDITOR", "VISUAL"] {
if let Ok(v) = std::env::var(var) {
if !v.trim().is_empty() {
return Ok(v);
}
}
}
Err(Error::NoEditor)
}
pub fn strip_comments(raw: &str) -> String {
raw.lines()
.filter(|l| !l.trim_start().starts_with('#'))
.collect::<Vec<_>>()
.join("\n")
.trim()
.to_string()
}
fn spawn_editor(editor_cmd: &str, file: &Path) -> Result<()> {
let mut parts = editor_cmd.split_whitespace();
let program = parts
.next()
.ok_or_else(|| Error::Editor("empty editor command".into()))?;
let status = Command::new(program)
.args(parts)
.arg(file)
.status()
.map_err(|e| Error::Editor(format!("failed to spawn '{editor_cmd}': {e}")))?;
if !status.success() {
return Err(Error::Editor(format!("editor exited with status {status}")));
}
Ok(())
}
pub fn capture_via_editor() -> Result<String> {
let editor = resolve_editor()?;
capture_with(&editor, WHY_TEMPLATE)
}
pub fn capture_with(editor: &str, template: &str) -> Result<String> {
let mut path = std::env::temp_dir();
let stamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
path.push(format!("logbook-why-{}-{}.md", std::process::id(), stamp));
std::fs::write(&path, template).map_err(|e| Error::io("write editor temp file", &path, e))?;
let spawn_result = spawn_editor(editor, &path);
let read_result = std::fs::read_to_string(&path);
let _ = std::fs::remove_file(&path);
spawn_result?;
let raw = read_result.map_err(|e| Error::io("read editor temp file", &path, e))?;
let cleaned = strip_comments(&raw);
if cleaned.is_empty() {
return Err(Error::EmptyEntry);
}
Ok(cleaned)
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
static ENV_LOCK: Mutex<()> = Mutex::new(());
#[test]
fn strip_comments_removes_hash_lines() {
assert_eq!(strip_comments("real\n# nope\nmore"), "real\nmore");
}
#[test]
fn strip_comments_trims_and_handles_indented_hash() {
assert_eq!(strip_comments("\n # indented comment\nbody\n\n"), "body");
}
#[test]
fn strip_comments_all_comments_is_empty() {
assert_eq!(strip_comments("# a\n#b\n # c"), "");
}
#[test]
fn strip_comments_preserves_hash_inside_line() {
assert_eq!(strip_comments("uses C# and F#"), "uses C# and F#");
}
#[test]
fn resolve_editor_prefers_editor_over_visual() {
let _g = ENV_LOCK.lock().unwrap();
let prev_e = std::env::var("EDITOR").ok();
let prev_v = std::env::var("VISUAL").ok();
std::env::set_var("EDITOR", "ed-cmd");
std::env::set_var("VISUAL", "vis-cmd");
assert_eq!(resolve_editor().unwrap(), "ed-cmd");
restore("EDITOR", prev_e);
restore("VISUAL", prev_v);
}
#[test]
fn resolve_editor_falls_back_to_visual() {
let _g = ENV_LOCK.lock().unwrap();
let prev_e = std::env::var("EDITOR").ok();
let prev_v = std::env::var("VISUAL").ok();
std::env::remove_var("EDITOR");
std::env::set_var("VISUAL", "vis-only");
assert_eq!(resolve_editor().unwrap(), "vis-only");
restore("EDITOR", prev_e);
restore("VISUAL", prev_v);
}
#[test]
fn resolve_editor_errors_when_neither_set() {
let _g = ENV_LOCK.lock().unwrap();
let prev_e = std::env::var("EDITOR").ok();
let prev_v = std::env::var("VISUAL").ok();
std::env::remove_var("EDITOR");
std::env::remove_var("VISUAL");
assert!(matches!(resolve_editor(), Err(Error::NoEditor)));
restore("EDITOR", prev_e);
restore("VISUAL", prev_v);
}
fn fake_editor_appending(body: &str) -> std::path::PathBuf {
let mut p = std::env::temp_dir();
let stamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
p.push(format!(
"logbook-fake-editor-{}-{stamp}.sh",
std::process::id()
));
std::fs::write(&p, format!("#!/bin/sh\nprintf '{body}' >> \"$1\"\n")).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap();
}
p
}
#[test]
fn capture_with_reads_back_written_content() {
let ed = fake_editor_appending("the reason\\n");
let why = capture_with(ed.to_str().unwrap(), WHY_TEMPLATE).unwrap();
assert_eq!(why, "the reason");
let _ = std::fs::remove_file(&ed);
}
#[test]
fn capture_with_empty_result_is_empty_entry_error() {
let editor = "true"; assert!(matches!(
capture_with(editor, WHY_TEMPLATE),
Err(Error::EmptyEntry)
));
}
#[test]
fn capture_with_failing_editor_is_editor_error() {
let editor = "false"; assert!(matches!(capture_with(editor, ""), Err(Error::Editor(_))));
}
fn restore(key: &str, prev: Option<String>) {
match prev {
Some(v) => std::env::set_var(key, v),
None => std::env::remove_var(key),
}
}
}