1use std::collections::{HashMap, HashSet};
7use std::path::PathBuf;
8use std::time::{SystemTime, UNIX_EPOCH};
9
10use crate::{
11 config::global::{atomic_write_private, GroupKey},
12 model::workspace::{FlatEntry, WorkspaceState},
13};
14use serde::{Deserialize, Deserializer, Serialize};
15
16#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
18pub enum CursorIdentity {
19 Project {
20 path: String,
21 },
22 Worktree {
23 path: String,
24 },
25 Session {
26 worktree_path: String,
27 #[serde(default, skip_serializing_if = "Option::is_none")]
28 terminal_id: Option<String>,
29 #[serde(default, skip_serializing_if = "Option::is_none")]
31 pane_id: Option<String>,
32 },
33 RoutinesHeader {
34 project_path: String,
35 },
36 Routine {
37 project_path: String,
38 routine_name: String,
39 },
40}
41
42#[derive(Serialize, Default, Clone)]
43pub struct WorkspaceCache {
44 #[serde(default)]
45 pub written_at_unix_ms: Option<u64>,
46 #[serde(default)]
47 pub worktree_expanded: HashMap<String, bool>,
48 #[serde(default)]
49 pub project_expanded: HashMap<String, bool>,
50 #[serde(default)]
51 pub routines_expanded: HashMap<String, bool>,
52 #[serde(default)]
53 pub tree_selected: usize,
54 #[serde(default)]
55 pub cursor_identity: Option<CursorIdentity>,
56 #[serde(default)]
58 pub muted_terminals: HashSet<String>,
59 #[serde(default)]
61 pub acknowledged_outcomes: HashMap<String, u64>,
62 #[serde(default, skip_serializing_if = "Option::is_none")]
64 pub integration_prompt_version: Option<String>,
65 #[serde(skip)]
66 migration_needed: bool,
67}
68
69#[derive(Deserialize, Default)]
70#[serde(default)]
71struct WorkspaceCacheWire {
72 written_at_unix_ms: Option<u64>,
73 worktree_expanded: HashMap<String, bool>,
74 project_expanded: HashMap<String, bool>,
75 routines_expanded: HashMap<String, bool>,
76 tree_selected: usize,
77 cursor_identity: Option<CursorIdentity>,
78 #[serde(alias = "muted_sessions")]
79 muted_terminals: HashSet<String>,
80 acknowledged_outcomes: HashMap<String, u64>,
81 active_group: Option<toml::Value>,
82 active_groups: Option<toml::Value>,
83 active_tab: Option<toml::Value>,
84 integration_prompt_version: Option<String>,
85}
86
87impl<'de> Deserialize<'de> for WorkspaceCache {
88 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
89 where
90 D: Deserializer<'de>,
91 {
92 let wire = WorkspaceCacheWire::deserialize(deserializer)?;
93 let migration_needed = wire.active_group.is_some()
96 || wire.active_groups.is_some()
97 || wire.active_tab.is_some();
98 Ok(Self {
99 written_at_unix_ms: wire.written_at_unix_ms,
100 worktree_expanded: wire.worktree_expanded,
101 project_expanded: wire.project_expanded,
102 routines_expanded: wire.routines_expanded,
103 tree_selected: wire.tree_selected,
104 cursor_identity: wire.cursor_identity,
105 muted_terminals: wire.muted_terminals,
106 acknowledged_outcomes: wire.acknowledged_outcomes,
107 integration_prompt_version: wire.integration_prompt_version,
108 migration_needed,
109 })
110 }
111}
112
113impl WorkspaceCache {
114 pub fn load() -> anyhow::Result<Self> {
115 Self::load_from_paths(&cache_path(), &legacy_cache_path())
116 }
117
118 fn load_from_paths(
119 canonical: &std::path::Path,
120 legacy: &std::path::Path,
121 ) -> anyhow::Result<Self> {
122 let (content, imported_legacy) = match std::fs::read_to_string(canonical) {
123 Ok(content) => (content, false),
124 Err(_) if !canonical.exists() => match std::fs::read_to_string(legacy) {
125 Ok(content) => (content, true),
126 Err(_) => return Ok(Self::default()),
127 },
128 Err(_) => return Ok(Self::default()),
129 };
130 let mut cache: Self = toml::from_str(&content).unwrap_or_default();
131 if imported_legacy || cache.migration_needed {
132 cache.save_to(canonical, false)?;
133 cache.migration_needed = false;
134 }
135 Ok(cache)
136 }
137
138 pub fn save(&self, sync: bool) -> anyhow::Result<()> {
139 self.save_to(&cache_path(), sync)
140 }
141
142 fn save_to(&self, path: &std::path::Path, sync: bool) -> anyhow::Result<()> {
143 let mut cache = self.clone();
144 cache.written_at_unix_ms = Some(now_unix_ms());
145 let text = toml::to_string(&cache)?;
146 atomic_write_private(path, text.as_bytes(), sync)?;
147 Ok(())
148 }
149}
150
151fn now_unix_ms() -> u64 {
152 SystemTime::now()
153 .duration_since(UNIX_EPOCH)
154 .unwrap_or_default()
155 .as_millis()
156 .try_into()
157 .unwrap_or(u64::MAX)
158}
159
160fn cache_path() -> PathBuf {
161 dirs::cache_dir()
162 .unwrap_or_else(|| PathBuf::from("/tmp"))
163 .join("wsx")
164 .join("workspace-v2.toml")
165}
166
167fn legacy_cache_path() -> PathBuf {
168 dirs::cache_dir()
169 .unwrap_or_else(|| PathBuf::from("/tmp"))
170 .join("wsx")
171 .join("workspace.toml")
172}
173
174#[derive(Serialize, Deserialize)]
175struct GroupSelection {
176 selected: GroupKey,
177}
178
179fn group_selection_path() -> PathBuf {
180 dirs::cache_dir()
181 .unwrap_or_else(|| PathBuf::from("/tmp"))
182 .join("wsx")
183 .join("group-selection-v1.toml")
184}
185
186pub fn load_group_selection() -> anyhow::Result<Option<GroupKey>> {
187 load_group_selection_from(&group_selection_path())
188}
189
190fn load_group_selection_from(path: &std::path::Path) -> anyhow::Result<Option<GroupKey>> {
191 let content = match std::fs::read_to_string(path) {
192 Ok(content) => content,
193 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
194 Err(error) => return Err(error.into()),
195 };
196 Ok(toml::from_str::<GroupSelection>(&content)
197 .ok()
198 .map(|selection| selection.selected))
199}
200
201pub fn save_group_selection(selected: &GroupKey) -> anyhow::Result<()> {
202 save_group_selection_to(&group_selection_path(), selected)
203}
204
205fn save_group_selection_to(path: &std::path::Path, selected: &GroupKey) -> anyhow::Result<()> {
206 let text = toml::to_string(&GroupSelection {
207 selected: selected.clone(),
208 })?;
209 atomic_write_private(path, text.as_bytes(), true)?;
210 Ok(())
211}
212
213pub type AppliedCache = (
214 usize,
215 Option<CursorIdentity>,
216 HashSet<String>,
217 HashMap<String, u64>,
218 Option<String>,
219);
220
221pub fn apply_cache(workspace: &mut WorkspaceState) -> anyhow::Result<AppliedCache> {
223 let cache = WorkspaceCache::load()?;
224 let mut migrated_muted_terminals = HashSet::new();
225 for project in &mut workspace.projects {
226 let project_key = project.path.to_string_lossy().to_string();
227 if let Some(expanded) = cache.project_expanded.get(&project_key) {
228 project.expanded = *expanded;
229 }
230 if let Some(expanded) = cache.routines_expanded.get(&project_key) {
231 project.routines_expanded = *expanded;
232 }
233 for worktree in &mut project.worktrees {
234 let key = worktree.path.to_string_lossy().to_string();
235 if let Some(expanded) = cache.worktree_expanded.get(&key) {
236 worktree.expanded = *expanded;
237 }
238 for session in &mut worktree.sessions {
239 session.muted = cache
240 .muted_terminals
241 .contains(&session.terminal_id.to_string())
242 || cache.muted_terminals.contains(&session.pane_id.to_string());
243 if session.muted {
244 migrated_muted_terminals.insert(session.terminal_id.to_string());
245 }
246 for pane in &mut session.panes {
247 pane.outcome_acknowledged = pane.agent_status
248 == crate::runtime::AgentState::Done
249 && cache
250 .acknowledged_outcomes
251 .get(&pane.terminal_id.to_string())
252 == Some(&pane.revision);
253 }
254 session.outcome_acknowledged = session
255 .panes
256 .iter()
257 .find(|pane| pane.pane_id == session.pane_id)
258 .is_some_and(|pane| pane.outcome_acknowledged);
259 }
260 }
261 }
262 Ok((
263 cache.tree_selected,
264 cache.cursor_identity,
265 migrated_muted_terminals,
266 cache.acknowledged_outcomes,
267 cache.integration_prompt_version,
268 ))
269}
270
271pub fn find_cursor_index(
272 workspace: &WorkspaceState,
273 flat: &[FlatEntry],
274 id: &CursorIdentity,
275) -> Option<usize> {
276 match id {
277 CursorIdentity::Project { path } => flat.iter().position(|entry| {
278 matches!(entry, FlatEntry::Project { idx } if workspace.projects[*idx].path.to_string_lossy() == path.as_str())
279 }),
280 CursorIdentity::Worktree { path } => flat.iter().position(|entry| {
281 matches!(entry, FlatEntry::Worktree { project_idx, worktree_idx } if workspace.projects[*project_idx].worktrees[*worktree_idx].path.to_string_lossy() == path.as_str())
282 }),
283 CursorIdentity::Session {
284 worktree_path,
285 terminal_id,
286 pane_id,
287 } => flat.iter().position(|entry| {
288 let (project_idx, worktree_idx, session_idx, pane_idx) = match entry {
289 FlatEntry::Session { project_idx, worktree_idx, session_idx } => {
290 (*project_idx, *worktree_idx, *session_idx, None)
291 }
292 FlatEntry::Pane { project_idx, worktree_idx, session_idx, pane_idx } => {
293 (*project_idx, *worktree_idx, *session_idx, Some(*pane_idx))
294 }
295 _ => return false,
296 };
297 let wt = &workspace.projects[project_idx].worktrees[worktree_idx];
298 let session = &wt.sessions[session_idx];
299 let (terminal, pane) = pane_idx
300 .and_then(|idx| session.panes.get(idx))
301 .map_or((session.terminal_id, session.pane_id), |pane| (pane.terminal_id, pane.pane_id));
302 wt.path.to_string_lossy() == worktree_path.as_str()
303 && terminal_id
304 .as_ref()
305 .map(|id| terminal.to_string() == *id)
306 .or_else(|| pane_id.as_ref().map(|id| pane.to_string() == *id))
307 .unwrap_or(false)
308 }),
309 CursorIdentity::RoutinesHeader { project_path } => flat.iter().position(|entry| {
310 matches!(entry, FlatEntry::RoutinesHeader { project_idx } if workspace.projects[*project_idx].path.to_string_lossy() == project_path.as_str())
311 }),
312 CursorIdentity::Routine { project_path, routine_name } => flat.iter().position(|entry| {
313 matches!(entry, FlatEntry::Routine { project_idx, routine_idx } if workspace.projects[*project_idx].path.to_string_lossy() == project_path.as_str() && workspace.projects[*project_idx].routines[*routine_idx].routine.name == *routine_name)
314 }),
315 }
316}
317
318pub fn save_cache(
319 workspace: &WorkspaceState,
320 tree_selected: usize,
321 flat: &[FlatEntry],
322 integration_prompt_version: Option<&str>,
323 sync: bool,
324) -> Option<String> {
325 let mut cache = WorkspaceCache {
326 written_at_unix_ms: Some(now_unix_ms()),
327 tree_selected,
328 cursor_identity: resolve_cursor_identity(workspace, flat, tree_selected),
329 integration_prompt_version: integration_prompt_version.map(str::to_owned),
330 ..Default::default()
331 };
332 for project in &workspace.projects {
333 let project_path = project.path.to_string_lossy().into_owned();
334 cache
335 .project_expanded
336 .insert(project_path.clone(), project.expanded);
337 cache
338 .routines_expanded
339 .insert(project_path, project.routines_expanded);
340 for worktree in &project.worktrees {
341 cache.worktree_expanded.insert(
342 worktree.path.to_string_lossy().into_owned(),
343 worktree.expanded,
344 );
345 cache.muted_terminals.extend(
346 worktree
347 .sessions
348 .iter()
349 .filter(|s| s.muted)
350 .map(|s| s.terminal_id.to_string()),
351 );
352 for session in &worktree.sessions {
353 for pane in &session.panes {
354 if pane.outcome_acknowledged {
355 cache
356 .acknowledged_outcomes
357 .insert(pane.terminal_id.to_string(), pane.revision);
358 }
359 }
360 }
361 }
362 }
363 cache
364 .save(sync)
365 .err()
366 .map(|e| format!("cache save failed: {e}"))
367}
368
369pub fn resolve_cursor_identity(
370 workspace: &WorkspaceState,
371 flat: &[FlatEntry],
372 idx: usize,
373) -> Option<CursorIdentity> {
374 match flat.get(idx)? {
375 FlatEntry::Project { idx } => Some(CursorIdentity::Project {
376 path: workspace.projects[*idx].path.to_string_lossy().into_owned(),
377 }),
378 FlatEntry::Worktree {
379 project_idx,
380 worktree_idx,
381 } => Some(CursorIdentity::Worktree {
382 path: workspace.projects[*project_idx].worktrees[*worktree_idx]
383 .path
384 .to_string_lossy()
385 .into_owned(),
386 }),
387 FlatEntry::Session {
388 project_idx,
389 worktree_idx,
390 session_idx,
391 } => {
392 let wt = &workspace.projects[*project_idx].worktrees[*worktree_idx];
393 Some(CursorIdentity::Session {
394 worktree_path: wt.path.to_string_lossy().into_owned(),
395 terminal_id: Some(wt.sessions[*session_idx].terminal_id.to_string()),
396 pane_id: None,
397 })
398 }
399 FlatEntry::Pane {
400 project_idx,
401 worktree_idx,
402 session_idx,
403 pane_idx,
404 } => {
405 let wt = &workspace.projects[*project_idx].worktrees[*worktree_idx];
406 Some(CursorIdentity::Session {
407 worktree_path: wt.path.to_string_lossy().into_owned(),
408 terminal_id: Some(
409 wt.sessions[*session_idx].panes[*pane_idx]
410 .terminal_id
411 .to_string(),
412 ),
413 pane_id: None,
414 })
415 }
416 FlatEntry::RoutinesHeader { project_idx } => Some(CursorIdentity::RoutinesHeader {
417 project_path: workspace.projects[*project_idx]
418 .path
419 .to_string_lossy()
420 .into_owned(),
421 }),
422 FlatEntry::Routine {
423 project_idx,
424 routine_idx,
425 } => Some(CursorIdentity::Routine {
426 project_path: workspace.projects[*project_idx]
427 .path
428 .to_string_lossy()
429 .into_owned(),
430 routine_name: workspace.projects[*project_idx].routines[*routine_idx]
431 .routine
432 .name
433 .clone(),
434 }),
435 }
436}
437
438#[cfg(test)]
439mod tests {
440 use super::*;
441
442 #[test]
443 fn legacy_cache_defaults_missing_integration_prompt_version() {
444 let cache: WorkspaceCache = toml::from_str("tree_selected = 2\n").unwrap();
445 assert_eq!(cache.integration_prompt_version, None);
446 }
447
448 #[test]
449 fn integration_prompt_version_round_trips() {
450 let cache = WorkspaceCache {
451 integration_prompt_version: Some("0.18.0".into()),
452 ..Default::default()
453 };
454 let decoded: WorkspaceCache = toml::from_str(&toml::to_string(&cache).unwrap()).unwrap();
455 assert_eq!(
456 decoded.integration_prompt_version.as_deref(),
457 Some("0.18.0")
458 );
459 }
460
461 #[test]
462 fn expansion_maps_round_trip_by_stable_path() {
463 let cache = WorkspaceCache {
464 project_expanded: HashMap::from([("/projects/app".into(), true)]),
465 worktree_expanded: HashMap::from([("/projects/app/feature".into(), false)]),
466 routines_expanded: HashMap::from([("/projects/app".into(), false)]),
467 ..Default::default()
468 };
469
470 let decoded: WorkspaceCache = toml::from_str(&toml::to_string(&cache).unwrap()).unwrap();
471
472 assert_eq!(decoded.project_expanded, cache.project_expanded);
473 assert_eq!(decoded.worktree_expanded, cache.worktree_expanded);
474 assert_eq!(decoded.routines_expanded, cache.routines_expanded);
475 }
476
477 #[test]
478 fn legacy_cache_defaults_missing_routines_expanded_map() {
479 let cache: WorkspaceCache = toml::from_str(
480 r#"[project_expanded]
481"/projects/app" = true
482
483[worktree_expanded]
484"/projects/app/main" = false
485"#,
486 )
487 .unwrap();
488
489 assert!(cache.routines_expanded.is_empty());
490 }
491
492 #[test]
493 fn acknowledged_outcome_revisions_round_trip() {
494 let cache = WorkspaceCache {
495 acknowledged_outcomes: HashMap::from([("42".into(), 7)]),
496 ..Default::default()
497 };
498
499 let decoded: WorkspaceCache = toml::from_str(&toml::to_string(&cache).unwrap()).unwrap();
500
501 assert_eq!(decoded.acknowledged_outcomes.get("42"), Some(&7));
502 }
503
504 #[test]
505 fn legacy_tmux_and_session_fields_are_ignored() {
506 let cache: WorkspaceCache = toml::from_str(
507 r#"tmux_server_pid = 123
508sessions = { "/tmp/repo" = ["old-tmux-session"] }
509muted_sessions = ["pane-1"]
510"#,
511 )
512 .unwrap();
513 assert_eq!(cache.muted_terminals, HashSet::from(["pane-1".to_string()]));
514 }
515
516 #[test]
517 fn legacy_pane_cursor_identity_deserializes_for_live_migration() {
518 let cache: WorkspaceCache = toml::from_str(
519 r#"[cursor_identity.Session]
520worktree_path = "/repo"
521pane_id = "pane-1"
522"#,
523 )
524 .unwrap();
525 assert_eq!(
526 cache.cursor_identity,
527 Some(CursorIdentity::Session {
528 worktree_path: "/repo".into(),
529 terminal_id: None,
530 pane_id: Some("pane-1".into()),
531 })
532 );
533 }
534
535 #[test]
536 fn historical_active_group_shapes_are_discarded_on_rewrite() {
537 for historical in [
538 "active_group = \"work\"\n",
539 "active_tab = \"work\"\n",
540 "active_groups = [\"work\", \"other\"]\n",
541 "active_groups = []\n",
542 ] {
543 let cache: WorkspaceCache = toml::from_str(historical).unwrap();
544 assert!(cache.migration_needed);
545 let encoded = toml::to_string(&cache).unwrap();
546 assert!(!encoded.contains("active_group"));
547 assert!(!encoded.contains("active_groups"));
548 assert!(!encoded.contains("active_tab"));
549 }
550 }
551
552 #[test]
553 fn group_selection_is_independent_and_malformed_data_defaults_absent() {
554 let unique = SystemTime::now()
555 .duration_since(UNIX_EPOCH)
556 .unwrap()
557 .as_nanos();
558 let directory = std::env::current_dir()
559 .unwrap()
560 .join(".work/group-selection-tests")
561 .join(format!("{}-{unique}", std::process::id()));
562 std::fs::create_dir_all(&directory).unwrap();
563 let path = directory.join("group-selection-v1.toml");
564
565 assert_eq!(load_group_selection_from(&path).unwrap(), None);
566 save_group_selection_to(&path, &GroupKey::Named("work".into())).unwrap();
567 assert_eq!(
568 load_group_selection_from(&path).unwrap(),
569 Some(GroupKey::Named("work".into()))
570 );
571 std::fs::write(&path, "selected = [\n").unwrap();
572 assert_eq!(load_group_selection_from(&path).unwrap(), None);
573 std::fs::remove_dir_all(directory).unwrap();
574 }
575
576 #[test]
577 fn workspace_cache_serialization_never_carries_group_selection() {
578 let encoded = toml::to_string(&WorkspaceCache::default()).unwrap();
579 assert!(!encoded.contains("selected_group"));
580 assert!(!encoded.contains("active_group"));
581 }
582
583 #[test]
584 fn first_v2_cache_load_imports_active_tab_without_rewriting_legacy() {
585 let unique = SystemTime::now()
586 .duration_since(UNIX_EPOCH)
587 .unwrap()
588 .as_nanos();
589 let directory = std::env::current_dir()
590 .unwrap()
591 .join(".work/cache-v2-tests")
592 .join(format!("{}-{unique}", std::process::id()));
593 std::fs::create_dir_all(&directory).unwrap();
594 let canonical = directory.join("workspace-v2.toml");
595 let legacy = directory.join("workspace.toml");
596 let legacy_text = "active_tab = \"personal\"\ntree_selected = 3\n";
597 std::fs::write(&legacy, legacy_text).unwrap();
598
599 let cache = WorkspaceCache::load_from_paths(&canonical, &legacy).unwrap();
600
601 assert_eq!(cache.tree_selected, 3);
602 assert_eq!(std::fs::read_to_string(&legacy).unwrap(), legacy_text);
603 let canonical_text = std::fs::read_to_string(&canonical).unwrap();
604 assert!(!canonical_text.contains("active_group"));
605 assert!(!canonical_text.contains("active_tab"));
606 std::fs::remove_dir_all(directory).unwrap();
607 }
608
609 #[test]
610 fn malformed_v2_cache_wins_without_falling_back_to_legacy() {
611 let unique = SystemTime::now()
612 .duration_since(UNIX_EPOCH)
613 .unwrap()
614 .as_nanos();
615 let directory = std::env::current_dir()
616 .unwrap()
617 .join(".work/cache-v2-tests")
618 .join(format!("malformed-{}-{unique}", std::process::id()));
619 std::fs::create_dir_all(&directory).unwrap();
620 let canonical = directory.join("workspace-v2.toml");
621 let legacy = directory.join("workspace.toml");
622 std::fs::write(&canonical, "active_group = [\n").unwrap();
623 std::fs::write(&legacy, "active_tab = \"personal\"\n").unwrap();
624
625 let cache = WorkspaceCache::load_from_paths(&canonical, &legacy).unwrap();
626
627 assert_eq!(cache.tree_selected, 0);
628 assert_eq!(
629 std::fs::read_to_string(&canonical).unwrap(),
630 "active_group = [\n"
631 );
632 std::fs::remove_dir_all(directory).unwrap();
633 }
634
635 #[test]
636 fn cursor_identity_round_trips_through_stable_terminal_id() {
637 use crate::{
638 model::workspace::{flatten_tree, Project, SessionInfo, WorktreeInfo},
639 runtime::{AgentState, PaneId, SessionId, TerminalId},
640 };
641 let workspace = WorkspaceState {
642 projects: vec![Project {
643 name: "repo".into(),
644 path: "/repo".into(),
645 default_branch: "main".into(),
646 last_agent_active_unix_ms: None,
647 last_terminal_active_unix_ms: None,
648 worktrees: vec![WorktreeInfo {
649 name: "main".into(),
650 branch: "main".into(),
651 path: "/repo".into(),
652 is_main: true,
653 alias: None,
654 sessions: vec![SessionInfo {
655 session_id: SessionId(1),
656 pane_id: PaneId(1),
657 terminal_id: TerminalId(1),
658 agent: None,
659 display_name: "agent".into(),
660 agent_status: AgentState::Working,
661 revision: 1,
662 layout: crate::runtime::PaneLayout::Leaf { pane_id: PaneId(1) },
663 panes: vec![],
664 muted: false,
665 outcome_acknowledged: false,
666 }],
667 expanded: true,
668 git_info: None,
669 fetch_failed: false,
670 fetch_fail_count: 0,
671 fetch_fail_reason: None,
672 last_fetched: None,
673 git_info_fetched_at: None,
674 }],
675 routines: vec![],
676 routine_revision: 0,
677 routines_expanded: true,
678 config: None,
679 expanded: true,
680 missing: false,
681 }],
682 };
683 let flat = flatten_tree(&workspace);
684 let identity = resolve_cursor_identity(&workspace, &flat, 2).unwrap();
685 assert_eq!(
686 identity,
687 CursorIdentity::Session {
688 worktree_path: "/repo".into(),
689 terminal_id: Some("1".into()),
690 pane_id: None,
691 }
692 );
693 assert_eq!(find_cursor_index(&workspace, &flat, &identity), Some(2));
694 }
695}