Files
zesdex/apps/infrastructure/src/utils.rs
T

314 lines
11 KiB
Rust
Raw Normal View History

//! 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<U> {
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<u64> for u128 {
#[inline]
fn cast_or(self, default: u64) -> u64 {
u64::try_from(self).unwrap_or(default)
}
}
impl CastOr<i64> for u128 {
#[inline]
fn cast_or(self, default: i64) -> i64 {
i64::try_from(self).unwrap_or(default)
}
}
impl CastOr<u32> 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.<uuid>` -> 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<T: Serialize>(path: &Path, data: &T, mode: Option<u32>) -> 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<String> {
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::<Vec<_>>()
.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;<base64>\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 result in WalkBuilder::new(root).hidden(false).build() {
if let Ok(entry) = result {
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, Git, Tech Stack, Rules) into a string.
pub fn build_rich_context(root: &Path) -> String {
use std::process::Command;
let mut ctx = String::new();
// 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()
}