//! Utility helpers inlined from `zesdex-utils` — safe integer casts, //! atomic JSON file writing, and slugification. //! //! # Inlining rationale //! //! These helpers are ported from `zesdex-utils` to avoid a hard //! dependency on that crate during the clean-architecture migration. //! Once `zesdex-utils` is fully migrated, these can be re-exported //! or removed. use serde::Serialize; use std::io::Write; use std::path::Path; // --------------------------------------------------------------------------- // CastOr — safe integer narrowing // --------------------------------------------------------------------------- /// Extension trait for checked integer narrowing with a fallback default. /// /// Implementations use `U::try_from(self).unwrap_or(default)` so overflow /// never panics. pub trait CastOr { fn cast_or(self, default: U) -> U; } macro_rules! impl_cast_or { ($from:ty => $($to:ty),+ $(,)?) => { $( impl CastOr<$to> for $from { #[inline] fn cast_or(self, default: $to) -> $to { <$to as TryFrom<$from>>::try_from(self).unwrap_or(default) } } )+ }; } impl_cast_or!(usize => u64, i64, u32, i32, u16); impl_cast_or!(u64 => i64, u32, i32, u16, u8); impl_cast_or!(i64 => u64, i32, u16, u8); impl_cast_or!(u32 => i32, u16, u8); impl CastOr for u128 { #[inline] fn cast_or(self, default: u64) -> u64 { u64::try_from(self).unwrap_or(default) } } impl CastOr for u128 { #[inline] fn cast_or(self, default: i64) -> i64 { i64::try_from(self).unwrap_or(default) } } impl CastOr for u128 { #[inline] fn cast_or(self, default: u32) -> u32 { u32::try_from(self).unwrap_or(default) } } // --------------------------------------------------------------------------- // Atomic JSON write // --------------------------------------------------------------------------- /// Atomically write serializable `data` to `path`. /// /// Flow: serialize to pretty JSON -> write to `path.tmp.` -> fsync -> rename /// -> fsync parent. If `mode` is `Some`, set permissions before rename (Unix only). /// /// A UUID-based temporary filename avoids collisions from concurrent writes. /// Orphaned tmp files are cleaned up on any error after creation. pub fn write_json_atomic(path: &Path, data: &T, mode: Option) -> std::io::Result<()> { let tmp = path.with_extension(format!("tmp.{}", uuid::Uuid::new_v4())); let result = (|| -> std::io::Result<()> { let bytes = serde_json::to_vec_pretty(data) .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; { let mut f = std::fs::OpenOptions::new() .create(true) .truncate(true) .write(true) .open(&tmp)?; f.write_all(&bytes)?; f.sync_all()?; } if let Some(m) = mode { #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(m))?; } #[cfg(not(unix))] { let _ = m; } } std::fs::rename(&tmp, path)?; Ok(()) })(); // Clean up orphaned temp file on error after creation if let Err(e) = result { let _ = std::fs::remove_file(&tmp); return Err(e); } if let Some(parent) = path.parent() { let _ = std::fs::File::open(parent).and_then(|d| d.sync_all()); } Ok(()) } // --------------------------------------------------------------------------- // Slugify // --------------------------------------------------------------------------- /// Convert an arbitrary string into a filesystem-safe slug. pub fn slugify(s: &str) -> Option { let slug: String = s .to_lowercase() .chars() .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }) .collect(); let slug: String = slug .split('-') .filter(|s| !s.is_empty()) .collect::>() .join("-"); if slug.is_empty() || slug.len() > 80 { return None; } Some(slug) } // --------------------------------------------------------------------------- // Clipboard (OSC-52) // --------------------------------------------------------------------------- /// Write `text` to the terminal's clipboard using the OSC-52 escape sequence. /// /// OSC-52 (`\x1b]52;c;\x1b\\`) is supported by many terminal emulators /// (iTerm2, Kitty, tmux, etc.) and allows writing to the system clipboard /// without external binaries. pub fn write_osc52(output: &mut impl Write, text: &str) -> std::io::Result<()> { use base64::Engine as _; let encoded = base64::engine::general_purpose::STANDARD.encode(text.as_bytes()); write!(output, "\x1b]52;c;{encoded}\x1b\\")?; output.flush()?; Ok(()) } // --------------------------------------------------------------------------- // Workspace Tree Builder // --------------------------------------------------------------------------- /// Build a string representing the directory tree of `root`, up to `max_files` limits. /// Automatically respects `.gitignore` and `.ignore` via the `ignore` crate. pub fn build_workspace_tree(root: &Path, max_files: usize) -> String { use ignore::WalkBuilder; let mut tree = String::new(); let mut count = 0; // hidden(false) allows files like .github to be seen, but it still respects .gitignore // and ignores .git directories by default. for entry in WalkBuilder::new(root).hidden(false).build().flatten() { if count >= max_files { tree.push_str("\n... (truncated)"); break; } let path = entry.path(); if let Ok(rel) = path.strip_prefix(root) { let name = rel.to_string_lossy(); if name.is_empty() { tree.push_str(".\n"); } else { let depth = entry.depth(); let indent = " ".repeat(depth.saturating_sub(1)); let is_dir = entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false); let suffix = if is_dir { "/" } else { "" }; let filename = entry.file_name().to_string_lossy(); tree.push_str(&format!("{indent}{filename}{suffix}\n")); count += 1; } } } tree.trim_end().to_string() } // --------------------------------------------------------------------------- // Rich Context Builder // --------------------------------------------------------------------------- /// Gathers essential project context (OS, Time, PWD, Git, Tech Stack, Rules) into a string. pub fn build_rich_context(root: &Path) -> String { use std::process::Command; let mut ctx = String::new(); // 0. Current working directory (PWD) let cwd = std::env::current_dir() .map(|p| p.to_string_lossy().to_string()) .unwrap_or_else(|_| "unknown".to_string()); ctx.push_str(&format!("### Current Directory (PWD)\n`{cwd}`\n\n")); // 1. Time and OS let os = std::env::consts::OS; let arch = std::env::consts::ARCH; let time = chrono::Local::now().to_rfc3339(); ctx.push_str(&format!("Environment: {} ({}), Current Time: {}\n\n", os, arch, time)); // 2. Custom Rules let rule_files = [".cursorrules", ".zesdexrules", "claude.md", "agent.md"]; for file in rule_files { let p = root.join(file); if let Ok(content) = std::fs::read_to_string(&p) { ctx.push_str(&format!("### Project Rules ({})\n```\n{}\n```\n\n", file, content.trim())); } } // 3. Git Status if root.join(".git").exists() { let branch = Command::new("git") .arg("branch") .arg("--show-current") .current_dir(root) .output() .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) .unwrap_or_default(); let status = Command::new("git") .arg("status") .arg("-s") .current_dir(root) .output() .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) .unwrap_or_default(); if !branch.is_empty() { ctx.push_str(&format!("### Git State (branch: {})\n", branch)); if !status.is_empty() { ctx.push_str(&format!("Uncommitted changes:\n```\n{}\n```\n", status)); } else { ctx.push_str("No uncommitted changes.\n"); } ctx.push('\n'); } } // 4. Tech Stack (manifests) let manifests = ["Cargo.toml", "package.json", "go.mod"]; for file in manifests { let p = root.join(file); if let Ok(content) = std::fs::read_to_string(&p) { let snippet = if content.len() > 1500 { format!("{}\n... (truncated)", &content[..1500]) } else { content }; ctx.push_str(&format!("### Tech Stack Manifest ({})\n```\n{}\n```\n\n", file, snippet.trim())); } } // 5. Active Background Jobs let jobs = crate::bgbash::control::bash_control().list(); if !jobs.is_empty() { ctx.push_str("### Active Background Jobs\n```\n"); for (id, cmd, _) in jobs { ctx.push_str(&format!("[{}] {}\n", id, cmd)); } ctx.push_str("```\n\n"); } // 6. Project Purpose (README) let readme_path = root.join("README.md"); if let Ok(content) = std::fs::read_to_string(&readme_path) { let snippet = if content.len() > 1000 { format!("{}\n... (truncated)", &content[..1000]) } else { content }; ctx.push_str(&format!("### Project Purpose (README.md)\n```\n{}\n```\n\n", snippet.trim())); } // 7. Recent Git History & Momentum if root.join(".git").exists() { let commits = Command::new("git") .arg("log") .arg("-n") .arg("3") .arg("--oneline") .current_dir(root) .output() .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) .unwrap_or_default(); if !commits.is_empty() { ctx.push_str(&format!("### Recent Commits (Momentum)\n```\n{}\n```\n\n", commits)); } let diff = Command::new("git") .arg("diff") .arg("--name-only") .current_dir(root) .output() .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) .unwrap_or_default(); if !diff.is_empty() { ctx.push_str(&format!("### Recently Modified Files (Uncommitted)\n```\n{}\n```\n\n", diff)); } } ctx.trim_end().to_string() }