//! 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) -> anyhow::Result { 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 { 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::>() .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::>() .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, 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::() .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(()) }