1#![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#[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
61pub type Result<T> = std::result::Result<T, GateError>;
63
64const LOCK_FILE: &str = "/tmp/nexcore-cargo.lock";
66
67const HASH_FILE: &str = "/tmp/nexcore-cargo.hash";
69
70const RESULT_FILE: &str = "/tmp/nexcore-cargo.result";
72
73const HASH_EXTENSIONS: &[&str] = &["rs", "toml", "lock"];
75
76const SKIP_DIRS: &[&str] = &["target", ".git", "node_modules"];
78
79pub struct BuildLock {
81 file: File,
82 start: Instant,
83}
84
85impl BuildLock {
86 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 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 pub fn elapsed(&self) -> Duration {
122 self.start.elapsed()
123 }
124}
125
126impl Drop for BuildLock {
127 fn drop(&mut self) {
128 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
137pub 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 hasher.update(path.to_string_lossy().as_bytes());
158
159 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 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
177pub 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 ¤t_hash[..16]
188 );
189 Ok(false)
190 } else {
191 tracing::info!(
192 "Hash changed: {}... -> {}...",
193 &cached[..16.min(cached.len())],
194 ¤t_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
206pub 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#[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 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 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 pub fn is_valid_for(&self, hash: &str) -> bool {
246 self.success && self.hash == hash
247 }
248}
249
250pub fn run_cargo(workspace: &Path, args: &[&str], force: bool) -> Result<BuildResult> {
252 let _lock = BuildLock::acquire()?;
253
254 if !force && !should_build(workspace)? {
256 let current_hash = hash_source_dir(workspace)?;
258 if let Some(cached) = BuildResult::load() {
259 if cached.is_valid_for(¤t_hash) {
260 tracing::info!("Using cached result from {}", cached.timestamp);
261 return Ok(cached);
262 }
263 }
264 }
265
266 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 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
305pub 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 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#[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 assert!(root.is_some() || true); }
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 let status = lock_status();
426 assert_eq!(status, LockStatus::Available);
427 }
428}
429
430pub 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}