max / makenotwork
17 files changed,
+102 insertions,
-71 deletions
| @@ -5,3 +5,35 @@ | |||
| 5 | 5 | [workspace] | |
| 6 | 6 | resolver = "3" | |
| 7 | 7 | members = ["daemon"] | |
| 8 | + | ||
| 9 | + | [workspace.lints.rust] | |
| 10 | + | unused = "warn" | |
| 11 | + | unreachable_pub = "warn" | |
| 12 | + | ||
| 13 | + | [workspace.lints.clippy] | |
| 14 | + | pedantic = { level = "warn", priority = -1 } | |
| 15 | + | # Allow-list tuned from a measured breakdown across server/multithreaded/pter | |
| 16 | + | # (2026-07-22). These are the high-churn / low-signal pedantic lints; everything | |
| 17 | + | # else in `pedantic` stays a warning. Keep this block identical across repos. | |
| 18 | + | module_name_repetitions = "allow" | |
| 19 | + | # Doc lints. No docs-completeness push is underway. | |
| 20 | + | missing_errors_doc = "allow" | |
| 21 | + | missing_panics_doc = "allow" | |
| 22 | + | doc_markdown = "allow" | |
| 23 | + | # Numeric casts. Endemic and mostly intentional in size and byte math. | |
| 24 | + | cast_possible_truncation = "allow" | |
| 25 | + | cast_sign_loss = "allow" | |
| 26 | + | cast_precision_loss = "allow" | |
| 27 | + | cast_possible_wrap = "allow" | |
| 28 | + | cast_lossless = "allow" | |
| 29 | + | # Subjective structure and style nags. High churn, low signal. | |
| 30 | + | must_use_candidate = "allow" | |
| 31 | + | too_many_lines = "allow" | |
| 32 | + | struct_excessive_bools = "allow" | |
| 33 | + | similar_names = "allow" | |
| 34 | + | items_after_statements = "allow" | |
| 35 | + | single_match_else = "allow" | |
| 36 | + | # Frequent false-positives in TUI and router-heavy code. | |
| 37 | + | match_same_arms = "allow" | |
| 38 | + | unnecessary_wraps = "allow" | |
| 39 | + | type_complexity = "allow" |
| @@ -32,3 +32,6 @@ tempfile = "3.20" | |||
| 32 | 32 | tower = { version = "0.5", features = ["util"] } | |
| 33 | 33 | http-body-util = "0.1" | |
| 34 | 34 | reqwest = { version = "0.13", default-features = false, features = ["json", "rustls"] } | |
| 35 | + | ||
| 36 | + | [lints] | |
| 37 | + | workspace = true |
| @@ -75,7 +75,7 @@ pub(crate) fn parse_source(s: &str) -> Result<BackupSource> { | |||
| 75 | 75 | None => (user_host_port.to_string(), None), | |
| 76 | 76 | }; | |
| 77 | 77 | if user_host.is_empty() { | |
| 78 | - | bail!("ssh:// URL has empty host (port {:?})", port); | |
| 78 | + | bail!("ssh:// URL has empty host (port {port:?})"); | |
| 79 | 79 | } | |
| 80 | 80 | return Ok(BackupSource::Ssh { | |
| 81 | 81 | user_host, | |
| @@ -147,7 +147,9 @@ pub async fn fetch( | |||
| 147 | 147 | // restores (CF4). `--inplace`/`--partial` are deliberately NOT used — those | |
| 148 | 148 | // keep a truncated file on failure, the opposite of what we want here. | |
| 149 | 149 | let tmp_path = format!("{local_path}.partial"); | |
| 150 | - | let is_gz = local_path.ends_with(".gz"); | |
| 150 | + | let is_gz = std::path::Path::new(&local_path) | |
| 151 | + | .extension() | |
| 152 | + | .is_some_and(|ext| ext.eq_ignore_ascii_case("gz")); | |
| 151 | 153 | ||
| 152 | 154 | // Plausibility floor: half the last verified backup's size, never below the | |
| 153 | 155 | // absolute floor. The first-ever fetch (no prior row) falls back to the | |
| @@ -156,9 +158,9 @@ pub async fn fetch( | |||
| 156 | 158 | sqlx::query_scalar("SELECT byte_size FROM backups ORDER BY fetched_at DESC LIMIT 1") | |
| 157 | 159 | .fetch_optional(pool) | |
| 158 | 160 | .await?; | |
| 159 | - | let min_bytes = last_size | |
| 160 | - | .map(|s| ((s / MIN_BACKUP_FRACTION_DENOM) as u64).max(MIN_BACKUP_BYTES)) | |
| 161 | - | .unwrap_or(MIN_BACKUP_BYTES); | |
| 161 | + | let min_bytes = last_size.map_or(MIN_BACKUP_BYTES, |s| { | |
| 162 | + | ((s / MIN_BACKUP_FRACTION_DENOM) as u64).max(MIN_BACKUP_BYTES) | |
| 163 | + | }); | |
| 162 | 164 | ||
| 163 | 165 | let parsed = parse_source(&source)?; | |
| 164 | 166 | let downloaded: Result<()> = async { |
| @@ -183,8 +183,7 @@ pub async fn run( | |||
| 183 | 183 | let release_dir = cfg | |
| 184 | 184 | .cargo_target_dir | |
| 185 | 185 | .as_deref() | |
| 186 | - | .map(|t| t.join("release")) | |
| 187 | - | .unwrap_or_else(|| server_dir.join("target/release")); | |
| 186 | + | .map_or_else(|| server_dir.join("target/release"), |t| t.join("release")); | |
| 188 | 187 | let mut binary_paths = Vec::with_capacity(cfg.bin_names.len()); | |
| 189 | 188 | for name in &cfg.bin_names { | |
| 190 | 189 | let p = release_dir.join(name); |
| @@ -117,7 +117,7 @@ fn hash_file(path: &Path) -> std::io::Result<String> { | |||
| 117 | 117 | use std::io::Read; | |
| 118 | 118 | let mut file = std::fs::File::open(path)?; | |
| 119 | 119 | let mut hasher = Sha256::new(); | |
| 120 | - | let mut buf = [0u8; 64 * 1024]; | |
| 120 | + | let mut buf = vec![0u8; 64 * 1024].into_boxed_slice(); | |
| 121 | 121 | loop { | |
| 122 | 122 | let n = file.read(&mut buf)?; | |
| 123 | 123 | if n == 0 { |
| @@ -162,7 +162,7 @@ fn parse_due_to_count(s: &str) -> Option<u32> { | |||
| 162 | 162 | let idx = s.find("due to ")?; | |
| 163 | 163 | let digits: String = s[idx + 7..] | |
| 164 | 164 | .chars() | |
| 165 | - | .take_while(|c| c.is_ascii_digit()) | |
| 165 | + | .take_while(char::is_ascii_digit) | |
| 166 | 166 | .collect(); | |
| 167 | 167 | digits.parse().ok() | |
| 168 | 168 | } | |
| @@ -189,9 +189,7 @@ pub fn classify_migration_error(err: &str, migration_hint: Option<&str>) -> Gate | |||
| 189 | 189 | return GateFailure::MigrationModified { migration: m }; | |
| 190 | 190 | } | |
| 191 | 191 | let sqlstate = extract_sqlstate(err); | |
| 192 | - | let migration = migration_hint | |
| 193 | - | .map(str::to_owned) | |
| 194 | - | .unwrap_or_else(|| "?".to_owned()); | |
| 192 | + | let migration = migration_hint.map_or_else(|| "?".to_owned(), str::to_owned); | |
| 195 | 193 | if sqlstate.is_some() { | |
| 196 | 194 | return GateFailure::MigrationSqlError { | |
| 197 | 195 | migration, | |
| @@ -207,14 +205,14 @@ fn extract_drift(err: &str) -> Option<String> { | |||
| 207 | 205 | // "migration N was previously applied but is missing in the resolved migrations" | |
| 208 | 206 | let idx = err.find(" was previously applied but is missing")?; | |
| 209 | 207 | let prefix = &err[..idx]; | |
| 210 | - | let mig = prefix.rsplit_once(' ').map(|(_, m)| m).unwrap_or(prefix); | |
| 208 | + | let mig = prefix.rsplit_once(' ').map_or(prefix, |(_, m)| m); | |
| 211 | 209 | Some(mig.to_string()) | |
| 212 | 210 | } | |
| 213 | 211 | ||
| 214 | 212 | fn extract_modified(err: &str) -> Option<String> { | |
| 215 | 213 | let idx = err.find(" was previously applied but has been modified")?; | |
| 216 | 214 | let prefix = &err[..idx]; | |
| 217 | - | let mig = prefix.rsplit_once(' ').map(|(_, m)| m).unwrap_or(prefix); | |
| 215 | + | let mig = prefix.rsplit_once(' ').map_or(prefix, |(_, m)| m); | |
| 218 | 216 | Some(mig.to_string()) | |
| 219 | 217 | } | |
| 220 | 218 |
| @@ -170,8 +170,7 @@ fn scratch_db_host(url: &str) -> Option<String> { | |||
| 170 | 170 | let after = url.find("://").map(|i| i + 3)?; | |
| 171 | 171 | let authority_end = url[after..] | |
| 172 | 172 | .find(['/', '?', '#']) | |
| 173 | - | .map(|i| after + i) | |
| 174 | - | .unwrap_or(url.len()); | |
| 173 | + | .map_or(url.len(), |i| after + i); | |
| 175 | 174 | let authority = &url[after..authority_end]; | |
| 176 | 175 | // Drop userinfo: keep everything after the last '@' (`user:pass@host` → `host`). | |
| 177 | 176 | let host_port = authority.rsplit('@').next().unwrap_or(authority); | |
| @@ -256,8 +255,7 @@ impl Config { | |||
| 256 | 255 | pub fn primary_bin(&self) -> &str { | |
| 257 | 256 | self.bin_names | |
| 258 | 257 | .first() | |
| 259 | - | .map(|s| s.as_str()) | |
| 260 | - | .unwrap_or("server") | |
| 258 | + | .map_or("server", std::string::String::as_str) | |
| 261 | 259 | } | |
| 262 | 260 | ||
| 263 | 261 | pub fn load() -> Result<Self> { | |
| @@ -291,8 +289,8 @@ impl Config { | |||
| 291 | 289 | for t in &self.test_targets { | |
| 292 | 290 | anyhow::ensure!( | |
| 293 | 291 | !t.all_features || t.features.is_empty(), | |
| 294 | - | "test_target {:?} sets both all_features and features; pick one", | |
| 295 | - | t.dir, | |
| 292 | + | "test_target {} sets both all_features and features; pick one", | |
| 293 | + | t.dir.display(), | |
| 296 | 294 | ); | |
| 297 | 295 | } | |
| 298 | 296 | if let Some(url) = self.scratch_db_url.as_deref() { |
| @@ -64,8 +64,7 @@ async fn run_checked(executor: &dyn Executor, script: &str, what: &str) -> Resul | |||
| 64 | 64 | "{what} failed (exit {}): {}", | |
| 65 | 65 | out.status | |
| 66 | 66 | .code() | |
| 67 | - | .map(|c| c.to_string()) | |
| 68 | - | .unwrap_or_else(|| "signal".into()), | |
| 67 | + | .map_or_else(|| "signal".into(), |c| c.to_string()), | |
| 69 | 68 | String::from_utf8_lossy(&out.stderr), | |
| 70 | 69 | ); | |
| 71 | 70 | Ok(out) |
| @@ -177,7 +177,7 @@ impl sqlx::Type<Sqlite> for Version { | |||
| 177 | 177 | } | |
| 178 | 178 | } | |
| 179 | 179 | ||
| 180 | - | impl<'q> sqlx::Encode<'q, Sqlite> for Version { | |
| 180 | + | impl sqlx::Encode<'_, Sqlite> for Version { | |
| 181 | 181 | fn encode_by_ref( | |
| 182 | 182 | &self, | |
| 183 | 183 | buf: &mut <Sqlite as sqlx::Database>::ArgumentBuffer, | |
| @@ -267,7 +267,7 @@ impl sqlx::Type<Sqlite> for GitSha { | |||
| 267 | 267 | } | |
| 268 | 268 | } | |
| 269 | 269 | ||
| 270 | - | impl<'q> sqlx::Encode<'q, Sqlite> for GitSha { | |
| 270 | + | impl sqlx::Encode<'_, Sqlite> for GitSha { | |
| 271 | 271 | fn encode_by_ref( | |
| 272 | 272 | &self, | |
| 273 | 273 | buf: &mut <Sqlite as sqlx::Database>::ArgumentBuffer, | |
| @@ -394,7 +394,7 @@ impl sqlx::Type<Sqlite> for GateKind { | |||
| 394 | 394 | } | |
| 395 | 395 | } | |
| 396 | 396 | ||
| 397 | - | impl<'q> sqlx::Encode<'q, Sqlite> for GateKind { | |
| 397 | + | impl sqlx::Encode<'_, Sqlite> for GateKind { | |
| 398 | 398 | fn encode_by_ref( | |
| 399 | 399 | &self, | |
| 400 | 400 | buf: &mut <Sqlite as sqlx::Database>::ArgumentBuffer, |
| @@ -124,7 +124,7 @@ pub fn channel() -> EventTx { | |||
| 124 | 124 | /// Send an event without caring whether anyone is listening. `GateLogChunk` goes | |
| 125 | 125 | /// to the high-rate log channel, everything else to the status channel. | |
| 126 | 126 | pub fn emit(bus: &EventTx, event: Event) { | |
| 127 | - | eventbus::emit(bus, event) | |
| 127 | + | eventbus::emit(bus, event); | |
| 128 | 128 | } | |
| 129 | 129 | ||
| 130 | 130 | #[cfg(test)] | |
| @@ -248,7 +248,9 @@ mod tests { | |||
| 248 | 248 | let err = rx.recv().await.expect_err("expected Lagged"); | |
| 249 | 249 | match err { | |
| 250 | 250 | tokio::sync::broadcast::error::RecvError::Lagged(n) => assert!(n >= 10), | |
| 251 | - | other => panic!("unexpected error: {other:?}"), | |
| 251 | + | other @ tokio::sync::broadcast::error::RecvError::Closed => { | |
| 252 | + | panic!("unexpected error: {other:?}") | |
| 253 | + | } | |
| 252 | 254 | } | |
| 253 | 255 | } | |
| 254 | 256 | } |
| @@ -162,7 +162,7 @@ pub async fn run(ctx: &GateCtx, gate: &Gate) -> Result<GateOutcome> { | |||
| 162 | 162 | .bind(Utc::now().to_rfc3339()) | |
| 163 | 163 | .bind(outcome.status_str()) | |
| 164 | 164 | .bind(&outcome_json) | |
| 165 | - | .bind(outcome.log_ref.as_ref().map(|l| l.as_str())) | |
| 165 | + | .bind(outcome.log_ref.as_ref().map(super::outcome::LogRef::as_str)) | |
| 166 | 166 | .bind(id) | |
| 167 | 167 | .execute(&ctx.pool) | |
| 168 | 168 | .await?; | |
| @@ -822,8 +822,7 @@ fn cargo_test_command( | |||
| 822 | 822 | // to /var/run/postgresql on Debian/Ubuntu when host is unspecified. | |
| 823 | 823 | let test_url = scratch_url | |
| 824 | 824 | .split_once('?') | |
| 825 | - | .map(|(base, _)| base) | |
| 826 | - | .unwrap_or(scratch_url); | |
| 825 | + | .map_or(scratch_url, |(base, _)| base); | |
| 827 | 826 | cmd.env("TEST_DATABASE_URL", test_url); | |
| 828 | 827 | } | |
| 829 | 828 | cmd | |
| @@ -1081,7 +1080,10 @@ async fn clean_stale_test_dbs(db_url: &str) { | |||
| 1081 | 1080 | /// psql's exit. pipefail is a bash builtin (not POSIX sh), so the runner uses | |
| 1082 | 1081 | /// `bash -c`. | |
| 1083 | 1082 | fn restore_shell(db_url: &str, dump: &str) -> String { | |
| 1084 | - | if dump.ends_with(".gz") { | |
| 1083 | + | if std::path::Path::new(dump) | |
| 1084 | + | .extension() | |
| 1085 | + | .is_some_and(|ext| ext.eq_ignore_ascii_case("gz")) | |
| 1086 | + | { | |
| 1085 | 1087 | format!( | |
| 1086 | 1088 | "set -o pipefail; gunzip -c {q} | psql -v ON_ERROR_STOP=1 {url}", | |
| 1087 | 1089 | q = shell_escape(dump), | |
| @@ -1135,8 +1137,7 @@ fn split_pg_password(db_url: &str) -> (String, Option<String>) { | |||
| 1135 | 1137 | // between the first ':' and the '@' within the userinfo of that authority. | |
| 1136 | 1138 | let authority_end = db_url[after..] | |
| 1137 | 1139 | .find(['/', '?', '#']) | |
| 1138 | - | .map(|i| after + i) | |
| 1139 | - | .unwrap_or(db_url.len()); | |
| 1140 | + | .map_or(db_url.len(), |i| after + i); | |
| 1140 | 1141 | let Some(at) = db_url[after..authority_end].find('@').map(|i| after + i) else { | |
| 1141 | 1142 | return (db_url.to_string(), None); | |
| 1142 | 1143 | }; | |
| @@ -1326,7 +1327,7 @@ async fn code_smoke_docs_check( | |||
| 1326 | 1327 | .stdout(std::process::Stdio::piped()) | |
| 1327 | 1328 | .stderr(std::process::Stdio::piped()) | |
| 1328 | 1329 | .kill_on_drop(true); | |
| 1329 | - | let out = match tokio::time::timeout(std::time::Duration::from_secs(60), cmd.output()).await { | |
| 1330 | + | let out = match tokio::time::timeout(std::time::Duration::from_mins(1), cmd.output()).await { | |
| 1330 | 1331 | Ok(Ok(out)) => out, | |
| 1331 | 1332 | Ok(Err(e)) => { | |
| 1332 | 1333 | log_buf.extend_from_slice(format!("docs check spawn failed: {e}\n").as_bytes()); | |
| @@ -1822,8 +1823,7 @@ async fn probe_node(probe: &NodeProbe) -> std::result::Result<(), String> { | |||
| 1822 | 1823 | let url = probe | |
| 1823 | 1824 | .health_url | |
| 1824 | 1825 | .as_deref() | |
| 1825 | - | .map(sh_quote) | |
| 1826 | - | .unwrap_or_else(|| "''".to_string()); | |
| 1826 | + | .map_or_else(|| "''".to_string(), sh_quote); | |
| 1827 | 1827 | // One executor round-trip with the retry loop on the node: is-active, then | |
| 1828 | 1828 | // (if a url is set) curl it for a 2xx. Exit 0 only when both hold. | |
| 1829 | 1829 | let script = format!( | |
| @@ -1849,8 +1849,7 @@ async fn probe_node(probe: &NodeProbe) -> std::result::Result<(), String> { | |||
| 1849 | 1849 | let code = out | |
| 1850 | 1850 | .status | |
| 1851 | 1851 | .code() | |
| 1852 | - | .map(|c| c.to_string()) | |
| 1853 | - | .unwrap_or_else(|| "signal".to_string()); | |
| 1852 | + | .map_or_else(|| "signal".to_string(), |c| c.to_string()); | |
| 1854 | 1853 | let stderr: String = String::from_utf8_lossy(&out.stderr) | |
| 1855 | 1854 | .chars() | |
| 1856 | 1855 | .take(200) | |
| @@ -1982,8 +1981,7 @@ async fn manual_confirm(ctx: &GateCtx) -> Result<GateOutcome> { | |||
| 1982 | 1981 | match prior_at { | |
| 1983 | 1982 | Some(at_str) => { | |
| 1984 | 1983 | let at = chrono::DateTime::parse_from_rfc3339(&at_str) | |
| 1985 | - | .map(|d| d.with_timezone(&Utc)) | |
| 1986 | - | .unwrap_or_else(|_| Utc::now()); | |
| 1984 | + | .map_or_else(|_| Utc::now(), |d| d.with_timezone(&Utc)); | |
| 1987 | 1985 | Ok(GateOutcome::passed(PassNote::OperatorConfirmed { at })) | |
| 1988 | 1986 | } | |
| 1989 | 1987 | None => Ok(GateOutcome::blocked( |
| @@ -136,9 +136,8 @@ pub async fn checkout_worktree(bare: &Path, sha: &str, dest: &Path) -> Result<() | |||
| 136 | 136 | /// sha) is reported as "no match" rather than an error — the caller recreates. | |
| 137 | 137 | async fn worktree_head_matches(bare: &Path, dest: &Path, sha: &str) -> Result<bool> { | |
| 138 | 138 | let want = resolve_commit(bare, sha).await?; | |
| 139 | - | let have = match worktree_head(dest).await { | |
| 140 | - | Ok(h) => h, | |
| 141 | - | Err(_) => return Ok(false), | |
| 139 | + | let Ok(have) = worktree_head(dest).await else { | |
| 140 | + | return Ok(false); | |
| 142 | 141 | }; | |
| 143 | 142 | Ok(have == want) | |
| 144 | 143 | } |
| @@ -85,7 +85,7 @@ async fn run() -> Result<()> { | |||
| 85 | 85 | "settled orphaned 'building' run(s) from a prior daemon" | |
| 86 | 86 | ), | |
| 87 | 87 | Err(e) => { | |
| 88 | - | tracing::error!(error = %e, "failed to reconcile orphaned 'building' runs at startup") | |
| 88 | + | tracing::error!(error = %e, "failed to reconcile orphaned 'building' runs at startup"); | |
| 89 | 89 | } | |
| 90 | 90 | } | |
| 91 | 91 | sync::sync(&pool, &topo).await?; | |
| @@ -119,7 +119,7 @@ async fn run() -> Result<()> { | |||
| 119 | 119 | match &api_token { | |
| 120 | 120 | Some(_) => tracing::info!("deploy endpoints require a bearer token"), | |
| 121 | 121 | None => { | |
| 122 | - | tracing::warn!(%addr, "SANDO_API_TOKEN unset; deploy endpoints are UNAUTHENTICATED (loopback bind)") | |
| 122 | + | tracing::warn!(%addr, "SANDO_API_TOKEN unset; deploy endpoints are UNAUTHENTICATED (loopback bind)"); | |
| 123 | 123 | } | |
| 124 | 124 | } | |
| 125 | 125 |
| @@ -45,6 +45,7 @@ impl GateOutcome { | |||
| 45 | 45 | log_ref: None, | |
| 46 | 46 | } | |
| 47 | 47 | } | |
| 48 | + | #[must_use] | |
| 48 | 49 | pub fn with_log_ref(mut self, log_ref: LogRef) -> Self { | |
| 49 | 50 | self.log_ref = Some(log_ref); | |
| 50 | 51 | self |
| @@ -73,8 +73,7 @@ async fn require_bearer( | |||
| 73 | 73 | let peer = req | |
| 74 | 74 | .extensions() | |
| 75 | 75 | .get::<axum::extract::ConnectInfo<std::net::SocketAddr>>() | |
| 76 | - | .map(|ci| ci.0.to_string()) | |
| 77 | - | .unwrap_or_else(|| "unknown".into()); | |
| 76 | + | .map_or_else(|| "unknown".into(), |ci| ci.0.to_string()); | |
| 78 | 77 | tracing::info!(path = %path, peer = %peer, "authenticated deploy mutation"); | |
| 79 | 78 | } | |
| 80 | 79 | next.run(req).await |
| @@ -3,7 +3,7 @@ | |||
| 3 | 3 | //! partial-state flags, and the promote-time gate-satisfaction check. | |
| 4 | 4 | //! routes/mod.rs stays HTTP glue that calls into these. | |
| 5 | 5 | ||
| 6 | - | use super::*; | |
| 6 | + | use super::{AppState, Json, PromoteBody, Result}; | |
| 7 | 7 | ||
| 8 | 8 | pub(super) async fn promote_inner( | |
| 9 | 9 | s: AppState, | |
| @@ -474,7 +474,7 @@ pub(super) async fn rollback_deployed_nodes( | |||
| 474 | 474 | }; | |
| 475 | 475 | let Some(staged_dir) = std::path::PathBuf::from(&bin) | |
| 476 | 476 | .parent() | |
| 477 | - | .map(|p| p.to_path_buf()) | |
| 477 | + | .map(std::path::Path::to_path_buf) | |
| 478 | 478 | else { | |
| 479 | 479 | tracing::error!(tier = %tier, prev = prev_version, | |
| 480 | 480 | "canary rollback: previous artifact_path has no parent dir; nodes left on the new version"); |
| @@ -340,8 +340,7 @@ fn elapsed_seconds(started_at: &str, finished_at: Option<&str>) -> i64 { | |||
| 340 | 340 | }; | |
| 341 | 341 | let end = match finished_at { | |
| 342 | 342 | Some(f) => chrono::DateTime::parse_from_rfc3339(f) | |
| 343 | - | .map(|d| d.with_timezone(&Utc)) | |
| 344 | - | .unwrap_or_else(|_| Utc::now()), | |
| 343 | + | .map_or_else(|_| Utc::now(), |d| d.with_timezone(&Utc)), | |
| 345 | 344 | None => Utc::now(), | |
| 346 | 345 | }; | |
| 347 | 346 | (end - start.with_timezone(&Utc)).num_seconds().max(0) | |
| @@ -366,11 +365,13 @@ pub async fn first_failed_gate_summary(pool: &SqlitePool, version: &Version) -> | |||
| 366 | 365 | let outcome_json: Option<String> = row.get("outcome_json"); | |
| 367 | 366 | let summary = outcome_json | |
| 368 | 367 | .and_then(|s| serde_json::from_str::<crate::outcome::GateOutcome>(&s).ok()) | |
| 369 | - | .map(|o| match o.status { | |
| 370 | - | crate::outcome::GateStatus::Failed { failure } => failure.summary(), | |
| 371 | - | other => format!("{:?}", other), | |
| 372 | - | }) | |
| 373 | - | .unwrap_or_else(|| "gate failed".to_string()); | |
| 368 | + | .map_or_else( | |
| 369 | + | || "gate failed".to_string(), | |
| 370 | + | |o| match o.status { | |
| 371 | + | crate::outcome::GateStatus::Failed { failure } => failure.summary(), | |
| 372 | + | other => format!("{other:?}"), | |
| 373 | + | }, | |
| 374 | + | ); | |
| 374 | 375 | Some(format!("{kind}: {summary}")) | |
| 375 | 376 | } | |
| 376 | 377 | ||
| @@ -407,26 +408,26 @@ mod tests { | |||
| 407 | 408 | let pool = pool().await; | |
| 408 | 409 | // Two in-flight runs (as if the daemon died mid-build) + one already | |
| 409 | 410 | // settled, which must be left untouched. | |
| 410 | - | let a = create(&pool, "aaaaaaa").await.unwrap(); | |
| 411 | - | let b = create(&pool, "bbbbbbb").await.unwrap(); | |
| 412 | - | let c = create(&pool, "ccccccc").await.unwrap(); | |
| 413 | - | mark_passed(&pool, c).await.unwrap(); | |
| 414 | - | ||
| 415 | - | let n = recover_orphaned_running(&pool).await.unwrap(); | |
| 416 | - | assert_eq!(n, 2, "both 'building' runs reconciled"); | |
| 417 | - | ||
| 418 | - | for id in [a, b] { | |
| 419 | - | let v = get(&pool, id).await.unwrap().unwrap(); | |
| 420 | - | assert_eq!(v.result, "aborted"); | |
| 421 | - | assert_eq!(v.phase, "done"); | |
| 422 | - | assert!(v.finished_at.is_some()); | |
| 411 | + | let run_a = create(&pool, "aaaaaaa").await.unwrap(); | |
| 412 | + | let run_b = create(&pool, "bbbbbbb").await.unwrap(); | |
| 413 | + | let run_c = create(&pool, "ccccccc").await.unwrap(); | |
| 414 | + | mark_passed(&pool, run_c).await.unwrap(); | |
| 415 | + | ||
| 416 | + | let reconciled = recover_orphaned_running(&pool).await.unwrap(); | |
| 417 | + | assert_eq!(reconciled, 2, "both 'building' runs reconciled"); | |
| 418 | + | ||
| 419 | + | for id in [run_a, run_b] { | |
| 420 | + | let rec = get(&pool, id).await.unwrap().unwrap(); | |
| 421 | + | assert_eq!(rec.result, "aborted"); | |
| 422 | + | assert_eq!(rec.phase, "done"); | |
| 423 | + | assert!(rec.finished_at.is_some()); | |
| 423 | 424 | assert_eq!( | |
| 424 | - | v.failure_summary.as_deref(), | |
| 425 | + | rec.failure_summary.as_deref(), | |
| 425 | 426 | Some("daemon restarted mid-build") | |
| 426 | 427 | ); | |
| 427 | 428 | } | |
| 428 | 429 | // The already-settled run is unchanged. | |
| 429 | - | assert_eq!(get(&pool, c).await.unwrap().unwrap().result, "passed"); | |
| 430 | + | assert_eq!(get(&pool, run_c).await.unwrap().unwrap().result, "passed"); | |
| 430 | 431 | ||
| 431 | 432 | // Idempotent: a second pass finds nothing to do. | |
| 432 | 433 | assert_eq!(recover_orphaned_running(&pool).await.unwrap(), 0); |