Skip to main content

max / makenotwork

Apply rustfmt across the crate Formatting only, no behavior change. cargo check --all-targets passes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-21 23:46 UTC
Commit: 2dd1f08afb8a9b2ab56c3513767857e14928292a
Parent: 29ec282
16 files changed, +1020 insertions, -316 deletions
@@ -34,11 +34,13 @@ pub async fn recover_orphaned_running(pool: &SqlitePool) -> Result<u64> {
34 34 .bind(&now)
35 35 .execute(&mut *tx)
36 36 .await?;
37 - let builds = sqlx::query("UPDATE builds SET status = 'failed', finished_at = ? WHERE status = 'running'")
38 - .bind(&now)
39 - .execute(&mut *tx)
40 - .await?
41 - .rows_affected();
37 + let builds = sqlx::query(
38 + "UPDATE builds SET status = 'failed', finished_at = ? WHERE status = 'running'",
39 + )
40 + .bind(&now)
41 + .execute(&mut *tx)
42 + .await?
43 + .rows_affected();
42 44 tx.commit().await?;
43 45 Ok(builds)
44 46 }
@@ -62,27 +64,48 @@ mod tests {
62 64 "INSERT INTO target_runs (build_id, app, version, target, status, started_at) \
63 65 VALUES (?, 'demo','0.1.0','linux/x86_64','running',?) RETURNING id",
64 66 )
65 - .bind(running).bind(&now).fetch_one(&pool).await.unwrap();
67 + .bind(running)
68 + .bind(&now)
69 + .fetch_one(&pool)
70 + .await
71 + .unwrap();
66 72 sqlx::query(
67 73 "INSERT INTO step_runs (target_run_id, step, status, log_ref, started_at) \
68 74 VALUES (?, 'build','running','x',?)",
69 75 )
70 - .bind(tr).bind(&now).execute(&pool).await.unwrap();
71 - sqlx::query("INSERT INTO builds (app, version, status, created_at) VALUES ('demo','0.0.9','ok',?)")
72 - .bind(&now).execute(&pool).await.unwrap();
76 + .bind(tr)
77 + .bind(&now)
78 + .execute(&pool)
79 + .await
80 + .unwrap();
81 + sqlx::query(
82 + "INSERT INTO builds (app, version, status, created_at) VALUES ('demo','0.0.9','ok',?)",
83 + )
84 + .bind(&now)
85 + .execute(&pool)
86 + .await
87 + .unwrap();
73 88
74 89 let reconciled = recover_orphaned_running(&pool).await.unwrap();
75 90 assert_eq!(reconciled, 1, "only the running build is reconciled");
76 91
77 92 // The orphaned chain is now failed; the ok build is untouched.
78 - let running_left: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM builds WHERE status='running'")
79 - .fetch_one(&pool).await.unwrap();
93 + let running_left: i64 =
94 + sqlx::query_scalar("SELECT COUNT(*) FROM builds WHERE status='running'")
95 + .fetch_one(&pool)
96 + .await
97 + .unwrap();
80 98 assert_eq!(running_left, 0);
81 99 let ok_left: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM builds WHERE status='ok'")
82 - .fetch_one(&pool).await.unwrap();
100 + .fetch_one(&pool)
101 + .await
102 + .unwrap();
83 103 assert_eq!(ok_left, 1);
84 104 let err: Option<String> = sqlx::query_scalar("SELECT error FROM target_runs WHERE id = ?")
85 - .bind(tr).fetch_one(&pool).await.unwrap();
105 + .bind(tr)
106 + .fetch_one(&pool)
107 + .await
108 + .unwrap();
86 109 assert_eq!(err.as_deref(), Some("daemon restarted while running"));
87 110
88 111 // Idempotent: a second sweep finds nothing.
@@ -133,7 +133,9 @@ impl fmt::Display for Target {
133 133 impl FromStr for Target {
134 134 type Err = String;
135 135 fn from_str(s: &str) -> Result<Self, Self::Err> {
136 - let (p, a) = s.split_once('/').ok_or_else(|| format!("target `{s}` is not `platform/arch`"))?;
136 + let (p, a) = s
137 + .split_once('/')
138 + .ok_or_else(|| format!("target `{s}` is not `platform/arch`"))?;
137 139 Ok(Target::new(p.parse()?, a.parse()?))
138 140 }
139 141 }
@@ -114,7 +114,14 @@ impl RecipeCtx {
114 114 fn published_versions(&self, name: &str) -> Vec<String> {
115 115 let url = format!("https://crates.io/api/v1/crates/{name}");
116 116 let Ok(out) = std::process::Command::new("curl")
117 - .args(["-sS", "--max-time", "15", "-H", "User-Agent: bento-preflight", &url])
117 + .args([
118 + "-sS",
119 + "--max-time",
120 + "15",
121 + "-H",
122 + "User-Agent: bento-preflight",
123 + &url,
124 + ])
118 125 .output()
119 126 else {
120 127 return Vec::new();
@@ -238,7 +245,11 @@ impl RecipeCtx {
238 245 Box::new(move |seq, text| {
239 246 events::emit(
240 247 &events,
241 - Event::StepLogChunk { run_id: cb_run_id, seq, text: text.to_string() },
248 + Event::StepLogChunk {
249 + run_id: cb_run_id,
250 + seq,
251 + text: text.to_string(),
252 + },
242 253 );
243 254 }),
244 255 ));
@@ -326,7 +337,12 @@ impl RecipeCtx {
326 337 /// The step currently open, or `Build` as a default for failure
327 338 /// attribution before any step was declared.
328 339 pub fn current_step(&self) -> Step {
329 - self.current.lock().unwrap().as_ref().map(|s| s.step).unwrap_or(Step::Build)
340 + self.current
341 + .lock()
342 + .unwrap()
343 + .as_ref()
344 + .map(|s| s.step)
345 + .unwrap_or(Step::Build)
330 346 }
331 347
332 348 /// The capability-scoped executor for `name`, or an error if the host isn't
@@ -365,7 +381,14 @@ impl RecipeCtx {
365 381 })?;
366 382 let code = out.status.code().unwrap_or(-1);
367 383 let stdout = String::from_utf8_lossy(&out.stdout);
368 - let tail: String = stdout.chars().rev().take(2000).collect::<Vec<_>>().into_iter().rev().collect();
384 + let tail: String = stdout
385 + .chars()
386 + .rev()
387 + .take(2000)
388 + .collect::<Vec<_>>()
389 + .into_iter()
390 + .rev()
391 + .collect();
369 392 Ok((code, tail))
370 393 }
371 394 }
@@ -373,10 +396,12 @@ impl RecipeCtx {
373 396 // ----- error bridging: anyhow -> Rhai runtime error -----
374 397
375 398 fn rhai_err(e: impl std::fmt::Display) -> Box<EvalAltResult> {
376 - Box::new(EvalAltResult::ErrorRuntime(e.to_string().into(), rhai::Position::NONE))
399 + Box::new(EvalAltResult::ErrorRuntime(
400 + e.to_string().into(),
401 + rhai::Position::NONE,
402 + ))
377 403 }
378 404
379 -
380 405 /// A crate's publish-relevant metadata, read from `cargo metadata`.
381 406 #[derive(Debug, Clone)]
382 407 pub struct CrateMeta {
@@ -396,7 +421,10 @@ pub fn crate_meta_from_json(raw: &str) -> Result<CrateMeta> {
396 421 .and_then(|a| a.first())
397 422 .context("cargo metadata reported no package")?;
398 423 let str_field = |k: &str| {
399 - p.get(k).and_then(|x| x.as_str()).filter(|s| !s.is_empty()).map(str::to_string)
424 + p.get(k)
425 + .and_then(|x| x.as_str())
426 + .filter(|s| !s.is_empty())
427 + .map(str::to_string)
400 428 };
401 429 Ok(CrateMeta {
402 430 name: str_field("name").context("package has no name")?,
@@ -445,7 +473,10 @@ pub fn crate_publish_problems(
445 473 out.push("no `license` or `license-file`".to_string());
446 474 }
447 475 if published.iter().any(|v| v == &meta.version) {
448 - out.push(format!("version {} is already published; bump it", meta.version));
476 + out.push(format!(
477 + "version {} is already published; bump it",
478 + meta.version
479 + ));
449 480 }
450 481 out
451 482 }
@@ -475,8 +506,12 @@ pub fn version_from_repo(repo: &str, version_path: Option<&str>) -> Result<Versi
475 506 return Version::parse(&version_from_tauri_json(&raw)?).map_err(|e| anyhow::anyhow!(e));
476 507 }
477 508 let cargo_toml = root.join("Cargo.toml");
478 - let raw = std::fs::read_to_string(&cargo_toml)
479 - .with_context(|| format!("reading {} (no tauri.conf.json either)", cargo_toml.display()))?;
509 + let raw = std::fs::read_to_string(&cargo_toml).with_context(|| {
510 + format!(
511 + "reading {} (no tauri.conf.json either)",
512 + cargo_toml.display()
513 + )
514 + })?;
480 515 Version::parse(&version_from_cargo_toml(&raw)?).map_err(|e| anyhow::anyhow!(e))
481 516 }
482 517
@@ -495,7 +530,11 @@ fn version_from_cargo_toml(raw: &str) -> Result<String> {
495 530 let doc: toml::Value = toml::from_str(raw).context("parsing Cargo.toml")?;
496 531 doc.get("package")
497 532 .and_then(|p| p.get("version"))
498 - .or_else(|| doc.get("workspace").and_then(|w| w.get("package")).and_then(|p| p.get("version")))
533 + .or_else(|| {
534 + doc.get("workspace")
535 + .and_then(|w| w.get("package"))
536 + .and_then(|p| p.get("version"))
537 + })
499 538 .and_then(|v| v.as_str())
500 539 .map(str::to_owned)
501 540 .context("no `[package].version` or `[workspace.package].version` in Cargo.toml")
@@ -504,9 +543,10 @@ fn version_from_cargo_toml(raw: &str) -> Result<String> {
504 543 /// Expand a leading `~/` to `$HOME`. Paths in the topology are written with `~`.
505 544 pub fn expand_tilde(p: &str) -> PathBuf {
506 545 if let Some(rest) = p.strip_prefix("~/")
507 - && let Ok(home) = std::env::var("HOME") {
508 - return Path::new(&home).join(rest);
509 - }
546 + && let Ok(home) = std::env::var("HOME")
547 + {
548 + return Path::new(&home).join(rest);
549 + }
510 550 PathBuf::from(p)
511 551 }
512 552
@@ -522,10 +562,13 @@ pub fn build_engine(ctx: Arc<RecipeCtx>) -> Engine {
522 562 // --- step(name) ---
523 563 {
524 564 let ctx = ctx.clone();
525 - engine.register_fn("step", move |name: &str| -> Result<(), Box<EvalAltResult>> {
526 - let step: Step = name.parse().map_err(rhai_err)?;
527 - ctx.begin_step(step).map_err(rhai_err)
528 - });
565 + engine.register_fn(
566 + "step",
567 + move |name: &str| -> Result<(), Box<EvalAltResult>> {
568 + let step: Step = name.parse().map_err(rhai_err)?;
569 + ctx.begin_step(step).map_err(rhai_err)
570 + },
571 + );
529 572 }
530 573
531 574 // --- sh(host, cmd) -> #{ code, stdout_tail } ---
@@ -537,13 +580,16 @@ pub fn build_engine(ctx: Arc<RecipeCtx>) -> Engine {
537 580 // therefore bars publish via the failed-step ledger) on a non-zero exit.
538 581 {
539 582 let ctx = ctx.clone();
540 - engine.register_fn("sh", move |host: &str, cmd: &str| -> Result<Map, Box<EvalAltResult>> {
541 - let (code, tail) = ctx.run(host, cmd).map_err(rhai_err)?;
542 - let mut m = Map::new();
543 - m.insert("code".into(), (code as i64).into());
544 - m.insert("stdout_tail".into(), tail.into());
545 - Ok(m)
546 - });
583 + engine.register_fn(
584 + "sh",
585 + move |host: &str, cmd: &str| -> Result<Map, Box<EvalAltResult>> {
586 + let (code, tail) = ctx.run(host, cmd).map_err(rhai_err)?;
587 + let mut m = Map::new();
588 + m.insert("code".into(), (code as i64).into());
589 + m.insert("stdout_tail".into(), tail.into());
590 + Ok(m)
591 + },
592 + );
547 593 }
548 594
549 595 // --- sh_ok(host, cmd): run + assert exit 0 (the must-succeed primitive) ---
@@ -553,16 +599,21 @@ pub fn build_engine(ctx: Arc<RecipeCtx>) -> Engine {
553 599 // must-succeed command failed.
554 600 {
555 601 let ctx = ctx.clone();
556 - engine.register_fn("sh_ok", move |host: &str, cmd: &str| -> Result<(), Box<EvalAltResult>> {
557 - let (code, _) = ctx.run(host, cmd).map_err(rhai_err)?;
558 - if code != 0 {
559 - // Attribute the failure to the current step explicitly so the
560 - // ledger bars publish even if a future caller swallowed the error.
561 - ctx.fail_current_step();
562 - return Err(rhai_err(format!("command on `{host}` exited {code}: {cmd}")));
563 - }
564 - Ok(())
565 - });
602 + engine.register_fn(
603 + "sh_ok",
604 + move |host: &str, cmd: &str| -> Result<(), Box<EvalAltResult>> {
605 + let (code, _) = ctx.run(host, cmd).map_err(rhai_err)?;
606 + if code != 0 {
607 + // Attribute the failure to the current step explicitly so the
608 + // ledger bars publish even if a future caller swallowed the error.
609 + ctx.fail_current_step();
610 + return Err(rhai_err(format!(
611 + "command on `{host}` exited {code}: {cmd}"
612 + )));
613 + }
614 + Ok(())
615 + },
616 + );
566 617 }
567 618
568 619 // --- log(msg): operator-visible line into the current step's tail ---
@@ -582,13 +633,18 @@ pub fn build_engine(ctx: Arc<RecipeCtx>) -> Engine {
582 633 // --- version_of(app) -> string ---
583 634 {
584 635 let ctx = ctx.clone();
585 - engine.register_fn("version_of", move |app: &str| -> Result<String, Box<EvalAltResult>> {
586 - // Only the current app is in scope; cross-app reads aren't needed.
587 - if app != ctx.app.as_str() {
588 - return Err(rhai_err(format!("version_of: `{app}` is not the app being built")));
589 - }
590 - Ok(ctx.version.to_string())
591 - });
636 + engine.register_fn(
637 + "version_of",
638 + move |app: &str| -> Result<String, Box<EvalAltResult>> {
639 + // Only the current app is in scope; cross-app reads aren't needed.
640 + if app != ctx.app.as_str() {
641 + return Err(rhai_err(format!(
642 + "version_of: `{app}` is not the app being built"
643 + )));
644 + }
645 + Ok(ctx.version.to_string())
646 + },
647 + );
592 648 }
593 649
594 650 // --- version() -> string: the version being built (no-arg form) ---
@@ -615,59 +671,65 @@ pub fn build_engine(ctx: Arc<RecipeCtx>) -> Engine {
615 671 // repository URL is permanent. pter 0.1.0 shipped with a dead one. ---
616 672 {
617 673 let ctx = ctx.clone();
618 - engine.register_fn("crate_preflight", move || -> Result<String, Box<EvalAltResult>> {
619 - let repo = expand_tilde(&ctx.repo);
620 -
621 - let out = std::process::Command::new("cargo")
622 - .args(["metadata", "--no-deps", "--format-version", "1"])
623 - .current_dir(&repo)
624 - .output()
625 - .map_err(|e| format!("running cargo metadata in {}: {e}", repo.display()))?;
626 - if !out.status.success() {
627 - return Err(format!(
628 - "cargo metadata failed in {}: {}",
629 - repo.display(),
630 - String::from_utf8_lossy(&out.stderr).trim()
631 - )
632 - .into());
633 - }
634 - let meta = crate_meta_from_json(&String::from_utf8_lossy(&out.stdout))
635 - .map_err(|e| e.to_string())?;
636 -
637 - // The real question is not whether a page renders but whether a
638 - // stranger with no credentials can fetch the source, so ask git.
639 - let clonable = meta.repository.as_ref().is_some_and(|url| {
640 - std::process::Command::new("git")
641 - .args(["ls-remote", url])
642 - .env("GIT_TERMINAL_PROMPT", "0")
643 - .output()
644 - .map(|o| o.status.success())
645 - .unwrap_or(false)
646 - });
674 + engine.register_fn(
675 + "crate_preflight",
676 + move || -> Result<String, Box<EvalAltResult>> {
677 + let repo = expand_tilde(&ctx.repo);
647 678
648 - // Ask the publishing host whether cargo has credentials, rather
649 - // than moving the token anywhere. It stays in cargo's own 0600
650 - // store; a shell line carrying it would be visible in `ps`.
651 - let creds = ctx
652 - .run(&ctx.build_host.clone(), "cargo login --help >/dev/null 2>&1 && \
679 + let out = std::process::Command::new("cargo")
680 + .args(["metadata", "--no-deps", "--format-version", "1"])
681 + .current_dir(&repo)
682 + .output()
683 + .map_err(|e| format!("running cargo metadata in {}: {e}", repo.display()))?;
684 + if !out.status.success() {
685 + return Err(format!(
686 + "cargo metadata failed in {}: {}",
687 + repo.display(),
688 + String::from_utf8_lossy(&out.stderr).trim()
689 + )
690 + .into());
691 + }
692 + let meta = crate_meta_from_json(&String::from_utf8_lossy(&out.stdout))
693 + .map_err(|e| e.to_string())?;
694 +
695 + // The real question is not whether a page renders but whether a
696 + // stranger with no credentials can fetch the source, so ask git.
697 + let clonable = meta.repository.as_ref().is_some_and(|url| {
698 + std::process::Command::new("git")
699 + .args(["ls-remote", url])
700 + .env("GIT_TERMINAL_PROMPT", "0")
701 + .output()
702 + .map(|o| o.status.success())
703 + .unwrap_or(false)
704 + });
705 +
706 + // Ask the publishing host whether cargo has credentials, rather
707 + // than moving the token anywhere. It stays in cargo's own 0600
708 + // store; a shell line carrying it would be visible in `ps`.
709 + let creds = ctx
710 + .run(
711 + &ctx.build_host.clone(),
712 + "cargo login --help >/dev/null 2>&1 && \
653 713 test -s \"${CARGO_HOME:-$HOME/.cargo}/credentials.toml\" \
654 - || test -s \"${CARGO_HOME:-$HOME/.cargo}/credentials\"")
655 - .map(|(code, _)| code == 0)
656 - .unwrap_or(false);
657 -
658 - let published = ctx.published_versions(&meta.name);
659 - let problems = crate_publish_problems(&meta, clonable, &published, creds);
660 - if !problems.is_empty() {
661 - return Err(format!(
662 - "{} {} is not safe to publish:\n - {}",
663 - meta.name,
664 - meta.version,
665 - problems.join("\n - ")
666 - )
667 - .into());
668 - }
669 - Ok(format!("{} {} passed preflight", meta.name, meta.version))
670 - });
714 + || test -s \"${CARGO_HOME:-$HOME/.cargo}/credentials\"",
715 + )
716 + .map(|(code, _)| code == 0)
717 + .unwrap_or(false);
718 +
719 + let published = ctx.published_versions(&meta.name);
720 + let problems = crate_publish_problems(&meta, clonable, &published, creds);
721 + if !problems.is_empty() {
722 + return Err(format!(
723 + "{} {} is not safe to publish:\n - {}",
724 + meta.name,
725 + meta.version,
726 + problems.join("\n - ")
727 + )
728 + .into());
729 + }
730 + Ok(format!("{} {} passed preflight", meta.name, meta.version))
731 + },
732 + );
671 733 }
672 734
673 735 // --- feature_flags() -> string: `--features a,b`, or "" when the app
@@ -692,11 +754,15 @@ pub fn build_engine(ctx: Arc<RecipeCtx>) -> Engine {
692 754 }
693 755 {
694 756 let ctx = ctx.clone();
695 - engine.register_fn("platform", move || -> String { ctx.target.platform.as_str().to_string() });
757 + engine.register_fn("platform", move || -> String {
758 + ctx.target.platform.as_str().to_string()
759 + });
696 760 }
697 761 {
698 762 let ctx = ctx.clone();
699 - engine.register_fn("arch", move || -> String { ctx.target.arch.as_str().to_string() });
763 + engine.register_fn("arch", move || -> String {
764 + ctx.target.arch.as_str().to_string()
765 + });
700 766 }
701 767
702 768 // --- secret(key) -> string (file under secrets_root; never logged) ---
@@ -729,29 +795,35 @@ pub fn build_engine(ctx: Arc<RecipeCtx>) -> Engine {
729 795 // --- env(host, key) -> string ---
730 796 {
731 797 let ctx = ctx.clone();
732 - engine.register_fn("env", move |host: &str, key: &str| -> Result<String, Box<EvalAltResult>> {
733 - // The key is interpolated into a `${...}` shell expansion, so it must
734 - // be a bare shell identifier — anything else (quotes, `}`, `$`, `;`)
735 - // could break out and run arbitrary commands on the host. Validate
736 - // before building the command; this is the one env read that can't
737 - // sh-quote its argument (a quoted var name doesn't expand).
738 - if key.is_empty()
739 - || !key.chars().next().is_some_and(|c| c == '_' || c.is_ascii_alphabetic())
740 - || !key.chars().all(|c| c == '_' || c.is_ascii_alphanumeric())
741 - {
742 - return Err(rhai_err(format!(
743 - "env name `{key}` must be a shell identifier ([A-Za-z_][A-Za-z0-9_]*)"
744 - )));
745 - }
746 - // Read via the shell so it works on remote hosts too.
747 - let (code, tail) = ctx
748 - .run(host, &format!("printf '%s' \"${{{key}}}\""))
749 - .map_err(rhai_err)?;
750 - if code != 0 {
751 - return Err(rhai_err(format!("env `{key}` on `{host}` failed")));
752 - }
753 - Ok(tail.trim().to_string())
754 - });
798 + engine.register_fn(
799 + "env",
800 + move |host: &str, key: &str| -> Result<String, Box<EvalAltResult>> {
801 + // The key is interpolated into a `${...}` shell expansion, so it must
802 + // be a bare shell identifier — anything else (quotes, `}`, `$`, `;`)
803 + // could break out and run arbitrary commands on the host. Validate
804 + // before building the command; this is the one env read that can't
805 + // sh-quote its argument (a quoted var name doesn't expand).
806 + if key.is_empty()
807 + || !key
808 + .chars()
809 + .next()
810 + .is_some_and(|c| c == '_' || c.is_ascii_alphabetic())
811 + || !key.chars().all(|c| c == '_' || c.is_ascii_alphanumeric())
812 + {
813 + return Err(rhai_err(format!(
814 + "env name `{key}` must be a shell identifier ([A-Za-z_][A-Za-z0-9_]*)"
815 + )));
816 + }
817 + // Read via the shell so it works on remote hosts too.
818 + let (code, tail) = ctx
819 + .run(host, &format!("printf '%s' \"${{{key}}}\""))
820 + .map_err(rhai_err)?;
821 + if code != 0 {
822 + return Err(rhai_err(format!("env `{key}` on `{host}` failed")));
823 + }
824 + Ok(tail.trim().to_string())
825 + },
826 + );
755 827 }
756 828
757 829 // --- collect(host, glob, app, version): pull artifacts to dist_root ---
@@ -759,7 +831,11 @@ pub fn build_engine(ctx: Arc<RecipeCtx>) -> Engine {
759 831 let ctx = ctx.clone();
760 832 engine.register_fn(
761 833 "collect",
762 - move |host: &str, glob: &str, app: &str, version: &str| -> Result<(), Box<EvalAltResult>> {
834 + move |host: &str,
835 + glob: &str,
836 + app: &str,
837 + version: &str|
838 + -> Result<(), Box<EvalAltResult>> {
763 839 ctx.collect(host, glob, app, version).map_err(rhai_err)
764 840 },
765 841 );
@@ -770,8 +846,15 @@ pub fn build_engine(ctx: Arc<RecipeCtx>) -> Engine {
770 846 let ctx = ctx.clone();
771 847 engine.register_fn(
772 848 "publish",
773 - move |channel: &str, app: &str, target: &str, version: &str, artifact: &str, meta: Map| -> Result<String, Box<EvalAltResult>> {
774 - ctx.publish(channel, app, target, version, artifact, meta).map_err(rhai_err)
849 + move |channel: &str,
850 + app: &str,
851 + target: &str,
852 + version: &str,
853 + artifact: &str,
854 + meta: Map|
855 + -> Result<String, Box<EvalAltResult>> {
856 + ctx.publish(channel, app, target, version, artifact, meta)
857 + .map_err(rhai_err)
775 858 },
776 859 );
777 860 }
@@ -796,7 +879,22 @@ impl RecipeCtx {
796 879 // Not a privilege boundary — a recipe already runs arbitrary shell via
797 880 // `sh_ok` — but it keeps a malformed pattern from becoming a command.
798 881 anyhow::ensure!(
799 - !glob.chars().any(|c| matches!(c, ';' | '&' | '|' | '$' | '`' | '\'' | '"' | '\\' | ' ' | '\n' | '(' | ')' | '<' | '>')),
882 + !glob.chars().any(|c| matches!(
883 + c,
884 + ';' | '&'
885 + | '|'
886 + | '$'
887 + | '`'
888 + | '\''
889 + | '"'
890 + | '\\'
891 + | ' '
892 + | '\n'
893 + | '('
894 + | ')'
895 + | '<'
896 + | '>'
897 + )),
800 898 "collect glob `{glob}` contains shell metacharacters"
801 899 );
802 900 std::fs::create_dir_all(&dest)
@@ -836,7 +934,10 @@ impl RecipeCtx {
836 934 // Never let a superseded build ship. This is the last and most important
837 935 // cooperative-cancel checkpoint: even if a long-running step finished
838 936 // after supersession, the artifact must not reach the backend.
839 - anyhow::ensure!(!self.is_cancelled(), "build superseded by a newer request; refusing to publish");
937 + anyhow::ensure!(
938 + !self.is_cancelled(),
939 + "build superseded by a newer request; refusing to publish"
940 + );
840 941 let backend = self
841 942 .ota
842 943 .get(channel)
@@ -859,7 +960,8 @@ impl RecipeCtx {
859 960 // `releases` column is TEXT, so compare by parsed semver precedence
860 961 // (Version: Ord), not lexically.
861 962 {
862 - let (app_s, target_s, chan_s) = (app.to_string(), target.to_string(), channel.to_string());
963 + let (app_s, target_s, chan_s) =
964 + (app.to_string(), target.to_string(), channel.to_string());
863 965 let me = self.clone();
864 966 let latest: Option<Version> = self.rt.block_on(async move {
865 967 let rows: Vec<(String,)> = sqlx::query_as(
@@ -871,7 +973,9 @@ impl RecipeCtx {
871 973 .fetch_all(&me.pool)
872 974 .await
873 975 .unwrap_or_default();
874 - rows.into_iter().filter_map(|(v,)| Version::parse(&v).ok()).max()
976 + rows.into_iter()
977 + .filter_map(|(v,)| Version::parse(&v).ok())
978 + .max()
875 979 });
876 980 if let Some(latest) = latest {
877 981 anyhow::ensure!(
@@ -891,17 +995,29 @@ impl RecipeCtx {
891 995 let gatekeeper = *self.gatekeeper_ok.lock().unwrap();
Lines truncated
@@ -23,11 +23,17 @@ impl IntoResponse for Error {
23 23 Error::BadRequest(m) => (StatusCode::BAD_REQUEST, m.clone()),
24 24 Error::Db(e) => {
25 25 tracing::error!(error = %e, "internal db error");
26 - (StatusCode::INTERNAL_SERVER_ERROR, "internal server error".to_string())
26 + (
27 + StatusCode::INTERNAL_SERVER_ERROR,
28 + "internal server error".to_string(),
29 + )
27 30 }
28 31 Error::Other(e) => {
29 32 tracing::error!(error = format!("{e:#}"), "internal error");
30 - (StatusCode::INTERNAL_SERVER_ERROR, "internal server error".to_string())
33 + (
34 + StatusCode::INTERNAL_SERVER_ERROR,
35 + "internal server error".to_string(),
36 + )
31 37 }
32 38 };
33 39 (status, axum::Json(serde_json::json!({ "error": message }))).into_response()
@@ -31,25 +31,79 @@ pub fn emit(tx: &EventTx, event: Event) {
31 31 #[serde(tag = "kind", rename_all = "snake_case")]
32 32 pub enum Event {
33 33 /// A `/build` was accepted.
34 - BuildRequested { app: AppId, version: Version, targets: Vec<Target> },
34 + BuildRequested {
35 + app: AppId,
36 + version: Version,
37 + targets: Vec<Target>,
38 + },
35 39 /// A previous in-flight run for this `(app, target)` was aborted because a
36 40 /// newer request arrived.
37 41 TargetAborted { app: AppId, target: Target },
38 - TargetStart { app: AppId, version: Version, target: Target },
39 - StepStart { run_id: StepRunId, app: AppId, version: Version, target: Target, step: Step },
42 + TargetStart {
43 + app: AppId,
44 + version: Version,
45 + target: Target,
46 + },
47 + StepStart {
48 + run_id: StepRunId,
49 + app: AppId,
50 + version: Version,
51 + target: Target,
52 + step: Step,
53 + },
40 54 /// A chunk of merged stdout+stderr from the step currently running.
41 55 /// `run_id` ties back to the `StepStart`; `seq` is a per-run monotonic
42 56 /// counter; `text` is UTF-8-lossy and NOT line-aligned. The on-disk log is
43 57 /// the byte-exact source.
44 - StepLogChunk { run_id: StepRunId, seq: u32, text: String },
45 - StepDone { run_id: StepRunId, app: AppId, target: Target, step: Step, status: Status },
46 - TargetOk { app: AppId, version: Version, target: Target, artifacts: Vec<String> },
47 - TargetFailed { app: AppId, version: Version, target: Target, step: Step, error: String },
58 + StepLogChunk {
59 + run_id: StepRunId,
60 + seq: u32,
61 + text: String,
62 + },
63 + StepDone {
64 + run_id: StepRunId,
65 + app: AppId,
66 + target: Target,
67 + step: Step,
68 + status: Status,
69 + },
70 + TargetOk {
71 + app: AppId,
72 + version: Version,
73 + target: Target,
74 + artifacts: Vec<String>,
75 + },
76 + TargetFailed {
77 + app: AppId,
78 + version: Version,
79 + target: Target,
80 + step: Step,
81 + error: String,
82 + },
48 83 /// Notarization is the one flaky, network-bound step; retries are surfaced.
49 - NotarizeRetry { app: AppId, target: Target, attempt: u32, reason: String },
50 - ArtifactCollected { app: AppId, target: Target, path: String, bytes: i64 },
51 - PublishOk { app: AppId, target: Target, channel: String },
52 - PublishFailed { app: AppId, target: Target, channel: String, error: String },
84 + NotarizeRetry {
85 + app: AppId,
86 + target: Target,
87 + attempt: u32,
88 + reason: String,
89 + },
90 + ArtifactCollected {
91 + app: AppId,
92 + target: Target,
93 + path: String,
94 + bytes: i64,
95 + },
96 + PublishOk {
97 + app: AppId,
98 + target: Target,
99 + channel: String,
100 + },
101 + PublishFailed {
102 + app: AppId,
103 + target: Target,
104 + channel: String,
105 + error: String,
106 + },
53 107 }
54 108
55 109 #[cfg(test)]
@@ -72,7 +126,14 @@ mod tests {
72 126
73 127 #[test]
74 128 fn only_log_chunks_are_high_rate() {
75 - assert!(Event::StepLogChunk { run_id: StepRunId(1), seq: 0, text: "x".into() }.is_high_rate());
129 + assert!(
130 + Event::StepLogChunk {
131 + run_id: StepRunId(1),
132 + seq: 0,
133 + text: "x".into()
134 + }
135 + .is_high_rate()
136 + );
76 137 assert!(!publish_failed().is_high_rate());
77 138 }
78 139
@@ -86,7 +147,14 @@ mod tests {
86 147 let mut logs_rx = tx.subscribe_logs();
87 148 emit(&tx, publish_failed());
88 149 for i in 0..(LOG_CAPACITY + 50) {
89 - emit(&tx, Event::StepLogChunk { run_id: StepRunId(1), seq: i as u32, text: "x".into() });
150 + emit(
151 + &tx,
152 + Event::StepLogChunk {
153 + run_id: StepRunId(1),
154 + seq: i as u32,
155 + text: "x".into(),
156 + },
157 + );
90 158 }
91 159 // The status event is intact despite the log overflow.
92 160 let env = status_rx.recv().await.expect("status event survived");
@@ -101,8 +169,12 @@ mod tests {
101 169 #[tokio::test]
102 170 async fn envelope_serializes_with_flat_kind() {
103 171 // Contract for the WS handler + TUI's `format_event_line`.
104 - let env = EventEnvelope { at: chrono::Utc::now(), event: publish_failed() };
105 - let v: serde_json::Value = serde_json::from_str(&serde_json::to_string(&env).unwrap()).unwrap();
172 + let env = EventEnvelope {
173 + at: chrono::Utc::now(),
174 + event: publish_failed(),
175 + };
176 + let v: serde_json::Value =
177 + serde_json::from_str(&serde_json::to_string(&env).unwrap()).unwrap();
106 178 assert_eq!(v["kind"], "publish_failed");
107 179 assert_eq!(v["channel"], "stable");
108 180 assert!(v.get("event").is_none());
@@ -29,10 +29,17 @@ async fn main() -> Result<()> {
29 29 // and target tasks don't survive a restart) so they don't orphan forever.
30 30 match db::recover_orphaned_running(&pool).await {
31 31 Ok(0) => {}
32 - Ok(n) => tracing::warn!(reconciled = n, "marked orphaned `running` builds failed after restart"),
32 + Ok(n) => tracing::warn!(
33 + reconciled = n,
34 + "marked orphaned `running` builds failed after restart"
35 + ),
33 36 Err(e) => tracing::error!(error = %e, "failed to reconcile orphaned running builds"),
34 37 }
35 - tracing::info!(hosts = topo.hosts.len(), apps = topo.app.len(), "topology loaded");
38 + tracing::info!(
39 + hosts = topo.hosts.len(),
40 + apps = topo.app.len(),
41 + "topology loaded"
42 + );
36 43
37 44 let prom = metrics::init();
38 45 let addr: SocketAddr = cfg.listen.parse()?;
@@ -52,7 +59,9 @@ async fn main() -> Result<()> {
52 59 }
53 60 match &api_token {
54 61 Some(_) => tracing::info!("build endpoints require a bearer token"),
55 - None => tracing::warn!(%addr, "BENTO_API_TOKEN unset; build endpoints are UNAUTHENTICATED (loopback bind)"),
62 + None => {
63 + tracing::warn!(%addr, "BENTO_API_TOKEN unset; build endpoints are UNAUTHENTICATED (loopback bind)")
64 + }
56 65 }
57 66
58 67 let executors = Arc::new(state::build_executors(&topo));
@@ -79,14 +88,15 @@ async fn main() -> Result<()> {
79 88 let mut tick = tokio::time::interval(std::time::Duration::from_secs(6 * 60 * 60));
80 89 loop {
81 90 tick.tick().await; // fires immediately, then every 6h
82 - let active: bento_daemon::retention::ActiveVersions = sqlx::query_as::<_, (String, String)>(
83 - "SELECT DISTINCT app, version FROM target_runs WHERE status = 'running'",
84 - )
85 - .fetch_all(&pool)
86 - .await
87 - .unwrap_or_default()
88 - .into_iter()
89 - .collect();
91 + let active: bento_daemon::retention::ActiveVersions =
92 + sqlx::query_as::<_, (String, String)>(
93 + "SELECT DISTINCT app, version FROM target_runs WHERE status = 'running'",
94 + )
95 + .fetch_all(&pool)
96 + .await
97 + .unwrap_or_default()
98 + .into_iter()
99 + .collect();
90 100 let cfg = cfg.clone();
91 101 let _ = tokio::task::spawn_blocking(move || {
92 102 bento_daemon::retention::prune_once(&cfg, &active)
@@ -3,7 +3,9 @@ use metrics::{counter, gauge, histogram};
3 3 use metrics_exporter_prometheus::{PrometheusBuilder, PrometheusHandle};
4 4
5 5 pub fn init() -> PrometheusHandle {
6 - PrometheusBuilder::new().install_recorder().expect("install prometheus recorder")
6 + PrometheusBuilder::new()
7 + .install_recorder()
8 + .expect("install prometheus recorder")
7 9 }
8 10
9 11 // --- Emission ---------------------------------------------------------------
@@ -52,7 +52,11 @@ impl PublishAuthority {
52 52 failed_steps.is_empty(),
53 53 "refusing to publish {target}: {} prior step(s) failed ({})",
54 54 failed_steps.len(),
55 - failed_steps.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", "),
55 + failed_steps
56 + .iter()
57 + .map(|s| s.as_str())
58 + .collect::<Vec<_>>()
59 + .join(", "),
56 60 );
57 61 if matches!(target.platform, Platform::Macos | Platform::Ios) {
58 62 match gatekeeper_ok {
@@ -94,7 +98,12 @@ pub trait OtaBackend: Send + Sync {
94 98 /// version+artifact is a no-op, not a duplicate release). Requires a
95 99 /// [`PublishAuthority`] — the type-level proof the artifact cleared the
96 100 /// publish gate — so this method is uncallable for an unverified artifact.
97 - fn publish(&self, rel: &Release, artifact: &Path, authority: &PublishAuthority) -> Result<String>;
101 + fn publish(
102 + &self,
103 + rel: &Release,
104 + artifact: &Path,
105 + authority: &PublishAuthority,
106 + ) -> Result<String>;
98 107 }
99 108
100 109 /// Named lookup of registered backends.
@@ -119,7 +128,9 @@ impl OtaRegistry {
119 128 /// The standard registry: `tauri-mnw` wired to the MNW OTA endpoint.
120 129 pub fn standard(mnw_base_url: impl Into<String>) -> Self {
121 130 let mut reg = Self::new();
122 - reg.register(Box::new(TauriMnwBackend { base_url: mnw_base_url.into() }));
131 + reg.register(Box::new(TauriMnwBackend {
132 + base_url: mnw_base_url.into(),
133 + }));
123 134 reg
124 135 }
125 136 }
@@ -144,7 +155,12 @@ impl OtaBackend for TauriMnwBackend {
144 155 matches!(target.platform, Macos | Linux | Windows)
145 156 }
146 157
147 - fn publish(&self, rel: &Release, artifact: &Path, authority: &PublishAuthority) -> Result<String> {
158 + fn publish(
159 + &self,
160 + rel: &Release,
161 + artifact: &Path,
162 + authority: &PublishAuthority,
163 + ) -> Result<String> {
148 164 // Defense-in-depth: the authority is proof the gate passed for *this*
149 165 // target; refuse a mismatched one rather than trust the caller paired
150 166 // them correctly.
@@ -161,8 +177,16 @@ impl OtaBackend for TauriMnwBackend {
161 177 // pre-check that catches a failed transfer.
162 178 let meta = std::fs::metadata(artifact)
163 179 .with_context(|| format!("artifact {} is not accessible", artifact.display()))?;
164 - anyhow::ensure!(meta.is_file(), "artifact {} is not a regular file", artifact.display());
165 - anyhow::ensure!(meta.len() > 0, "artifact {} is empty (0 bytes)", artifact.display());
180 + anyhow::ensure!(
181 + meta.is_file(),
182 + "artifact {} is not a regular file",
183 + artifact.display()
184 + );
185 + anyhow::ensure!(
186 + meta.len() > 0,
187 + "artifact {} is empty (0 bytes)",
188 + artifact.display()
189 + );
166 190 // P2: POST the release + upload the artifact + its minisign .sig to the
167 191 // MNW OTA API at `self.base_url`. Until then, report what would happen
168 192 // so the recipe path is exercised without a half-published release.
@@ -199,7 +223,12 @@ mod tests {
199 223 let app = AppId::new("goingson");
200 224 let ver = Version::parse("0.4.1").unwrap();
201 225 let target: Target = "macos/aarch64".parse().unwrap();
202 - let rel = Release { app: &app, target, version: &ver, notes: String::new() };
226 + let rel = Release {
227 + app: &app,
228 + target,
229 + version: &ver,
230 + notes: String::new(),
231 + };
203 232 let auth = PublishAuthority::for_test(target);
204 233 assert!(b.publish(&rel, Path::new("/no/such/file"), &auth).is_err());
205 234 }
@@ -211,7 +240,12 @@ mod tests {
211 240 let app = AppId::new("goingson");
212 241 let ver = Version::parse("0.4.1").unwrap();
213 242 let target: Target = "macos/aarch64".parse().unwrap();
214 - let rel = Release { app: &app, target, version: &ver, notes: String::new() };
243 + let rel = Release {
244 + app: &app,
245 + target,
246 + version: &ver,
247 + notes: String::new(),
248 + };
215 249 let auth = PublishAuthority::for_test(target);
216 250 let tmp = tempfile::tempdir().unwrap();
217 251 // Zero-byte file: exists() would pass, but a truncated collect must not publish.
@@ -231,7 +265,12 @@ mod tests {
231 265 let app = AppId::new("goingson");
232 266 let ver = Version::parse("0.4.1").unwrap();
233 267 let rel_target: Target = "macos/aarch64".parse().unwrap();
234 - let rel = Release { app: &app, target: rel_target, version: &ver, notes: String::new() };
268 + let rel = Release {
269 + app: &app,
270 + target: rel_target,
271 + version: &ver,
272 + notes: String::new(),
273 + };
235 274 // Authority proven for a different target must not wave this release through.
236 275 let auth = PublishAuthority::for_test("linux/x86_64".parse().unwrap());
237 276 let tmp = tempfile::tempdir().unwrap();
@@ -31,7 +31,9 @@ pub fn prune_once(cfg: &Config, active: &ActiveVersions) {
31 31 }
32 32
33 33 fn prune_root(root: &Path, label: &str, active: &ActiveVersions) {
34 - let Ok(apps) = std::fs::read_dir(root) else { return };
34 + let Ok(apps) = std::fs::read_dir(root) else {
35 + return;
36 + };
35 37 for app in apps.filter_map(|e| e.ok()) {
36 38 let app_dir = app.path();
37 39 if app_dir.is_dir() {
@@ -42,7 +44,9 @@ fn prune_root(root: &Path, label: &str, active: &ActiveVersions) {
42 44 }
43 45
44 46 fn prune_app_dir(app_dir: &Path, app_name: &str, label: &str, active: &ActiveVersions) {
45 - let Ok(entries) = std::fs::read_dir(app_dir) else { return };
47 + let Ok(entries) = std::fs::read_dir(app_dir) else {
48 + return;
49 + };
46 50 // Only consider subdirectories whose name parses as a semver — anything
47 51 // else (a stray file, a non-version dir) is left untouched.
48 52 let mut versions: Vec<(Version, String, PathBuf)> = entries
@@ -65,8 +69,12 @@ fn prune_app_dir(app_dir: &Path, app_name: &str, label: &str, active: &ActiveVer
65 69 continue;
66 70 }
67 71 match std::fs::remove_dir_all(&path) {
68 - Ok(()) => tracing::info!(%ver, dir = %path.display(), "retention: pruned old {label} version"),
69 - Err(e) => tracing::warn!(error = %e, dir = %path.display(), "retention: could not prune {label} dir"),
72 + Ok(()) => {
73 + tracing::info!(%ver, dir = %path.display(), "retention: pruned old {label} version")
74 + }
75 + Err(e) => {
76 + tracing::warn!(error = %e, dir = %path.display(), "retention: could not prune {label} dir")
77 + }
70 78 }
71 79 }
72 80 }
@@ -86,7 +94,9 @@ mod tests {
86 94 let tmp = tempfile::tempdir().unwrap();
87 95 let root = tmp.path().join("dist");
88 96 // Seven versions; lexical order would mis-rank 0.4.10 vs 0.4.9.
89 - for v in ["0.4.0", "0.4.1", "0.4.2", "0.4.9", "0.4.10", "0.5.0", "0.5.1"] {
97 + for v in [
98 + "0.4.0", "0.4.1", "0.4.2", "0.4.9", "0.4.10", "0.5.0", "0.5.1",
99 + ] {
90 100 touch_version(&root, "demo", v);
91 101 }
92 102 // A non-version dir must be left alone.
@@ -101,7 +111,10 @@ mod tests {
101 111 .collect();
102 112 kept.sort();
103 113 // Newest 5 by semver: 0.5.1, 0.5.0, 0.4.10, 0.4.9, 0.4.2 (+ the non-version dir).
104 - assert_eq!(kept, vec!["0.4.10", "0.4.2", "0.4.9", "0.5.0", "0.5.1", "scratch"]);
114 + assert_eq!(
115 + kept,
116 + vec!["0.4.10", "0.4.2", "0.4.9", "0.5.0", "0.5.1", "scratch"]
117 + );
105 118 }
106 119
107 120 #[test]
@@ -120,15 +133,25 @@ mod tests {
120 133 let tmp = tempfile::tempdir().unwrap();
121 134 let root = tmp.path().join("dist");
122 135 // Seven versions, keep 5 -> 0.1.0 and 0.2.0 are beyond the window.
123 - for v in ["0.1.0", "0.2.0", "0.3.0", "0.4.0", "0.5.0", "0.6.0", "0.7.0"] {
136 + for v in [
137 + "0.1.0", "0.2.0", "0.3.0", "0.4.0", "0.5.0", "0.6.0", "0.7.0",
138 + ] {
124 139 touch_version(&root, "demo", v);
125 140 }
126 141 // 0.1.0 is beyond the window but is being built right now.
127 - let active: ActiveVersions = [("demo".to_string(), "0.1.0".to_string())].into_iter().collect();
142 + let active: ActiveVersions = [("demo".to_string(), "0.1.0".to_string())]
143 + .into_iter()
144 + .collect();
128 145 prune_root(&root, "dist", &active);
129 - assert!(root.join("demo").join("0.1.0").exists(), "in-flight version must survive");
146 + assert!(
147 + root.join("demo").join("0.1.0").exists(),
148 + "in-flight version must survive"
149 + );
130 150 // 0.2.0 (also beyond the window, not active) is pruned instead.
131 151 assert!(!root.join("demo").join("0.2.0").exists());
132 - assert!(root.join("demo").join("0.3.0").exists(), "newest 5 are kept");
152 + assert!(
153 + root.join("demo").join("0.3.0").exists(),
154 + "newest 5 are kept"
155 + );
133 156 }
134 157 }
@@ -72,7 +72,11 @@ async fn require_bearer(
72 72 next.run(req).await
73 73 } else {
74 74 tracing::warn!(path = %path, "rejected unauthenticated request to a build trigger");
75 - (axum::http::StatusCode::UNAUTHORIZED, "missing or invalid bearer token\n").into_response()
75 + (
76 + axum::http::StatusCode::UNAUTHORIZED,
77 + "missing or invalid bearer token\n",
78 + )
79 + .into_response()
76 80 }
77 81 }
78 82
@@ -150,7 +154,9 @@ async fn get_state(State(s): State<AppState>) -> Result<Json<StateView>> {
150 154 let Some(b) = latest else {
151 155 return Ok(Json(StateView { build: None }));
152 156 };
153 - Ok(Json(StateView { build: Some(build_view(&s, &b).await?) }))
157 + Ok(Json(StateView {
158 + build: Some(build_view(&s, &b).await?),
159 + }))
154 160 }
155 161
156 162 /// Hydrate one `builds` row into its target x step matrix.
@@ -277,7 +283,10 @@ struct BuildBody {
277 283 targets: Vec<String>,
278 284 }
279 285
280 - async fn build(State(s): State<AppState>, Json(body): Json<BuildBody>) -> Result<Json<serde_json::Value>> {
286 + async fn build(
287 + State(s): State<AppState>,
288 + Json(body): Json<BuildBody>,
289 + ) -> Result<Json<serde_json::Value>> {
281 290 let app = AppId::new(body.app);
282 291 let targets = parse_targets(body.targets)?;
283 292 let version = runner::resolve_version(&s, &app, body.version)?;
@@ -285,7 +294,9 @@ async fn build(State(s): State<AppState>, Json(body): Json<BuildBody>) -> Result
285 294 let build_id = runner::start_build(s, app, version.clone(), targets)
286 295 .await
287 296 .map_err(Error::Other)?;
288 - Ok(Json(serde_json::json!({ "accepted": true, "build_id": build_id, "version": version.to_string() })))
297 + Ok(Json(
298 + serde_json::json!({ "accepted": true, "build_id": build_id, "version": version.to_string() }),
299 + ))
289 300 }
290 301
291 302 #[derive(Deserialize)]
@@ -296,7 +307,10 @@ struct RetryBody {
296 307 version: Option<String>,
297 308 }
298 309
299 - async fn retry(State(s): State<AppState>, Json(body): Json<RetryBody>) -> Result<Json<serde_json::Value>> {
310 + async fn retry(
311 + State(s): State<AppState>,
312 + Json(body): Json<RetryBody>,
313 + ) -> Result<Json<serde_json::Value>> {
300 314 let app = AppId::new(body.app);
301 315 let target: Target = body.target.parse().map_err(Error::BadRequest)?;
302 316 let version = runner::resolve_version(&s, &app, body.version)?;
@@ -304,11 +318,15 @@ async fn retry(State(s): State<AppState>, Json(body): Json<RetryBody>) -> Result
304 318 let build_id = runner::start_build(s, app, version.clone(), targets)
305 319 .await
306 320 .map_err(Error::Other)?;
307 - Ok(Json(serde_json::json!({ "accepted": true, "build_id": build_id, "target": target.to_string() })))
321 + Ok(Json(
322 + serde_json::json!({ "accepted": true, "build_id": build_id, "target": target.to_string() }),
323 + ))
308 324 }
309 325
310 326 fn parse_targets(raw: Vec<String>) -> Result<Vec<Target>> {
311 - raw.into_iter().map(|t| t.parse::<Target>().map_err(Error::BadRequest)).collect()
327 + raw.into_iter()
328 + .map(|t| t.parse::<Target>().map_err(Error::BadRequest))
329 + .collect()
312 330 }
313 331
314 332 async fn get_step_log(
@@ -318,10 +336,19 @@ async fn get_step_log(
318 336 fn safe(seg: &str) -> bool {
319 337 !seg.is_empty() && !seg.contains('/') && !seg.contains('\\') && seg != "." && seg != ".."
320 338 }
321 - if ![&app, &version, &target, &step].into_iter().all(|s| safe(s)) {
339 + if ![&app, &version, &target, &step]
340 + .into_iter()
341 + .all(|s| safe(s))
342 + {
322 343 return Err(Error::NotFound);
323 344 }
324 - let path = s.cfg.logs_root.join(&app).join(&version).join(&target).join(format!("{step}.log"));
345 + let path = s
346 + .cfg
347 + .logs_root
348 + .join(&app)
349 + .join(&version)
350 + .join(&target)
351 + .join(format!("{step}.log"));
325 352 // Stream the log in chunks rather than reading the whole (potentially large,
326 353 // verbose-build) file into memory.
327 354 let file = match tokio::fs::File::open(&path).await {
@@ -329,7 +356,8 @@ async fn get_step_log(
329 356 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Err(Error::NotFound),
330 357 Err(e) => return Err(Error::Other(e.into())),
331 358 };
332 - let (tx, rx) = tokio::sync::mpsc::channel::<std::result::Result<axum::body::Bytes, std::io::Error>>(8);
359 + let (tx, rx) =
360 + tokio::sync::mpsc::channel::<std::result::Result<axum::body::Bytes, std::io::Error>>(8);
333 361 tokio::spawn(async move {
334 362 use tokio::io::AsyncReadExt;
335 363 let mut file = file;
@@ -338,7 +366,11 @@ async fn get_step_log(
338 366 match file.read(&mut buf).await {
339 367 Ok(0) => break,
340 368 Ok(n) => {
341 - if tx.send(Ok(axum::body::Bytes::copy_from_slice(&buf[..n]))).await.is_err() {
369 + if tx
370 + .send(Ok(axum::body::Bytes::copy_from_slice(&buf[..n])))
371 + .await
372 + .is_err()
373 + {
342 374 break;
343 375 }
344 376 }
@@ -351,7 +383,10 @@ async fn get_step_log(
351 383 });
352 384 let body = axum::body::Body::from_stream(tokio_stream::wrappers::ReceiverStream::new(rx));
353 385 Ok((
354 - [(axum::http::header::CONTENT_TYPE, "text/plain; charset=utf-8")],
386 + [(
387 + axum::http::header::CONTENT_TYPE,
388 + "text/plain; charset=utf-8",
389 + )],
355 390 body,
356 391 )
357 392 .into_response())
@@ -388,7 +423,9 @@ async fn events_ws(ws: WebSocketUpgrade, State(s): State<AppState>) -> impl Into
388 423 }
389 424 Err(RecvError::Lagged(n)) => {
390 425 let _ = socket
391 - .send(Message::Text(format!(r#"{{"kind":"lagged","skipped":{n}}}"#).into()))
426 + .send(Message::Text(
427 + format!(r#"{{"kind":"lagged","skipped":{n}}}"#).into(),
428 + ))
392 429 .await;
393 430 }
394 431 Err(RecvError::Closed) => break,
@@ -456,7 +493,12 @@ repo = "{}"
456 493 let tmp = tempfile::tempdir().unwrap();
457 494 let app = router(test_state(tmp.path()).await);
458 495 let resp = app
459 - .oneshot(Request::builder().uri("/state").body(Body::empty()).unwrap())
496 + .oneshot(
497 + Request::builder()
498 + .uri("/state")
499 + .body(Body::empty())
500 + .unwrap(),
501 + )
460 502 .await
461 503 .unwrap();
462 504 assert_eq!(resp.status(), StatusCode::OK);
@@ -472,7 +514,12 @@ repo = "{}"
472 514 let tmp = tempfile::tempdir().unwrap();
473 515 let app = router(test_state(tmp.path()).await);
474 516 let resp = app
475 - .oneshot(Request::builder().uri("/status.json").body(Body::empty()).unwrap())
517 + .oneshot(
518 + Request::builder()
519 + .uri("/status.json")
520 + .body(Body::empty())
521 + .unwrap(),
522 + )
476 523 .await
477 524 .unwrap();
478 525 assert_eq!(resp.status(), StatusCode::OK);
@@ -512,13 +559,15 @@ repo = "{}"
512 559
513 560 // Wrong token -> 401.
514 561 let mut bad = build_req();
515 - bad.headers_mut().insert("authorization", "Bearer nope".parse().unwrap());
562 + bad.headers_mut()
563 + .insert("authorization", "Bearer nope".parse().unwrap());
516 564 let resp = app.clone().oneshot(bad).await.unwrap();
517 565 assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
518 566
519 567 // Correct token -> passes auth (NOT 401; the build itself is accepted).
520 568 let mut good = build_req();
521 - good.headers_mut().insert("authorization", "Bearer s3cr3t".parse().unwrap());
569 + good.headers_mut()
570 + .insert("authorization", "Bearer s3cr3t".parse().unwrap());
522 571 let resp = app.clone().oneshot(good).await.unwrap();
523 572 assert_ne!(resp.status(), StatusCode::UNAUTHORIZED);
524 573 }
@@ -530,7 +579,12 @@ repo = "{}"
530 579 state.api_token = Some(std::sync::Arc::from("s3cr3t"));
531 580 let app = router(state);
532 581 let resp = app
533 - .oneshot(Request::builder().uri("/state").body(Body::empty()).unwrap())
582 + .oneshot(
583 + Request::builder()
584 + .uri("/state")
585 + .body(Body::empty())
586 + .unwrap(),
587 + )
534 588 .await
535 589 .unwrap();
536 590 assert_eq!(resp.status(), StatusCode::OK);
@@ -565,7 +619,12 @@ repo = "{}"
565 619 let tmp = tempfile::tempdir().unwrap();
566 620 let state = test_state(tmp.path()).await;
567 621 // Write a log at the path the route resolves.
568 - let dir = state.cfg.logs_root.join("goingson").join("0.4.1").join("linux-x86_64");
622 + let dir = state
623 + .cfg
624 + .logs_root
625 + .join("goingson")
626 + .join("0.4.1")
627 + .join("linux-x86_64");
569 628 std::fs::create_dir_all(&dir).unwrap();
570 629 std::fs::write(dir.join("build.log"), b"compiling\nlinked\n").unwrap();
571 630 let app = router(state);
@@ -619,13 +678,18 @@ repo = "{}"
619 678 // Explicit version so resolve_version doesn't read a
620 679 // (nonexistent) tauri.conf first — we're testing the target
621 680 // validation specifically.
622 - .body(Body::from(r#"{"app":"goingson","version":"0.4.1","targets":["windows/x86_64"]}"#))
681 + .body(Body::from(
682 + r#"{"app":"goingson","version":"0.4.1","targets":["windows/x86_64"]}"#,
683 + ))
623 684 .unwrap(),
624 685 )
625 686 .await
626 687 .unwrap();
627 688 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
628 - assert!(body_string(resp).await.contains("does not ship target"), "names the problem");
689 + assert!(
690 + body_string(resp).await.contains("does not ship target"),
691 + "names the problem"
692 + );
629 693 }
630 694
631 695 #[tokio::test]
@@ -644,7 +708,10 @@ repo = "{}"
644 708 .await
645 709 .unwrap();
646 710 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
647 - assert!(body_string(resp).await.contains("unknown app"), "names the problem");
711 + assert!(
712 + body_string(resp).await.contains("unknown app"),
713 + "names the problem"
714 + );
648 715 }
649 716
650 717 #[tokio::test]
@@ -658,7 +725,9 @@ repo = "{}"
658 725 .method("POST")
659 726 .uri("/build")
660 727 .header("content-type", "application/json")
661 - .body(Body::from(r#"{"app":"goingson","targets":["not-a-target"]}"#))
728 + .body(Body::from(
729 + r#"{"app":"goingson","targets":["not-a-target"]}"#,
730 + ))
662 731 .unwrap(),
663 732 )
664 733 .await
@@ -53,7 +53,9 @@ pub fn resolve_targets(
53 53 }
54 54 for t in &requested {
55 55 if !cfg.targets.contains(t) {
56 - return Err(Error::BadRequest(format!("app `{app}` does not ship target {t}")));
56 + return Err(Error::BadRequest(format!(
57 + "app `{app}` does not ship target {t}"
58 + )));
57 59 }
58 60 if state.topo.host_for(*t).is_none() {
59 61 return Err(Error::BadRequest(format!("no host can build {t}")));
@@ -81,7 +83,11 @@ pub async fn start_build(
81 83
82 84 events::emit(
83 85 &state.events,
84 - Event::BuildRequested { app: app.clone(), version: version.clone(), targets: targets.clone() },
86 + Event::BuildRequested {
87 + app: app.clone(),
88 + version: version.clone(),
89 + targets: targets.clone(),
90 + },
85 91 );
86 92 crate::metrics::build_started();
87 93
@@ -107,10 +113,30 @@ pub async fn start_build(
107 113 // so set the cooperative flag the engine checks at step boundaries.
108 114 prev.cancel.store(true, std::sync::atomic::Ordering::SeqCst);
109 115 prev.abort.abort();
110 - events::emit(&state.events, Event::TargetAborted { app: app.clone(), target });
116 + events::emit(
117 + &state.events,
118 + Event::TargetAborted {
119 + app: app.clone(),
120 + target,
121 + },
122 + );
111 123 }
112 - let abort = set.spawn(run_target(state.clone(), build_id, app, version, target, cancel.clone()));
113 - active.insert(key, crate::state::ActiveSlot { build_id, abort, cancel });
124 + let abort = set.spawn(run_target(
125 + state.clone(),
126 + build_id,
127 + app,
128 + version,
129 + target,
130 + cancel.clone(),
131 + ));
132 + active.insert(
133 + key,
134 + crate::state::ActiveSlot {
135 + build_id,
136 + abort,
137 + cancel,
138 + },
139 + );
114 140 crate::metrics::set_in_flight(active.len());
115 141 }
116 142
@@ -153,14 +179,31 @@ async fn run_target(
153 179
154 180 events::emit(
155 181 &state.events,
156 - Event::TargetStart { app: app.clone(), version: version.clone(), target },
182 + Event::TargetStart {
183 + app: app.clone(),
184 + version: version.clone(),
185 + target,
186 + },
157 187 );
158 188
159 189 let recipe_src = match read_recipe(&state, &app, target) {
160 190 Ok(s) => s,
161 191 Err(e) => {
162 - fail_target(&state, target_run_id, &app, &version, target, Step::Checkout, &format!("{e:#}")).await;
163 - crate::metrics::target_finished(&target.to_string(), "failed", started.elapsed().as_secs_f64());
192 + fail_target(
193 + &state,
194 + target_run_id,
195 + &app,
196 + &version,
197 + target,
198 + Step::Checkout,
199 + &format!("{e:#}"),
200 + )
201 + .await;
202 + crate::metrics::target_finished(
203 + &target.to_string(),
204 + "failed",
205 + started.elapsed().as_secs_f64(),
206 + );
164 207 return;
165 208 }
166 209 };
@@ -174,8 +217,21 @@ async fn run_target(
174 217 && let Some(exec) = state.executors.get(&host.name)
175 218 && let Err(e) = exec.preflight().await
176 219 {
177 - fail_target(&state, target_run_id, &app, &version, target, Step::Checkout, &format!("{e:#}")).await;
178 - crate::metrics::target_finished(&target.to_string(), "failed", started.elapsed().as_secs_f64());
220 + fail_target(
221 + &state,
222 + target_run_id,
223 + &app,
224 + &version,
225 + target,
226 + Step::Checkout,
227 + &format!("{e:#}"),
228 + )
229 + .await;
230 + crate::metrics::target_finished(
231 + &target.to_string(),
232 + "failed",
233 + started.elapsed().as_secs_f64(),
234 + );
179 235 return;
180 236 }
181 237
@@ -252,19 +308,56 @@ async fn run_target(
252 308 // finalize_build reconciles it, but log so the cause is visible.
253 309 tracing::error!(%app, %target, error = %e, "could not stamp target_run ok");
254 310 }
255 - crate::metrics::target_finished(&target.to_string(), "ok", started.elapsed().as_secs_f64());
256 - events::emit(&state.events, Event::TargetOk { app, version, target, artifacts });
311 + crate::metrics::target_finished(
312 + &target.to_string(),
313 + "ok",
314 + started.elapsed().as_secs_f64(),
315 + );
316 + events::emit(
317 + &state.events,
318 + Event::TargetOk {
319 + app,
320 + version,
321 + target,
322 + artifacts,
323 + },
324 + );
257 325 }
258 326 Ok(Err((step, msg))) => {
259 327 fail_target(&state, target_run_id, &app, &version, target, step, &msg).await;
260 - crate::metrics::target_finished(&target.to_string(), "failed", started.elapsed().as_secs_f64());
328 + crate::metrics::target_finished(
329 + &target.to_string(),
330 + "failed",
331 + started.elapsed().as_secs_f64(),
332 + );
261 333 }
262 334 Err(join_err) => {
263 335 // Task was aborted (superseded) or panicked.
264 - let status = if join_err.is_cancelled() { "aborted" } else { "failed" };
265 - crate::metrics::target_finished(&target.to_string(), status, started.elapsed().as_secs_f64());
266 - let msg = if join_err.is_cancelled() { "aborted (superseded)".to_string() } else { format!("recipe task panicked: {join_err}") };
267 - fail_target(&state, target_run_id, &app, &version, target, Step::Build, &msg).await;
336 + let status = if join_err.is_cancelled() {
337 + "aborted"
338 + } else {
339 + "failed"
340 + };
341 + crate::metrics::target_finished(
342 + &target.to_string(),
343 + status,
344 + started.elapsed().as_secs_f64(),
345 + );
346 + let msg = if join_err.is_cancelled() {
347 + "aborted (superseded)".to_string()
348 + } else {
349 + format!("recipe task panicked: {join_err}")
350 + };
351 + fail_target(
352 + &state,
353 + target_run_id,
354 + &app,
355 + &version,
356 + target,
357 + Step::Build,
358 + &msg,
359 + )
360 + .await;
268 361 }
269 362 }
270 363 }
@@ -291,7 +384,13 @@ async fn fail_target(
291 384 }
292 385 events::emit(
293 386 &state.events,
294 - Event::TargetFailed { app: app.clone(), version: version.clone(), target, step, error: error.to_string() },
387 + Event::TargetFailed {
388 + app: app.clone(),
389 + version: version.clone(),
390 + target,
391 + step,
392 + error: error.to_string(),
393 + },
295 394 );
296 395 }
297 396
@@ -317,7 +416,10 @@ async fn finalize_build(state: AppState, build_id: i64, mut set: tokio::task::Jo
317 416 }
318 417 Ok(None) => break, // all targets settled
319 418 Err(_elapsed) => {
320 - tracing::error!(build_id, "finalize_build deadline hit; aborting remaining target tasks");
419 + tracing::error!(
420 + build_id,
421 + "finalize_build deadline hit; aborting remaining target tasks"
422 + );
321 423 set.abort_all();
322 424 while set.join_next().await.is_some() {}
323 425 break;
@@ -376,19 +478,32 @@ async fn finalize_build(state: AppState, build_id: i64, mut set: tokio::task::Jo
376 478 /// host uploads it, so it uses a single `publish.rhai` — a `linux.rhai` naming
377 479 /// a registry upload would imply a per-platform artifact that does not exist.
378 480 fn read_recipe(state: &AppState, app: &AppId, target: Target) -> Result<String> {
379 - let cfg = state.topo.app(app).ok_or_else(|| anyhow::anyhow!("unknown app `{app}`"))?;
481 + let cfg = state
482 + .topo
483 + .app(app)
484 + .ok_or_else(|| anyhow::anyhow!("unknown app `{app}`"))?;
380 485 let file = match cfg.kind {
381 486 crate::topology::Kind::Library => "publish.rhai".to_string(),
382 487 crate::topology::Kind::App => format!("{}.rhai", target.platform.as_str()),
383 488 };
384 - let path: PathBuf = engine::expand_tilde(&cfg.repo).join(&cfg.recipe_dir).join(file);
489 + let path: PathBuf = engine::expand_tilde(&cfg.repo)
490 + .join(&cfg.recipe_dir)
491 + .join(file);
385 492 std::fs::read_to_string(&path).with_context(|| format!("reading recipe {}", path.display()))
386 493 }
387 494
388 495 fn collected_artifacts(state: &AppState, app: &AppId, version: &Version) -> Vec<String> {
389 - let dir = state.cfg.dist_root.join(app.as_str()).join(version.to_string());
390 - let Ok(rd) = std::fs::read_dir(&dir) else { return Vec::new() };
391 - rd.filter_map(|e| e.ok()).map(|e| e.file_name().to_string_lossy().into_owned()).collect()
496 + let dir = state
497 + .cfg
498 + .dist_root
499 + .join(app.as_str())
500 + .join(version.to_string());
501 + let Ok(rd) = std::fs::read_dir(&dir) else {
502 + return Vec::new();
503 + };
504 + rd.filter_map(|e| e.ok())
505 + .map(|e| e.file_name().to_string_lossy().into_owned())
506 + .collect()
392 507 }
393 508
394 509 #[cfg(test)]
@@ -467,9 +582,14 @@ repo = "{}"
467 582
468 583 let app = AppId::new("demo");
469 584 let version = Version::parse("0.0.1").unwrap();
470 - let build_id = start_build(state.clone(), app, version, vec!["linux/x86_64".parse().unwrap()])
471 - .await
472 - .unwrap();
585 + let build_id = start_build(
586 + state.clone(),
587 + app,
588 + version,
589 + vec!["linux/x86_64".parse().unwrap()],
590 + )
591 + .await
592 + .unwrap();
473 593
474 594 // Wait for the target run to settle. The row may not be inserted on the
475 595 // first poll (run_target is spawned, not awaited), so treat a missing row
@@ -503,7 +623,10 @@ repo = "{}"
503 623 // Artifact landed in dist_root and a step log was written.
504 624 let artifact = state.cfg.dist_root.join("demo/0.0.1/demo.bin");
505 625 assert!(artifact.exists(), "collect should copy the artifact");
506 - let log = state.cfg.logs_root.join("demo/0.0.1/linux-x86_64/build.log");
626 + let log = state
627 + .cfg
628 + .logs_root
629 + .join("demo/0.0.1/linux-x86_64/build.log");
507 630 assert!(log.exists(), "build step log should exist");
508 631 assert!(std::fs::read_to_string(&log).unwrap().contains("compiling"));
509 632 }
@@ -517,7 +640,11 @@ repo = "{}"
517 640 let root = tmp.path();
518 641 let repo = root.join("app");
519 642 std::fs::create_dir_all(repo.join("src-tauri")).unwrap();
520 - std::fs::write(repo.join("src-tauri/tauri.conf.json"), r#"{"version":"0.2.0"}"#).unwrap();
643 + std::fs::write(
644 + repo.join("src-tauri/tauri.conf.json"),
645 + r#"{"version":"0.2.0"}"#,
646 + )
647 + .unwrap();
521 648 std::fs::create_dir_all(repo.join("dist/recipes")).unwrap();
522 649 let artifact = repo.join("out/app.tar.gz");
523 650 // Build an artifact, publish 0.2.0 (records a release), then try to
@@ -594,13 +721,19 @@ repo = "{}"
594 721 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
595 722 }
596 723 assert_eq!(status, "failed", "non-monotonic publish must fail the run");
597 - assert!(error.contains("not newer"), "expected monotonicity error, got: {error}");
724 + assert!(
725 + error.contains("not newer"),
726 + "expected monotonicity error, got: {error}"
727 + );
598 728 // Exactly one release was recorded (0.2.0); 0.1.0 never landed.
599 729 let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM releases")
600 730 .fetch_one(&pool)
601 731 .await
602 732 .unwrap();
603 - assert_eq!(count, 1, "only the first (newer) publish should record a release");
733 + assert_eq!(
734 + count, 1,
735 + "only the first (newer) publish should record a release"
736 + );
604 737 }
605 738
606 739 /// The audit fix: a `build` step dispatched to a host whose executor lacks the
@@ -614,7 +747,11 @@ repo = "{}"
614 747
615 748 let repo = root.join("app");
616 749 std::fs::create_dir_all(repo.join("src-tauri")).unwrap();
617 - std::fs::write(repo.join("src-tauri/tauri.conf.json"), r#"{"version":"0.0.1"}"#).unwrap();
750 + std::fs::write(
751 + repo.join("src-tauri/tauri.conf.json"),
752 + r#"{"version":"0.0.1"}"#,
753 + )
754 + .unwrap();
618 755 std::fs::create_dir_all(repo.join("dist/recipes")).unwrap();
619 756 // The recipe (wrongly) tries to compile on `prod`, a host with no build
620 757 // grant. A marker file would appear if the command actually ran.
@@ -109,12 +109,18 @@ pub fn build_sync(host: &Host) -> Arc<dyn Executor> {
109 109
110 110 /// Build the full host name -> exec-executor map from the topology.
111 111 pub fn build_executors(topo: &Topology) -> ExecutorMap {
112 - topo.hosts.iter().map(|h| (h.name.clone(), build_executor(h))).collect()
112 + topo.hosts
113 + .iter()
114 + .map(|h| (h.name.clone(), build_executor(h)))
115 + .collect()
113 116 }
114 117
115 118 /// Build the full host name -> sync-transport map from the topology.
116 119 pub fn build_syncs(topo: &Topology) -> ExecutorMap {
117 - topo.hosts.iter().map(|h| (h.name.clone(), build_sync(h))).collect()
120 + topo.hosts
121 + .iter()
122 + .map(|h| (h.name.clone(), build_sync(h)))
123 + .collect()
118 124 }
119 125
120 126 #[cfg(test)]
@@ -159,7 +165,11 @@ mod tests {
159 165 // by design, so a non-refusing error proves we got an ssh transport.
160 166 let sync = build_sync(&h);
161 167 let err = sync
162 - .pull_glob("/nonexistent/*.dmg", std::path::Path::new("/tmp"), &Default::default())
168 + .pull_glob(
169 + "/nonexistent/*.dmg",
170 + std::path::Path::new("/tmp"),
171 + &Default::default(),
172 + )
163 173 .await
164 174 .expect_err("nothing matches, so this must error either way");
165 175 assert!(
@@ -169,10 +179,17 @@ mod tests {
169 179 // ...while the exec transport for the same host still is the agent.
170 180 let exec = build_executor(&h);
171 181 let err = exec
172 - .pull_glob("/x/*.dmg", std::path::Path::new("/tmp"), &Default::default())
182 + .pull_glob(
183 + "/x/*.dmg",
184 + std::path::Path::new("/tmp"),
185 + &Default::default(),
186 + )
173 187 .await
174 188 .expect_err("AgentRpc has no glob form");
175 - assert!(err.to_string().contains("unsupported by design"), "exec plane is the agent: {err}");
189 + assert!(
190 + err.to_string().contains("unsupported by design"),
191 + "exec plane is the agent: {err}"
192 + );
176 193 }
177 194
178 195 #[test]
@@ -225,7 +225,10 @@ impl Topology {
225 225 },
226 226 );
227 227 }
228 - let topo = Topology { hosts: raw.hosts, app };
228 + let topo = Topology {
229 + hosts: raw.hosts,
230 + app,
231 + };
229 232 topo.validate()?;
230 233 Ok(topo)
231 234 }
@@ -240,8 +243,14 @@ impl Topology {
240 243 }
241 244
242 245 fn validate(&self) -> Result<()> {
243 - anyhow::ensure!(!self.hosts.is_empty(), "topology must declare at least one host");
244 - anyhow::ensure!(!self.app.is_empty(), "topology must declare at least one app");
246 + anyhow::ensure!(
247 + !self.hosts.is_empty(),
248 + "topology must declare at least one host"
249 + );
250 + anyhow::ensure!(
251 + !self.app.is_empty(),
252 + "topology must declare at least one app"
253 + );
245 254 // Every target an app ships must have a host that can build it.
246 255 for (name, app) in &self.app {
247 256 for t in &app.targets {
@@ -261,7 +270,10 @@ impl Topology {
261 270 );
262 271 }
263 272 if h.transport == HostTransport::Agent && h.agent_url.is_none() {
264 - anyhow::bail!("host `{}` uses transport = \"agent\" but sets no agent_url", h.name);
273 + anyhow::bail!(
274 + "host `{}` uses transport = \"agent\" but sets no agent_url",
275 + h.name
276 + );
265 277 }
266 278 }
267 279 Ok(())
@@ -421,25 +433,36 @@ mod live_config_smoke {
421 433 /// worth catching. Skips when the file is absent (CI, another host).
422 434 #[test]
423 435 fn live_topology_loads_if_present() {
424 - let Some(home) = std::env::var_os("HOME") else { return };
436 + let Some(home) = std::env::var_os("HOME") else {
437 + return;
438 + };
425 439 let path = Path::new(&home).join(".config/bento/bento.toml");
426 440 if !path.exists() {
427 441 return;
428 442 }
429 443 let topo = Topology::load(&path).expect("live bento.toml must load");
430 - let bb = topo.app(&"balanced_breakfast".into()).expect("bb configured");
444 + let bb = topo
445 + .app(&"balanced_breakfast".into())
446 + .expect("bb configured");
431 447 assert!(
432 448 bb.features.contains(&"supernote".to_string()),
433 449 "bb must ship the supernote feature, got {:?}",
434 450 bb.features
435 451 );
436 - let af = topo.app(&"audiofiles".into()).expect("audiofiles configured");
437 - assert_eq!(af.version_path.as_deref(), Some("crates/audiofiles-app/Cargo.toml"));
452 + let af = topo
453 + .app(&"audiofiles".into())
454 + .expect("audiofiles configured");
455 + assert_eq!(
456 + af.version_path.as_deref(),
457 + Some("crates/audiofiles-app/Cargo.toml")
458 + );
438 459
439 460 // The library crates resolve as libraries, so they take publish.rhai
440 461 // rather than a per-platform recipe.
441 462 for name in ["makeover", "pter", "everycycle", "supernote-push"] {
442 - let c = topo.app(&name.into()).unwrap_or_else(|| panic!("{name} configured"));
463 + let c = topo
464 + .app(&name.into())
465 + .unwrap_or_else(|| panic!("{name} configured"));
443 466 assert_eq!(c.kind, Kind::Library, "{name} must be a library");
444 467 }
445 468 assert_eq!(topo.app(&"goingson".into()).unwrap().kind, Kind::App);
@@ -118,7 +118,10 @@ impl ReleasePlan {
118 118 pub fn checkout_step(&self) -> Step {
119 119 Step::shell(
120 120 Action::Build,
121 - format!("set -e; git fetch --all --tags --prune && git checkout v{}", self.version),
121 + format!(
122 + "set -e; git fetch --all --tags --prune && git checkout v{}",
123 + self.version
124 + ),
122 125 )
123 126 .with_cwd(&self.repo_path)
124 127 }
@@ -141,7 +144,10 @@ impl ReleasePlan {
141 144 pub fn verify_step(&self) -> Step {
142 145 Step::shell(
143 146 Action::Observe(ObserveKind::Custom("gatekeeper".into())),
144 - format!("spctl --assess -vv --type install {} 2>&1", shell_quote(&self.dmg_remote_path())),
147 + format!(
148 + "spctl --assess -vv --type install {} 2>&1",
149 + shell_quote(&self.dmg_remote_path())
150 + ),
145 151 )
146 152 }
147 153
@@ -181,7 +187,10 @@ async fn run_step(
181 187 anyhow::ensure!(
182 188 out.status.success(),
183 189 "{label} failed (exit {}): {}",
184 - out.status.code().map(|c| c.to_string()).unwrap_or_else(|| "signal".into()),
190 + out.status
191 + .code()
192 + .map(|c| c.to_string())
193 + .unwrap_or_else(|| "signal".into()),
185 194 failure_tail(&out),
186 195 );
187 196 Ok(out)
@@ -197,7 +206,11 @@ fn failure_tail(out: &RunOutput) -> String {
197 206 const MAX_LINES: usize = 5;
198 207 let stderr = String::from_utf8_lossy(&out.stderr);
199 208 let stdout = String::from_utf8_lossy(&out.stdout);
200 - let source = if stderr.trim().is_empty() { stdout } else { stderr };
209 + let source = if stderr.trim().is_empty() {
210 + stdout
211 + } else {
212 + stderr
213 + };
201 214 let tail: Vec<&str> = source
202 215 .lines()
203 216 .filter(|l| !l.trim().is_empty())
@@ -220,7 +233,13 @@ pub async fn run_release(
220 233 sink: &mut dyn LogSink,
221 234 ) -> Result<ReleaseOutcome> {
222 235 run_step(exec, &plan.checkout_step(), sink, "checkout").await?;
223 - run_step(exec, &plan.release_step(), sink, "release-macos.sh --keychain").await?;
236 + run_step(
237 + exec,
238 + &plan.release_step(),
239 + sink,
240 + "release-macos.sh --keychain",
241 + )
242 + .await?;
224 243 let verify = run_step(exec, &plan.verify_step(), sink, "verify gatekeeper").await?;
225 244
226 245 // spctl writes its verdict to stderr/stdout (we merged with 2>&1).
@@ -286,7 +305,12 @@ mod tests {
286 305 /// driver must not depend on that per-host state. Only the tag is needed.
287 306 #[test]
288 307 fn checkout_step_does_not_depend_on_a_tracking_branch() {
289 - let script = go_plan("/repo", "/dist").checkout_step().argv.last().unwrap().clone();
308 + let script = go_plan("/repo", "/dist")
309 + .checkout_step()
310 + .argv
311 + .last()
312 + .unwrap()
313 + .clone();
290 314 assert!(script.contains("git fetch"), "{script}");
291 315 assert!(!script.contains("git pull"), "must not use pull: {script}");
292 316 }
@@ -312,18 +336,34 @@ mod tests {
312 336 #[test]
313 337 fn expand_local_tilde_resolves_against_our_own_home() {
314 338 let home = std::env::var("HOME").expect("HOME set in the test env");
315 - assert_eq!(expand_local_tilde("~/Dist/goingson").unwrap(), PathBuf::from(&home).join("Dist/goingson"));
339 + assert_eq!(
340 + expand_local_tilde("~/Dist/goingson").unwrap(),
341 + PathBuf::from(&home).join("Dist/goingson")
342 + );
316 343 assert_eq!(expand_local_tilde("~").unwrap(), PathBuf::from(&home));
317 344 // The bug this closes: the tilde must not survive into the path.
318 - assert!(!expand_local_tilde("~/Dist/goingson").unwrap().starts_with("~"));
345 + assert!(
346 + !expand_local_tilde("~/Dist/goingson")
347 + .unwrap()
348 + .starts_with("~")
349 + );
319 350 }
320 351
321 352 #[test]
322 353 fn expand_local_tilde_leaves_other_paths_alone() {
323 - assert_eq!(expand_local_tilde("/Users/max/Dist").unwrap(), PathBuf::from("/Users/max/Dist"));
324 - assert_eq!(expand_local_tilde("Dist/goingson").unwrap(), PathBuf::from("Dist/goingson"));
354 + assert_eq!(
355 + expand_local_tilde("/Users/max/Dist").unwrap(),
356 + PathBuf::from("/Users/max/Dist")
357 + );
358 + assert_eq!(
359 + expand_local_tilde("Dist/goingson").unwrap(),
360 + PathBuf::from("Dist/goingson")
361 + );
325 362 // A `~` that isn't leading is just a character.
326 - assert_eq!(expand_local_tilde("/tmp/a~b").unwrap(), PathBuf::from("/tmp/a~b"));
363 + assert_eq!(
364 + expand_local_tilde("/tmp/a~b").unwrap(),
365 + PathBuf::from("/tmp/a~b")
366 + );
327 367 }
328 368
329 369 #[test]
@@ -334,7 +374,10 @@ mod tests {
334 374
335 375 #[test]
336 376 fn validate_accepts_absolute_build_host_paths() {
337 - let mut plan = go_plan("/Users/max/Code/Apps/goingson", "/Users/max/Dist/goingson/macos");
377 + let mut plan = go_plan(
378 + "/Users/max/Code/Apps/goingson",
379 + "/Users/max/Dist/goingson/macos",
380 + );
338 381 plan.env_file = "/Users/max/.tauri/passwords.env".into();
339 382 assert!(plan.validate().is_ok());
340 383 }
@@ -373,7 +416,10 @@ mod tests {
373 416 #[test]
374 417 fn verify_step_is_an_observe_action_on_the_dmg() {
375 418 let s = go_plan("/repo", "/dist").verify_step();
376 - assert_eq!(s.action, Action::Observe(ObserveKind::Custom("gatekeeper".into())));
419 + assert_eq!(
420 + s.action,
421 + Action::Observe(ObserveKind::Custom("gatekeeper".into()))
422 + );
377 423 let script = s.argv.last().unwrap();
378 424 assert!(script.contains("spctl --assess"));
379 425 assert!(script.contains("/dist/GoingsOn_0.4.1_aarch64.dmg"));
@@ -390,8 +436,12 @@ mod tests {
390 436 assert!(gatekeeper_accepted(
391 437 "GoingsOn.dmg: accepted\nsource=Notarized Developer ID\norigin=Developer ID Application: ..."
392 438 ));
393 - assert!(gatekeeper_accepted("X.dmg: accepted\nsource=Notarized Developer ID"));
394 - assert!(!gatekeeper_accepted("X.dmg: rejected\nsource=no usable signature"));
439 + assert!(gatekeeper_accepted(
440 + "X.dmg: accepted\nsource=Notarized Developer ID"
441 + ));
442 + assert!(!gatekeeper_accepted(
443 + "X.dmg: rejected\nsource=no usable signature"
444 + ));
395 445 assert!(!gatekeeper_accepted(""));
396 446 }
397 447
@@ -424,13 +474,18 @@ mod tests {
424 474 // The fake release script writes the DMG into dist_root.
425 475 write_shim(
426 476 &repo.join("dist/release-macos.sh"),
427 - &format!("#!/bin/sh\nset -e\nprintf 'building %s\\n' \"$PWD\"\n: > '{}'\n", dmg.display()),
477 + &format!(
478 + "#!/bin/sh\nset -e\nprintf 'building %s\\n' \"$PWD\"\n: > '{}'\n",
479 + dmg.display()
480 + ),
428 481 )
429 482 .await;
430 483
431 484 let orig_path = std::env::var("PATH").unwrap_or_default();
432 485 // SAFETY: serialized by PATH_LOCK; restored before the guard drops.
433 - unsafe { std::env::set_var("PATH", format!("{}:{}", bindir.display(), orig_path)); }
486 + unsafe {
487 + std::env::set_var("PATH", format!("{}:{}", bindir.display(), orig_path));
488 + }
434 489
435 490 let plan = ReleasePlan {
436 491 app: "goingson".into(),
@@ -446,21 +501,33 @@ mod tests {
446 501 ));
447 502
448 503 let mut sink = Discard;
449 - let outcome = run_release(&exec, &plan, &mut sink).await.expect("recipe should run");
504 + let outcome = run_release(&exec, &plan, &mut sink)
505 + .await
506 + .expect("recipe should run");
450 507
451 508 // SAFETY: serialized by PATH_LOCK.
452 - unsafe { std::env::set_var("PATH", orig_path); }
509 + unsafe {
510 + std::env::set_var("PATH", orig_path);
511 + }
453 512
454 513 assert!(dmg.exists(), "release step should produce the DMG");
455 514 assert!(outcome.gatekeeper_accepted, "fake spctl reports notarized");
456 - assert_eq!(outcome.dmg_remote, dist.join("GoingsOn_0.4.1_aarch64.dmg").to_string_lossy());
515 + assert_eq!(
516 + outcome.dmg_remote,
517 + dist.join("GoingsOn_0.4.1_aarch64.dmg").to_string_lossy()
518 + );
457 519 }
458 520
459 521 static PATH_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
460 522
461 523 async fn write_shim(path: &std::path::Path, body: &str) {
462 524 tokio::fs::write(path, body).await.unwrap();
463 - let out = tokio::process::Command::new("chmod").arg("+x").arg(path).output().await.unwrap();
525 + let out = tokio::process::Command::new("chmod")
526 + .arg("+x")
527 + .arg(path)
528 + .output()
529 + .await
530 + .unwrap();
464 531 assert!(out.status.success());
465 532 }
466 533
@@ -115,7 +115,10 @@ async fn main() -> Result<()> {
115 115 )?;
116 116 tracing::info!(actuate = ?health.actuate, "agent reachable");
117 117
118 - println!("==> releasing {} {} via {}", plan.app, plan.version, cfg.agent.host_label);
118 + println!(
119 + "==> releasing {} {} via {}",
120 + plan.app, plan.version, cfg.agent.host_label
121 + );
119 122 let mut sink = StdoutSink;
120 123 let outcome = run_release(&exec, &plan, &mut sink).await?;
121 124 if !outcome.gatekeeper_accepted {
@@ -158,7 +161,9 @@ impl Args {
158 161 match a.as_str() {
159 162 "--config" | "-c" => config = it.next(),
160 163 "--version" | "-v" => version = it.next(),
161 - other => anyhow::bail!("unexpected arg `{other}` (usage: --config <toml> [--version <ver>])"),
164 + other => anyhow::bail!(
165 + "unexpected arg `{other}` (usage: --config <toml> [--version <ver>])"
166 + ),
162 167 }
163 168 }
164 169 Ok(Self {
@@ -20,6 +20,8 @@
20 20 //! <!-- wiki: bento-overview -->
21 21
22 22 use anyhow::{Context, Result};
23 + use bento_daemon::domain::{Step, StepRunId};
24 + use bento_daemon::events::{Event, EventEnvelope};
23 25 use crossterm::event::{self, Event as XEvent, KeyCode, KeyModifiers};
24 26 use crossterm::terminal::{
25 27 EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
@@ -27,8 +29,6 @@ use crossterm::terminal::{
27 29 use futures_util::StreamExt;
28 30 use ratatui::prelude::*;
29 31 use ratatui::widgets::{Block, Borders, Cell, List, ListItem, Paragraph, Row, Table};
30 - use bento_daemon::domain::{Step, StepRunId};
31 - use bento_daemon::events::{Event, EventEnvelope};
32 32 use serde::Deserialize;
33 33 use std::collections::{BTreeMap, VecDeque};
34 34 use std::io;
@@ -269,7 +269,12 @@ async fn state_poller(daemon: String, shared: Arc<Mutex<Shared>>) {
269 269 let url = format!("{daemon}/state");
270 270 let client = reqwest::Client::new();
271 271 loop {
272 - match client.get(&url).timeout(Duration::from_secs(2)).send().await {
272 + match client
273 + .get(&url)
274 + .timeout(Duration::from_secs(2))
275 + .send()
276 + .await
277 + {
273 278 Ok(resp) if resp.status().is_success() => match resp.json::<StateView>().await {
274 279 Ok(s) => {
275 280 let mut g = shared.lock().unwrap();
@@ -317,7 +322,10 @@ async fn events_subscriber(daemon: String, shared: Arc<Mutex<Shared>>) {
317 322 }
318 323
319 324 fn ws_url_from(daemon: &str) -> String {
320 - daemon.replacen("https://", "wss://", 1).replacen("http://", "ws://", 1) + "/events"
325 + daemon
326 + .replacen("https://", "wss://", 1)
327 + .replacen("http://", "ws://", 1)
328 + + "/events"
321 329 }
322 330
323 331 /// Route a WS frame: log chunks update the per-step tail; everything else
@@ -333,7 +341,12 @@ fn dispatch_ws_frame(shared: &Arc<Mutex<Shared>>, raw: &str) {
333 341 return;
334 342 };
335 343 match &env.event {
336 - Event::StepStart { run_id, target, step, .. } => {
344 + Event::StepStart {
345 + run_id,
346 + target,
347 + step,
348 + ..
349 + } => {
337 350 let mut g = shared.lock().unwrap();
338 351 g.open_tail(*run_id, target.to_string(), step.to_string());
339 352 let line = format_event_line(&env);
@@ -363,27 +376,72 @@ fn format_event_line(env: &EventEnvelope) -> String {
363 376
364 377 fn format_event_body(e: &Event) -> String {
365 378 match e {
366 - Event::BuildRequested { app, version, targets } => {
367 - format!("build_requested {app} {version} [{}]", targets.iter().map(|t| t.to_string()).collect::<Vec<_>>().join(","))
379 + Event::BuildRequested {
380 + app,
381 + version,
382 + targets,
383 + } => {
384 + format!(
385 + "build_requested {app} {version} [{}]",
386 + targets
387 + .iter()
388 + .map(|t| t.to_string())
389 + .collect::<Vec<_>>()
390 + .join(",")
391 + )
368 392 }
369 393 Event::TargetAborted { target, .. } => format!("target_aborted {target}"),
370 394 Event::TargetStart { target, .. } => format!("target_start {target}"),
371 395 Event::StepStart { target, step, .. } => format!("step_start {target} {step}"),
372 - Event::StepLogChunk { run_id, text, .. } => format!("log[{run_id}] {}", truncate(text.trim_end(), 80)),
373 - Event::StepDone { target, step, status, .. } => format!("step_done {target} {step} {}", status.as_str()),
374 - Event::TargetOk { target, artifacts, .. } => format!("target_ok {target} ({} artifacts)", artifacts.len()),
375 - Event::TargetFailed { target, step, error, .. } => format!("target_failed {target} {step}: {}", truncate(error, 80)),
376 - Event::NotarizeRetry { target, attempt, reason, .. } => format!("notarize_retry {target} #{attempt} {reason}"),
377 - Event::ArtifactCollected { target, path, bytes, .. } => format!("artifact {target} {path} {bytes}B"),
378 - Event::PublishOk { target, channel, .. } => format!("publish_ok {target} {channel}"),
379 - Event::PublishFailed { target, channel, error, .. } => format!("publish_failed {target} {channel}: {}", truncate(error, 80)),
396 + Event::StepLogChunk { run_id, text, .. } => {
397 + format!("log[{run_id}] {}", truncate(text.trim_end(), 80))
398 + }
399 + Event::StepDone {
400 + target,
401 + step,
402 + status,
403 + ..
404 + } => format!("step_done {target} {step} {}", status.as_str()),
405 + Event::TargetOk {
406 + target, artifacts, ..
407 + } => format!("target_ok {target} ({} artifacts)", artifacts.len()),
408 + Event::TargetFailed {
409 + target,
410 + step,
411 + error,
412 + ..
413 + } => format!("target_failed {target} {step}: {}", truncate(error, 80)),
414 + Event::NotarizeRetry {
415 + target,
416 + attempt,
417 + reason,
418 + ..
419 + } => format!("notarize_retry {target} #{attempt} {reason}"),
420 + Event::ArtifactCollected {
421 + target,
422 + path,
423 + bytes,
424 + ..
425 + } => format!("artifact {target} {path} {bytes}B"),
426 + Event::PublishOk {
427 + target, channel, ..
428 + } => format!("publish_ok {target} {channel}"),
429 + Event::PublishFailed {
430 + target,
431 + channel,
432 + error,
433 + ..
434 + } => format!("publish_failed {target} {channel}: {}", truncate(error, 80)),
380 435 }
381 436 }
382 437
383 438 fn format_lagged(raw: &str) -> Option<String> {
384 439 let v: serde_json::Value = serde_json::from_str(raw).ok()?;
385 440 if v.get("kind")?.as_str()? == "lagged" {
386 - Some(format!("(lagged, skipped {})", v.get("skipped").and_then(|n| n.as_i64()).unwrap_or(0)))
441 + Some(format!(
442 + "(lagged, skipped {})",
443 + v.get("skipped").and_then(|n| n.as_i64()).unwrap_or(0)
444 + ))
387 445 } else {
388 446 None
389 447 }
@@ -410,11 +468,15 @@ async fn dispatch_action(daemon: &str, act: &Action) -> Result<String> {
410 468 let client = reqwest::Client::new();
411 469 let (url, body) = match act {
412 470 Action::Build { app } => (format!("{daemon}/build"), serde_json::json!({ "app": app })),
413 - Action::Retry { app, target } => {
414 - (format!("{daemon}/retry"), serde_json::json!({ "app": app, "target": target }))
415 - }
471 + Action::Retry { app, target } => (
472 + format!("{daemon}/retry"),
473 + serde_json::json!({ "app": app, "target": target }),
474 + ),
416 475 };
417 - let mut req = client.post(&url).timeout(Duration::from_secs(30)).json(&body);
476 + let mut req = client
477 + .post(&url)
478 + .timeout(Duration::from_secs(30))
479 + .json(&body);
418 480 // Build triggers require a bearer token when the daemon is configured with
419 481 // one (CF2). Operator supplies it via BENTO_API_TOKEN.
420 482 if let Ok(tok) = std::env::var("BENTO_API_TOKEN") {
@@ -430,9 +492,13 @@ async fn dispatch_action(daemon: &str, act: &Action) -> Result<String> {
430 492 // turning a read hiccup into "" renders as a contentless "HTTP 409: ".
431 493 match (status.is_success(), resp.text().await) {
432 494 (true, Ok(text)) => Ok(truncate(&text, 120)),
433 - (true, Err(e)) => Err(anyhow::anyhow!("HTTP {status} but response body unreadable: {e}")),
495 + (true, Err(e)) => Err(anyhow::anyhow!(
496 + "HTTP {status} but response body unreadable: {e}"
497 + )),
434 498 (false, Ok(text)) => Err(anyhow::anyhow!("HTTP {status}: {}", truncate(&text, 200))),
435 - (false, Err(e)) => Err(anyhow::anyhow!("HTTP {status} (response body unreadable: {e})")),
499 + (false, Err(e)) => Err(anyhow::anyhow!(
500 + "HTTP {status} (response body unreadable: {e})"
501 + )),
436 502 }
437 503 }
438 504
@@ -542,8 +608,10 @@ fn draw(f: &mut Frame, daemon: &str, shared: &Arc<Mutex<Shared>>) {
542 608 .and_then(|s| s.build.as_ref())
543 609 .map(|b| format!("{} {} ({})", b.app, b.version, b.status))
544 610 .unwrap_or_else(|| "no builds yet".into());
545 - let header = Paragraph::new(format!("bento -> {daemon} ({ws_label}) build: {build_label}"))
546 - .block(Block::default().title("daemon").borders(Borders::ALL));
611 + let header = Paragraph::new(format!(
612 + "bento -> {daemon} ({ws_label}) build: {build_label}"
613 + ))
614 + .block(Block::default().title("daemon").borders(Borders::ALL));
547 615 f.render_widget(header, chunks[0]);
548 616
549 617 // Matrix: rows = targets, columns = steps.
@@ -559,8 +627,7 @@ fn draw(f: &mut Frame, daemon: &str, shared: &Arc<Mutex<Shared>>) {
559 627 .enumerate()
560 628 .map(|(i, t)| {
561 629 let marker = if i == g.selected { ">" } else { " " };
562 - let mut cells: Vec<Cell> =
563 - vec![format!("{marker} {}", t.target).into()];
630 + let mut cells: Vec<Cell> = vec![format!("{marker} {}", t.target).into()];
564 631 for (step, _) in STEP_COLS {
565 632 let status = t
566 633 .steps
@@ -589,8 +656,12 @@ fn draw(f: &mut Frame, daemon: &str, shared: &Arc<Mutex<Shared>>) {
589 656 f.render_widget(table, chunks[1]);
590 657 }
591 658 _ => {
592 - let msg = g.last_err.clone().unwrap_or_else(|| "no active build — press [b] to start".into());
593 - let placeholder = Paragraph::new(msg).block(Block::default().title("matrix").borders(Borders::ALL));
659 + let msg = g
660 + .last_err
661 + .clone()
662 + .unwrap_or_else(|| "no active build — press [b] to start".into());
663 + let placeholder =
664 + Paragraph::new(msg).block(Block::default().title("matrix").borders(Borders::ALL));
594 665 f.render_widget(placeholder, chunks[1]);
595 666 }
596 667 }
@@ -598,9 +669,18 @@ fn draw(f: &mut Frame, daemon: &str, shared: &Arc<Mutex<Shared>>) {
598 669 // Events.
599 670 let area = chunks[2];
600 671 let visible = area.height.saturating_sub(2) as usize;
601 - let items: Vec<ListItem> = g.events.iter().rev().take(visible).rev().map(|l| ListItem::new(l.clone())).collect();
672 + let items: Vec<ListItem> = g
673 + .events
674 + .iter()
675 + .rev()
676 + .take(visible)
677 + .rev()
678 + .map(|l| ListItem::new(l.clone()))
679 + .collect();
602 680 let events_block = List::new(items).block(
603 - Block::default().title(format!("events ({})", g.events.len())).borders(Borders::ALL),
681 + Block::default()
682 + .title(format!("events ({})", g.events.len()))
683 + .borders(Borders::ALL),
604 684 );
605 685 f.render_widget(events_block, area);
606 686
@@ -621,8 +701,14 @@ fn draw(f: &mut Frame, daemon: &str, shared: &Arc<Mutex<Shared>>) {
621 701 g.tails.len(),
622 702 );
623 703 let visible = tail_area.height.saturating_sub(2) as usize;
624 - let items: Vec<ListItem> =
625 - t.lines.iter().rev().take(visible).rev().map(|l| ListItem::new(truncate(l, 200))).collect();
704 + let items: Vec<ListItem> = t
705 + .lines
706 + .iter()
707 + .rev()
708 + .take(visible)
709 + .rev()
710 + .map(|l| ListItem::new(truncate(l, 200)))
711 + .collect();
626 712 List::new(items).block(Block::default().title(title).borders(Borders::ALL))
627 713 }
628 714 None => List::new(vec![ListItem::new("no recent step runs")])
@@ -656,14 +742,23 @@ fn step_mark(status: Option<&str>) -> (&'static str, Style) {
656 742 match status {
657 743 Some("ok") => ("O", Style::default().fg(Color::Green)),
658 744 Some("failed") => ("X", Style::default().fg(Color::Red)),
659 - Some("running") => (">", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
745 + Some("running") => (
746 + ">",
747 + Style::default()
748 + .fg(Color::Yellow)
749 + .add_modifier(Modifier::BOLD),
750 + ),
660 751 Some("pending") => (".", Style::default().fg(Color::DarkGray)),
661 752 _ => ("-", Style::default().fg(Color::DarkGray)),
662 753 }
663 754 }
664 755
665 756 fn tail_position(tails: &BTreeMap<StepRunId, StepTail>, id: StepRunId) -> usize {
666 - tails.keys().position(|k| *k == id).map(|i| i + 1).unwrap_or(0)
757 + tails
758 + .keys()
759 + .position(|k| *k == id)
760 + .map(|i| i + 1)
761 + .unwrap_or(0)
667 762 }
668 763
669 764 fn cycle_focus(
@@ -724,14 +819,24 @@ mod tests {
724 819 let mut tails = BTreeMap::new();
725 820 tails.insert(StepRunId(1), StepTail::new("a".into(), "build".into()));
726 821 tails.insert(StepRunId(2), StepTail::new("b".into(), "sign".into()));
727 - assert_eq!(cycle_focus(&tails, Some(StepRunId(2)), 1), Some(StepRunId(1)));
728 - assert_eq!(cycle_focus(&tails, Some(StepRunId(1)), -1), Some(StepRunId(2)));
822 + assert_eq!(
823 + cycle_focus(&tails, Some(StepRunId(2)), 1),
824 + Some(StepRunId(1))
825 + );
826 + assert_eq!(
827 + cycle_focus(&tails, Some(StepRunId(1)), -1),
828 + Some(StepRunId(2))
829 + );
729 830 assert_eq!(cycle_focus(&BTreeMap::new(), None, 1), None);
730 831 }
731 832
732 833 #[test]
733 834 fn format_lagged_detects() {
734 - assert!(format_lagged(r#"{"kind":"lagged","skipped":5}"#).unwrap().contains("5"));
835 + assert!(
836 + format_lagged(r#"{"kind":"lagged","skipped":5}"#)
837 + .unwrap()
838 + .contains("5")
839 + );
735 840 assert!(format_lagged(r#"{"kind":"step_start"}"#).is_none());
736 841 }
737 842 }