1use std::path::{Path, PathBuf};
22use std::sync::atomic::{AtomicBool, Ordering};
23use std::sync::Arc;
24use std::thread::JoinHandle;
25use std::time::{Duration, Instant};
26
27use crate::hooks::common::{hl, ok, say};
28use crate::manifest::{TreeGate, CACHE_PLACEHOLDER};
29use crate::tree_run::TreeRun;
30
31pub const TOGGLE: &str = "amont.treeLint";
32pub const SLACK: &str = "amont.treeLintSlack";
33pub const OUTPUTS: &str = "amont.snapshotPrepareOutputs";
34
35const DEFAULT_SLACK: i64 = 2;
37
38const CEILING: Duration = Duration::from_secs(600);
42
43pub const DEFAULT_OUTPUTS: &[&str] = &[
46 "node_modules/",
47 ".venv/",
48 "venv/",
49 "__pycache__/",
50 ".ruff_cache/",
51 ".pytest_cache/",
52 ".mypy_cache/",
53 ".hypothesis/",
54 ".eslintcache",
55 ".prettiercache",
56 ".DS_Store",
57];
58
59pub fn enabled(settings: &crate::config::Settings) -> bool {
62 crate::config::boolean_or(settings, TOGGLE, crate::attest::enabled(settings))
63}
64
65fn slack_ms(settings: &crate::config::Settings) -> u64 {
66 u64::try_from(slack(settings).as_millis()).unwrap_or(u64::MAX)
67}
68
69fn slack(settings: &crate::config::Settings) -> Duration {
70 let secs = crate::config::integer_or(settings, SLACK, DEFAULT_SLACK, 0..=600);
71 Duration::from_secs(u64::try_from(secs).unwrap_or(0))
72}
73
74pub fn outputs(settings: &crate::config::Settings) -> Vec<String> {
76 let mut out: Vec<String> = DEFAULT_OUTPUTS.iter().map(|s| s.to_string()).collect();
77 if let Some(v) = crate::config::string_value(settings, OUTPUTS) {
78 out.extend(v.split_whitespace().map(str::to_string));
79 }
80 out
81}
82
83pub fn allowed(entry: &str, patterns: &[String]) -> bool {
90 patterns.iter().any(|p| {
91 if let Some(dir) = p.strip_suffix('/') {
92 let dir = dir.trim_start_matches("./");
93 entry == format!("{dir}/")
94 || entry.starts_with(&format!("{dir}/"))
95 || entry.contains(&format!("/{dir}/"))
96 } else {
97 let base = entry
98 .trim_end_matches('/')
99 .rsplit('/')
100 .next()
101 .unwrap_or(entry);
102 glob_basename(p, base)
103 }
104 })
105}
106
107fn glob_basename(pattern: &str, name: &str) -> bool {
109 let parts: Vec<&str> = pattern.split('*').collect();
110 if parts.len() == 1 {
111 return pattern == name;
112 }
113 let mut rest = name;
114 for (i, part) in parts.iter().enumerate() {
115 if i == 0 {
116 let Some(r) = rest.strip_prefix(part) else {
117 return false;
118 };
119 rest = r;
120 } else if i == parts.len() - 1 {
121 return rest.ends_with(part);
122 } else {
123 let Some(at) = rest.find(part) else {
124 return false;
125 };
126 rest = &rest[at + part.len()..];
127 }
128 }
129 true
130}
131
132pub fn guard(settings: &crate::config::Settings) -> Option<String> {
135 if !crate::git_states_in_progress().is_empty() {
136 return Some("a merge, rebase, cherry-pick or revert is in progress".into());
137 }
138 if !crate::git::succeeds(&["diff", "--quiet"]) {
139 return Some("tracked files have unstaged edits (the tree is not the commit's)".into());
140 }
141 content_guard(Path::new(&crate::hooks::common::repo_root()), settings)
142}
143
144fn content_guard(dir: &Path, settings: &crate::config::Settings) -> Option<String> {
146 let Some(untracked) = crate::git::stdout_in(
148 dir,
149 &["ls-files", "--others", "--exclude-standard", "--directory"],
150 ) else {
151 return Some("git could not list untracked files".into());
152 };
153 if let Some(first) = untracked.lines().find(|l| !l.trim().is_empty()) {
154 return Some(format!("an untracked file is present ({first})"));
155 }
156 let Some(ignored) = crate::git::stdout_in(
157 dir,
158 &[
159 "ls-files",
160 "--others",
161 "--ignored",
162 "--exclude-standard",
163 "--directory",
164 ],
165 ) else {
166 return Some("git could not list ignored files".into());
167 };
168 let allow = outputs(settings);
169 if let Some(first) = ignored
170 .lines()
171 .map(str::trim)
172 .find(|l| !l.is_empty() && !allowed(l, &allow))
173 {
174 return Some(format!(
175 "an ignored file outside {} is present ({first})",
176 hl("snapshotPrepareOutputs")
177 ));
178 }
179 None
180}
181
182pub fn argv(gate: &TreeGate, cache_flags: &str) -> Vec<String> {
185 if cache_flags.trim().is_empty() {
186 return vec!["sh".into(), "-c".into(), gate.normalized()];
188 }
189 let command: Vec<&str> = gate
190 .command
191 .split_whitespace()
192 .flat_map(|w| {
193 if w == CACHE_PLACEHOLDER {
194 cache_flags.split_whitespace().collect::<Vec<_>>()
195 } else {
196 vec![w]
197 }
198 })
199 .collect();
200 vec!["sh".into(), "-c".into(), command.join(" ")]
201}
202
203enum Decision {
205 Ran(TreeRun, u64),
207 Skew(String),
209 NoVersion,
211 Cold,
213 Busy,
215 Slow(u64),
217}
218
219type InFlight = (String, JoinHandle<Decision>);
220
221pub struct SideCar {
223 cancel: Arc<AtomicBool>,
224 running: Vec<InFlight>,
225}
226
227static EVIDENCE: std::sync::Mutex<Vec<crate::gate_stamp::Run>> = std::sync::Mutex::new(Vec::new());
230
231fn note(name: &str, outcome: crate::gate_stamp::RunOutcome, ms: u64) {
232 let at = std::time::SystemTime::now()
233 .duration_since(std::time::UNIX_EPOCH)
234 .map(|d| d.as_secs())
235 .unwrap_or(0);
236 if let Ok(mut e) = EVIDENCE.lock() {
237 e.push(crate::gate_stamp::Run {
238 at,
239 gate: format!("tree-{name}"),
240 outcome,
241 ms,
242 });
243 }
244}
245
246pub fn note_withheld(gates: &[TreeGate]) {
248 for g in gates {
249 note(&g.name, crate::gate_stamp::RunOutcome::Withheld, 0);
250 }
251}
252
253pub fn flush_evidence(tree: &str) {
256 let runs = EVIDENCE
257 .lock()
258 .map(|mut e| std::mem::take(&mut *e))
259 .unwrap_or_default();
260 if !runs.is_empty() {
261 crate::gate_stamp::record_runs(tree, &runs);
262 }
263}
264
265fn cwd_of(root: &Path, gate: &TreeGate) -> PathBuf {
266 match &gate.cwd {
267 Some(dir) => root.join(dir),
268 None => root.to_path_buf(),
269 }
270}
271
272pub fn start(
280 settings: &crate::config::Settings,
281 root: &Path,
282 gates: &[TreeGate],
283 pins: &[crate::manifest::ToolPin],
284 cover_ms: u64,
285) -> Option<SideCar> {
286 if gates.is_empty() {
287 return None;
288 }
289 if cfg!(not(unix)) {
290 say(" tree gates need a Unix shell and do not run here — CI will lint");
291 return None;
292 }
293 let cancel = Arc::new(AtomicBool::new(false));
294 let deadline = Instant::now() + CEILING;
295 let budget_ms = cover_ms.saturating_add(slack_ms(settings));
296 let running = gates
297 .iter()
298 .map(|g| {
299 let gate = g.clone();
300 let pins = pins.to_vec();
301 let cwd = cwd_of(root, g);
302 let flag = Arc::clone(&cancel);
303 let handle = std::thread::spawn(move || {
304 decide_and_run(&gate, &cwd, &pins, budget_ms, deadline, &flag)
305 });
306 (g.name.clone(), handle)
307 })
308 .collect();
309 Some(SideCar { cancel, running })
310}
311
312fn decide_and_run(
313 g: &TreeGate,
314 cwd: &Path,
315 pins: &[crate::manifest::ToolPin],
316 budget_ms: u64,
317 deadline: Instant,
318 cancel: &AtomicBool,
319) -> Decision {
320 if let Some(ms) = crate::tree_cache::gate_last_ms(g).filter(|ms| *ms > budget_ms) {
324 return Decision::Slow(ms);
325 }
326 let Some(version) = crate::tree_cache::tool_version(cwd, g, deadline, cancel) else {
327 return Decision::NoVersion;
328 };
329 if let Some(why) = crate::tree_skew::skew(cwd, g, pins, &version) {
330 return Decision::Skew(why);
331 }
332 let Some(ns) =
333 crate::tree_cache::namespace(g, &version).filter(|ns| crate::tree_cache::is_warm(g, ns))
334 else {
335 return Decision::Cold;
336 };
337 let Some(ns_dir) = crate::tree_cache::gate_dir(g).map(|d| d.join(&ns)) else {
338 return Decision::Cold;
339 };
340 let Some(lock) = crate::tree_cache::try_lock(g) else {
341 return Decision::Busy;
342 };
343 let argv = argv(
344 g,
345 &crate::tree_cache::cache_flags(cwd, g, &ns_dir, deadline, cancel),
346 );
347 let began = Instant::now();
348 let run = crate::tree_run::run(&argv, cwd, deadline, cancel);
349 let ms = u64::try_from(began.elapsed().as_millis()).unwrap_or(u64::MAX);
350 drop(lock);
353 Decision::Ran(run, ms)
354}
355
356pub fn tree_verdict(gates: &[TreeGate], tips: &[String]) -> (Vec<String>, Vec<String>) {
360 let trees: Vec<Option<String>> = tips
361 .iter()
362 .map(|t| crate::git::stdout(&["rev-parse", &format!("{t}^{{tree}}")]))
363 .collect();
364 let tokens: Vec<Vec<String>> = trees
365 .iter()
366 .map(|t| {
367 t.as_deref()
368 .map(crate::gate_stamp::tree_tokens)
369 .unwrap_or_default()
370 })
371 .collect();
372 let mut proven = Vec::new();
373 let mut unproven = Vec::new();
374 for g in gates {
375 let key = g.stamp_key();
376 if !tips.is_empty() && tokens.iter().all(|toks| toks.iter().any(|t| t == &key)) {
377 proven.push(g.id());
378 } else {
379 unproven.push(g.name.clone());
380 }
381 }
382 (proven, unproven)
383}
384
385pub const REHEARSAL_TIMEOUT: &str = "amont.treeLintRehearsalTimeout";
386pub const WAIT: &str = "amont.treeLintWait";
387
388pub fn prepare_guard(snapshot: &Path, settings: &crate::config::Settings) -> Option<String> {
393 if !crate::git::succeeds_in(snapshot, &["diff", "--quiet", "HEAD"]) {
394 return Some("snapshotPrepare changed tracked content".into());
395 }
396 content_guard(snapshot, settings)
397}
398
399pub fn rehearse(
404 settings: &crate::config::Settings,
405 snapshot: &Path,
406 tree: &str,
407 gates: &[TreeGate],
408) -> Vec<String> {
409 if gates.is_empty() || cfg!(not(unix)) {
410 return Vec::new();
411 }
412 if let Some(why) = prepare_guard(snapshot, settings) {
413 println!("tree lint not proven in the snapshot: {why}");
414 note_withheld(gates);
415 flush_evidence(tree);
416 return Vec::new();
417 }
418 let budget = crate::config::integer_or(settings, REHEARSAL_TIMEOUT, 120, 1..=3600);
419 let budget = Duration::from_secs(u64::try_from(budget).unwrap_or(120));
420 let mut tokens = Vec::new();
421 let pins = crate::manifest::load(snapshot).pins;
422 for g in gates {
423 let cwd = cwd_of(snapshot, g);
424 let Some(version) = crate::tree_cache::tool_version(
425 &cwd,
426 g,
427 Instant::now() + budget,
428 &AtomicBool::new(false),
429 ) else {
430 note(&g.name, crate::gate_stamp::RunOutcome::Skew, 0);
431 println!(
432 "tree lint not proven: {} — its tool's version could not be read",
433 g.name
434 );
435 continue;
436 };
437 if let Some(why) = crate::tree_skew::skew(&cwd, g, &pins, &version) {
438 note(&g.name, crate::gate_stamp::RunOutcome::Skew, 0);
439 println!("tree lint not proven: {} — {why}", g.name);
440 continue;
441 }
442 let started = Instant::now();
443 let run = crate::tree_run::run(
444 &argv(g, ""),
445 &cwd,
446 started + budget,
447 &AtomicBool::new(false),
448 );
449 let ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX);
450 match run {
451 TreeRun::Passed => {
452 note(&g.name, crate::gate_stamp::RunOutcome::Passed, ms);
453 println!(
454 "tree lint proven: {} ({:.1}s)",
455 g.name,
456 started.elapsed().as_secs_f32()
457 );
458 tokens.push(g.stamp_key());
459 }
460 other => {
461 let outcome = match other {
462 TreeRun::Failed(_) => crate::gate_stamp::RunOutcome::Failed,
463 TreeRun::TimedOut | TreeRun::Cancelled => {
464 crate::gate_stamp::RunOutcome::Cancelled
465 }
466 TreeRun::Spawn(_) => crate::gate_stamp::RunOutcome::Unavailable,
467 TreeRun::Passed => unreachable!("matched above"),
468 };
469 note(&g.name, outcome, ms);
470 println!("tree lint not proven: {} — {other:?}", g.name);
471 }
472 }
473 }
474 flush_evidence(tree);
475 if !tokens.is_empty() && !crate::gate_stamp::stamp_tree(tree, &tokens) {
476 println!("git refused the tree note — nothing stamped");
477 return Vec::new();
478 }
479 tokens
480}
481
482pub fn await_verdict(
487 settings: &crate::config::Settings,
488 gates: &[TreeGate],
489 tips: &[String],
490 started: Instant,
491) -> (Vec<String>, Vec<String>) {
492 let secs = crate::config::integer_or(settings, WAIT, 30, 0..=3600);
493 let until = started + Duration::from_secs(u64::try_from(secs).unwrap_or(30));
494 let trees: Vec<String> = tips
495 .iter()
496 .filter_map(|t| crate::git::stdout(&["rev-parse", &format!("{t}^{{tree}}")]))
497 .collect();
498 loop {
499 let verdict = tree_verdict(gates, tips);
500 let rehearsing =
501 crate::rehearsal::read().is_some_and(|s| s.alive() && trees.contains(&s.tree));
502 if verdict.1.is_empty() || !rehearsing || Instant::now() >= until {
503 return verdict;
504 }
505 std::thread::sleep(Duration::from_millis(250));
506 }
507}
508
509pub fn fit_line(prefix: &str, body: &str, suffix: &str) -> String {
513 const COLUMNS: usize = 80;
514 let room = COLUMNS.saturating_sub(prefix.chars().count() + suffix.chars().count());
515 let body = if body.chars().count() > room && room > 1 {
516 let cut: String = body.chars().take(room - 1).collect();
517 format!("{cut}…")
518 } else {
519 body.to_string()
520 };
521 format!("{prefix}{body}{suffix}")
522}
523
524#[cfg(unix)]
526fn warm_log() -> Option<PathBuf> {
527 crate::git::stdout(&["rev-parse", "--absolute-git-dir"])
528 .map(|d| PathBuf::from(d).join("amont-warm.log"))
529}
530
531fn warm_later(cold: &[String]) {
535 #[cfg(unix)]
536 {
537 let Some(log) = warm_log() else { return };
538 match crate::rehearsal::spawn_amont(&["warm", "--worker"], &log) {
539 Ok(_) => say(&fit_line(
540 " tree lint cold: ",
541 &cold.join(" "),
542 " — warming ($GIT_DIR/amont-warm.log)",
543 )),
544 Err(e) => say(&format!(
545 " tree lint cold: {} — could not start the warm-up ({e}); run {}",
546 cold.join(" "),
547 hl("amont warm")
548 )),
549 }
550 }
551 #[cfg(not(unix))]
552 {
553 let _ = cold;
554 }
555}
556
557pub fn warm(root: &Path) -> i32 {
563 let manifest = crate::manifest::load(root);
564 if manifest.tree.is_empty() {
565 println!("amont warm: no trusted tree gate here");
566 return 0;
567 }
568 for g in &manifest.tree {
569 let cwd = cwd_of(root, g);
570 let Some(version) = crate::tree_cache::tool_version(
571 &cwd,
572 g,
573 Instant::now() + Duration::from_secs(60),
574 &AtomicBool::new(false),
575 ) else {
576 println!("{}: its tool's version could not be read — skipped", g.name);
577 continue;
578 };
579 let Some(ns) = crate::tree_cache::namespace(g, &version) else {
580 println!("{}: git could not name the namespace — skipped", g.name);
581 continue;
582 };
583 if crate::tree_cache::is_warm(g, &ns) {
584 println!("{}: already warm", g.name);
585 continue;
586 }
587 let Some(_lock) = crate::tree_cache::try_lock(g) else {
588 println!("{}: another run holds the cache — skipped", g.name);
589 continue;
590 };
591 let Some(ns_dir) = crate::tree_cache::enter_namespace(g, &ns) else {
592 println!("{}: cannot create the cache directory — skipped", g.name);
593 continue;
594 };
595 let argv = argv(
596 g,
597 &crate::tree_cache::cache_flags(
598 &cwd,
599 g,
600 &ns_dir,
601 Instant::now() + CEILING,
602 &AtomicBool::new(false),
603 ),
604 );
605 let started = Instant::now();
606 let run = crate::tree_run::run(&argv, &cwd, started + CEILING, &AtomicBool::new(false));
607 let secs = started.elapsed().as_secs_f32();
608 match run {
609 TreeRun::Passed | TreeRun::Failed(_) => {
610 crate::tree_cache::mark_complete(&ns_dir);
611 println!("{}: warm ({secs:.1}s)", g.name);
612 }
613 other => println!("{}: not warmed — {other:?}", g.name),
614 }
615 }
616 0
617}
618
619pub fn finish(settings: &crate::config::Settings, car: SideCar, stampable: bool) -> Vec<String> {
625 use crate::gate_stamp::RunOutcome;
626 let until = Instant::now()
627 + if stampable {
628 slack(settings)
629 } else {
630 Duration::ZERO
631 };
632 while Instant::now() < until && car.running.iter().any(|(_, h)| !h.is_finished()) {
633 std::thread::sleep(Duration::from_millis(20));
634 }
635 car.cancel.store(true, Ordering::SeqCst);
636 let mut proven = Vec::new();
637 let mut unproven: Vec<(String, String, bool)> = Vec::new();
639 let mut cold = Vec::new();
640 for (name, handle) in car.running {
641 let decision = handle
642 .join()
643 .unwrap_or(Decision::Ran(TreeRun::Spawn("thread died".into()), 0));
644 match decision {
645 Decision::Ran(run, ms) => {
646 if !matches!(run, TreeRun::Spawn(_)) {
649 crate::tree_cache::record_gate_last_ms(&name, ms);
650 }
651 match run {
652 TreeRun::Passed if stampable => {
653 note(&name, RunOutcome::Passed, ms);
654 proven.push(name);
655 }
656 TreeRun::Passed => note(&name, RunOutcome::Withheld, ms),
657 TreeRun::Failed(summary) => {
658 note(&name, RunOutcome::Failed, ms);
659 unproven.push((name, summary, false));
660 }
661 TreeRun::TimedOut | TreeRun::Cancelled => {
662 note(&name, RunOutcome::Cancelled, ms);
663 unproven.push((
664 name,
665 "still running when the commit was ready".into(),
666 false,
667 ));
668 }
669 TreeRun::Spawn(e) => {
670 note(&name, RunOutcome::Unavailable, ms);
671 unproven.push((name, format!("could not start ({e})"), false));
672 }
673 }
674 }
675 Decision::Skew(why) => {
676 note(&name, RunOutcome::Skew, 0);
677 unproven.push((name, why, false));
678 }
679 Decision::NoVersion => {
680 note(&name, RunOutcome::Skew, 0);
681 unproven.push((
682 name,
683 "its tool's version could not be read in time".into(),
684 false,
685 ));
686 }
687 Decision::Cold => {
688 note(&name, RunOutcome::Cold, 0);
689 cold.push(name);
690 }
691 Decision::Busy => {
692 note(&name, RunOutcome::Busy, 0);
693 unproven.push((name, "a warm-up holds its cache".into(), false));
694 }
695 Decision::Slow(ms) => {
696 note(&name, RunOutcome::Slow, 0);
697 unproven.push((
698 name,
699 format!(
700 "skipped: needs ~{:.1}s, more than this commit leaves",
701 ms as f64 / 1000.0
702 ),
703 true,
704 ));
705 }
706 }
707 }
708 if stampable && !proven.is_empty() {
712 let moved = if crate::git::succeeds(&["diff", "--quiet"]) {
713 content_guard(Path::new(&crate::hooks::common::repo_root()), settings)
714 } else {
715 Some("tracked files changed while it ran".into())
716 };
717 if let Some(why) = moved {
718 for name in proven.drain(..) {
719 note(&name, RunOutcome::Withheld, 0);
720 unproven.push((name, format!("the tree moved while it ran ({why})"), false));
721 }
722 }
723 }
724 if !proven.is_empty() {
725 ok(settings, &format!("tree lint proven: {}", proven.join(" ")));
726 }
727 let quiet = crate::live::quiet(settings);
730 let unproven: Vec<(String, String, bool)> = unproven
731 .into_iter()
732 .filter(|(_, _, skipped)| !(quiet && *skipped))
733 .collect();
734 if stampable && !unproven.is_empty() {
735 say(" tree lint not proven — CI will lint:");
736 for (name, why, _) in &unproven {
737 say(&fit_line(
738 &format!(" {} — ", crate::ui::sanitize(name)),
739 &crate::ui::sanitize(why),
740 "",
741 ));
742 }
743 }
744 if !cold.is_empty() {
745 warm_later(&cold);
746 }
747 proven.iter().map(|n| format!("tree:{n}")).collect()
748}
749
750#[cfg(test)]
751mod tests {
752 use super::*;
753
754 fn pats(extra: &[&str]) -> Vec<String> {
755 DEFAULT_OUTPUTS
756 .iter()
757 .chain(extra)
758 .map(|s| s.to_string())
759 .collect()
760 }
761
762 #[test]
763 fn tree_lint_allows_tool_caches_anywhere() {
764 let p = pats(&[]);
765 assert!(allowed("node_modules/", &p));
766 assert!(allowed("packages/api/__pycache__/", &p));
767 assert!(allowed(".venv/", &p));
768 assert!(allowed("web/.DS_Store", &p));
769 }
770
771 #[test]
772 fn tree_lint_refuses_an_ignored_module() {
773 let p = pats(&[]);
774 assert!(!allowed("src/generated/types.ts", &p));
775 assert!(!allowed(".react-router/", &p));
776 assert!(!allowed("build/", &p));
777 }
778
779 #[test]
780 fn tree_lint_repo_patterns_extend_the_list() {
781 let p = pats(&["build/", ".react-router/", "*.db", "*.db-wal"]);
782 assert!(allowed("build/", &p));
783 assert!(allowed(".react-router/", &p));
784 assert!(allowed("landscape.db", &p));
785 assert!(allowed("landscape.db-wal", &p));
786 assert!(!allowed("landscape.sqlite", &p));
787 }
788
789 #[test]
790 fn tree_lint_fit_line_cuts_the_body_to_eighty_columns() {
791 let l = fit_line(
792 " tree lint not proven: ",
793 "eslint — 12 problems (12 errors, 0 warnings) and some more words",
794 " — CI will lint",
795 );
796 assert_eq!(l.chars().count(), 80, "{l}");
797 assert!(l.ends_with("… — CI will lint"), "{l}");
798 let body = fit_line(" eslint — ", &"x".repeat(200), "");
799 assert_eq!(body.chars().count(), 80);
800 let short = fit_line("a", "b", "c");
801 assert_eq!(short, "abc");
802 }
803
804 #[test]
805 fn tree_lint_argv_runs_through_sh_with_cache_expanded() {
806 let g = crate::manifest::tree_gates("tree eslint eslint * attest npm run lint -- {cache}")
807 .remove(0);
808 assert_eq!(
809 argv(&g, "--cache --cache-location /x"),
810 vec!["sh", "-c", "npm run lint -- --cache --cache-location /x"]
811 );
812 assert_eq!(argv(&g, ""), vec!["sh", "-c", "npm run lint"]);
813 }
814}