doot/crates/doot-cli/src/commands/mod.rs

222 lines
7 KiB
Rust

//! CLI command handlers.
pub mod apply;
pub mod check;
pub mod decrypt;
pub mod decrypt_entries;
pub mod decrypt_var;
pub mod diff;
pub mod edit;
pub mod encrypt;
pub mod encrypt_var;
pub mod fmt;
pub mod init;
pub mod keygen;
pub mod lsp;
pub mod package;
pub mod reencrypt;
pub mod rollback;
pub mod snapshot;
pub mod status;
pub mod tui;
use doot_core::Config;
use doot_lang::evaluator::{EvalResult, Value};
use doot_lang::{Lexer, Parser, TypeChecker};
use indexmap::IndexMap;
use std::collections::HashMap;
use std::path::PathBuf;
/// Resolves the config file path, checking the given path or default locations.
/// Always returns an absolute path so source_dir is correct regardless of CWD.
#[tracing::instrument(skip_all)]
pub fn find_config_file(base: Option<PathBuf>) -> anyhow::Result<PathBuf> {
if let Some(path) = base {
if path.exists() {
return Ok(std::fs::canonicalize(&path)?);
}
anyhow::bail!("config file not found: {}", path.display());
}
let candidates = vec![PathBuf::from("doot.doot"), Config::default_config_file()];
for candidate in candidates {
if candidate.exists() {
return Ok(std::fs::canonicalize(&candidate)?);
}
}
anyhow::bail!(
"no config file found. searched:\n - ./doot.doot\n - {}",
Config::default_config_file().display()
)
}
fn byte_offset_to_line(source: &str, offset: usize) -> usize {
source[..offset.min(source.len())]
.chars()
.filter(|&c| c == '\n')
.count()
+ 1
}
/// Parses a `.doot` config file into a program AST.
#[tracing::instrument(skip_all, fields(path = %path.display()))]
pub fn parse_config(path: &PathBuf) -> anyhow::Result<doot_lang::Program> {
let source = std::fs::read_to_string(path)?;
let tokens = Lexer::lex(&source).map_err(|errs| {
let msg = errs
.iter()
.map(|e| {
let line = byte_offset_to_line(&source, e.span().start);
format!("{}:{}: {}", path.display(), line, e)
})
.collect::<Vec<_>>()
.join("\n");
anyhow::anyhow!("lexer errors:\n{}", msg)
})?;
let program = Parser::parse(tokens).map_err(|errs| {
let msg = errs
.iter()
.map(|e| {
let line = byte_offset_to_line(&source, e.span().start);
format!("{}:{}: {}", path.display(), line, e)
})
.collect::<Vec<_>>()
.join("\n");
anyhow::anyhow!("parser errors:\n{}", msg)
})?;
Ok(program)
}
/// Runs the type checker on a parsed program.
#[tracing::instrument(skip_all)]
pub fn type_check(
program: &doot_lang::Program,
source: &str,
filename: &str,
) -> anyhow::Result<()> {
let mut checker = TypeChecker::new();
if let Err(errors) = checker.check(program) {
for error in &errors {
error.report(source, filename);
}
anyhow::bail!("{} type error(s) found", errors.len());
}
Ok(())
}
/// Decrypts encrypted vars and files, resolving file paths relative to `source_dir`.
pub fn decrypt_encrypted_vars_with_source_dir(
result: &EvalResult,
config: &Config,
template_vars: &mut HashMap<String, Value>,
source_dir: Option<&std::path::Path>,
) -> anyhow::Result<()> {
if result.encrypted_vars.is_empty() && result.encrypted_files.is_empty() {
return Ok(());
}
// Read identity key (filter out comment lines from age-keygen output)
let identity_raw = if let Ok(key) = std::env::var("DOOT_AGE_IDENTITY") {
key
} else if config.identity_file.exists() {
std::fs::read_to_string(&config.identity_file)?
} else {
anyhow::bail!(
"encrypted entries found but no identity available. set DOOT_AGE_IDENTITY env var or create {}",
config.identity_file.display()
);
};
let identity_key = identity_raw
.lines()
.find(|line| line.starts_with("AGE-SECRET-KEY-"))
.map(|s| s.trim().to_string())
.unwrap_or_else(|| identity_raw.trim().to_string());
let identity = identity_key
.parse::<age::x25519::Identity>()
.map_err(|e| anyhow::anyhow!("invalid age identity: {}", e))?;
let mut decrypted_map = IndexMap::new();
// Decrypt inline vars
for (name, ciphertext_b64) in &result.encrypted_vars {
let encrypted_bytes = doot_lang::builtins::crypto::base64_decode(ciphertext_b64)
.map_err(|e| anyhow::anyhow!("invalid base64 for encrypted var '{}': {}", name, e))?;
let decryptor = match age::Decryptor::new(&encrypted_bytes[..])
.map_err(|e| anyhow::anyhow!("decryption error for '{}': {}", name, e))?
{
age::Decryptor::Recipients(d) => d,
_ => anyhow::bail!("unexpected decryptor type for '{}'", name),
};
let mut decrypted = vec![];
let mut reader = decryptor
.decrypt(std::iter::once(&identity as &dyn age::Identity))
.map_err(|e| anyhow::anyhow!("decryption failed for '{}': {}", name, e))?;
use std::io::Read;
reader
.read_to_end(&mut decrypted)
.map_err(|e| anyhow::anyhow!("read error decrypting '{}': {}", name, e))?;
let plaintext = String::from_utf8(decrypted)
.map_err(|e| anyhow::anyhow!("invalid UTF-8 in decrypted '{}': {}", name, e))?;
decrypted_map.insert(name.clone(), Value::Str(plaintext));
}
// Decrypt file entries
for (name, rel_path) in &result.encrypted_files {
let full_path = if rel_path.is_relative() {
source_dir
.map(|d| d.join(rel_path))
.unwrap_or_else(|| rel_path.clone())
} else {
rel_path.clone()
};
let encrypted_bytes = std::fs::read(&full_path).map_err(|e| {
anyhow::anyhow!(
"cannot read encrypted file '{}' ({}): {}",
name,
full_path.display(),
e
)
})?;
let decryptor = match age::Decryptor::new(&encrypted_bytes[..])
.map_err(|e| anyhow::anyhow!("decryption error for file '{}': {}", name, e))?
{
age::Decryptor::Recipients(d) => d,
_ => anyhow::bail!("unexpected decryptor type for file '{}'", name),
};
let mut decrypted = vec![];
let mut reader = decryptor
.decrypt(std::iter::once(&identity as &dyn age::Identity))
.map_err(|e| anyhow::anyhow!("decryption failed for file '{}': {}", name, e))?;
use std::io::Read;
reader
.read_to_end(&mut decrypted)
.map_err(|e| anyhow::anyhow!("read error decrypting file '{}': {}", name, e))?;
let plaintext = String::from_utf8(decrypted)
.map_err(|e| anyhow::anyhow!("invalid UTF-8 in decrypted file '{}': {}", name, e))?;
decrypted_map.insert(name.clone(), Value::Str(plaintext));
}
template_vars.insert(
"encrypted".to_string(),
Value::Struct("encrypted".to_string(), decrypted_map),
);
Ok(())
}