1import os
2from pathlib import Path
3import shutil
4import subprocess
5import tempfile
6import unittest
7from unittest.mock import patch
8
9import cargo_fresh
10import ci
11import release_web
12
13
14@unittest.skipUnless(shutil.which('cargo'), 'needs Cargo')
15class CargoFreshTest(unittest.TestCase):
16 def test_old_checkout_inputs_are_rebuilt_without_cleaning_dependencies(self):
17 with tempfile.TemporaryDirectory(prefix='snowbound-cargo-fresh-') as folder:
18 folder = Path(folder)
19 for name, value in [('a', 1), ('b', 2)]:
20 root = folder / name
21 (root / 'app/src').mkdir(parents=True)
22 (root / 'owned/src').mkdir(parents=True)
23 (root / 'vendor/src').mkdir(parents=True)
24 (root / 'vendor/Cargo.toml').write_text(
25 '[package]\nname="shared_dependency"\nversion="0.1.0"\nedition="2024"\n')
26 (root / 'vendor/src/lib.rs').write_text(f'pub fn value() -> u8 {{ {value * 10} }}\n')
27 (root / 'Cargo.toml').write_text(
28 '[workspace]\nmembers=["app", "owned"]\nexclude=["vendor"]\nresolver="2"\n')
29 (root / 'app/Cargo.toml').write_text(
30 '[package]\nname="freshness_fixture"\nversion="0.1.0"\nedition="2024"\n'
31 '[dependencies]\nowned={path="../owned"}\nshared_dependency={path="../vendor"}\nitoa="=1.0.18"\n')
32 (root / 'owned/Cargo.toml').write_text(
33 '[package]\nname="owned"\nversion="0.1.0"\nedition="2024"\n')
34 (root / 'owned/src/lib.rs').write_text('include!(concat!(env!("OUT_DIR"), "/value.rs"));\n')
35 (root / 'owned/value.txt').write_text(str(value))
36 (root / 'owned/build.rs').write_text(
37 'fn main() {\n'
38 ' println!("cargo::rerun-if-changed=value.txt");\n'
39 ' let value = std::fs::read_to_string("value.txt").unwrap();\n'
40 ' let out = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap());\n'
41 ' std::fs::write(out.join("value.rs"), format!("pub fn value() -> u8 {{ {value} }}")).unwrap();\n'
42 '}\n')
43 (root / 'app/src/lib.rs').write_text(
44 f'#[test] fn checkout_{name}() {{ '
45 f'assert_eq!(itoa::Buffer::new().format(owned::value() + shared_dependency::value()), "{value * 11}"); }}\n')
46 for path in root.rglob('*'):
47 if path.is_file():
48 os.utime(path, (1, 1))
49 environment = {key: value for key, value in os.environ.items()
50 if not key.startswith(('CARGO_', 'RUST', 'SNOWBOUND_', 'ONESTORE_'))}
51 environment.update(CARGO_TARGET_DIR=str(folder / 'cache'), CARGO_BUILD_JOBS='2', CARGO_NET_OFFLINE='true')
52
53 def cargo(root, *args):
54 return subprocess.run(['cargo', *args], cwd=root, env=environment, check=True,
55 capture_output=True, text=True).stdout
56
57 self.assertIn('test checkout_a ... ok', cargo(folder / 'a', 'test', '--offline'))
58 artifacts = {path: (path.stat().st_mtime_ns, path.read_bytes())
59 for path in (folder / 'cache/debug/deps').glob('*itoa*')}
60 self.assertTrue(artifacts)
61 cargo(folder / 'b', 'generate-lockfile', '--offline')
62 host = subprocess.check_output(['rustc', '-vV'], env=environment, text=True)
63 host = next(line.removeprefix('host: ') for line in host.splitlines() if line.startswith('host: '))
64 cargo(folder / 'a', 'build', '--offline', '--release')
65 cargo(folder / 'a', 'build', '--offline', '--release', '--target', host)
66 previous = {path: (path.stat().st_mtime_ns, path.read_bytes())
67 for path in (folder / 'cache/release/deps').glob('*.rlib')}
68 self.assertTrue(previous)
69 cargo_fresh.clean_local(folder / 'b', '--release', '--target', host, environment=environment)
70 cargo(folder / 'b', 'build', '--offline', '--release', '--target', host)
71 for path, expected in previous.items():
72 self.assertEqual((path.stat().st_mtime_ns, path.read_bytes()), expected)
73 cargo_fresh.clean_local(folder / 'b', environment=environment)
74 self.assertIn('test checkout_b ... ok',
75 cargo(folder / 'b', 'test', '--offline', '--', '--exact', 'checkout_b'))
76 for path, expected in artifacts.items():
77 self.assertEqual((path.stat().st_mtime_ns, path.read_bytes()), expected)
78
79 def test_clean_failure_stops_web_build_before_packaging(self):
80 with patch.object(release_web, 'environment', return_value={}), \
81 patch.object(release_web, 'clean_local', side_effect=subprocess.CalledProcessError(1, 'cargo')), \
82 patch.object(release_web, 'run') as run:
83 with self.assertRaises(subprocess.CalledProcessError):
84 release_web.build(Path('unused-output'))
85 run.assert_not_called()
86
87 def test_empty_metadata_never_cleans_the_entire_cache(self):
88 with patch.object(cargo_fresh.subprocess, 'check_output', return_value='{"packages": []}'), \
89 patch.object(cargo_fresh.subprocess, 'run') as run:
90 with self.assertRaisesRegex(ValueError, 'no local packages'):
91 cargo_fresh.clean_local(Path('unused-checkout'))
92 run.assert_not_called()
93
94 def test_clean_failure_stops_ci_before_concurrent_lanes(self):
95 with tempfile.TemporaryDirectory(prefix='snowbound-ci-clean-') as folder, \
96 patch.object(ci, 'RUNS', Path(folder)), \
97 patch.object(ci, 'CI', Path(folder) / 'checkout'), \
98 patch.object(ci, 'lanes', return_value=[{'name': 'test'}]), \
99 patch.object(ci, 'checkout', return_value='commit'), \
100 patch.object(ci, 'jj', return_value='fixture'), \
101 patch.object(ci, 'local_packages', return_value=['fixture']), \
102 patch.object(ci.sys, 'argv', ['ci.py']), \
103 patch.object(ci.Run, 'execute', return_value=101) as execute, \
104 patch.object(ci, 'ThreadPoolExecutor') as pool:
105 with self.assertRaisesRegex(SystemExit, 'Workspace cleanup failed'):
106 ci.main()
107 execute.assert_called_once()
108 self.assertEqual(execute.call_args.args[0], ['cargo', 'clean', '--locked', '-p', 'fixture'])
109 pool.assert_not_called()
110
111
112if __name__ == '__main__':
113 unittest.main()