doot/crates/doot-cli/src/commands/edit.rs
2026-02-06 03:13:06 -06:00

254 lines
7.2 KiB
Rust

use super::{find_config_file, parse_config, type_check};
use doot_core::{
Config,
deploy::{Linker, TemplateEngine},
state::{DeployMode, StateStore},
};
use doot_lang::Evaluator;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::process::Command;
#[tracing::instrument(skip_all, fields(target = %target, auto_apply, skip_prompt))]
pub fn run(
config_path: Option<PathBuf>,
target: String,
auto_apply: bool,
skip_prompt: bool,
) -> anyhow::Result<()> {
let path = find_config_file(config_path)?;
let source = std::fs::read_to_string(&path)?;
let program = parse_config(&path)?;
type_check(&program, &source, &path.display().to_string())?;
let mut evaluator = Evaluator::new();
let result = evaluator.eval_sync(&program)?;
let source_dir = path.parent().unwrap_or(&PathBuf::from(".")).to_path_buf();
let config = Config::default();
let state = StateStore::new(&config.state_file);
let target_path = expand_tilde(&target);
let (source_file, dotfile) =
find_source_and_dotfile(&target_path, &result.dotfiles, &source_dir, &state)?;
tracing::debug!(source = %source_file.display(), "editing source file");
// Get hash before editing
let hash_before = hash_file(&source_file);
// Open in editor
let editor = std::env::var("EDITOR").unwrap_or_else(|_| "vim".to_string());
let status = Command::new(&editor).arg(&source_file).status()?;
if !status.success() {
anyhow::bail!("editor exited with non-zero status");
}
// Check if file changed
let hash_after = hash_file(&source_file);
if hash_before == hash_after {
println!("no changes made");
return Ok(());
}
// Determine if we should apply
let should_apply = if auto_apply {
true
} else if skip_prompt {
false
} else {
prompt_apply()?
};
if should_apply {
if let Some(df) = dotfile {
apply_single(&source_file, &df.target, df, &config)?;
println!("applied changes to {}", df.target.display());
} else {
println!("hint: run 'doot apply' to deploy changes");
}
} else {
println!("hint: run 'doot apply' to deploy changes");
}
Ok(())
}
fn prompt_apply() -> anyhow::Result<bool> {
print!("Apply changes? [y/N] ");
io::stdout().flush()?;
let mut input = String::new();
io::stdin().read_line(&mut input)?;
Ok(input.trim().eq_ignore_ascii_case("y") || input.trim().eq_ignore_ascii_case("yes"))
}
fn hash_file(path: &PathBuf) -> String {
std::fs::read(path)
.map(|content| blake3::hash(&content).to_hex().to_string())
.unwrap_or_default()
}
#[tracing::instrument(skip_all, fields(source = %source.display(), target = %target.display()))]
fn apply_single(
source: &PathBuf,
target: &PathBuf,
dotfile: &doot_lang::evaluator::DotfileConfig,
config: &Config,
) -> anyhow::Result<()> {
let deploy_mode = match dotfile.deploy {
doot_lang::evaluator::DeployMode::Copy => DeployMode::Copy,
doot_lang::evaluator::DeployMode::Link => DeployMode::Link,
};
let mut state = StateStore::new(&config.state_file);
// Handle templates specially
if dotfile.template {
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent)?;
}
let content = std::fs::read_to_string(source)?;
let engine = TemplateEngine::new();
let rendered = engine
.render(&content)
.map_err(|e| anyhow::anyhow!("template error: {}", e))?;
std::fs::write(target, rendered)?;
tracing::debug!(
source = %source.display(),
target = %target.display(),
"rendered template"
);
state.record_deployment_with_template(source, target, DeployMode::Copy, true);
state.save()?;
return Ok(());
}
match deploy_mode {
DeployMode::Link => {
let linker = Linker::new(config.clone());
linker.link(source, target)?;
tracing::debug!(
source = %source.display(),
target = %target.display(),
"linked"
);
}
DeployMode::Copy => {
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent)?;
}
if source.is_dir() {
copy_dir_recursive(source, target)?;
} else {
std::fs::copy(source, target)?;
}
tracing::debug!(
source = %source.display(),
target = %target.display(),
"copied"
);
}
}
state.record_deployment(source, target, deploy_mode);
state.save()?;
Ok(())
}
fn copy_dir_recursive(src: &PathBuf, dst: &PathBuf) -> std::io::Result<()> {
std::fs::create_dir_all(dst)?;
for entry in std::fs::read_dir(src)? {
let entry = entry?;
let ty = entry.file_type()?;
let src_path = entry.path();
let dst_path = dst.join(entry.file_name());
if ty.is_dir() {
copy_dir_recursive(&src_path, &dst_path)?;
} else {
std::fs::copy(&src_path, &dst_path)?;
}
}
Ok(())
}
#[tracing::instrument(level = "trace")]
fn expand_tilde(path: &str) -> PathBuf {
if path.starts_with("~/")
&& let Some(home) = dirs::home_dir()
{
return home.join(&path[2..]);
}
PathBuf::from(path)
}
#[tracing::instrument(skip_all)]
fn find_source_and_dotfile<'a>(
target: &PathBuf,
dotfiles: &'a [doot_lang::evaluator::DotfileConfig],
source_dir: &Path,
state: &StateStore,
) -> anyhow::Result<(PathBuf, Option<&'a doot_lang::evaluator::DotfileConfig>)> {
// Exact match with dotfile targets
for df in dotfiles {
if &df.target == target {
return Ok((source_dir.join(&df.source), Some(df)));
}
}
// Match by name
let target_str = target.to_string_lossy();
for df in dotfiles {
let target_name = df
.target
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
if target_name == target_str.as_ref() {
return Ok((source_dir.join(&df.source), Some(df)));
}
let source_name = df
.source
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
if source_name == target_str.as_ref() {
return Ok((source_dir.join(&df.source), Some(df)));
}
}
// State lookup
if let Some(record) = state.get_deployment(target) {
return Ok((record.source.clone(), None));
}
// Partial path matching
for df in dotfiles {
if target.starts_with(&df.target) {
let relative = target.strip_prefix(&df.target).unwrap_or(target);
return Ok((source_dir.join(&df.source).join(relative), Some(df)));
}
}
anyhow::bail!(
"could not find source for '{}'\n\nAvailable dotfiles:\n{}",
target.display(),
dotfiles
.iter()
.map(|df| format!(" {} -> {}", df.source.display(), df.target.display()))
.collect::<Vec<_>>()
.join("\n")
)
}