254 lines
7.2 KiB
Rust
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")
|
|
)
|
|
}
|