-
-
Notifications
You must be signed in to change notification settings - Fork 63
Expand file tree
/
Copy pathbuild.rs
More file actions
473 lines (396 loc) · 15 KB
/
build.rs
File metadata and controls
473 lines (396 loc) · 15 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
// std imports
use std::collections::HashMap;
use std::fs::{self, File};
use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{Duration, Instant};
// third-party imports
use anyhow::{Result, anyhow};
use const_str::join;
use semver::Version;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use ureq::tls;
const DEFAULTS_DIR: &str = "etc/defaults";
const THEME_DIR: &str = join!(&[DEFAULTS_DIR, "themes"], "/");
const SCHEMA_DIR: &str = "schema";
const JSON_SCHEMA_DIR: &str = join!(&[SCHEMA_DIR, "json"], "/");
const THEME_SCHEMA_PATH: &str = join!(&[JSON_SCHEMA_DIR, "theme.schema.v1.json"], "/");
const CAPNP_DIR: &str = SCHEMA_DIR;
const SRC_DIR: &str = "src";
const BUILD_CAPNP_DIR: &str = ".build/capnp";
const MAX_FETCH_ATTEMPTS: u32 = 3;
const BASE_FETCH_TIMEOUT: Duration = Duration::from_secs(2);
fn main() {
if let Err(e) = run() {
eprintln!("{:?}", e);
std::process::exit(1);
}
}
fn run() -> Result<()> {
build_capnp()?;
set_git_build_info()?;
update_schema_directives()?;
let theme_version = set_theme_version()?;
update_theme_versions(&theme_version)
}
fn set_git_build_info() -> Result<()> {
let base_version = env!("CARGO_PKG_VERSION");
// Parse the base version
let Ok(mut version) = Version::parse(base_version) else {
// If version doesn't parse, just use it as-is
println!("cargo:rustc-env=VERSION={}", base_version);
return Ok(());
};
// Determine if we should add git info (only for pre-release builds)
let final_version = if version.pre.is_empty() {
// For stable releases, just use the base version
base_version.into()
} else {
// Get commit hash
let commit = Command::new("git")
.args(["rev-parse", "--short", "HEAD"])
.output()
.ok()
.filter(|o| o.status.success())
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_string());
// Check if working directory is dirty
let is_dirty = Command::new("git")
.args(["status", "--porcelain"])
.output()
.ok()
.filter(|o| o.status.success())
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| !s.trim().is_empty())
.unwrap_or(false);
// Build the metadata string
let mut metadata_parts = Vec::new();
// Add existing build metadata if any
if !version.build.is_empty() {
metadata_parts.push(version.build.to_string());
}
// Add commit hash
if let Some(commit) = commit {
metadata_parts.push(commit);
}
// Add dirty flag
if is_dirty {
metadata_parts.push("dirty".to_string());
}
// Construct the final version string
if metadata_parts.is_empty() {
version.to_string()
} else {
version.build = metadata_parts.join(".").parse()?;
version.to_string()
}
};
// Set VERSION
println!("cargo:rustc-env=VERSION={}", final_version);
Ok(())
}
fn build_capnp() -> Result<()> {
for filename in ["index.capnp"] {
let source_file = Path::new(CAPNP_DIR).join(filename);
let target_file = Path::new(SRC_DIR).join(filename.replace(".", "_") + ".rs");
let hashes = HashInfo {
source: hex::encode(text_file_hash(&source_file)?),
target: hex::encode(text_file_hash(&target_file)?),
};
let hash_file = Path::new(BUILD_CAPNP_DIR).join(format!("{}.json", filename));
if hash_file.is_file() {
let file = File::open(&hash_file)
.map_err(|e| anyhow!("Failed to open hash file {}: {}", hash_file.display(), e))?;
if let Ok(stored_hashes) = json::from_reader::<_, HashInfo>(file) {
if stored_hashes == hashes {
continue;
}
}
}
capnpc::CompilerCommand::new()
.src_prefix(CAPNP_DIR)
.file(source_file)
.output_path(SRC_DIR)
.run()
.map_err(|e| anyhow!("Failed to compile capnp schema {}: {}", filename, e))?;
std::fs::write(&hash_file, json::to_string_pretty(&hashes).unwrap())?;
}
Ok(())
}
fn update_schema_directives() -> Result<()> {
// Build middleware chain: cache wraps retry wraps fetch
let fetch_hash = with_cache(with_retry(fetch_and_hash_url, MAX_FETCH_ATTEMPTS));
// Process all TOML files in etc/defaults recursively
update_toml_schema_urls_in_dir(Path::new(DEFAULTS_DIR), &fetch_hash)?;
Ok(())
}
fn update_toml_schema_urls_in_dir(dir: &Path, fetch_hash: &impl Fn(&str) -> Result<Hash>) -> Result<()> {
for entry in fs::read_dir(dir).map_err(|e| anyhow!("Failed to read directory {}: {}", dir.display(), e))? {
let entry = entry.map_err(|e| anyhow!("Failed to read directory entry: {}", e))?;
let path = entry.path();
if path.is_dir() {
update_toml_schema_urls_in_dir(&path, fetch_hash)?;
} else if path.extension().and_then(|s| s.to_str()) == Some("toml") {
println!("cargo:rerun-if-changed={}", path.display());
update_toml_schema_url(&path, fetch_hash)?;
}
}
Ok(())
}
fn update_toml_schema_url(toml_path: &Path, fetch_hash: &impl Fn(&str) -> Result<Hash>) -> Result<()> {
const SCHEMA_PREFIX: &str = "#:schema ";
let content = fs::read_to_string(toml_path)
.map_err(|e| anyhow!("Failed to read TOML file {}: {}", toml_path.display(), e))?;
// Find the #:schema directive
let schema_line = content.lines().find(|line| line.trim().starts_with(SCHEMA_PREFIX));
let Some(schema_line) = schema_line else {
return Ok(());
};
let schema_url = schema_line
.trim()
.strip_prefix(SCHEMA_PREFIX)
.ok_or_else(|| anyhow!("Invalid schema directive in {}", toml_path.display()))?
.trim();
// If it's already a relative path, nothing to do
if !schema_url.starts_with("http://") && !schema_url.starts_with("https://") {
return Ok(());
}
// Extract the schema file name from the URL
let schema_filename = schema_url
.rsplit('/')
.next()
.ok_or_else(|| anyhow!("Invalid schema URL: {}", schema_url))?;
// Find the local schema file
let local_schema_path = find_local_schema_file(schema_filename)?;
// Fetch remote hash with caching and retry
let remote_hash = match fetch_hash(schema_url) {
Ok(hash) => hash,
Err(e) => {
// Remote fetch failed after retries - log warning and skip update
println!(
"cargo:warning=failed to fetch schema for {}: {}, skipped update",
toml_path.display(),
e
);
return Ok(());
}
};
// Compare sha256 hashes
let local_hash = text_file_hash(&local_schema_path)?;
// If hashes match, no need to update
if remote_hash == local_hash {
return Ok(());
}
// Hashes differ - replace with relative path
let relative_path = calculate_relative_path(toml_path, &local_schema_path)?;
let new_schema_line = format!("#:schema {}", relative_path);
// Only update if different
if schema_line.trim() == new_schema_line.trim() {
return Ok(());
}
// Replace the schema directive
rewrite_file_lines(toml_path, |line| {
if line.trim().starts_with(SCHEMA_PREFIX) {
new_schema_line.clone()
} else {
line.to_string()
}
})
}
fn find_local_schema_file(filename: &str) -> Result<PathBuf> {
let schema_dir = Path::new(JSON_SCHEMA_DIR);
let schema_path = schema_dir.join(filename);
if schema_path.exists() {
Ok(schema_path)
} else {
Err(anyhow!("Local schema file not found: {}", schema_path.display()))
}
}
/// Middleware that adds retry logic with exponential backoff delay and timeout.
fn with_retry<F>(fetch: F, max_attempts: u32) -> impl Fn(&str) -> Result<Hash>
where
F: Fn(&str, u32) -> Result<Hash>,
{
move |url: &str| {
let mut last_error = None;
for attempt in 1..=max_attempts {
match fetch(url, attempt) {
Ok(hash) => return Ok(hash),
Err(e) => {
if attempt < max_attempts {
let delay_ms = 125 * (1 << (attempt - 1));
println!(
"cargo:warning=retrying {}, attempt {}/{}, delay {}ms",
url, attempt, max_attempts, delay_ms
);
std::thread::sleep(Duration::from_millis(delay_ms));
}
last_error = Some(e);
}
}
}
Err(last_error.unwrap())
}
}
/// Middleware that caches results (both successful and failed) by URL.
fn with_cache<F>(fetch: F) -> impl Fn(&str) -> Result<Hash>
where
F: Fn(&str) -> Result<Hash>,
{
let cache = std::sync::Mutex::new(HashMap::<String, Result<Hash, String>>::new());
move |url: &str| {
let mut cache = cache.lock().unwrap();
if let Some(cached) = cache.get(url) {
return cached.clone().map_err(|e| anyhow!("{}", e));
}
let result = fetch(url);
cache.insert(url.to_string(), result.as_ref().copied().map_err(|e| e.to_string()));
result
}
}
fn fetch_and_hash_url(url: &str, attempt: u32) -> Result<Hash> {
let timeout = BASE_FETCH_TIMEOUT * (1 << (attempt - 1));
eprintln!("hl: fetching {} (timeout: {}s)", url, timeout.as_secs());
let start = Instant::now();
let agent = {
ureq::Agent::config_builder()
.timeout_global(Some(timeout))
.tls_config(tls_config())
.build()
.new_agent()
};
let result = agent.get(url).call();
let elapsed = start.elapsed();
match result {
Ok(mut response) => {
eprintln!("hl: fetched {} in {:.2}s", url, elapsed.as_secs_f64());
text_reader_hash(BufReader::new(response.body_mut().as_reader()))
}
Err(e) => {
eprintln!("hl: failed to fetch {} in {:.2}s: {}", url, elapsed.as_secs_f64(), e);
Err(anyhow!("{}", e))
}
}
}
// On Windows ARM64, use native-tls to avoid ring/clang requirement
#[cfg(all(target_os = "windows", target_arch = "aarch64"))]
fn tls_config() -> tls::TlsConfig {
tls::TlsConfig::builder()
.provider(tls::TlsProvider::NativeTls)
.root_certs(tls::RootCerts::PlatformVerifier)
.build()
}
#[cfg(not(all(target_os = "windows", target_arch = "aarch64")))]
fn tls_config() -> tls::TlsConfig {
tls::TlsConfig::builder().build()
}
fn text_file_hash(path: &Path) -> Result<Hash> {
let file = File::open(path).map_err(|e| anyhow!("Failed to open {}: {}", path.display(), e))?;
text_reader_hash(file)
}
fn text_reader_hash<R: std::io::Read>(reader: R) -> Result<Hash> {
let mut hasher = Sha256::new();
for line in BufReader::new(reader).lines() {
let line = line.map_err(|e| anyhow!("Failed to read line for hashing: {}", e))?;
hasher.update(line);
hasher.update(b"\n");
}
Ok(hasher.finalize().into())
}
fn rewrite_file_lines<F>(path: &Path, transform: F) -> Result<()>
where
F: Fn(&str) -> String,
{
let content = fs::read_to_string(path).map_err(|e| anyhow!("Failed to read file {}: {}", path.display(), e))?;
let new_content: String = content.lines().map(transform).collect::<Vec<_>>().join("\n");
let new_content = if content.ends_with('\n') {
format!("{}\n", new_content)
} else {
new_content
};
fs::write(path, new_content).map_err(|e| anyhow!("Failed to write file {}: {}", path.display(), e))?;
Ok(())
}
fn calculate_relative_path(from: &Path, to: &Path) -> Result<String> {
let from_dir = from
.parent()
.ok_or_else(|| anyhow!("Failed to get parent directory of {}", from.display()))?;
let from_components: Vec<_> = from_dir.components().collect();
let to_components: Vec<_> = to.components().collect();
// Find common prefix length
let common_len = from_components
.iter()
.zip(to_components.iter())
.take_while(|(a, b)| a == b)
.count();
// Build relative path
let up_levels = from_components.len() - common_len;
let mut rel_path = String::new();
for _ in 0..up_levels {
rel_path.push_str("../");
}
for component in &to_components[common_len..] {
if let std::path::Component::Normal(comp) = component {
if !rel_path.is_empty() && !rel_path.ends_with('/') {
rel_path.push('/');
}
rel_path.push_str(comp.to_str().ok_or_else(|| anyhow!("Invalid path component"))?);
}
}
Ok(rel_path)
}
fn set_theme_version() -> Result<String> {
let schema_path = Path::new(THEME_SCHEMA_PATH);
let file =
File::open(schema_path).map_err(|e| anyhow!("Failed to open theme schema {}: {}", schema_path.display(), e))?;
let schema: json::Value = json::from_reader(file)
.map_err(|e| anyhow!("Failed to parse theme schema {}: {}", schema_path.display(), e))?;
let version = schema
.get("version")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("Missing 'version' field in theme schema"))?;
println!("cargo:rustc-env=HL_BUILD_THEME_VERSION={}", version);
println!("cargo:rerun-if-changed={}", schema_path.display());
Ok(version.to_string())
}
fn update_theme_versions(schema_version: &str) -> Result<()> {
const VERSION_PREFIX: &str = "version = \"";
let themes_dir = Path::new(THEME_DIR);
let expected_version_line = format!("version = \"{}\"", schema_version);
for entry in fs::read_dir(themes_dir)
.map_err(|e| anyhow!("Failed to read themes directory {}: {}", themes_dir.display(), e))?
{
let entry = entry.map_err(|e| anyhow!("Failed to read directory entry: {}", e))?;
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) != Some("toml") {
continue;
}
let content =
fs::read_to_string(&path).map_err(|e| anyhow!("Failed to read theme file {}: {}", path.display(), e))?;
// Check if version needs updating
let version_needs_update = content
.lines()
.any(|line| line.starts_with(VERSION_PREFIX) && line != expected_version_line);
// Only update files if version differs
if !version_needs_update {
continue;
}
// Update version line
rewrite_file_lines(&path, |line| {
if line.starts_with(VERSION_PREFIX) {
expected_version_line.clone()
} else {
line.to_string()
}
})?;
}
Ok(())
}
type Hash = [u8; 32];
#[derive(Debug, Eq, PartialEq, Deserialize, Serialize)]
struct HashInfo {
source: String,
target: String,
}