Skip to main content

quasar_cli/
build.rs

1use {
2    crate::{config::QuasarConfig, error::CliResult, style, toolchain, utils},
3    std::{
4        fs,
5        path::{Path, PathBuf},
6        process::{Command, Stdio},
7        time::Instant,
8    },
9};
10
11pub fn run(debug: bool, watch: bool, features: Option<String>) -> CliResult {
12    if watch {
13        return run_watch(debug, features);
14    }
15
16    run_once(debug, features.as_deref())
17}
18
19fn run_once(debug: bool, features: Option<&str>) -> CliResult {
20    let config = QuasarConfig::load()?;
21    let start = Instant::now();
22
23    crate::idl::generate(Path::new("."), config.has_typescript_tests())?;
24
25    let sp = style::spinner("Building...");
26
27    let output = if config.is_solana_toolchain() {
28        let mut cmd = Command::new("cargo");
29        cmd.arg("build-sbf");
30        if debug {
31            cmd.arg("--debug");
32        }
33        if let Some(f) = features {
34            cmd.args(["--features", f]);
35        }
36        cmd.stdout(Stdio::piped()).stderr(Stdio::piped()).output()
37    } else {
38        if !toolchain::has_sbpf_linker() {
39            sp.finish_and_clear();
40            eprintln!("\n  {}", style::fail("sbpf-linker not found on PATH."));
41            eprintln!();
42            eprintln!("  Install platform-tools first:");
43            eprintln!(
44                "    {}",
45                style::bold("git clone https://github.com/anza-xyz/platform-tools")
46            );
47            eprintln!("    {}", style::bold("cd platform-tools"));
48            eprintln!("    {}", style::bold("cargo install-with-gallery"));
49            std::process::exit(1);
50        }
51
52        let mut cmd = Command::new("cargo");
53        if debug {
54            cmd.env("RUSTFLAGS", "-C link-arg=--btf -C debuginfo=2");
55        }
56        cmd.arg("build-bpf");
57        if let Some(f) = features {
58            cmd.args(["--features", f]);
59        }
60        cmd.stdout(Stdio::piped()).stderr(Stdio::piped()).output()
61    };
62
63    sp.finish_and_clear();
64
65    match output {
66        Ok(o) if o.status.success() => {
67            let elapsed = start.elapsed();
68
69            if !config.is_solana_toolchain() {
70                let program = config.module_name();
71                let src = PathBuf::from("target")
72                    .join("bpfel-unknown-none")
73                    .join("release")
74                    .join(format!("lib{}.so", program));
75                let dest_dir = PathBuf::from("target").join("deploy");
76                fs::create_dir_all(&dest_dir)?;
77                let dest = dest_dir.join(format!("lib{}.so", program));
78                fs::copy(&src, &dest).map_err(|e| {
79                    eprintln!(
80                        "  {}",
81                        style::fail(&format!("failed to copy {}: {e}", src.display()))
82                    );
83                    e
84                })?;
85            }
86
87            // Show warnings even on success
88            let stderr = String::from_utf8_lossy(&o.stderr);
89            let warnings = extract_warnings(&stderr);
90            if !warnings.is_empty() {
91                eprintln!();
92                for line in &warnings {
93                    eprintln!("  {line}");
94                }
95            }
96
97            let so_path = utils::find_so(&config, false);
98            let size_info = so_path
99                .and_then(|p| {
100                    let meta = fs::metadata(&p).ok()?;
101                    let new_size = meta.len();
102                    let delta = size_delta(&p, new_size);
103                    save_last_size(&p, new_size);
104                    Some(format!(
105                        " ({}{delta})",
106                        style::dim(&style::human_size(new_size))
107                    ))
108                })
109                .unwrap_or_default();
110
111            println!(
112                "  {}",
113                style::success(&format!(
114                    "Build complete in {}{size_info}",
115                    style::bold(&style::human_duration(elapsed))
116                ))
117            );
118            Ok(())
119        }
120        Ok(o) => {
121            let elapsed = start.elapsed();
122            let stderr = String::from_utf8_lossy(&o.stderr);
123            print_build_errors(&stderr, elapsed);
124            std::process::exit(o.status.code().unwrap_or(1));
125        }
126        Err(e) => {
127            eprintln!(
128                "  {}",
129                style::fail(&format!("failed to run build command: {e}"))
130            );
131            std::process::exit(1);
132        }
133    }
134}
135
136/// Build with debug symbols only (no feature flags) for profiling.
137/// Copies the .so to target/profile/ and returns the path.
138pub fn profile_build() -> Result<PathBuf, crate::error::CliError> {
139    let config = QuasarConfig::load()?;
140    let start = Instant::now();
141
142    crate::idl::generate(Path::new("."), config.has_typescript_tests())?;
143
144    let sp = style::spinner("Profile build...");
145
146    let output = if config.is_solana_toolchain() {
147        let mut cmd = Command::new("cargo");
148        cmd.arg("build-sbf").arg("--debug");
149        cmd.stdout(Stdio::piped()).stderr(Stdio::piped()).output()
150    } else {
151        if !toolchain::has_sbpf_linker() {
152            sp.finish_and_clear();
153            eprintln!("\n  {}", style::fail("sbpf-linker not found on PATH."));
154            eprintln!();
155            eprintln!("  Install platform-tools first:");
156            eprintln!(
157                "    {}",
158                style::bold("git clone https://github.com/anza-xyz/platform-tools")
159            );
160            eprintln!("    {}", style::bold("cd platform-tools"));
161            eprintln!("    {}", style::bold("cargo install-with-gallery"));
162            std::process::exit(1);
163        }
164
165        // Read existing rustflags from .cargo/config.toml and append debug flags
166        let existing_flags = read_target_rustflags();
167        let mut all_flags = existing_flags;
168        all_flags.extend([
169            "-C".to_string(),
170            "link-arg=--btf".to_string(),
171            "-C".to_string(),
172            "debuginfo=2".to_string(),
173        ]);
174
175        // Use CARGO_ENCODED_RUSTFLAGS (0x1f-separated) which takes priority
176        let encoded = all_flags.join("\x1f");
177        let mut cmd = Command::new("cargo");
178        cmd.env("CARGO_ENCODED_RUSTFLAGS", encoded);
179        cmd.arg("build-bpf");
180        cmd.stdout(Stdio::piped()).stderr(Stdio::piped()).output()
181    };
182
183    sp.finish_and_clear();
184
185    match output {
186        Ok(o) if o.status.success() => {
187            let elapsed = start.elapsed();
188            let program = config.module_name();
189            let profile_dir = PathBuf::from("target").join("profile");
190            fs::create_dir_all(&profile_dir)?;
191
192            // Find the built .so and copy to target/profile/
193            let src = if config.is_solana_toolchain() {
194                // build-sbf --debug puts it in target/deploy/ or
195                // target/sbf-solana-solana/release/
196                utils::find_so(&config, false).unwrap_or_else(|| {
197                    PathBuf::from("target")
198                        .join("sbf-solana-solana")
199                        .join("release")
200                        .join(format!("{}.so", program))
201                })
202            } else {
203                PathBuf::from("target")
204                    .join("bpfel-unknown-none")
205                    .join("release")
206                    .join(format!("lib{}.so", program))
207            };
208
209            let dest = profile_dir.join(format!("{}.so", program));
210            fs::copy(&src, &dest).map_err(|e| {
211                eprintln!(
212                    "  {}",
213                    style::fail(&format!("failed to copy {}: {e}", src.display()))
214                );
215                e
216            })?;
217
218            let size = fs::metadata(&dest).map(|m| m.len()).unwrap_or(0);
219            println!(
220                "  {}",
221                style::success(&format!(
222                    "Profile build in {} ({})",
223                    style::bold(&style::human_duration(elapsed)),
224                    style::dim(&style::human_size(size))
225                ))
226            );
227
228            Ok(dest)
229        }
230        Ok(o) => {
231            let elapsed = start.elapsed();
232            let stderr = String::from_utf8_lossy(&o.stderr);
233            print_build_errors(&stderr, elapsed);
234            std::process::exit(o.status.code().unwrap_or(1));
235        }
236        Err(e) => {
237            eprintln!(
238                "  {}",
239                style::fail(&format!("failed to run build command: {e}"))
240            );
241            std::process::exit(1);
242        }
243    }
244}
245
246fn run_watch(debug: bool, features: Option<String>) -> CliResult {
247    if let Err(e) = run_once(debug, features.as_deref()) {
248        eprintln!("  {}", style::fail(&format!("{e}")));
249    }
250
251    loop {
252        let baseline = collect_mtimes(Path::new("src"));
253        loop {
254            std::thread::sleep(std::time::Duration::from_secs(1));
255            let current = collect_mtimes(Path::new("src"));
256            if current != baseline {
257                if let Err(e) = run_once(debug, features.as_deref()) {
258                    eprintln!("  {}", style::fail(&format!("{e}")));
259                }
260                break;
261            }
262        }
263    }
264}
265
266// ---------------------------------------------------------------------------
267// Build error formatting
268// ---------------------------------------------------------------------------
269
270/// Extract warning lines from cargo output (for display on success).
271fn extract_warnings(stderr: &str) -> Vec<String> {
272    let mut warnings = Vec::new();
273    let mut capture = false;
274
275    for line in stderr.lines() {
276        if line.starts_with("warning") {
277            if line.contains("warnings emitted")
278                || line.contains("warning emitted")
279                || line.contains("user-defined alias")
280                || line.contains("shadowing")
281            {
282                continue;
283            }
284            capture = true;
285            warnings.push(line.to_string());
286        } else if capture {
287            if line.starts_with("  ") || line.starts_with(" -->") || line.is_empty() {
288                warnings.push(line.to_string());
289            } else {
290                capture = false;
291            }
292        }
293    }
294
295    warnings
296}
297
298/// Extract and display only the meaningful error/warning lines from cargo
299/// output.
300fn print_build_errors(stderr: &str, elapsed: std::time::Duration) {
301    let mut errors: Vec<String> = Vec::new();
302    let mut capture = false;
303
304    for line in stderr.lines() {
305        // Primary error/warning lines from rustc or cargo
306        if line.starts_with("error") || line.starts_with("warning") {
307            // Skip "warning: N warnings emitted" summary lines
308            if line.contains("warnings emitted") || line.contains("warning emitted") {
309                continue;
310            }
311            // Skip the cargo alias shadow warning
312            if line.contains("user-defined alias") || line.contains("shadowing") {
313                continue;
314            }
315            capture = true;
316            errors.push(line.to_string());
317        } else if capture {
318            // Capture continuation lines (source snippets, arrows, notes, "Caused by:")
319            if line.starts_with("  ")
320                || line.starts_with(" -->")
321                || line.starts_with("Caused by:")
322                || line.is_empty()
323            {
324                errors.push(line.to_string());
325            } else {
326                capture = false;
327            }
328        }
329    }
330
331    if errors.is_empty() {
332        // Fallback: show raw stderr if we couldn't parse errors
333        if !stderr.is_empty() {
334            eprint!("{stderr}");
335        }
336        eprintln!(
337            "  {}",
338            style::fail(&format!(
339                "build failed in {}",
340                style::bold(&style::human_duration(elapsed))
341            ))
342        );
343        return;
344    }
345
346    eprintln!();
347    for line in &errors {
348        eprintln!("  {line}");
349    }
350    eprintln!();
351
352    // Count errors vs warnings
353    let err_count = errors.iter().filter(|l| l.starts_with("error")).count();
354    let warn_count = errors.iter().filter(|l| l.starts_with("warning")).count();
355
356    let mut summary = String::new();
357    if err_count > 0 {
358        summary.push_str(&format!(
359            "{err_count} error{}",
360            if err_count == 1 { "" } else { "s" }
361        ));
362    }
363    if warn_count > 0 {
364        if !summary.is_empty() {
365            summary.push_str(", ");
366        }
367        summary.push_str(&format!(
368            "{warn_count} warning{}",
369            if warn_count == 1 { "" } else { "s" }
370        ));
371    }
372
373    eprintln!(
374        "  {}",
375        style::fail(&format!(
376            "build failed in {} ({summary})",
377            style::bold(&style::human_duration(elapsed))
378        ))
379    );
380}
381
382// ---------------------------------------------------------------------------
383// Build size tracking
384// ---------------------------------------------------------------------------
385
386const LAST_SIZE_FILE: &str = "target/.quasar-last-size";
387
388fn size_delta(so_path: &Path, new_size: u64) -> String {
389    let key = so_path.to_string_lossy();
390    let last = fs::read_to_string(LAST_SIZE_FILE)
391        .ok()
392        .and_then(|contents| {
393            contents
394                .lines()
395                .find(|l| l.starts_with(&*key))
396                .and_then(|l| l.rsplit_once(' '))
397                .and_then(|(_, s)| s.parse::<u64>().ok())
398        });
399
400    let Some(prev) = last else {
401        return String::new();
402    };
403
404    if new_size == prev {
405        return String::new();
406    }
407
408    let diff = new_size as i64 - prev as i64;
409    if diff > 0 {
410        format!(
411            ", {}",
412            style::color(196, &format!("+{}", style::human_size(diff as u64)))
413        )
414    } else {
415        format!(
416            ", {}",
417            style::color(83, &format!("-{}", style::human_size((-diff) as u64)))
418        )
419    }
420}
421
422fn save_last_size(so_path: &Path, size: u64) {
423    let key = so_path.to_string_lossy();
424    let entry = format!("{key} {size}");
425
426    // Read existing entries, replace or append
427    let mut lines: Vec<String> = fs::read_to_string(LAST_SIZE_FILE)
428        .unwrap_or_default()
429        .lines()
430        .filter(|l| !l.starts_with(&*key))
431        .map(String::from)
432        .collect();
433    lines.push(entry);
434    let _ = fs::write(LAST_SIZE_FILE, lines.join("\n"));
435}
436
437/// Read rustflags from .cargo/config.toml for the bpfel-unknown-none target.
438fn read_target_rustflags() -> Vec<String> {
439    let config_path = Path::new(".cargo").join("config.toml");
440    let contents = match fs::read_to_string(&config_path) {
441        Ok(c) => c,
442        Err(_) => return vec![],
443    };
444    let value: toml::Value = match contents.parse() {
445        Ok(v) => v,
446        Err(_) => return vec![],
447    };
448    value
449        .get("target")
450        .and_then(|t| t.get("bpfel-unknown-none"))
451        .and_then(|t| t.get("rustflags"))
452        .and_then(|f| f.as_array())
453        .map(|arr| {
454            arr.iter()
455                .filter_map(|v| v.as_str().map(String::from))
456                .collect()
457        })
458        .unwrap_or_default()
459}
460
461pub fn collect_mtimes(dir: &Path) -> Vec<(PathBuf, std::time::SystemTime)> {
462    let mut times = Vec::new();
463    if let Ok(entries) = fs::read_dir(dir) {
464        for entry in entries.flatten() {
465            let path = entry.path();
466            if path.is_dir() {
467                times.extend(collect_mtimes(&path));
468            } else if path.extension().is_some_and(|e| e == "rs") {
469                if let Ok(meta) = fs::metadata(&path) {
470                    if let Ok(mtime) = meta.modified() {
471                        times.push((path, mtime));
472                    }
473                }
474            }
475        }
476    }
477    times.sort_by(|a, b| a.0.cmp(&b.0));
478    times
479}