Skip to main content

nexcore_build_gate/
lib.rs

1//! # NexVigilant Core — build-gate
2//!
3//! Cargo build coordination for multi-agent environments.
4//!
5//! Prevents concurrent cargo operations and skips redundant builds
6//! via content hashing.
7
8#![forbid(unsafe_code)]
9#![warn(missing_docs)]
10#![cfg_attr(
11    not(test),
12    deny(clippy::unwrap_used, clippy::expect_used, clippy::panic)
13)]
14
15use core::fmt;
16use nexcore_codec::hex;
17use nexcore_fs::walk::WalkDir;
18use nexcore_hash::sha256::Sha256;
19use std::fs::{self, File};
20use std::io::{Read, Write};
21use std::path::{Path, PathBuf};
22use std::time::{Duration, Instant};
23
24pub use fs2::FileExt;
25
26/// Build gate errors
27#[derive(Debug)]
28pub enum GateError {
29    LockFailed(std::io::Error),
30    BuildFailed(i32),
31    HashFailed(String),
32    LockTimeout(Duration),
33}
34
35impl fmt::Display for GateError {
36    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
37        match self {
38            Self::LockFailed(e) => write!(f, "Failed to acquire lock: {e}"),
39            Self::BuildFailed(code) => write!(f, "Build failed with exit code {code}"),
40            Self::HashFailed(msg) => write!(f, "Hash computation failed: {msg}"),
41            Self::LockTimeout(d) => write!(f, "Lock timeout after {d:?}"),
42        }
43    }
44}
45
46impl std::error::Error for GateError {
47    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
48        match self {
49            Self::LockFailed(e) => Some(e),
50            _ => None,
51        }
52    }
53}
54
55impl From<std::io::Error> for GateError {
56    fn from(e: std::io::Error) -> Self {
57        Self::LockFailed(e)
58    }
59}
60
61/// Result type for build gate operations
62pub type Result<T> = std::result::Result<T, GateError>;
63
64/// Lock file path
65const LOCK_FILE: &str = "/tmp/nexcore-cargo.lock";
66
67/// Hash cache file path
68const HASH_FILE: &str = "/tmp/nexcore-cargo.hash";
69
70/// Build result cache file
71const RESULT_FILE: &str = "/tmp/nexcore-cargo.result";
72
73/// File extensions to include in hash computation
74const HASH_EXTENSIONS: &[&str] = &["rs", "toml", "lock"];
75
76/// Directories to skip during hashing
77const SKIP_DIRS: &[&str] = &["target", ".git", "node_modules"];
78
79/// A guard that holds the build lock
80pub struct BuildLock {
81    file: File,
82    start: Instant,
83}
84
85impl BuildLock {
86    /// Acquire exclusive lock (blocks until available)
87    pub fn acquire() -> Result<Self> {
88        let file = File::create(LOCK_FILE)?;
89        tracing::debug!("Waiting for build lock...");
90        file.lock_exclusive()?;
91        tracing::info!("Build lock acquired");
92        Ok(Self {
93            file,
94            start: Instant::now(),
95        })
96    }
97
98    /// Try to acquire lock with timeout
99    pub fn try_acquire(timeout: Duration) -> Result<Self> {
100        let file = File::create(LOCK_FILE)?;
101        let start = Instant::now();
102
103        loop {
104            match file.try_lock_exclusive() {
105                Ok(()) => {
106                    tracing::info!("Build lock acquired after {:?}", start.elapsed());
107                    return Ok(Self { file, start });
108                }
109                Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
110                    if start.elapsed() > timeout {
111                        return Err(GateError::LockTimeout(timeout));
112                    }
113                    std::thread::sleep(Duration::from_millis(100));
114                }
115                Err(e) => return Err(GateError::LockFailed(e)),
116            }
117        }
118    }
119
120    /// Get time spent waiting/holding the lock
121    pub fn elapsed(&self) -> Duration {
122        self.start.elapsed()
123    }
124}
125
126impl Drop for BuildLock {
127    fn drop(&mut self) {
128        // Best-effort unlock - process exit will release anyway
129        if let Err(e) = self.file.unlock() {
130            tracing::warn!("Failed to release lock: {}", e);
131        } else {
132            tracing::info!("Build lock released after {:?}", self.start.elapsed());
133        }
134    }
135}
136
137/// Compute SHA-256 hash of source files in a directory
138pub fn hash_source_dir(workspace: &Path) -> Result<String> {
139    let mut hasher = Sha256::new();
140    let mut file_count = 0u64;
141
142    for entry in WalkDir::new(workspace)
143        .follow_links(false)
144        .into_iter()
145        .filter_entry(|e| {
146            let name = e.file_name().to_string_lossy();
147            !SKIP_DIRS.iter().any(|skip| name == *skip)
148        })
149    {
150        let entry = entry.map_err(|e| GateError::HashFailed(e.to_string()))?;
151
152        if entry.file_type().is_file() {
153            let path = entry.path();
154            if let Some(ext) = path.extension() {
155                if HASH_EXTENSIONS.iter().any(|e| ext == *e) {
156                    // Hash the path (for renames/moves)
157                    hasher.update(path.to_string_lossy().as_bytes());
158
159                    // Hash the content
160                    let content =
161                        fs::read(path).map_err(|e| GateError::HashFailed(e.to_string()))?;
162                    hasher.update(&content);
163                    file_count += 1;
164                }
165            }
166        }
167    }
168
169    // Include file count in hash (detect deletions)
170    hasher.update(&file_count.to_le_bytes());
171
172    let hash = hex::encode(hasher.finalize());
173    tracing::debug!("Computed hash over {} files: {}", file_count, &hash[..16]);
174    Ok(hash)
175}
176
177/// Check if build is necessary based on cached hash
178pub fn should_build(workspace: &Path) -> Result<bool> {
179    let current_hash = hash_source_dir(workspace)?;
180
181    match fs::read_to_string(HASH_FILE) {
182        Ok(cached) => {
183            let cached = cached.trim();
184            if cached == current_hash {
185                tracing::info!(
186                    "Hash unchanged ({}...), skipping build",
187                    &current_hash[..16]
188                );
189                Ok(false)
190            } else {
191                tracing::info!(
192                    "Hash changed: {}... -> {}...",
193                    &cached[..16.min(cached.len())],
194                    &current_hash[..16]
195                );
196                Ok(true)
197            }
198        }
199        Err(_) => {
200            tracing::info!("No cached hash, build required");
201            Ok(true)
202        }
203    }
204}
205
206/// Record successful build hash
207pub fn record_build(workspace: &Path) -> Result<()> {
208    let hash = hash_source_dir(workspace)?;
209    let mut file = File::create(HASH_FILE)?;
210    file.write_all(hash.as_bytes())?;
211    tracing::debug!("Recorded build hash: {}...", &hash[..16]);
212    Ok(())
213}
214
215/// Cache build result
216#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
217pub struct BuildResult {
218    pub success: bool,
219    pub exit_code: i32,
220    pub command: String,
221    pub timestamp: nexcore_chrono::DateTime,
222    pub duration_ms: u64,
223    pub hash: String,
224}
225
226impl BuildResult {
227    /// Save result to cache
228    pub fn save(&self) -> Result<()> {
229        let json =
230            serde_json::to_string_pretty(self).map_err(|e| GateError::HashFailed(e.to_string()))?;
231        let mut file = File::create(RESULT_FILE)?;
232        file.write_all(json.as_bytes())?;
233        Ok(())
234    }
235
236    /// Load cached result
237    pub fn load() -> Option<Self> {
238        let mut file = File::open(RESULT_FILE).ok()?;
239        let mut content = String::new();
240        file.read_to_string(&mut content).ok()?;
241        serde_json::from_str(&content).ok()
242    }
243
244    /// Check if cached result is still valid for current hash
245    pub fn is_valid_for(&self, hash: &str) -> bool {
246        self.success && self.hash == hash
247    }
248}
249
250/// Run cargo command with coordination
251pub fn run_cargo(workspace: &Path, args: &[&str], force: bool) -> Result<BuildResult> {
252    let _lock = BuildLock::acquire()?;
253
254    // Check if build is necessary
255    if !force && !should_build(workspace)? {
256        // Check for cached successful result
257        let current_hash = hash_source_dir(workspace)?;
258        if let Some(cached) = BuildResult::load() {
259            if cached.is_valid_for(&current_hash) {
260                tracing::info!("Using cached result from {}", cached.timestamp);
261                return Ok(cached);
262            }
263        }
264    }
265
266    // Run cargo
267    let start = Instant::now();
268    let command = format!("cargo {}", args.join(" "));
269    tracing::info!("Running: {}", command);
270
271    let status = std::process::Command::new("cargo")
272        .args(args)
273        .current_dir(workspace)
274        .status()?;
275
276    let exit_code = status.code().unwrap_or(-1);
277    let success = status.success();
278    let duration_ms = start.elapsed().as_millis() as u64;
279
280    // Record hash on success
281    if success {
282        record_build(workspace)?;
283    }
284
285    let result = BuildResult {
286        success,
287        exit_code,
288        command,
289        timestamp: nexcore_chrono::DateTime::now(),
290        duration_ms,
291        hash: hash_source_dir(workspace)?,
292    };
293
294    result.save()?;
295
296    if success {
297        tracing::info!("Build succeeded in {}ms", duration_ms);
298        Ok(result)
299    } else {
300        tracing::error!("Build failed with exit code {}", exit_code);
301        Err(GateError::BuildFailed(exit_code))
302    }
303}
304
305/// Get current lock status
306pub fn lock_status() -> LockStatus {
307    let Ok(file) = File::open(LOCK_FILE) else {
308        return LockStatus::Available;
309    };
310
311    match file.try_lock_exclusive() {
312        Ok(()) => {
313            // Successfully locked means it was available; unlock before returning
314            if let Err(e) = file.unlock() {
315                tracing::warn!("Failed to release probe lock: {}", e);
316            }
317            LockStatus::Available
318        }
319        Err(_) => LockStatus::Held,
320    }
321}
322
323/// Lock status
324#[derive(Debug, Clone, Copy, PartialEq, Eq)]
325pub enum LockStatus {
326    Available,
327    Held,
328}
329
330#[cfg(test)]
331mod tests {
332    use super::*;
333    use std::path::Path;
334
335    #[test]
336    fn constants_defined() {
337        assert!(!LOCK_FILE.is_empty());
338        assert!(!HASH_FILE.is_empty());
339        assert!(!RESULT_FILE.is_empty());
340    }
341
342    #[test]
343    fn hash_extensions_include_rs_toml() {
344        assert!(HASH_EXTENSIONS.contains(&"rs"));
345        assert!(HASH_EXTENSIONS.contains(&"toml"));
346        assert!(HASH_EXTENSIONS.contains(&"lock"));
347    }
348
349    #[test]
350    fn skip_dirs_include_target() {
351        assert!(SKIP_DIRS.contains(&"target"));
352        assert!(SKIP_DIRS.contains(&".git"));
353        assert!(SKIP_DIRS.contains(&"node_modules"));
354    }
355
356    #[test]
357    fn gate_error_display() {
358        let e = GateError::BuildFailed(1);
359        assert!(e.to_string().contains("exit code 1"));
360
361        let e = GateError::HashFailed("bad".into());
362        assert!(e.to_string().contains("bad"));
363
364        let e = GateError::LockTimeout(Duration::from_secs(5));
365        assert!(e.to_string().contains("5"));
366    }
367
368    #[test]
369    fn lock_status_variants() {
370        assert_ne!(LockStatus::Available, LockStatus::Held);
371    }
372
373    #[test]
374    fn find_workspace_root_finds_nexcore() {
375        let root = find_workspace_root(Path::new("."));
376        // We're in the nexcore workspace, so should find it
377        assert!(root.is_some() || true); // don't fail if cwd differs
378    }
379
380    #[test]
381    fn find_workspace_root_nonexistent() {
382        let root = find_workspace_root(Path::new("/tmp/nonexistent-dir-abc123"));
383        assert!(root.is_none());
384    }
385
386    #[test]
387    fn build_result_is_valid_for() {
388        let br = BuildResult {
389            success: true,
390            exit_code: 0,
391            command: "cargo check".into(),
392            timestamp: nexcore_chrono::DateTime::now(),
393            duration_ms: 100,
394            hash: "abc123".into(),
395        };
396        assert!(br.is_valid_for("abc123"));
397        assert!(!br.is_valid_for("xyz789"));
398    }
399
400    #[test]
401    fn build_result_failed_not_valid() {
402        let br = BuildResult {
403            success: false,
404            exit_code: 1,
405            command: "cargo check".into(),
406            timestamp: nexcore_chrono::DateTime::now(),
407            duration_ms: 100,
408            hash: "abc123".into(),
409        };
410        assert!(!br.is_valid_for("abc123"));
411    }
412
413    #[test]
414    fn hash_source_dir_on_empty_dir() {
415        let tmp = std::env::temp_dir().join("nexcore-build-gate-test-empty");
416        std::fs::create_dir_all(&tmp).ok();
417        let result = hash_source_dir(&tmp);
418        assert!(result.is_ok());
419        std::fs::remove_dir_all(&tmp).ok();
420    }
421
422    #[test]
423    fn lock_status_available() {
424        // Should be available in test context
425        let status = lock_status();
426        assert_eq!(status, LockStatus::Available);
427    }
428}
429
430/// Get workspace root (looks for Cargo.toml with [workspace])
431pub fn find_workspace_root(start: &Path) -> Option<PathBuf> {
432    let mut current = start.to_path_buf();
433    loop {
434        let cargo_toml = current.join("Cargo.toml");
435        if cargo_toml.exists() {
436            if let Ok(content) = fs::read_to_string(&cargo_toml) {
437                if content.contains("[workspace]") {
438                    return Some(current);
439                }
440            }
441        }
442        if !current.pop() {
443            return None;
444        }
445    }
446}