//! Shared utility functions used by tool implementations: JSON argument //! extraction, command execution, path resolution, and edit-log persistence. use crate::utils::CastOr; use anyhow::Result; use serde_json::Value; use sha2::Digest; use std::path::PathBuf; use tracing::{debug, info, instrument, warn}; /// Extract a required string argument from a JSON args map. pub fn arg_str(args: &Value, name: &str) -> Result { args.get(name) .and_then(|v| v.as_str()) .map(std::string::ToString::to_string) .ok_or_else(|| anyhow::anyhow!("missing required argument: {name}")) } /// Execute a `std::process::Command` and return its combined stdout/stderr. /// /// Flow: spawn \u{2192} collect stdout + stderr \u{2192} check exit code \u{2192} return /// combined output or bail with the error message. #[instrument(skip(cmd))] pub fn execute_cmd(cmd: &mut std::process::Command) -> Result { let output = cmd .output() .map_err(|e| anyhow::anyhow!("command execution failed: {e}"))?; let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); let stdout_len = stdout.len(); let stderr_len = stderr.len(); let combined = if stderr.is_empty() { stdout } else { format!("{}\n{}", stdout, stderr) .trim() .to_string() }; let code = output.status.code().unwrap_or(-1); if output.status.success() { info!(exit_code = code, stdout_len, "command succeeded"); Ok(combined) } else { warn!(exit_code = code, stderr_len, "command failed"); anyhow::bail!("command failed with exit code {code}:\n{combined}") } } /// Resolve a tool-supplied relative path to an absolute path within a workspace /// root, rejecting escapes. /// /// Flow: parse optional `[idx]` prefix \u{2192} join with workspace root \u{2192} /// canonicalize \u{2192} verify result is inside one of the workspace roots. #[instrument(skip(workspaces))] pub fn resolve_path(workspaces: &[PathBuf], rel: &str) -> Result { let (ws_idx, path) = if rel.starts_with('[') { let close = rel .find(']') .ok_or_else(|| anyhow::anyhow!("invalid workspace prefix"))?; let idx: usize = rel[1..close] .parse() .map_err(|_| anyhow::anyhow!("invalid workspace index"))?; (idx, &rel[close + 1..]) } else { (0, rel) }; let base = workspaces .get(ws_idx) .ok_or_else(|| anyhow::anyhow!("workspace index {ws_idx} out of range"))?; let abs = if path.is_empty() { base.clone() } else { base.join(path) }; let canon = if let Ok(c) = abs.canonicalize() { c } else { let base_canon = workspaces .iter() .find_map(|w| w.canonicalize().ok()) .unwrap_or_else(|| base.clone()); let mut resolved = base_canon.clone(); if let Ok(rel_components) = abs.strip_prefix(&base_canon) { for comp in rel_components.components() { match comp { std::path::Component::ParentDir => { resolved.pop(); } std::path::Component::CurDir => {} c => resolved.push(c), } } } resolved }; debug!(resolved = %canon.display(), "path resolved within workspace"); if workspaces.iter().any(|w| canon.starts_with(w)) { Ok(canon) } else { warn!(path = %canon.display(), rel = rel, "path is outside all workspace roots"); anyhow::bail!("path '{rel}' is outside all workspace roots") } } /// After a successful write/edit tool run, compute content hash and byte /// delta, then persist an `EditLogEntry` to the session's edit log. /// /// Flow: extract path/content/reason from args \u{2192} compute SHA-256 of content /// \u{2192} compute byte delta \u{2192} build `EditLogEntry` \u{2192} open repo \u{2192} append entry. #[instrument(skip(args, session_dir))] pub fn log_write_edit_tool( args: &serde_json::Value, tool_name: &str, origin_tag: &str, session_dir: &std::path::Path, session_id: &str, ) { let reason = args .get("reason") .and_then(|v| v.as_str()) .unwrap_or("unnamed"); let path = args .get("path") .and_then(|v| v.as_str()) .unwrap_or("unknown"); let content = args.get("content").or_else(|| args.get("new")); let content_str = content.and_then(|v| v.as_str()).unwrap_or(""); let content_sha256 = hex::encode(sha2::Sha256::digest(content_str.as_bytes())); let bytes_delta = if tool_name == "write" { content_str.len().cast_or(0i64) } else { let old = args.get("old").and_then(|v| v.as_str()).unwrap_or(""); let new = args.get("new").and_then(|v| v.as_str()).unwrap_or(""); let new_len: i64 = new.len().cast_or(0i64); let old_len: i64 = old.len().cast_or(0i64); (new_len - old_len).abs() }; let entry = zesdex_domain::cms::EditLogEntry { ts: chrono::Utc::now().timestamp_millis(), tool: tool_name.to_string(), path: path.to_string(), reason: reason.to_string(), content_sha256, bytes_delta, origin: origin_tag.to_string(), session_id: session_id.to_string(), }; use zesdex_domain::cms::repository::EditLogRepository; let repo = crate::persistence::cms::edit_log_repo::JsonlEditLogRepository::new(); if let Ok(mut el) = repo.open(session_dir) { let _ = repo.append(session_dir, &mut el, entry); debug!(tool = tool_name, path = path, "edit-log entry persisted"); } else { warn!(tool = tool_name, path = path, "failed to open edit-log repository"); } }