返回 CodeWhale
telemetry_notice.rs
根目录 / crates / tui / src / telemetry_notice.rs
1 //! Eligibility and persistence for the interactive telemetry disclosure.
2 //!
3 //! Rendering belongs to the native TUI event loop.
4 //! This module owns only the privacy-sensitive state transitions: deciding
5 //! whether disclosure is owed and applying the explicit Settings preference.
6 //! Drawing a notice never records acceptance or arms collection.
7
8 use std::io::IsTerminal;
9 use std::path::{Path, PathBuf};
10
11 use anyhow::{Result, anyhow};
12 use codewhale_config::{SetupState, TELEMETRY_NOTICE_VERSION};
13 use codewhale_telemetry::SessionSource;
14
15 use codewhale_localization::{Locale, MessageId, tr};
16
17 /// Marker for a nonblocking disclosure of default-on usage and Settings opt-out.
18 #[derive(Debug, Clone)]
19 pub(crate) struct PendingTelemetryNotice;
20
21 /// Whether an interactive launch owes the native notice, may arm immediately,
22 /// or must stay unarmed because the durable privacy state could not be read.
23 #[derive(Debug)]
24 pub(crate) enum TelemetryNoticePlan {
25 Due(PendingTelemetryNotice),
26 NotDue,
27 SuppressArming,
28 }
29
30 impl TelemetryNoticePlan {
31 pub(crate) fn should_arm_before_tui(&self) -> bool {
32 !matches!(self, Self::SuppressArming)
33 }
34
35 pub(crate) fn into_pending(self) -> Option<PendingTelemetryNotice> {
36 match self {
37 Self::Due(pending) => Some(pending),
38 Self::NotDue | Self::SuppressArming => None,
39 }
40 }
41 }
42
43 /// Typed result of changing the durable telemetry preference from `/settings`.
44 #[derive(Debug, Clone, PartialEq, Eq)]
45 pub(crate) struct AppliedTelemetryPreference {
46 outcome: TelemetryPreferenceOutcome,
47 }
48
49 #[derive(Debug, Clone, PartialEq, Eq)]
50 enum TelemetryPreferenceOutcome {
51 EnabledNextLaunch,
52 Disabled,
53 DisabledWithWarning(String),
54 DisabledForSession(String),
55 SaveFailed(String),
56 }
57
58 impl AppliedTelemetryPreference {
59 pub(crate) fn is_error(&self) -> bool {
60 matches!(
61 self.outcome,
62 TelemetryPreferenceOutcome::DisabledWithWarning(_)
63 | TelemetryPreferenceOutcome::DisabledForSession(_)
64 | TelemetryPreferenceOutcome::SaveFailed(_)
65 )
66 }
67
68 pub(crate) fn message(&self, locale: Locale) -> String {
69 let (id, detail) = match &self.outcome {
70 TelemetryPreferenceOutcome::EnabledNextLaunch => {
71 (MessageId::TelemetryPreferenceEnabledNextLaunch, None)
72 }
73 TelemetryPreferenceOutcome::Disabled => (MessageId::TelemetryPreferenceDisabled, None),
74 TelemetryPreferenceOutcome::DisabledWithWarning(detail) => (
75 MessageId::TelemetryPreferenceDisabledWithWarning,
76 Some(detail),
77 ),
78 TelemetryPreferenceOutcome::DisabledForSession(detail) => (
79 MessageId::TelemetryPreferenceDisabledForSession,
80 Some(detail),
81 ),
82 TelemetryPreferenceOutcome::SaveFailed(detail) => {
83 (MessageId::TelemetryPreferenceSaveFailed, Some(detail))
84 }
85 };
86 let mut message = tr(locale, id).into_owned();
87 if let Some(detail) = detail {
88 message = message.replace("{detail}", detail);
89 }
90 message
91 }
92 }
93
94 /// Return a native-notice plan when this interactive launch owes disclosure.
95 ///
96 /// This is read-only. It never prints, blocks on a line read, creates telemetry
97 /// state, or records a fictional answer. `--skip-onboarding` intentionally has
98 /// no bearing on a privacy disclosure.
99 pub(crate) fn plan_if_due(
100 config_path: Option<PathBuf>,
101 session_source: SessionSource,
102 ) -> TelemetryNoticePlan {
103 if !(std::io::stdin().is_terminal() && std::io::stdout().is_terminal()) {
104 return TelemetryNoticePlan::NotDue;
105 }
106
107 let store = match codewhale_config::ConfigStore::load(config_path) {
108 Ok(store) => store,
109 Err(error) => {
110 // A config we cannot read is a config we must not write.
111 tracing::warn!("telemetry stays unarmed; config unreadable: {error}");
112 return TelemetryNoticePlan::SuppressArming;
113 }
114 };
115 let setup_state_path = match SetupState::path() {
116 Ok(path) => path,
117 Err(error) => {
118 tracing::warn!("telemetry stays unarmed; setup-state path unavailable: {error}");
119 return TelemetryNoticePlan::SuppressArming;
120 }
121 };
122 plan_for_store_and_state(store, setup_state_path, session_source)
123 }
124
125 fn plan_for_store_and_state(
126 store: codewhale_config::ConfigStore,
127 setup_state_path: PathBuf,
128 _session_source: SessionSource,
129 ) -> TelemetryNoticePlan {
130 let resolved = store
131 .config
132 .resolve_runtime_options(&codewhale_config::CliRuntimeOverrides::default());
133 let state = match load_notice_state_at(&setup_state_path) {
134 Ok(state) => state,
135 Err(error) => {
136 // Never replace a corrupt constitution/setup sidecar with a fresh
137 // telemetry-only record. The next successful setup repair can
138 // make this notice eligible again.
139 tracing::warn!("telemetry stays unarmed; setup state unreadable: {error}");
140 return TelemetryNoticePlan::SuppressArming;
141 }
142 };
143 let gate = NoticeGate {
144 needs_notice: state.needs_telemetry_notice(TELEMETRY_NOTICE_VERSION),
145 persisted_off: resolved.telemetry_explicit_off,
146 recorded_opt_out: state.telemetry_opted_out(),
147 floor_in_force: codewhale_config::telemetry_floor_in_force(),
148 };
149 if gate.may_ask() {
150 TelemetryNoticePlan::Due(PendingTelemetryNotice)
151 } else {
152 TelemetryNoticePlan::NotDue
153 }
154 }
155
156 /// Record that the interactive disclosure was drawn for this policy version.
157 ///
158 /// Display bookkeeping only: it never touches the preference registers, and a
159 /// failed write merely repeats the notice on a later launch.
160 pub(crate) fn record_presented() {
161 let Ok(path) = SetupState::path() else {
162 return;
163 };
164 let _ = SetupState::update_telemetry_at(&path, |state| {
165 state.record_telemetry_notice_shown(TELEMETRY_NOTICE_VERSION);
166 });
167 }
168
169 /// Return the saved telemetry preference shown by `/settings`.
170 ///
171 /// A missing preference defaults on. An existing
172 /// unreadable record may contain an opt-out, so the Settings row fails closed
173 /// and shows Off rather than guessing.
174 pub(crate) fn saved_preference_enabled(config: &crate::config::Config) -> bool {
175 let Ok(path) = SetupState::path() else {
176 return false;
177 };
178 saved_preference_enabled_at(config, &path)
179 }
180
181 fn saved_preference_enabled_at(config: &crate::config::Config, setup_state_path: &Path) -> bool {
182 if config.telemetry == Some(false) {
183 return false;
184 }
185 match codewhale_telemetry::load_setup_state_for_decision_at(setup_state_path) {
186 // The telemetry owner returns a fresh default state for a genuinely
187 // absent sidecar. `None` therefore means an existing record was
188 // unreadable (or the path could not be inspected) and must fail closed.
189 Some(state) => !state.telemetry_opted_out(),
190 None => false,
191 }
192 }
193
194 /// Persist the `/settings` telemetry toggle through the same two privacy
195 /// registers as the CLI preference command.
196 ///
197 /// Turning off is successful when either durable register records the opt-out;
198 /// existing local telemetry is then wiped under the telemetry ordering lock.
199 /// The wipe writes its tombstone first, so a partial erase still fails closed.
200 /// Turning on is stricter: both registers must agree before the UI reports the
201 /// preference as enabled. Re-enabling takes effect for new sessions so a
202 /// process that was disabled never starts collecting again behind the user's
203 /// back.
204 pub(crate) fn apply_persistent_preference(
205 config_path: Option<PathBuf>,
206 enabled: bool,
207 ) -> AppliedTelemetryPreference {
208 let setup_state_path = match SetupState::path() {
209 Ok(path) => path,
210 Err(error) => {
211 return AppliedTelemetryPreference {
212 outcome: TelemetryPreferenceOutcome::SaveFailed(bounded_failure_detail(
213 error.to_string(),
214 )),
215 };
216 }
217 };
218 let telemetry_root = codewhale_config::codewhale_home()
219 .ok()
220 .map(|home| home.join(codewhale_telemetry::TELEMETRY_DIR));
221 apply_persistent_preference_at(config_path, setup_state_path, telemetry_root, enabled)
222 }
223
224 fn apply_persistent_preference_at(
225 config_path: Option<PathBuf>,
226 setup_state_path: PathBuf,
227 telemetry_root: Option<PathBuf>,
228 enabled: bool,
229 ) -> AppliedTelemetryPreference {
230 if enabled {
231 // Keep a durable Off floor in place until both privacy registers have
232 // accepted the explicit re-enable. This makes every partial failure
233 // resolve Off on the next launch.
234 match load_notice_state_at(&setup_state_path) {
235 Ok(_) => {}
236 Err(error) => {
237 return AppliedTelemetryPreference {
238 outcome: TelemetryPreferenceOutcome::SaveFailed(bounded_failure_detail(
239 format!("privacy record: {error}"),
240 )),
241 };
242 }
243 };
244 if let Err(error) = write_config_preference(config_path.clone(), false) {
245 return AppliedTelemetryPreference {
246 outcome: TelemetryPreferenceOutcome::SaveFailed(bounded_failure_detail(format!(
247 "config: {error}"
248 ))),
249 };
250 }
251 if let Err(error) = SetupState::update_telemetry_at(&setup_state_path, |state| {
252 state.record_telemetry_notice(TELEMETRY_NOTICE_VERSION, true);
253 }) {
254 return AppliedTelemetryPreference {
255 outcome: TelemetryPreferenceOutcome::SaveFailed(bounded_failure_detail(format!(
256 "privacy record: {error}"
257 ))),
258 };
259 }
260 if let Err(error) = write_config_preference(config_path.clone(), true) {
261 let mut failures = vec![format!("config: {error}")];
262 if let Err(rollback) = write_config_preference(config_path, false) {
263 failures.push(format!("restoring the config Off floor: {rollback}"));
264 }
265 // The config Off floor is authoritative, but put the privacy
266 // sidecar back in the same fail-closed state as well. Otherwise a
267 // later manual edit could expose the partial enable as consent.
268 if let Err(rollback) = SetupState::update_telemetry_at(&setup_state_path, |state| {
269 state.record_telemetry_notice(TELEMETRY_NOTICE_VERSION, false);
270 }) {
271 failures.push(format!("restoring the privacy opt-out: {rollback}"));
272 }
273 return AppliedTelemetryPreference {
274 outcome: TelemetryPreferenceOutcome::SaveFailed(bounded_failure_detail(
275 failures.join("; "),
276 )),
277 };
278 }
279 return AppliedTelemetryPreference {
280 outcome: TelemetryPreferenceOutcome::EnabledNextLaunch,
281 };
282 }
283
284 let config_result = write_config_preference(config_path, false);
285 let state_result = SetupState::update_telemetry_at(&setup_state_path, |state| {
286 state.record_telemetry_notice(TELEMETRY_NOTICE_VERSION, false);
287 });
288 // Wipe even if both durable writes fail: a successful tombstone stops the
289 // already-armed process for the remainder of this session.
290 let wipe_result = match telemetry_root.as_deref().filter(|root| root.is_dir()) {
291 Some(root) => codewhale_telemetry::buffer::wipe(root),
292 None => Ok(()),
293 };
294 let durable = config_result.is_ok() || state_result.is_ok();
295 if !durable {
296 let mut failures = vec![
297 format!("config: {}", config_result.expect_err("failed result")),
298 format!(
299 "privacy record: {}",
300 state_result.expect_err("failed result")
301 ),
302 ];
303 if let Err(error) = wipe_result {
304 failures.push(format!("local erase: {error}"));
305 return AppliedTelemetryPreference {
306 outcome: TelemetryPreferenceOutcome::SaveFailed(bounded_failure_detail(
307 failures.join("; "),
308 )),
309 };
310 }
311 let detail = bounded_failure_detail(failures.join("; "));
312 return AppliedTelemetryPreference {
313 outcome: TelemetryPreferenceOutcome::DisabledForSession(detail),
314 };
315 }
316
317 let mut warnings = Vec::new();
318 if let Err(error) = config_result {
319 warnings.push(format!("config: {error}"));
320 }
321 if let Err(error) = state_result {
322 warnings.push(format!("privacy record: {error}"));
323 }
324 if let Err(error) = wipe_result {
325 warnings.push(format!("local erase: {error}"));
326 }
327 if warnings.is_empty() {
328 AppliedTelemetryPreference {
329 outcome: TelemetryPreferenceOutcome::Disabled,
330 }
331 } else {
332 AppliedTelemetryPreference {
333 outcome: TelemetryPreferenceOutcome::DisabledWithWarning(bounded_failure_detail(
334 warnings.join("; "),
335 )),
336 }
337 }
338 }
339
340 fn bounded_failure_detail(detail: String) -> String {
341 const MAX_CHARS: usize = 240;
342 let single_line = detail.split_whitespace().collect::<Vec<_>>().join(" ");
343 let mut chars = single_line.chars();
344 let bounded = chars.by_ref().take(MAX_CHARS).collect::<String>();
345 if chars.next().is_some() {
346 format!("{bounded}…")
347 } else {
348 bounded
349 }
350 }
351
352 fn write_config_preference(config_path: Option<PathBuf>, enabled: bool) -> Result<()> {
353 let mut store = codewhale_config::ConfigStore::load(config_path)?;
354 store
355 .config
356 .set_value("telemetry", if enabled { "true" } else { "false" })?;
357 store.save()
358 }
359
360 /// Load a missing sidecar as a fresh state, but distinguish it from an
361 /// existing unreadable/corrupt sidecar so the notice can never overwrite the
362 /// latter with defaults.
363 fn load_notice_state_at(path: &Path) -> Result<SetupState> {
364 if !path
365 .try_exists()
366 .map_err(|error| anyhow!("could not inspect {}: {error}", path.display()))?
367 {
368 return Ok(SetupState::default());
369 }
370 SetupState::load_from(path)
371 .ok_or_else(|| anyhow!("{} could not be read as setup state", path.display()))
372 }
373
374 /// Everything that decides whether the disclosure may be shown.
375 struct NoticeGate {
376 needs_notice: bool,
377 persisted_off: bool,
378 recorded_opt_out: bool,
379 floor_in_force: bool,
380 }
381
382 impl NoticeGate {
383 fn may_ask(&self) -> bool {
384 self.needs_notice && !self.persisted_off && !self.recorded_opt_out && !self.floor_in_force
385 }
386 }
387
388 #[cfg(test)]
389 mod tests {
390 use super::*;
391
392 fn gate(
393 needs_notice: bool,
394 persisted_off: bool,
395 recorded_opt_out: bool,
396 floor_in_force: bool,
397 ) -> NoticeGate {
398 NoticeGate {
399 needs_notice,
400 persisted_off,
401 recorded_opt_out,
402 floor_in_force,
403 }
404 }
405
406 #[test]
407 fn the_notice_is_not_put_to_someone_who_already_answered_durably() {
408 assert!(gate(true, false, false, false).may_ask());
409 assert!(!gate(true, true, false, false).may_ask());
410 assert!(!gate(true, false, true, false).may_ask());
411 assert!(!gate(true, false, false, true).may_ask());
412 assert!(!gate(false, false, false, false).may_ask());
413 }
414
415 #[test]
416 fn a_due_disclosure_does_not_gate_default_on_collection() {
417 let dir = tempfile::tempdir().unwrap();
418 let config_path = dir.path().join("config.toml");
419 std::fs::write(&config_path, "").unwrap();
420 let store = codewhale_config::ConfigStore::load(Some(config_path)).unwrap();
421 let plan = plan_for_store_and_state(
422 store,
423 dir.path().join("setup_state.json"),
424 SessionSource::Interactive,
425 );
426 assert!(matches!(plan, TelemetryNoticePlan::Due(_)));
427 assert!(plan.should_arm_before_tui());
428 assert!(
429 !dir.path().join("setup_state.json").exists(),
430 "planning is not presentation or acceptance"
431 );
432 }
433
434 #[test]
435 fn corrupt_setup_state_is_never_replaced_with_telemetry_defaults() {
436 let dir = tempfile::tempdir().expect("tempdir");
437 let config_path = dir.path().join("config.toml");
438 let state_path = dir.path().join("setup_state.json");
439 std::fs::write(&config_path, "").expect("seed config");
440 std::fs::write(&state_path, "not-json").expect("seed corrupt state");
441
442 assert!(load_notice_state_at(&state_path).is_err());
443 let store = codewhale_config::ConfigStore::load(Some(config_path)).expect("load config");
444 let plan = plan_for_store_and_state(store, state_path.clone(), SessionSource::Interactive);
445 assert!(matches!(&plan, TelemetryNoticePlan::SuppressArming));
446 assert!(
447 !plan.should_arm_before_tui(),
448 "unreadable privacy state must fail closed instead of arming by default"
449 );
450 assert_eq!(
451 std::fs::read_to_string(&state_path).expect("read corrupt state"),
452 "not-json"
453 );
454 }
455
456 #[test]
457 fn settings_preference_defaults_on_and_preserves_old_declines() {
458 let dir = tempfile::tempdir().expect("tempdir");
459 let state_path = dir.path().join("setup_state.json");
460 let config = crate::config::Config {
461 telemetry: Some(true),
462 ..crate::config::Config::default()
463 };
464
465 assert!(
466 saved_preference_enabled_at(&config, &state_path),
467 "a missing privacy record uses default-on"
468 );
469
470 let mut state = SetupState::default();
471 state.record_telemetry_notice("3", true);
472 state.save_to(&state_path).expect("seed old acceptance");
473 assert!(saved_preference_enabled_at(&config, &state_path));
474 state.record_telemetry_notice("4", false);
475 state.save_to(&state_path).expect("seed historical decline");
476 assert!(!saved_preference_enabled_at(&config, &state_path));
477 state.record_telemetry_notice(TELEMETRY_NOTICE_VERSION, true);
478 state.save_to(&state_path).expect("seed current acceptance");
479 assert!(saved_preference_enabled_at(&config, &state_path));
480 assert!(saved_preference_enabled_at(
481 &crate::config::Config::default(),
482 &state_path
483 ));
484
485 std::fs::write(&state_path, "not-json").expect("seed corrupt state");
486 assert!(
487 !saved_preference_enabled_at(&config, &state_path),
488 "an unreadable privacy record may contain an opt-out"
489 );
490
491 let explicitly_off = crate::config::Config {
492 telemetry: Some(false),
493 ..crate::config::Config::default()
494 };
495 assert!(!saved_preference_enabled_at(&explicitly_off, &state_path));
496 }
497
498 #[test]
499 fn settings_off_persists_both_registers_and_stops_the_armed_buffer() {
500 let dir = tempfile::tempdir().expect("tempdir");
501 let config_path = dir.path().join("config.toml");
502 let state_path = dir.path().join("setup_state.json");
503 let telemetry_root = dir.path().join("telemetry");
504 std::fs::write(&config_path, "telemetry = true\n").expect("seed config");
505 std::fs::create_dir_all(&telemetry_root).expect("seed telemetry root");
506 std::fs::write(
507 codewhale_telemetry::buffer::buffer_path(&telemetry_root),
508 "queued-event\n",
509 )
510 .expect("seed buffer");
511
512 let applied = apply_persistent_preference_at(
513 Some(config_path.clone()),
514 state_path.clone(),
515 Some(telemetry_root.clone()),
516 false,
517 );
518
519 assert_eq!(applied.outcome, TelemetryPreferenceOutcome::Disabled);
520 assert!(
521 std::fs::read_to_string(&config_path)
522 .expect("read config")
523 .contains("telemetry = false")
524 );
525 assert!(
526 SetupState::load_from(&state_path)
527 .expect("saved state")
528 .telemetry_opted_out()
529 );
530 assert!(codewhale_telemetry::buffer::tombstone_present(
531 &telemetry_root
532 ));
533 assert_eq!(
534 std::fs::read_to_string(codewhale_telemetry::buffer::buffer_path(&telemetry_root))
535 .expect("read wiped buffer"),
536 ""
537 );
538 }
539
540 #[test]
541 fn settings_on_is_saved_for_next_launch_without_clearing_the_tombstone() {
542 let dir = tempfile::tempdir().expect("tempdir");
543 let config_path = dir.path().join("config.toml");
544 let state_path = dir.path().join("setup_state.json");
545 let telemetry_root = dir.path().join("telemetry");
546 std::fs::write(&config_path, "telemetry = true\n").expect("seed config");
547 std::fs::create_dir_all(&telemetry_root).expect("seed telemetry root");
548 let disabled = apply_persistent_preference_at(
549 Some(config_path.clone()),
550 state_path.clone(),
551 Some(telemetry_root.clone()),
552 false,
553 );
554 assert_eq!(disabled.outcome, TelemetryPreferenceOutcome::Disabled);
555
556 let enabled = apply_persistent_preference_at(
557 Some(config_path.clone()),
558 state_path.clone(),
559 Some(telemetry_root.clone()),
560 true,
561 );
562
563 assert_eq!(
564 enabled.outcome,
565 TelemetryPreferenceOutcome::EnabledNextLaunch
566 );
567 assert!(
568 std::fs::read_to_string(&config_path)
569 .expect("read config")
570 .contains("telemetry = true")
571 );
572 assert!(
573 SetupState::load_from(&state_path)
574 .expect("saved state")
575 .telemetry_accepted(TELEMETRY_NOTICE_VERSION)
576 );
577 assert!(
578 codewhale_telemetry::buffer::tombstone_present(&telemetry_root),
579 "the already-running process stays off; the next launch clears this after a fresh permission check"
580 );
581
582 let store = codewhale_config::ConfigStore::load(Some(config_path))
583 .expect("reload enabled config for the next launch");
584 let resolved = store
585 .config
586 .resolve_runtime_options(&codewhale_config::CliRuntimeOverrides::default());
587 let reloaded_state = SetupState::load_from(&state_path).expect("reload enabled state");
588 assert!(
589 codewhale_telemetry::decide_in_home(
590 Some(dir.path()),
591 &resolved,
592 &reloaded_state,
593 codewhale_telemetry::Surface::Tui,
594 )
595 .is_enabled(),
596 "a fresh launch may clear the prior tombstone only after both saved registers resolve enabled"
597 );
598 }
599
600 #[test]
601 fn failed_settings_enable_preserves_the_existing_opt_out() {
602 let dir = tempfile::tempdir().expect("tempdir");
603 let config_path = dir.path().to_path_buf();
604 let state_path = dir.path().join("setup_state.json");
605 let mut state = SetupState::default();
606 state.record_telemetry_notice(TELEMETRY_NOTICE_VERSION, false);
607 state.save_to(&state_path).expect("seed opt-out");
608
609 let applied =
610 apply_persistent_preference_at(Some(config_path), state_path.clone(), None, true);
611
612 assert!(matches!(
613 applied.outcome,
614 TelemetryPreferenceOutcome::SaveFailed(_)
615 ));
616 assert!(
617 SetupState::load_from(&state_path)
618 .expect("saved state")
619 .telemetry_opted_out()
620 );
621 }
622
623 #[test]
624 fn unsaved_settings_off_still_suppresses_the_current_session() {
625 let dir = tempfile::tempdir().expect("tempdir");
626 let config_path = dir.path().to_path_buf();
627 let state_path = dir.path().join("setup_state.json");
628 let telemetry_root = dir.path().join("telemetry");
629 std::fs::write(&state_path, "not-json").expect("seed corrupt state");
630 std::fs::create_dir_all(&telemetry_root).expect("seed telemetry root");
631
632 let applied = apply_persistent_preference_at(
633 Some(config_path),
634 state_path,
635 Some(telemetry_root.clone()),
636 false,
637 );
638
639 assert!(matches!(
640 applied.outcome,
641 TelemetryPreferenceOutcome::DisabledForSession(_)
642 ));
643 assert!(codewhale_telemetry::buffer::tombstone_present(
644 &telemetry_root
645 ));
646 }
647
648 #[test]
649 fn a_non_tty_test_surface_cannot_schedule_the_native_notice() {
650 assert!(matches!(
651 plan_if_due(None, SessionSource::Interactive),
652 TelemetryNoticePlan::NotDue
653 ));
654 }
655 }
656
656 lines RUST