返回 CodeWhale
models_dev_live.rs
根目录 / crates / tui / src / models_dev_live.rs
1 //! Live Models.dev catalog fetch + secret-free disk cache (#4187).
2 //!
3 //! OpenCode-style producer that:
4 //! - reads a stale/fresh disk cache on startup (never blocks model selection),
5 //! - fetches `https://models.dev/catalog.json` in the background with a bounded
6 //! timeout and explicit user-agent (no credentials),
7 //! - writes the cache atomically via temp file + rename,
8 //! - compiles parsed rows into `CatalogOffering`s and publishes them into
9 //! [`crate::provider_lake`],
10 //! - falls back to the prior cache or the bundled snapshot on any failure.
11 //!
12 //! Override knobs (tests / dogfood):
13 //! - `CODEWHALE_MODELS_DEV_URL` — base URL (appends `/catalog.json`) or a full
14 //! `*.json` URL.
15 //! - `CODEWHALE_MODELS_DEV_PATH` — local file path; skips the network.
16 //! - `CODEWHALE_DISABLE_MODELS_DEV_FETCH` — when truthy, never hits the network.
17
18 use std::path::{Path, PathBuf};
19 use std::sync::RwLock;
20 use std::time::Duration;
21
22 use codewhale_config::catalog::{
23 CatalogSnapshot, base_url_fingerprint, live_offerings_from_models_dev, now_unix,
24 };
25 use codewhale_config::models_dev::{MODELS_DEV_CATALOG_URL, ModelsDevCatalog};
26 use codewhale_config::persistence::atomic_write;
27 use serde::{Deserialize, Serialize};
28
29 /// Default TTL for a live Models.dev snapshot (24h, #4187 / #4114).
30 pub const DEFAULT_MODELS_DEV_TTL_SECS: u64 = 24 * 60 * 60;
31
32 /// Bounded HTTP timeout for the Models.dev fetch.
33 pub const FETCH_TIMEOUT: Duration = Duration::from_secs(15);
34
35 /// Explicit user-agent; no credentials, no session cookies.
36 pub const USER_AGENT: &str = concat!("CodeWhale/", env!("CARGO_PKG_VERSION"), " (+models-dev)");
37
38 /// Filename under the CodeWhale `catalog` state dir.
39 pub const CACHE_FILE: &str = "models-dev-catalog.json";
40
41 /// Env: override Models.dev base URL or full catalog URL.
42 pub const ENV_MODELS_DEV_URL: &str = "CODEWHALE_MODELS_DEV_URL";
43 /// Env: load catalog JSON from a local path (skips network).
44 pub const ENV_MODELS_DEV_PATH: &str = "CODEWHALE_MODELS_DEV_PATH";
45 /// Env: disable network fetch entirely (`1`/`true`/`yes`/`on`).
46 pub const ENV_DISABLE_FETCH: &str = "CODEWHALE_DISABLE_MODELS_DEV_FETCH";
47
48 const CACHE_SCHEMA_VERSION: u32 = 1;
49
50 /// Provenance / freshness of the Models.dev live layer for UI chips (#4187).
51 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
52 #[serde(rename_all = "snake_case")]
53 pub enum ModelsDevFreshness {
54 /// No live/cache layer; pickers see bundled rows only.
55 #[default]
56 Bundled,
57 /// Live (or disk-cache) rows within TTL.
58 Live,
59 /// Disk-cache / prior live rows past TTL; still visible.
60 Stale,
61 /// Last refresh failed; prior/bundled rows remain available.
62 Failed,
63 }
64
65 /// Quiet status snapshot for UI / `/model refresh` feedback.
66 #[derive(Debug, Clone, PartialEq, Eq, Default)]
67 pub struct ModelsDevStatus {
68 pub freshness: ModelsDevFreshness,
69 pub offering_count: usize,
70 pub fetched_at: Option<u64>,
71 pub source_label: String,
72 pub last_error: Option<String>,
73 }
74
75 static STATUS: RwLock<ModelsDevStatus> = RwLock::new(ModelsDevStatus {
76 freshness: ModelsDevFreshness::Bundled,
77 offering_count: 0,
78 fetched_at: None,
79 source_label: String::new(),
80 last_error: None,
81 });
82
83 #[derive(Debug, Clone, Serialize, Deserialize)]
84 struct PersistedModelsDevCache {
85 schema_version: u32,
86 /// Unix seconds the payload was fetched (or loaded from an override path).
87 fetched_at: u64,
88 /// Fingerprint of the source URL/path used for `CatalogSource::Live`.
89 source_fingerprint: String,
90 /// Human-readable source label (URL or `file:…`); never a secret.
91 source_label: String,
92 /// Raw Models.dev catalog JSON body (secret-free by construction).
93 body: String,
94 }
95
96 /// Why a Models.dev refresh did not publish new rows.
97 #[derive(Debug, Clone, PartialEq, Eq)]
98 pub enum ModelsDevRefreshError {
99 Disabled,
100 Network(String),
101 HttpStatus(u16),
102 InvalidResponse(String),
103 EmptyCatalog,
104 Io(String),
105 }
106
107 impl std::fmt::Display for ModelsDevRefreshError {
108 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
109 match self {
110 Self::Disabled => write!(f, "Models.dev fetch disabled"),
111 Self::Network(msg) => write!(f, "network: {msg}"),
112 Self::HttpStatus(code) => write!(f, "HTTP {code}"),
113 Self::InvalidResponse(msg) => write!(f, "invalid response: {msg}"),
114 Self::EmptyCatalog => write!(f, "empty catalog"),
115 Self::Io(msg) => write!(f, "io: {msg}"),
116 }
117 }
118 }
119
120 /// Resolve the on-disk cache path under the CodeWhale `catalog` state dir.
121 #[must_use]
122 pub fn cache_path() -> Option<PathBuf> {
123 codewhale_config::resolve_state_dir("catalog")
124 .ok()
125 .map(|dir| dir.join(CACHE_FILE))
126 }
127
128 /// Current quiet status (for UI / slash-command feedback).
129 #[must_use]
130 pub fn status() -> ModelsDevStatus {
131 STATUS.read().map(|guard| guard.clone()).unwrap_or_default()
132 }
133
134 fn set_status(next: ModelsDevStatus) {
135 if let Ok(mut guard) = STATUS.write() {
136 *guard = next;
137 }
138 }
139
140 fn env_truthy(name: &str) -> bool {
141 std::env::var(name)
142 .map(|v| {
143 matches!(
144 v.trim().to_ascii_lowercase().as_str(),
145 "1" | "true" | "yes" | "on"
146 )
147 })
148 .unwrap_or(false)
149 }
150
151 /// Resolve the catalog URL from env override or the Models.dev default.
152 #[must_use]
153 pub fn resolve_catalog_url() -> String {
154 match std::env::var(ENV_MODELS_DEV_URL) {
155 Ok(raw) => {
156 let trimmed = raw.trim();
157 if trimmed.is_empty() {
158 MODELS_DEV_CATALOG_URL.to_string()
159 } else if trimmed.ends_with(".json") {
160 trimmed.to_string()
161 } else {
162 format!("{}/catalog.json", trimmed.trim_end_matches('/'))
163 }
164 }
165 Err(_) => MODELS_DEV_CATALOG_URL.to_string(),
166 }
167 }
168
169 /// Seed ProviderLake from the on-disk Models.dev cache before any picker read.
170 ///
171 /// Missing / corrupt / empty caches are a no-op — bundled rows remain available.
172 /// Stale caches still publish (freshness = Stale) so offline startups keep the
173 /// last-known live rows.
174 pub fn maybe_load_persisted_cache() {
175 let Some(path) = cache_path() else {
176 return;
177 };
178 if let Some(cache) = load_cache_file(&path) {
179 let age = now_unix().saturating_sub(cache.fetched_at);
180 let freshness = if age > DEFAULT_MODELS_DEV_TTL_SECS {
181 ModelsDevFreshness::Stale
182 } else {
183 ModelsDevFreshness::Live
184 };
185 if let Err(err) = publish_from_body(
186 &cache.body,
187 &cache.source_fingerprint,
188 cache.fetched_at,
189 &cache.source_label,
190 freshness,
191 ) {
192 tracing::debug!(
193 target: "models_dev_live",
194 error = %err,
195 "persisted Models.dev cache failed to publish; keeping bundled"
196 );
197 }
198 }
199 }
200
201 /// Force a refresh: prefer `CODEWHALE_MODELS_DEV_PATH`, else network fetch.
202 ///
203 /// On success, updates the disk cache and ProviderLake. On failure, keeps any
204 /// prior live/bundled rows and records a quiet Failed status.
205 pub async fn refresh(force_network: bool) -> Result<usize, ModelsDevRefreshError> {
206 if let Ok(path) = std::env::var(ENV_MODELS_DEV_PATH) {
207 let trimmed = path.trim();
208 if !trimmed.is_empty() {
209 return refresh_from_path(Path::new(trimmed)).await;
210 }
211 }
212
213 if env_truthy(ENV_DISABLE_FETCH) {
214 mark_failed(ModelsDevRefreshError::Disabled);
215 return Err(ModelsDevRefreshError::Disabled);
216 }
217
218 if !force_network {
219 let current = status();
220 if current.freshness == ModelsDevFreshness::Live
221 && current
222 .fetched_at
223 .is_some_and(|ts| now_unix().saturating_sub(ts) < DEFAULT_MODELS_DEV_TTL_SECS)
224 {
225 return Ok(current.offering_count);
226 }
227 }
228
229 let url = resolve_catalog_url();
230 let body = match fetch_catalog_body(&url).await {
231 Ok(body) => body,
232 Err(err) => {
233 mark_failed(err.clone());
234 return Err(err);
235 }
236 };
237 let fetched_at = now_unix();
238 let fingerprint = base_url_fingerprint(&url);
239 let count = publish_from_body(
240 &body,
241 &fingerprint,
242 fetched_at,
243 &url,
244 ModelsDevFreshness::Live,
245 )?;
246 if let Some(path) = cache_path() {
247 let _ = save_cache_file(
248 &path,
249 &PersistedModelsDevCache {
250 schema_version: CACHE_SCHEMA_VERSION,
251 fetched_at,
252 source_fingerprint: fingerprint,
253 source_label: url,
254 body,
255 },
256 );
257 }
258 Ok(count)
259 }
260
261 /// Best-effort background refresh: never panics, never blocks callers.
262 pub fn spawn_background_refresh() {
263 if env_truthy(ENV_DISABLE_FETCH) && std::env::var(ENV_MODELS_DEV_PATH).is_err() {
264 return;
265 }
266 tokio::spawn(async {
267 match refresh(false).await {
268 Ok(count) => {
269 tracing::debug!(
270 target: "models_dev_live",
271 offering_count = count,
272 "Models.dev live catalog refreshed"
273 );
274 }
275 Err(err) => {
276 tracing::debug!(
277 target: "models_dev_live",
278 error = %err,
279 "Models.dev live catalog refresh skipped"
280 );
281 }
282 }
283 });
284 }
285
286 async fn refresh_from_path(path: &Path) -> Result<usize, ModelsDevRefreshError> {
287 let body = match tokio::fs::read_to_string(path).await {
288 Ok(body) => body,
289 Err(err) => {
290 let mapped = ModelsDevRefreshError::Io(err.to_string());
291 mark_failed(mapped.clone());
292 return Err(mapped);
293 }
294 };
295 let fetched_at = now_unix();
296 let label = format!("file:{}", path.display());
297 let fingerprint = base_url_fingerprint(&label);
298 let count = publish_from_body(
299 &body,
300 &fingerprint,
301 fetched_at,
302 &label,
303 ModelsDevFreshness::Live,
304 )?;
305 if let Some(cache) = cache_path() {
306 let _ = save_cache_file(
307 &cache,
308 &PersistedModelsDevCache {
309 schema_version: CACHE_SCHEMA_VERSION,
310 fetched_at,
311 source_fingerprint: fingerprint,
312 source_label: label,
313 body,
314 },
315 );
316 }
317 Ok(count)
318 }
319
320 async fn fetch_catalog_body(url: &str) -> Result<String, ModelsDevRefreshError> {
321 let client = crate::tls::reqwest_client_builder()
322 .timeout(FETCH_TIMEOUT)
323 .connect_timeout(Duration::from_secs(10))
324 .user_agent(USER_AGENT)
325 .build()
326 .map_err(|err| ModelsDevRefreshError::Network(err.to_string()))?;
327
328 let response = client
329 .get(url)
330 .send()
331 .await
332 .map_err(|err| ModelsDevRefreshError::Network(err.to_string()))?;
333
334 let status = response.status();
335 if !status.is_success() {
336 return Err(ModelsDevRefreshError::HttpStatus(status.as_u16()));
337 }
338
339 response
340 .text()
341 .await
342 .map_err(|err| ModelsDevRefreshError::Network(err.to_string()))
343 }
344
345 fn publish_from_body(
346 body: &str,
347 fingerprint: &str,
348 fetched_at: u64,
349 source_label: &str,
350 freshness: ModelsDevFreshness,
351 ) -> Result<usize, ModelsDevRefreshError> {
352 let catalog = ModelsDevCatalog::parse_json(body).map_err(|err| {
353 let mapped = ModelsDevRefreshError::InvalidResponse(err.to_string());
354 mark_failed(mapped.clone());
355 mapped
356 })?;
357 let offerings = live_offerings_from_models_dev(&catalog, fingerprint, fetched_at);
358 if offerings.is_empty() {
359 let err = ModelsDevRefreshError::EmptyCatalog;
360 mark_failed(err.clone());
361 return Err(err);
362 }
363 let count = offerings.len();
364 crate::provider_lake::set_live_snapshot(
365 CatalogSnapshot { offerings },
366 crate::provider_lake::LiveSource::ModelsDev,
367 );
368 set_status(ModelsDevStatus {
369 freshness,
370 offering_count: count,
371 fetched_at: Some(fetched_at),
372 source_label: source_label.to_string(),
373 last_error: None,
374 });
375 Ok(count)
376 }
377
378 fn mark_failed(err: ModelsDevRefreshError) {
379 let mut next = status();
380 // Keep prior offering_count / fetched_at so UI can still show the last
381 // rows, but mark the last refresh outcome distinctly from TTL staleness.
382 next.freshness = ModelsDevFreshness::Failed;
383 next.last_error = Some(err.to_string());
384 set_status(next);
385 }
386
387 fn load_cache_file(path: &Path) -> Option<PersistedModelsDevCache> {
388 let bytes = std::fs::read(path).ok()?;
389 let cache: PersistedModelsDevCache = serde_json::from_slice(&bytes).ok()?;
390 if cache.schema_version != CACHE_SCHEMA_VERSION {
391 return None;
392 }
393 if cache.body.trim().is_empty() {
394 return None;
395 }
396 Some(cache)
397 }
398
399 fn save_cache_file(
400 path: &Path,
401 cache: &PersistedModelsDevCache,
402 ) -> Result<(), ModelsDevRefreshError> {
403 let bytes =
404 serde_json::to_vec(cache).map_err(|err| ModelsDevRefreshError::Io(err.to_string()))?;
405 atomic_write(path, &bytes).map_err(|err| ModelsDevRefreshError::Io(err.to_string()))
406 }
407
408 /// Compile helper exposed for unit tests: body → live offerings with normalized
409 /// provider ids.
410 #[cfg(test)]
411 pub(crate) fn offerings_from_json_for_test(
412 body: &str,
413 ) -> Result<Vec<codewhale_config::catalog::CatalogOffering>, String> {
414 let catalog = ModelsDevCatalog::parse_json(body).map_err(|e| e.to_string())?;
415 Ok(live_offerings_from_models_dev(
416 &catalog,
417 "test-fp",
418 1_700_000_000,
419 ))
420 }
421
422 #[cfg(test)]
423 mod tests {
424 use super::*;
425 use crate::config::ApiProvider;
426 use crate::provider_lake::{all_catalog_models_for_provider, clear_live_snapshot};
427 use crate::test_support::{EnvVarGuard, lock_test_env};
428 use codewhale_config::catalog::CatalogSource;
429
430 const FIXTURE: &str = r#"{
431 "models": {},
432 "providers": {
433 "togetherai": {
434 "id": "togetherai",
435 "models": {
436 "deepseek-ai/DeepSeek-V4-Pro": {
437 "id": "deepseek-ai/DeepSeek-V4-Pro",
438 "name": "DeepSeek V4 Pro",
439 "modalities": { "input": ["text"], "output": ["text"] },
440 "limit": { "context": 128000, "output": 8192 }
441 }
442 }
443 },
444 "moonshotai": {
445 "id": "moonshotai",
446 "models": {
447 "kimi-k2.5": {
448 "id": "kimi-k2.5",
449 "name": "Kimi K2.5",
450 "modalities": { "input": ["text"], "output": ["text"] },
451 "limit": { "context": 256000, "output": 8192 }
452 }
453 }
454 },
455 "unknown-gateway": {
456 "id": "unknown-gateway",
457 "models": {
458 "mystery-1": {
459 "id": "mystery-1",
460 "modalities": { "input": ["text"], "output": ["text"] }
461 }
462 }
463 }
464 }
465 }"#;
466
467 #[test]
468 fn resolve_catalog_url_defaults_and_overrides() {
469 let _lock = lock_test_env();
470 let _url = EnvVarGuard::remove(ENV_MODELS_DEV_URL);
471 assert_eq!(resolve_catalog_url(), MODELS_DEV_CATALOG_URL);
472
473 let _url = EnvVarGuard::set(ENV_MODELS_DEV_URL, "https://example.test");
474 assert_eq!(resolve_catalog_url(), "https://example.test/catalog.json");
475
476 let _url = EnvVarGuard::set(ENV_MODELS_DEV_URL, "https://example.test/api.json");
477 assert_eq!(resolve_catalog_url(), "https://example.test/api.json");
478 }
479
480 #[test]
481 fn live_offerings_normalize_models_dev_provider_ids() {
482 let rows = offerings_from_json_for_test(FIXTURE).expect("fixture");
483 let providers: Vec<_> = rows.iter().map(|r| r.provider.as_str()).collect();
484 assert!(providers.contains(&"together"));
485 assert!(providers.contains(&"moonshot"));
486 assert!(providers.contains(&"unknown-gateway"));
487 assert!(!providers.contains(&"togetherai"));
488 assert!(!providers.contains(&"moonshotai"));
489 assert!(
490 rows.iter()
491 .all(|r| matches!(r.source, CatalogSource::Live { .. }))
492 );
493 }
494
495 #[test]
496 fn publish_from_path_updates_provider_lake() {
497 let _lock = lock_test_env();
498 clear_live_snapshot();
499 let dir = tempfile::tempdir().expect("tempdir");
500 let path = dir.path().join("catalog.json");
501 std::fs::write(&path, FIXTURE).expect("write fixture");
502
503 let _home = EnvVarGuard::set("CODEWHALE_HOME", dir.path().join("home"));
504 let _disable = EnvVarGuard::set(ENV_DISABLE_FETCH, "1");
505 let _path = EnvVarGuard::set(ENV_MODELS_DEV_PATH, &path);
506
507 let rt = tokio::runtime::Builder::new_current_thread()
508 .enable_all()
509 .build()
510 .expect("runtime");
511 let count = rt.block_on(refresh(true)).expect("refresh from path");
512 assert!(count >= 2);
513
514 let together = all_catalog_models_for_provider(ApiProvider::Together);
515 assert!(
516 together.iter().any(|m| m == "deepseek-ai/DeepSeek-V4-Pro"),
517 "Together lake missing live Models.dev row: {together:?}"
518 );
519 let moonshot = all_catalog_models_for_provider(ApiProvider::Moonshot);
520 assert!(
521 moonshot.iter().any(|m| m == "kimi-k2.5"),
522 "Moonshot lake missing live Models.dev row: {moonshot:?}"
523 );
524
525 let st = status();
526 assert_eq!(st.freshness, ModelsDevFreshness::Live);
527 assert!(st.last_error.is_none());
528 assert!(st.offering_count >= 2);
529
530 // Cache file should exist and be secret-free.
531 let cache = cache_path().expect("cache path");
532 assert!(cache.exists());
533 let on_disk = std::fs::read_to_string(&cache).expect("read cache");
534 let lowered = on_disk.to_lowercase();
535 for needle in ["api_key", "authorization", "bearer", "password"] {
536 assert!(
537 !lowered.contains(&format!("\"{needle}\"")),
538 "cache must not persist `{needle}`"
539 );
540 }
541
542 clear_live_snapshot();
543 }
544
545 #[test]
546 fn invalid_json_keeps_bundled_and_marks_failed() {
547 let _lock = lock_test_env();
548 clear_live_snapshot();
549 let dir = tempfile::tempdir().expect("tempdir");
550 let path = dir.path().join("bad.json");
551 std::fs::write(&path, "{not-json").expect("write");
552
553 let _home = EnvVarGuard::set("CODEWHALE_HOME", dir.path().join("home"));
554 let _path = EnvVarGuard::set(ENV_MODELS_DEV_PATH, &path);
555
556 let before = all_catalog_models_for_provider(ApiProvider::Together);
557 assert!(!before.is_empty(), "bundled Together rows required");
558
559 let rt = tokio::runtime::Builder::new_current_thread()
560 .enable_all()
561 .build()
562 .expect("runtime");
563 let err = rt.block_on(refresh(true)).expect_err("bad json");
564 assert!(matches!(err, ModelsDevRefreshError::InvalidResponse(_)));
565
566 let after = all_catalog_models_for_provider(ApiProvider::Together);
567 assert_eq!(after, before, "bundled rows must survive parse failure");
568 let st = status();
569 assert_eq!(st.freshness, ModelsDevFreshness::Failed);
570 assert!(st.last_error.is_some());
571 clear_live_snapshot();
572 }
573
574 #[test]
575 fn stale_disk_cache_still_publishes() {
576 let _lock = lock_test_env();
577 clear_live_snapshot();
578 let dir = tempfile::tempdir().expect("tempdir");
579 let home = dir.path().join("home");
580 let _home = EnvVarGuard::set("CODEWHALE_HOME", &home);
581
582 let cache_dir = home.join("catalog");
583 std::fs::create_dir_all(&cache_dir).expect("mkdir");
584 let cache = cache_dir.join(CACHE_FILE);
585 let stale = PersistedModelsDevCache {
586 schema_version: CACHE_SCHEMA_VERSION,
587 fetched_at: 1, // far in the past → stale
588 source_fingerprint: "stale-fp".into(),
589 source_label: "https://models.dev/catalog.json".into(),
590 body: FIXTURE.into(),
591 };
592 save_cache_file(&cache, &stale).expect("save");
593
594 maybe_load_persisted_cache();
595 let st = status();
596 assert_eq!(st.freshness, ModelsDevFreshness::Stale);
597 assert!(st.offering_count >= 2);
598 let together = all_catalog_models_for_provider(ApiProvider::Together);
599 assert!(together.iter().any(|m| m == "deepseek-ai/DeepSeek-V4-Pro"));
600 clear_live_snapshot();
601 }
602
603 #[test]
604 fn network_failure_keeps_prior_rows_and_marks_failed() {
605 let _lock = lock_test_env();
606 clear_live_snapshot();
607 let dir = tempfile::tempdir().expect("tempdir");
608 let path = dir.path().join("catalog.json");
609 std::fs::write(&path, FIXTURE).expect("write");
610
611 let _home = EnvVarGuard::set("CODEWHALE_HOME", dir.path().join("home"));
612 let _path = EnvVarGuard::set(ENV_MODELS_DEV_PATH, &path);
613
614 let rt = tokio::runtime::Builder::new_current_thread()
615 .enable_all()
616 .build()
617 .expect("runtime");
618 let count = rt.block_on(refresh(true)).expect("seed from path");
619 assert!(count >= 2);
620
621 // Point at a dead URL and force network (clear path override).
622 let _path = EnvVarGuard::remove(ENV_MODELS_DEV_PATH);
623 let _disable = EnvVarGuard::remove(ENV_DISABLE_FETCH);
624 let _url = EnvVarGuard::set(ENV_MODELS_DEV_URL, "http://127.0.0.1:1");
625
626 let err = rt.block_on(refresh(true)).expect_err("dead URL");
627 assert!(matches!(err, ModelsDevRefreshError::Network(_)));
628
629 let together = all_catalog_models_for_provider(ApiProvider::Together);
630 assert!(
631 together.iter().any(|m| m == "deepseek-ai/DeepSeek-V4-Pro"),
632 "prior live rows must survive network failure"
633 );
634 let st = status();
635 assert_eq!(st.freshness, ModelsDevFreshness::Failed);
636 assert!(st.last_error.is_some());
637 assert!(
638 st.offering_count >= 2,
639 "status should retain prior live row count after failure"
640 );
641 clear_live_snapshot();
642 }
643 }
644
644 lines RUST