use std::{
fs,
path::{Path, PathBuf},
process::Command,
};
use crate::{
config::Config,
error::{Error, Result},
store, ui,
};
pub fn run(query: &str) -> Result<()> {
let repo_root = store::require_repo_root()?;
let config_path = store::config_path(&repo_root);
let config = Config::load(&config_path)?;
let entry = config.find_entry(query).ok_or_else(|| {
Error::User(format!(
"`{query}` is not tracked — use `dotling status` to list tracked entries"
))
})?;
let entry = entry.clone();
let editor = find_editor()?;
if entry.encrypted {
run_encrypted_edit(&entry, &repo_root, &editor)
} else {
run_plain_edit(&entry, &repo_root, &editor)
}
}
fn run_encrypted_edit(entry: &crate::config::Entry, repo_root: &Path, editor: &str) -> Result<()> {
let enc_path = repo_root.join(&entry.source);
if !enc_path.exists() {
return Err(Error::Deploy {
entry: entry.source.clone(),
message: format!(
"encrypted source `{}` not found in repo",
enc_path.display()
),
});
}
let password = ui::password("Vault password");
let master_key = crate::crypto::vault::unlock_vault(&password)?;
if entry.directory {
edit_encrypted_directory(entry, &enc_path, &master_key, editor, repo_root)
} else {
edit_encrypted_file(entry, &enc_path, &master_key, editor, repo_root)
}
}
fn edit_encrypted_file(
entry: &crate::config::Entry,
enc_path: &Path,
master_key: &[u8; 32],
editor: &str,
repo_root: &Path,
) -> Result<()> {
let ciphertext =
fs::read(enc_path).map_err(|e| Error::io(enc_path, "read encrypted file", e))?;
let plaintext = crate::crypto::decrypt_with_key(&ciphertext, master_key)?;
let basename = Path::new(&entry.source).file_name().map_or_else(
|| "dotling-edit".to_string(),
|n| n.to_string_lossy().into_owned(),
);
let tmp_dir = make_secure_temp_dir(repo_root)?;
let tmp_path = tmp_dir.join(&basename);
write_secure(&tmp_path, &plaintext)?;
let hash_before = blake2_hash(&plaintext);
let changed = launch_editor_and_check(editor, &tmp_path, &hash_before)?;
if !changed {
ui::info("no changes — skipping re-encryption");
secure_remove(&tmp_path);
remove_temp_dir(&tmp_dir);
return Ok(());
}
let edited =
fs::read(&tmp_path).map_err(|e| Error::io(&tmp_path, "read edited temp file", e))?;
let new_ciphertext = crate::crypto::encrypt_with_key(&edited, master_key)?;
crate::fs::atomic_write(enc_path, &new_ciphertext)?;
secure_remove(&tmp_path);
remove_temp_dir(&tmp_dir);
update_fingerprint_encrypted(entry, enc_path, repo_root);
ui::success(&format!("saved and re-encrypted `{}`", entry.source));
ui::hint("run `dotling sync` to push the changes to your deployed file");
Ok(())
}
fn edit_encrypted_directory(
entry: &crate::config::Entry,
dir_path: &Path,
master_key: &[u8; 32],
editor: &str,
repo_root: &Path,
) -> Result<()> {
let enc_files = collect_enc_files(dir_path)?;
if enc_files.is_empty() {
ui::info("no encrypted files found in directory");
return Ok(());
}
ui::header(&format!(
"Editing {} encrypted file{} in `{}`",
enc_files.len(),
if enc_files.len() == 1 { "" } else { "s" },
entry.source
));
let tmp_dir = make_secure_temp_dir(repo_root)?;
let mut edited_count = 0usize;
for enc_file in &enc_files {
let rel = enc_file
.strip_prefix(dir_path)
.unwrap_or(enc_file)
.display()
.to_string();
ui::info(&format!("editing `{rel}`"));
let ciphertext =
fs::read(enc_file).map_err(|e| Error::io(enc_file, "read encrypted file", e))?;
let plaintext = crate::crypto::decrypt_with_key(&ciphertext, master_key)?;
let tmp_name = enc_file.file_name().map_or_else(
|| "dotling-edit".to_string(),
|s| s.to_string_lossy().into_owned(),
);
let tmp_path = tmp_dir.join(&tmp_name);
write_secure(&tmp_path, &plaintext)?;
let hash_before = blake2_hash(&plaintext);
let changed = launch_editor_and_check(editor, &tmp_path, &hash_before)?;
if changed {
let edited =
fs::read(&tmp_path).map_err(|e| Error::io(&tmp_path, "read edited file", e))?;
let new_ciphertext = crate::crypto::encrypt_with_key(&edited, master_key)?;
crate::fs::atomic_write(enc_file, &new_ciphertext)?;
edited_count += 1;
ui::success(&format!("saved `{rel}`"));
} else {
ui::dim(&format!(" no changes — skipped `{rel}`"));
}
secure_remove(&tmp_path);
}
remove_temp_dir(&tmp_dir);
if edited_count > 0 {
update_fingerprint_encrypted(entry, dir_path, repo_root);
ui::hint("run `dotling sync` to push the changes to your deployed files");
}
ui::summary(edited_count, 0, 0);
Ok(())
}
fn run_plain_edit(entry: &crate::config::Entry, repo_root: &Path, editor: &str) -> Result<()> {
let source_path = repo_root.join(&entry.source);
if !source_path.exists() {
return Err(Error::Deploy {
entry: entry.source.clone(),
message: format!("source `{}` not found in repo", source_path.display()),
});
}
if entry.template {
ui::info(&format!(
"editing template source `{}` (use `dotling sync` to redeploy after saving)",
entry.source
));
} else if entry.directory {
ui::info(&format!("opening directory `{}` in editor", entry.source));
}
launch_editor(editor, &source_path)?;
if !entry.template {
ui::hint("run `dotling sync` to push the changes to your deployed file");
}
Ok(())
}
fn find_editor() -> Result<String> {
for var in &["DOTLING_EDITOR", "VISUAL", "EDITOR"] {
if let Ok(val) = std::env::var(var) {
let val = val.trim().to_string();
if !val.is_empty() {
return Ok(normalize_gui_editor(val));
}
}
}
for candidate in &["vim", "nano", "vi"] {
if which(candidate) {
return Ok((*candidate).to_string());
}
}
Err(Error::User(
"no editor found — set $EDITOR, $VISUAL, or $DOTLING_EDITOR".into(),
))
}
fn normalize_gui_editor(editor: String) -> String {
const NEEDS_WAIT: &[&str] = &["code", "subl", "zed", "pulsar", "atom"];
let bin = editor
.split_whitespace()
.next()
.unwrap_or(&editor)
.to_string();
let bin_name = std::path::Path::new(&bin)
.file_name()
.map_or_else(|| bin.clone(), |n| n.to_string_lossy().into_owned());
let is_gui = NEEDS_WAIT
.iter()
.any(|&name| bin_name.eq_ignore_ascii_case(name));
if is_gui && !editor.contains("--wait") {
format!("{editor} --wait")
} else {
editor
}
}
fn which(cmd: &str) -> bool {
#[cfg(unix)]
{
Command::new("sh")
.args(["-c", &format!("command -v {cmd}")])
.output()
.is_ok_and(|o| o.status.success())
}
#[cfg(not(unix))]
{
Command::new("where")
.arg(cmd)
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
}
fn launch_editor(editor: &str, path: &Path) -> Result<()> {
let parts: Vec<&str> = editor.split_whitespace().collect();
let (bin, args) = parts.split_first().unwrap_or((&"vi", &[]));
let status = Command::new(bin)
.args(args)
.arg(path)
.status()
.map_err(|e| {
Error::User(format!(
"could not launch editor `{editor}`: {e}\n \
Tip: set $EDITOR or $DOTLING_EDITOR to your preferred editor"
))
})?;
if !status.success() {
ui::warning(&format!(
"editor exited with status {status} — check your changes"
));
}
Ok(())
}
fn launch_editor_and_check(editor: &str, path: &Path, hash_before: &[u8]) -> Result<bool> {
launch_editor(editor, path)?;
let after = fs::read(path).map_err(|e| Error::io(path, "read file after edit", e))?;
let hash_after = blake2_hash(&after);
Ok(hash_after != hash_before)
}
fn make_secure_temp_dir(repo_root: &Path) -> Result<PathBuf> {
let base = store::state_dir().unwrap_or_else(|_| {
repo_root.join(".dotling-tmp")
});
let unique = format!("edit-{}-{}", std::process::id(), timestamp_nanos());
let dir = base.join("tmp").join(unique);
fs::create_dir_all(&dir).map_err(|e| Error::io(&dir, "create temp directory", e))?;
#[cfg(unix)]
crate::fs::set_permissions(&dir, 0o700)?;
Ok(dir)
}
fn write_secure(path: &Path, data: &[u8]) -> Result<()> {
crate::fs::atomic_write(path, data)?;
#[cfg(unix)]
crate::fs::set_permissions(path, 0o600)?;
Ok(())
}
fn secure_remove(path: &Path) {
if !path.exists() {
return;
}
if let Ok(Ok(len)) = path.metadata().map(|m| usize::try_from(m.len())) {
let zeros = vec![0u8; len];
let _ = crate::fs::atomic_write(path, &zeros);
}
let _ = fs::remove_file(path);
}
fn remove_temp_dir(dir: &Path) {
let _ = fs::remove_dir_all(dir);
}
fn collect_enc_files(dir: &Path) -> Result<Vec<PathBuf>> {
let mut files = Vec::new();
collect_enc_files_inner(dir, &mut files)?;
files.sort(); Ok(files)
}
fn collect_enc_files_inner(dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
for entry in fs::read_dir(dir).map_err(|e| Error::io(dir, "read directory", e))? {
let entry = entry.map_err(|e| Error::io(dir, "read directory entry", e))?;
let path = entry.path();
if path.is_dir() {
collect_enc_files_inner(&path, out)?;
} else {
out.push(path);
}
}
Ok(())
}
fn update_fingerprint_encrypted(entry: &crate::config::Entry, enc_path: &Path, _repo_root: &Path) {
let Ok(fp_path) = store::fingerprint_path() else {
return;
};
let mut fp_store = crate::fingerprint::FingerprintStore::load(fp_path);
if let Ok(target_path) = crate::path::expand_tilde(std::path::Path::new(&entry.target)) {
if fp_store
.record(&entry.source, enc_path, &target_path)
.is_ok()
{
let _ = fp_store.save();
}
}
}
fn blake2_hash(data: &[u8]) -> Vec<u8> {
use blake2::{Blake2b512, Digest};
let mut h = Blake2b512::new();
h.update(data);
h.finalize().to_vec()
}
fn timestamp_nanos() -> u128 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_nanos())
}
#[cfg(test)]
mod tests {
use super::*;
fn find_editor_from_map(env: &[(&str, &str)]) -> Option<String> {
for var in &["DOTLING_EDITOR", "VISUAL", "EDITOR"] {
if let Some(val) = env.iter().find(|(k, _)| k == var).map(|(_, v)| *v) {
let val = val.trim().to_string();
if !val.is_empty() {
return Some(val);
}
}
}
None
}
#[test]
fn find_editor_from_env() {
let env = [
("DOTLING_EDITOR", "emacs"),
("VISUAL", "code --wait"),
("EDITOR", "nano"),
];
assert_eq!(find_editor_from_map(&env).as_deref(), Some("emacs"));
}
#[test]
fn find_editor_falls_back_to_visual() {
let env = [("VISUAL", "code --wait"), ("EDITOR", "nano")];
assert_eq!(find_editor_from_map(&env).as_deref(), Some("code --wait"));
}
#[test]
fn find_editor_falls_back_to_editor() {
let env = [("EDITOR", "nano")];
assert_eq!(find_editor_from_map(&env).as_deref(), Some("nano"));
}
#[test]
fn find_editor_returns_none_when_all_empty() {
assert_eq!(find_editor_from_map(&[]).as_deref(), None);
}
#[test]
fn normalize_adds_wait_for_vscode() {
assert_eq!(normalize_gui_editor("code".into()), "code --wait");
}
#[test]
fn normalize_adds_wait_for_vscode_full_path() {
assert_eq!(
normalize_gui_editor("/usr/local/bin/code".into()),
"/usr/local/bin/code --wait"
);
}
#[test]
fn normalize_does_not_duplicate_wait() {
assert_eq!(normalize_gui_editor("code --wait".into()), "code --wait");
}
#[test]
fn normalize_adds_wait_for_subl() {
assert_eq!(normalize_gui_editor("subl".into()), "subl --wait");
}
#[test]
fn normalize_does_not_touch_terminal_editors() {
assert_eq!(normalize_gui_editor("vim".into()), "vim");
assert_eq!(normalize_gui_editor("nano".into()), "nano");
assert_eq!(normalize_gui_editor("nvim".into()), "nvim");
assert_eq!(normalize_gui_editor("emacs".into()), "emacs");
}
#[test]
fn blake2_hash_deterministic() {
let a = blake2_hash(b"hello");
let b = blake2_hash(b"hello");
assert_eq!(a, b);
}
#[test]
fn blake2_hash_differs_on_different_input() {
let a = blake2_hash(b"hello");
let b = blake2_hash(b"world");
assert_ne!(a, b);
}
#[test]
fn blake2_hash_detects_change() {
let original = b"original content";
let modified = b"modified content";
let h1 = blake2_hash(original);
let h2 = blake2_hash(modified);
assert_ne!(h1, h2, "hashes must differ after edit");
}
}