1use std::collections::{HashMap, HashSet, VecDeque};
2use std::fs;
3use std::path::Path;
4use std::time::{Duration, SystemTime};
5
6use anyhow::{Context as _, Result};
7
8pub fn static_regex(pattern: &str) -> regex::Regex {
16 regex::Regex::new(pattern)
17 .unwrap_or_else(|e| panic!("invalid static regex literal `{}`: {}", pattern, e))
18}
19
20pub fn topological_sort(items: &[(impl AsRef<str>, impl AsRef<[String]>)]) -> Vec<String> {
34 let names: HashSet<&str> = items.iter().map(|(n, _)| n.as_ref()).collect();
35
36 let mut in_degree: HashMap<&str, usize> = items
37 .iter()
38 .map(|(n, deps)| {
39 let deg = deps
40 .as_ref()
41 .iter()
42 .filter(|d| names.contains(d.as_str()))
43 .count();
44 (n.as_ref(), deg)
45 })
46 .collect();
47
48 let mut edges: HashMap<&str, Vec<&str>> = HashMap::new();
50 for (n, deps) in items {
51 for dep in deps.as_ref() {
52 if names.contains(dep.as_str()) {
53 edges.entry(dep.as_str()).or_default().push(n.as_ref());
54 }
55 }
56 }
57
58 let mut queue: VecDeque<&str> = {
60 let mut v: Vec<&str> = in_degree
61 .iter()
62 .filter(|(_, d)| **d == 0)
63 .map(|(&n, _)| n)
64 .collect();
65 v.sort_unstable();
66 VecDeque::from(v)
67 };
68
69 let mut result = Vec::with_capacity(items.len());
70 while let Some(node) = queue.pop_front() {
71 result.push(node.to_string());
72 if let Some(dependents) = edges.get(node) {
73 let mut next: Vec<&str> = dependents
74 .iter()
75 .filter_map(|&dep| {
76 let deg = in_degree.get_mut(dep)?;
77 *deg -= 1;
78 if *deg == 0 { Some(dep) } else { None }
79 })
80 .collect();
81 next.sort_unstable();
82 for n in next {
83 queue.push_back(n);
84 }
85 }
86 }
87
88 if result.len() < items.len() {
90 let in_result: HashSet<String> = result.iter().cloned().collect();
91 for (n, _) in items {
92 if !in_result.contains(n.as_ref()) {
93 result.push(n.as_ref().to_string());
94 }
95 }
96 }
97
98 result
99}
100
101pub fn find_binary(name: &str) -> bool {
113 if name.contains('/') || name.contains('\\') {
114 return Path::new(name).exists();
115 }
116
117 let extensions: Vec<String> = if cfg!(windows) {
120 std::env::var("PATHEXT")
121 .unwrap_or_else(|_| ".COM;.EXE;.BAT;.CMD".to_string())
122 .split(';')
123 .filter(|e| !e.is_empty())
124 .map(|e| e.to_string())
125 .collect()
126 } else {
127 Vec::new()
128 };
129
130 if let Ok(path_var) = std::env::var("PATH") {
131 for dir in std::env::split_paths(&path_var) {
132 let candidate = dir.join(name);
133 if candidate.is_file() {
134 return true;
135 }
136 for ext in &extensions {
137 let with_ext = dir.join(format!("{}{}", name, ext));
138 if with_ext.is_file() {
139 return true;
140 }
141 }
142 }
143 }
144
145 false
146}
147
148pub fn parse_mod_timestamp(raw: &str) -> Result<SystemTime> {
162 if let Ok(epoch_secs) = raw.parse::<u64>() {
164 return Ok(SystemTime::UNIX_EPOCH + Duration::from_secs(epoch_secs));
165 }
166 if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(raw) {
168 let epoch_secs = dt.timestamp() as u64;
169 return Ok(SystemTime::UNIX_EPOCH + Duration::from_secs(epoch_secs));
170 }
171 if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(raw, "%Y-%m-%dT%H:%M:%S") {
173 let epoch_secs = dt.and_utc().timestamp() as u64;
174 return Ok(SystemTime::UNIX_EPOCH + Duration::from_secs(epoch_secs));
175 }
176 anyhow::bail!(
177 "mod_timestamp value '{raw}' is not a valid timestamp. \
178 Accepted formats: Unix epoch seconds (e.g. \"1704067200\") or \
179 RFC 3339 datetime (e.g. \"2024-01-01T00:00:00Z\")"
180 )
181}
182
183pub fn apply_mod_timestamp(dir: &Path, raw: &str, log: &crate::log::StageLogger) -> Result<()> {
188 let mtime = parse_mod_timestamp(raw)?;
189
190 for entry in fs::read_dir(dir).with_context(|| format!("read staging dir {}", dir.display()))? {
191 let entry = entry?;
192 let ft = entry.file_type()?;
193 if ft.is_file() {
194 set_file_mtime(&entry.path(), mtime)?;
195 }
196 }
197
198 log.status(&format!("applied mod_timestamp={raw} to staging files"));
199 Ok(())
200}
201
202pub fn set_file_mtime(path: &Path, mtime: SystemTime) -> Result<()> {
204 let file = std::fs::OpenOptions::new()
205 .write(true)
206 .open(path)
207 .with_context(|| format!("open {} for mtime update", path.display()))?;
208 file.set_times(
209 std::fs::FileTimes::new()
210 .set_accessed(mtime)
211 .set_modified(mtime),
212 )
213 .with_context(|| format!("set mtime on {}", path.display()))?;
214 Ok(())
215}
216
217pub fn set_file_mtime_epoch(path: &Path, epoch_secs: i64) -> Result<()> {
222 let mtime = if epoch_secs >= 0 {
223 SystemTime::UNIX_EPOCH + Duration::from_secs(epoch_secs as u64)
224 } else {
225 SystemTime::UNIX_EPOCH - Duration::from_secs((-epoch_secs) as u64)
226 };
227 set_file_mtime(path, mtime)
228}
229
230pub fn copy_dir_tree(src: &Path, dst: &Path) -> Result<()> {
242 fs::create_dir_all(dst).with_context(|| format!("create dir {}", dst.display()))?;
243 for entry in fs::read_dir(src).with_context(|| format!("read dir {}", src.display()))? {
244 let entry = entry.with_context(|| format!("read entry under {}", src.display()))?;
245 let from = entry.path();
246 let to = dst.join(entry.file_name());
247 let file_type = entry
250 .file_type()
251 .with_context(|| format!("stat {}", from.display()))?;
252 if file_type.is_symlink() {
253 #[cfg(unix)]
254 {
255 let target = fs::read_link(&from)
256 .with_context(|| format!("read symlink {}", from.display()))?;
257 std::os::unix::fs::symlink(&target, &to).with_context(|| {
258 format!("recreate symlink {} -> {}", to.display(), target.display())
259 })?;
260 }
261 #[cfg(not(unix))]
262 {
263 if from.is_dir() {
264 copy_dir_tree(&from, &to)?;
265 } else {
266 fs::copy(&from, &to)
267 .with_context(|| format!("copy {} to {}", from.display(), to.display()))?;
268 }
269 }
270 } else if file_type.is_dir() {
271 copy_dir_tree(&from, &to)?;
272 } else {
273 fs::copy(&from, &to)
274 .with_context(|| format!("copy {} to {}", from.display(), to.display()))?;
275 }
276 }
277 Ok(())
278}
279
280pub fn collect_replace_archives(
286 artifacts: &crate::artifact::ArtifactRegistry,
287 crate_name: &str,
288 target: Option<&str>,
289) -> Vec<std::path::PathBuf> {
290 artifacts
291 .by_kind_and_crate(crate::artifact::ArtifactKind::Archive, crate_name)
292 .iter()
293 .filter(|a| a.target.as_deref() == target)
294 .map(|a| a.path.clone())
295 .collect()
296}
297
298pub fn collect_if_replace(
305 replace: Option<bool>,
306 artifacts: &crate::artifact::ArtifactRegistry,
307 crate_name: &str,
308 target: Option<&str>,
309) -> Vec<std::path::PathBuf> {
310 if replace.unwrap_or(false) {
311 collect_replace_archives(artifacts, crate_name, target)
312 } else {
313 Vec::new()
314 }
315}
316
317pub fn normalize_path_separators(s: &str) -> String {
322 s.replace('\\', "/")
323}
324
325pub fn apply_minimal_env(command: &mut std::process::Command) {
336 const PASSTHROUGH: &[&str] = &[
337 "HOME",
338 "USER",
339 "USERPROFILE",
340 "TMPDIR",
341 "TMP",
342 "TEMP",
343 "PATH",
344 "LOCALAPPDATA",
345 ];
346 for key in PASSTHROUGH {
347 if let Ok(val) = std::env::var(key) {
348 command.env(key, val);
349 }
350 }
351}
352
353#[cfg(test)]
354mod tests {
355 use super::*;
356
357 #[test]
362 fn test_topo_sort_simple_chain() {
363 let items = vec![
364 ("c".to_string(), vec!["b".to_string()]),
365 ("b".to_string(), vec!["a".to_string()]),
366 ("a".to_string(), vec![]),
367 ];
368 let sorted = topological_sort(&items);
369 assert_eq!(sorted, vec!["a", "b", "c"]);
370 }
371
372 #[test]
373 fn test_topo_sort_no_deps() {
374 let items = vec![("b".to_string(), vec![]), ("a".to_string(), vec![])];
375 let sorted = topological_sort(&items);
377 assert_eq!(sorted, vec!["a", "b"]);
378 }
379
380 #[test]
381 fn test_topo_sort_ignores_external_deps() {
382 let items = vec![
383 (
384 "b".to_string(),
385 vec!["a".to_string(), "external".to_string()],
386 ),
387 ("a".to_string(), vec![]),
388 ];
389 let sorted = topological_sort(&items);
390 assert_eq!(sorted, vec!["a", "b"]);
391 }
392
393 #[test]
394 fn test_topo_sort_diamond() {
395 let items = vec![
396 ("d".to_string(), vec!["b".to_string(), "c".to_string()]),
397 ("b".to_string(), vec!["a".to_string()]),
398 ("c".to_string(), vec!["a".to_string()]),
399 ("a".to_string(), vec![]),
400 ];
401 let sorted = topological_sort(&items);
402 assert_eq!(sorted[0], "a");
404 assert_eq!(sorted[3], "d");
405 }
406
407 #[test]
408 fn test_topo_sort_cycle_appends_remaining() {
409 let items = vec![
410 ("a".to_string(), vec!["b".to_string()]),
411 ("b".to_string(), vec!["a".to_string()]),
412 ("c".to_string(), vec![]),
413 ];
414 let sorted = topological_sort(&items);
415 assert_eq!(sorted.len(), 3);
416 assert_eq!(sorted[0], "c");
418 }
419
420 #[test]
421 fn test_topo_sort_empty() {
422 let items: Vec<(String, Vec<String>)> = vec![];
423 let sorted = topological_sort(&items);
424 assert!(sorted.is_empty());
425 }
426
427 #[test]
432 fn test_find_binary_absolute_path_exists() {
433 if cfg!(windows) {
434 assert!(find_binary("C:\\Windows\\System32\\cmd.exe"));
436 } else {
437 assert!(find_binary("/usr/bin/env"));
439 }
440 }
441
442 #[test]
443 fn test_find_binary_absolute_path_does_not_exist() {
444 if cfg!(windows) {
445 assert!(!find_binary("C:\\nonexistent\\binary\\path.exe"));
446 } else {
447 assert!(!find_binary("/nonexistent/binary/path"));
448 }
449 }
450
451 #[test]
452 fn test_find_binary_bare_name_on_path() {
453 if cfg!(windows) {
454 assert!(find_binary("cmd.exe"));
457 } else {
458 assert!(find_binary("env"));
460 }
461 }
462
463 #[test]
464 fn test_find_binary_bare_name_not_on_path() {
465 assert!(!find_binary("nonexistent-binary-xyz-12345"));
466 }
467
468 #[test]
473 fn test_parse_mod_timestamp_epoch_integer() {
474 let t = parse_mod_timestamp("1704067200").unwrap();
475 let epoch = t.duration_since(SystemTime::UNIX_EPOCH).unwrap().as_secs();
476 assert_eq!(epoch, 1704067200);
477 }
478
479 #[test]
480 fn test_parse_mod_timestamp_rfc3339() {
481 let t = parse_mod_timestamp("2024-01-01T00:00:00Z").unwrap();
482 let epoch = t.duration_since(SystemTime::UNIX_EPOCH).unwrap().as_secs();
483 assert_eq!(epoch, 1704067200);
484 }
485
486 #[test]
487 fn test_parse_mod_timestamp_rfc3339_with_offset() {
488 let t = parse_mod_timestamp("2024-01-01T01:00:00+01:00").unwrap();
489 let epoch = t.duration_since(SystemTime::UNIX_EPOCH).unwrap().as_secs();
490 assert_eq!(epoch, 1704067200);
492 }
493
494 #[test]
495 fn test_parse_mod_timestamp_naive_datetime() {
496 let t = parse_mod_timestamp("2024-01-01T00:00:00").unwrap();
497 let epoch = t.duration_since(SystemTime::UNIX_EPOCH).unwrap().as_secs();
498 assert_eq!(epoch, 1704067200);
499 }
500
501 #[test]
502 fn test_parse_mod_timestamp_invalid() {
503 let err = parse_mod_timestamp("not-a-timestamp").unwrap_err();
504 let msg = err.to_string();
505 assert!(
506 msg.contains("not a valid timestamp"),
507 "unexpected error: {msg}"
508 );
509 assert!(
512 msg.contains("not-a-timestamp"),
513 "error must include the bad value, got: {msg}"
514 );
515 }
516
517 #[test]
518 fn test_parse_mod_timestamp_zero() {
519 let t = parse_mod_timestamp("0").unwrap();
520 assert_eq!(t, SystemTime::UNIX_EPOCH);
521 }
522
523 #[test]
528 fn test_set_file_mtime_sets_both_atime_and_mtime() {
529 let dir = std::env::temp_dir().join("anodizer_test_set_file_mtime");
530 let _ = std::fs::remove_dir_all(&dir);
531 std::fs::create_dir_all(&dir).unwrap();
532
533 let file_path = dir.join("test.txt");
534 std::fs::write(&file_path, "hello").unwrap();
535
536 let target = SystemTime::UNIX_EPOCH + Duration::from_secs(1704067200);
538 set_file_mtime(&file_path, target).unwrap();
539
540 let meta = std::fs::metadata(&file_path).unwrap();
541 let actual_mtime = meta.modified().unwrap();
542
543 let diff = if actual_mtime > target {
545 actual_mtime.duration_since(target).unwrap()
546 } else {
547 target.duration_since(actual_mtime).unwrap()
548 };
549 assert!(
550 diff.as_secs() <= 1,
551 "mtime should be within 1s of target, diff={:?}",
552 diff
553 );
554
555 let actual_atime = meta.accessed().unwrap();
557 let diff_a = if actual_atime > target {
558 actual_atime.duration_since(target).unwrap()
559 } else {
560 target.duration_since(actual_atime).unwrap()
561 };
562 assert!(
563 diff_a.as_secs() <= 1,
564 "atime should be within 1s of target, diff={:?}",
565 diff_a
566 );
567
568 let _ = std::fs::remove_dir_all(&dir);
569 }
570
571 #[test]
572 fn test_set_file_mtime_nonexistent_file() {
573 let result = set_file_mtime(Path::new("/nonexistent/file.txt"), SystemTime::UNIX_EPOCH);
574 assert!(result.is_err());
575 }
576
577 #[test]
582 fn test_apply_mod_timestamp_sets_mtime_on_regular_files() {
583 let dir = std::env::temp_dir().join("anodizer_test_apply_mod_timestamp");
584 let _ = std::fs::remove_dir_all(&dir);
585 std::fs::create_dir_all(&dir).unwrap();
586
587 std::fs::write(dir.join("a.txt"), "aaa").unwrap();
589 std::fs::write(dir.join("b.txt"), "bbb").unwrap();
590 std::fs::create_dir(dir.join("subdir")).unwrap();
591
592 let log = crate::log::StageLogger::new("test", crate::log::Verbosity::Quiet);
593 apply_mod_timestamp(&dir, "1704067200", &log).unwrap();
594
595 let target = SystemTime::UNIX_EPOCH + Duration::from_secs(1704067200);
596 for name in &["a.txt", "b.txt"] {
597 let meta = std::fs::metadata(dir.join(name)).unwrap();
598 let mtime = meta.modified().unwrap();
599 let diff = if mtime > target {
600 mtime.duration_since(target).unwrap()
601 } else {
602 target.duration_since(mtime).unwrap()
603 };
604 assert!(
605 diff.as_secs() <= 1,
606 "{name}: mtime should be within 1s of target, diff={:?}",
607 diff
608 );
609 }
610
611 let _ = std::fs::remove_dir_all(&dir);
612 }
613
614 #[test]
615 fn test_apply_mod_timestamp_invalid_timestamp_errors() {
616 let dir = std::env::temp_dir().join("anodizer_test_apply_mod_timestamp_invalid");
617 let _ = std::fs::remove_dir_all(&dir);
618 std::fs::create_dir_all(&dir).unwrap();
619
620 let log = crate::log::StageLogger::new("test", crate::log::Verbosity::Quiet);
621 let result = apply_mod_timestamp(&dir, "not-valid", &log);
622 assert!(result.is_err());
623
624 let _ = std::fs::remove_dir_all(&dir);
625 }
626}