1use anyhow::{Context, Result};
6use serde::{de::Error as _, ser::Error as _, Deserialize, Deserializer, Serialize, Serializer};
7use std::collections::{HashMap, HashSet};
8use std::io::{self, Write};
9use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
10use std::path::{Path, PathBuf};
11use std::sync::atomic::{AtomicU64, Ordering};
12
13const MILLIS_PER_HOUR: u64 = 60 * 60 * 1_000;
14static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(1);
15const CONFIG_V2_FILE: &str = "config-v2.toml";
16const LEGACY_CONFIG_FILE: &str = "config.toml";
17
18#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
19pub enum GroupKey {
20 Ungrouped,
21 Named(String),
22}
23
24impl GroupKey {
25 pub fn named(name: impl Into<String>) -> std::result::Result<Self, String> {
26 let name = name.into();
27 if is_reserved_group_name(&name) {
28 Err(format!("reserved group name: {name}"))
29 } else {
30 Ok(Self::Named(name))
31 }
32 }
33}
34
35impl Serialize for GroupKey {
36 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
37 where
38 S: Serializer,
39 {
40 match self {
41 Self::Ungrouped => serializer.serialize_str("ungrouped"),
42 Self::Named(name) if is_reserved_group_name(name) => {
43 Err(S::Error::custom(format!("reserved group name: {name}")))
44 }
45 Self::Named(name) => serializer.serialize_str(name),
46 }
47 }
48}
49
50impl<'de> Deserialize<'de> for GroupKey {
51 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
52 where
53 D: Deserializer<'de>,
54 {
55 let value = String::deserialize(deserializer)?;
56 if value.eq_ignore_ascii_case("ungrouped") {
57 Ok(Self::Ungrouped)
58 } else if value.eq_ignore_ascii_case("default") {
59 Err(D::Error::custom("default is a reserved group name"))
60 } else {
61 Ok(Self::Named(value))
62 }
63 }
64}
65
66pub fn is_reserved_group_name(name: &str) -> bool {
67 ["ungrouped", "default"]
68 .iter()
69 .any(|reserved| name.eq_ignore_ascii_case(reserved))
70}
71
72pub fn project_has_activity_within(
73 last_agent_active_unix_ms: Option<u64>,
74 last_terminal_active_unix_ms: Option<u64>,
75 now_unix_ms: u64,
76 window_ms: u64,
77) -> bool {
78 [last_agent_active_unix_ms, last_terminal_active_unix_ms]
79 .into_iter()
80 .flatten()
81 .any(|active| now_unix_ms.saturating_sub(active) <= window_ms)
82}
83
84pub fn project_matches_group(project_groups: &[String], active_group: Option<&GroupKey>) -> bool {
86 active_group.is_none_or(|group| match group {
87 GroupKey::Ungrouped => project_groups.is_empty(),
88 GroupKey::Named(name) => project_groups.iter().any(|candidate| candidate == name),
89 })
90}
91
92fn default_exclude_worktree_paths() -> Vec<String> {
93 vec![".claude/worktrees".to_string()]
94}
95
96fn default_terminal_escape_chord() -> String {
97 "ctrl+a w".to_string()
98}
99
100fn default_resume_agents_on_restore() -> bool {
101 true
102}
103
104fn default_wake_mode() -> bool {
105 true
106}
107
108fn default_auto_collapse_after_hours() -> u64 {
109 24
110}
111
112fn default_notification_timeout_seconds() -> u64 {
113 4
114}
115
116fn default_show_release_status() -> bool {
117 true
118}
119
120#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
122#[serde(rename_all = "snake_case")]
123pub enum TerminalSidebar {
124 #[default]
125 Compact,
126 Expanded,
127}
128
129#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
130#[serde(rename_all = "snake_case")]
131pub enum PortVisibility {
132 Hidden,
133 #[default]
134 NonAgentic,
135 All,
136}
137
138impl PortVisibility {
139 pub fn shows_session(self, is_agentic: bool) -> bool {
140 match self {
141 Self::Hidden => false,
142 Self::NonAgentic => !is_agentic,
143 Self::All => true,
144 }
145 }
146}
147
148pub fn normalize_project_path(path: &Path) -> PathBuf {
154 PathBuf::from(path.to_string_lossy().trim_end_matches('/').to_string())
155}
156
157#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
158pub struct GlobalConfig {
159 pub groups: Vec<String>,
160 #[serde(default)]
161 pub projects: Vec<ProjectEntry>,
162 #[serde(default = "default_exclude_worktree_paths")]
163 pub exclude_worktree_paths: Vec<String>,
164 #[serde(default = "default_terminal_escape_chord")]
165 pub terminal_escape_chord: String,
166 #[serde(default = "default_resume_agents_on_restore")]
167 pub resume_agents_on_restore: bool,
168 #[serde(default = "default_wake_mode")]
169 pub wake_mode: bool,
170 #[serde(default = "default_auto_collapse_after_hours")]
171 pub auto_collapse_after_hours: u64,
172 #[serde(default = "default_notification_timeout_seconds")]
173 pub notification_timeout_seconds: u64,
174 #[serde(default = "default_show_release_status")]
175 pub show_release_status: bool,
176 #[serde(default)]
177 pub terminal_sidebar: TerminalSidebar,
178 #[serde(default)]
179 pub port_visibility: PortVisibility,
180}
181
182impl Default for GlobalConfig {
183 fn default() -> Self {
184 Self {
185 groups: vec![],
186 projects: vec![],
187 exclude_worktree_paths: default_exclude_worktree_paths(),
188 terminal_escape_chord: default_terminal_escape_chord(),
189 resume_agents_on_restore: default_resume_agents_on_restore(),
190 wake_mode: default_wake_mode(),
191 auto_collapse_after_hours: default_auto_collapse_after_hours(),
192 notification_timeout_seconds: default_notification_timeout_seconds(),
193 show_release_status: default_show_release_status(),
194 terminal_sidebar: TerminalSidebar::default(),
195 port_visibility: PortVisibility::default(),
196 }
197 }
198}
199
200#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
201pub struct ProjectEntry {
202 pub name: String,
203 pub path: PathBuf,
204 pub groups: Vec<String>,
205 #[serde(default)]
206 pub aliases: HashMap<String, String>,
207}
208
209#[derive(Deserialize)]
210#[serde(untagged)]
211enum StringList {
212 One(String),
213 Many(Vec<String>),
214}
215
216impl StringList {
217 fn into_vec(self) -> Vec<String> {
218 match self {
219 Self::One(value) => vec![value],
220 Self::Many(values) => values,
221 }
222 }
223}
224
225#[derive(Deserialize)]
226struct GlobalConfigWire {
227 #[serde(default)]
228 groups: Option<StringList>,
229 #[serde(default)]
230 group: Option<String>,
231 #[serde(default)]
232 tabs: Option<StringList>,
233 #[serde(default)]
234 projects: Vec<ProjectEntryWire>,
235 #[serde(default = "default_exclude_worktree_paths")]
236 exclude_worktree_paths: Vec<String>,
237 #[serde(default = "default_terminal_escape_chord")]
238 terminal_escape_chord: String,
239 #[serde(default = "default_resume_agents_on_restore")]
240 resume_agents_on_restore: bool,
241 #[serde(default = "default_wake_mode")]
242 wake_mode: bool,
243 #[serde(default = "default_auto_collapse_after_hours")]
244 auto_collapse_after_hours: u64,
245 #[serde(default = "default_notification_timeout_seconds")]
246 notification_timeout_seconds: u64,
247 #[serde(default = "default_show_release_status")]
248 show_release_status: bool,
249 #[serde(default)]
250 terminal_sidebar: TerminalSidebar,
251 #[serde(default)]
252 port_visibility: PortVisibility,
253}
254
255#[derive(Deserialize)]
256struct ProjectEntryWire {
257 name: String,
258 path: PathBuf,
259 #[serde(default)]
260 groups: Option<StringList>,
261 #[serde(default)]
262 group: Option<String>,
263 #[serde(default)]
264 tab: Option<String>,
265 #[serde(default)]
266 aliases: HashMap<String, String>,
267}
268
269fn append_unique(target: &mut Vec<String>, values: impl IntoIterator<Item = String>) {
270 for value in values {
271 if !target.contains(&value) {
272 target.push(value);
273 }
274 }
275}
276
277impl<'de> Deserialize<'de> for GlobalConfig {
278 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
279 where
280 D: Deserializer<'de>,
281 {
282 let wire = GlobalConfigWire::deserialize(deserializer)?;
283 let mut groups = Vec::new();
284 if let Some(canonical) = wire.groups {
285 append_unique(&mut groups, canonical.into_vec());
286 }
287 if let Some(group) = wire.group {
288 append_unique(&mut groups, [group]);
289 }
290 if let Some(tabs) = wire.tabs {
291 append_unique(&mut groups, tabs.into_vec());
292 }
293
294 let mut projects = Vec::with_capacity(wire.projects.len());
295 for project in wire.projects {
296 let mut project_groups = Vec::new();
297 if let Some(canonical) = project.groups {
298 append_unique(&mut project_groups, canonical.into_vec());
299 }
300 if let Some(group) = project.group {
301 append_unique(&mut project_groups, [group]);
302 }
303 if let Some(tab) = project.tab {
304 append_unique(&mut project_groups, [tab]);
305 }
306 projects.push(ProjectEntry {
307 name: project.name,
308 path: normalize_project_path(&project.path),
309 groups: project_groups,
310 aliases: project.aliases,
311 });
312 }
313
314 if wire.notification_timeout_seconds == 0 {
315 return Err(D::Error::custom(
316 "notification_timeout_seconds must be at least 1",
317 ));
318 }
319 let mut config = Self {
320 groups,
321 projects,
322 exclude_worktree_paths: wire.exclude_worktree_paths,
323 terminal_escape_chord: wire.terminal_escape_chord,
324 resume_agents_on_restore: wire.resume_agents_on_restore,
325 wake_mode: wire.wake_mode,
326 auto_collapse_after_hours: wire.auto_collapse_after_hours,
327 notification_timeout_seconds: wire.notification_timeout_seconds,
328 show_release_status: wire.show_release_status,
329 terminal_sidebar: wire.terminal_sidebar,
330 port_visibility: wire.port_visibility,
331 };
332 config.migrate_reserved_names();
333 Ok(config)
334 }
335}
336
337fn stored_data_needs_migration(text: &str) -> bool {
338 fn contains_reserved(value: Option<&toml::Value>) -> bool {
339 match value {
340 Some(toml::Value::Array(values)) => values
341 .iter()
342 .any(|value| value.as_str().is_some_and(is_reserved_group_name)),
343 Some(toml::Value::String(value)) => is_reserved_group_name(value),
344 _ => false,
345 }
346 }
347
348 let Ok(toml::Value::Table(root)) = text.parse::<toml::Value>() else {
349 return false;
350 };
351 if root.contains_key("tabs")
352 || root.contains_key("group")
353 || root.get("groups").is_some_and(|value| !value.is_array())
354 || contains_reserved(root.get("groups"))
355 {
356 return true;
357 }
358 root.get("projects")
359 .and_then(toml::Value::as_array)
360 .is_some_and(|projects| {
361 projects.iter().any(|project| {
362 project.as_table().is_some_and(|project| {
363 project.contains_key("tab")
364 || project.contains_key("group")
365 || project.get("groups").is_some_and(|value| !value.is_array())
366 || contains_reserved(project.get("groups"))
367 })
368 })
369 })
370}
371
372impl GlobalConfig {
373 fn migrate_reserved_names(&mut self) {
374 let mut occupied: HashSet<String> = self
375 .groups
376 .iter()
377 .chain(self.projects.iter().flat_map(|project| &project.groups))
378 .filter(|name| !is_reserved_group_name(name))
379 .map(|name| name.to_ascii_lowercase())
380 .collect();
381 let mut replacements = HashMap::<String, String>::new();
382 for name in self
383 .groups
384 .iter()
385 .chain(self.projects.iter().flat_map(|project| &project.groups))
386 {
387 if !is_reserved_group_name(name) || replacements.contains_key(name) {
388 continue;
389 }
390 let mut suffix = 2;
391 let replacement = loop {
392 let candidate = format!("{name}-{suffix}");
393 if !occupied.contains(&candidate.to_ascii_lowercase()) {
394 occupied.insert(candidate.to_ascii_lowercase());
395 break candidate;
396 }
397 suffix += 1;
398 };
399 replacements.insert(name.clone(), replacement);
400 }
401 if !replacements.is_empty() {
402 for name in &mut self.groups {
403 if let Some(replacement) = replacements.get(name) {
404 *name = replacement.clone();
405 }
406 }
407 for project in &mut self.projects {
408 for name in &mut project.groups {
409 if let Some(replacement) = replacements.get(name) {
410 *name = replacement.clone();
411 }
412 }
413 }
414 }
415 let mut seen = HashSet::new();
416 self.groups.retain(|name| seen.insert(name.clone()));
417 for project in &mut self.projects {
418 let mut seen = HashSet::new();
419 project.groups.retain(|name| seen.insert(name.clone()));
420 }
421 }
422
423 pub fn config_path() -> Option<PathBuf> {
424 dirs::config_dir().map(|directory| directory.join("wsx").join(CONFIG_V2_FILE))
425 }
426
427 fn legacy_config_path() -> Option<PathBuf> {
428 dirs::config_dir().map(|directory| directory.join("wsx").join(LEGACY_CONFIG_FILE))
429 }
430
431 pub fn load() -> Result<(Self, Option<String>)> {
435 let canonical = Self::config_path().context("no config dir")?;
436 let legacy = Self::legacy_config_path().context("no config dir")?;
437 Self::load_from_paths(&canonical, &legacy)
438 }
439
440 fn load_from_paths(canonical: &Path, legacy: &Path) -> Result<(Self, Option<String>)> {
441 if canonical.exists() {
442 let text = std::fs::read_to_string(canonical)
443 .with_context(|| format!("reading {}", canonical.display()))?;
444 return match toml::from_str::<Self>(&text) {
445 Err(error) => Ok((
446 Self::default(),
447 Some(format!("config parse error (using defaults): {error}")),
448 )),
449 Ok(config) => {
450 if stored_data_needs_migration(&text) {
451 config.save_to(canonical)?;
452 }
453 Ok((config, None))
454 }
455 };
456 }
457 if !legacy.exists() {
458 return Ok((Self::default(), None));
459 }
460 let text = std::fs::read_to_string(legacy)
461 .with_context(|| format!("reading {}", legacy.display()))?;
462 match toml::from_str::<Self>(&text) {
463 Err(error) => Ok((
464 Self::default(),
465 Some(format!("config parse error (using defaults): {error}")),
466 )),
467 Ok(config) => {
468 let encoded = toml::to_string_pretty(&config)?;
469 match atomic_create_private(canonical, encoded.as_bytes(), true) {
470 Ok(()) => Ok((config, None)),
471 Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
472 Self::load_from_paths(canonical, legacy)
473 }
474 Err(error) => {
475 Err(error).with_context(|| format!("writing {}", canonical.display()))
476 }
477 }
478 }
479 }
480 }
481
482 pub fn save(&self) -> Result<()> {
483 let path = Self::config_path().context("no config dir")?;
484 self.save_to(&path)
485 }
486
487 fn save_to(&self, path: &Path) -> Result<()> {
488 let text = toml::to_string_pretty(self)?;
489 atomic_write_private(path, text.as_bytes(), true)
490 .with_context(|| format!("writing {}", path.display()))
491 }
492
493 pub fn prepare_for_edit(&self) -> Result<PathBuf> {
496 let path = Self::config_path().context("no config dir")?;
497 let text = toml::to_string_pretty(self)?;
498 prepare_private_file_for_edit(&path, text.as_bytes())
499 .with_context(|| format!("preparing {}", path.display()))?;
500 Ok(path)
501 }
502
503 pub fn auto_collapse_window_ms(&self) -> Option<u64> {
504 (self.auto_collapse_after_hours > 0).then(|| {
505 self.auto_collapse_after_hours
506 .saturating_mul(MILLIS_PER_HOUR)
507 })
508 }
509
510 pub fn is_worktree_excluded(&self, path: &Path) -> bool {
511 let path_str = path.to_string_lossy();
512 self.exclude_worktree_paths
513 .iter()
514 .any(|pat| path_str.contains(pat.as_str()))
515 }
516
517 pub fn ordered_group_keys(&self) -> Vec<GroupKey> {
518 let mut keys = vec![GroupKey::Ungrouped];
519 keys.extend(self.groups.iter().cloned().map(GroupKey::Named));
520 keys
521 }
522
523 pub fn named_group_exists(&self, name: &str) -> bool {
524 self.groups.iter().any(|group| group == name)
525 }
526
527 pub fn project_groups<'a>(&'a self, path: &Path) -> &'a [String] {
528 self.projects
529 .iter()
530 .find(|entry| entry.path == path)
531 .map_or(&[], |entry| entry.groups.as_slice())
532 }
533
534 pub fn add_project_to_group(&mut self, path: &Path, group: &str) -> bool {
535 if !self.named_group_exists(group) {
536 return false;
537 }
538 let Some(entry) = self.projects.iter_mut().find(|entry| entry.path == path) else {
539 return false;
540 };
541 if !entry.groups.iter().any(|existing| existing == group) {
542 entry.groups.push(group.to_owned());
543 }
544 true
545 }
546
547 pub fn remove_project_from_group(&mut self, path: &Path, group: &str) -> bool {
548 let Some(entry) = self.projects.iter_mut().find(|entry| entry.path == path) else {
549 return false;
550 };
551 let old_len = entry.groups.len();
552 entry.groups.retain(|existing| existing != group);
553 entry.groups.len() != old_len
554 }
555
556 pub fn add_project(&mut self, name: String, path: PathBuf) {
557 let path = normalize_project_path(&path);
558 self.projects.retain(|project| project.path != path);
559 self.projects.push(ProjectEntry {
560 name,
561 path,
562 groups: Vec::new(),
563 aliases: Default::default(),
564 });
565 }
566
567 pub fn remove_project(&mut self, path: &PathBuf) {
568 self.projects.retain(|project| &project.path != path);
569 }
570
571 pub fn set_alias(&mut self, project_path: &PathBuf, branch: &str, alias: &str) {
572 if let Some(entry) = self
573 .projects
574 .iter_mut()
575 .find(|project| &project.path == project_path)
576 {
577 if alias.is_empty() {
578 entry.aliases.remove(branch);
579 } else {
580 entry.aliases.insert(branch.to_string(), alias.to_string());
581 }
582 }
583 }
584}
585
586fn prepare_private_file_for_edit(path: &Path, bytes: &[u8]) -> io::Result<()> {
587 match std::fs::symlink_metadata(path) {
588 Ok(metadata) => {
589 if !metadata.file_type().is_file() {
590 return Err(io::Error::new(
591 io::ErrorKind::InvalidInput,
592 "config path is not a regular file",
593 ));
594 }
595 let existing = std::fs::read_to_string(path)?;
596 if existing.trim().is_empty() {
597 atomic_write_private(path, bytes, true)?;
598 }
599 Ok(())
600 }
601 Err(error) if error.kind() == io::ErrorKind::NotFound => {
602 atomic_create_private(path, bytes, true)
603 }
604 Err(error) => Err(error),
605 }
606}
607
608fn atomic_create_private(path: &Path, bytes: &[u8], sync: bool) -> io::Result<()> {
609 let parent = path
610 .parent()
611 .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "path has no parent"))?;
612 std::fs::create_dir_all(parent)?;
613 let temporary = private_temporary_path(path);
614 let result = (|| {
615 let mut file = std::fs::OpenOptions::new()
616 .write(true)
617 .create_new(true)
618 .mode(0o600)
619 .open(&temporary)?;
620 file.write_all(bytes)?;
621 if sync {
622 file.sync_all()?;
623 }
624 drop(file);
625 std::fs::hard_link(&temporary, path)?;
626 let _ = std::fs::remove_file(&temporary);
627 if sync {
628 std::fs::File::open(parent)?.sync_all()?;
629 }
630 Ok(())
631 })();
632 if result.is_err() {
633 let _ = std::fs::remove_file(&temporary);
634 }
635 result
636}
637
638fn private_temporary_path(path: &Path) -> PathBuf {
639 path.with_extension(format!(
640 "tmp.{}.{}",
641 std::process::id(),
642 TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed)
643 ))
644}
645
646pub(crate) fn atomic_write_private(path: &Path, bytes: &[u8], sync: bool) -> io::Result<()> {
647 let parent = path
648 .parent()
649 .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "path has no parent"))?;
650 std::fs::create_dir_all(parent)?;
651 let temporary = private_temporary_path(path);
652 let result = (|| {
653 let mut file = std::fs::OpenOptions::new()
654 .write(true)
655 .create_new(true)
656 .mode(0o600)
657 .open(&temporary)?;
658 file.write_all(bytes)?;
659 if sync {
660 file.sync_all()?;
661 }
662 std::fs::rename(&temporary, path)?;
663 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
664 if sync {
665 std::fs::File::open(parent)?.sync_all()?;
666 }
667 Ok(())
668 })();
669 if result.is_err() {
670 let _ = std::fs::remove_file(&temporary);
671 }
672 result
673}
674
675#[cfg(test)]
676mod tests {
677 use super::*;
678 use std::time::{SystemTime, UNIX_EPOCH};
679
680 struct TestDir(PathBuf);
681
682 impl TestDir {
683 fn new(name: &str) -> Self {
684 let unique = SystemTime::now()
685 .duration_since(UNIX_EPOCH)
686 .unwrap()
687 .as_nanos();
688 let path = std::env::current_dir()
689 .unwrap()
690 .join(".work/global-config-tests")
691 .join(format!("{name}-{}-{unique}", std::process::id()));
692 std::fs::create_dir_all(&path).unwrap();
693 Self(path)
694 }
695 }
696
697 impl Drop for TestDir {
698 fn drop(&mut self) {
699 let _ = std::fs::remove_dir_all(&self.0);
700 }
701 }
702
703 #[test]
704 fn edit_preparation_initializes_missing_and_empty_private_files() {
705 let dir = TestDir::new("edit-empty");
706 let missing = dir.0.join("missing.toml");
707 prepare_private_file_for_edit(&missing, b"value = 1\n").unwrap();
708 assert_eq!(std::fs::read_to_string(&missing).unwrap(), "value = 1\n");
709
710 let empty = dir.0.join("empty.toml");
711 std::fs::write(&empty, " \n").unwrap();
712 prepare_private_file_for_edit(&empty, b"value = 2\n").unwrap();
713 assert_eq!(std::fs::read_to_string(empty).unwrap(), "value = 2\n");
714 }
715
716 #[test]
717 fn edit_preparation_preserves_nonempty_private_file() {
718 let dir = TestDir::new("edit-existing");
719 let path = dir.0.join("config.toml");
720 std::fs::write(&path, "malformed = [\n").unwrap();
721
722 prepare_private_file_for_edit(&path, b"replacement = true\n").unwrap();
723
724 assert_eq!(std::fs::read_to_string(path).unwrap(), "malformed = [\n");
725 }
726
727 #[test]
728 fn edit_preparation_rejects_nonregular_private_path() {
729 let dir = TestDir::new("edit-directory");
730 let path = dir.0.join("config.toml");
731 std::fs::create_dir(&path).unwrap();
732
733 let error = prepare_private_file_for_edit(&path, b"value = 1\n").unwrap_err();
734
735 assert_eq!(error.kind(), io::ErrorKind::InvalidInput);
736 }
737
738 #[test]
739 fn path_normalization_strips_only_trailing_slashes() {
740 assert_eq!(
741 normalize_project_path(Path::new("/foo//bar/")),
742 PathBuf::from("/foo//bar")
743 );
744 assert_eq!(normalize_project_path(Path::new("///")), PathBuf::from(""));
745 }
746
747 #[test]
748 fn first_v2_load_migrates_legacy_tabs_without_rewriting_legacy_file() {
749 let dir = TestDir::new("v2-migration");
750 let canonical = dir.0.join(CONFIG_V2_FILE);
751 let legacy = dir.0.join(LEGACY_CONFIG_FILE);
752 let legacy_text = "tabs = [\"personal\"]\n[[projects]]\nname = \"p\"\npath = \"/p\"\ntab = \"personal\"\n";
753 std::fs::write(&legacy, legacy_text).unwrap();
754
755 let (config, warning) = GlobalConfig::load_from_paths(&canonical, &legacy).unwrap();
756
757 assert!(warning.is_none());
758 assert_eq!(config.groups, ["personal"]);
759 assert_eq!(config.projects[0].groups, ["personal"]);
760 assert_eq!(std::fs::read_to_string(&legacy).unwrap(), legacy_text);
761 let canonical_text = std::fs::read_to_string(&canonical).unwrap();
762 assert!(canonical_text.contains("groups = [\"personal\"]"));
763 assert!(!canonical_text.contains("tabs"));
764 assert!(!canonical_text.contains("tab ="));
765 }
766
767 #[test]
768 fn first_v2_load_copies_existing_group_format_without_rewriting_source() {
769 let dir = TestDir::new("v2-current-copy");
770 let canonical = dir.0.join(CONFIG_V2_FILE);
771 let legacy = dir.0.join(LEGACY_CONFIG_FILE);
772 let source = "groups = [\"personal\"]\n[[projects]]\nname = \"p\"\npath = \"/p\"\ngroups = [\"personal\"]\n";
773 std::fs::write(&legacy, source).unwrap();
774
775 let (config, warning) = GlobalConfig::load_from_paths(&canonical, &legacy).unwrap();
776
777 assert!(warning.is_none());
778 assert_eq!(config.groups, ["personal"]);
779 assert_eq!(std::fs::read_to_string(&legacy).unwrap(), source);
780 assert_eq!(
781 toml::from_str::<GlobalConfig>(&std::fs::read_to_string(canonical).unwrap())
782 .unwrap()
783 .groups,
784 ["personal"]
785 );
786 }
787
788 #[test]
789 fn existing_v2_config_wins_even_when_malformed() {
790 let dir = TestDir::new("v2-wins");
791 let canonical = dir.0.join(CONFIG_V2_FILE);
792 let legacy = dir.0.join(LEGACY_CONFIG_FILE);
793 std::fs::write(&canonical, "groups = [\n").unwrap();
794 std::fs::write(&legacy, "tabs = [\"personal\"]\n").unwrap();
795
796 let (config, warning) = GlobalConfig::load_from_paths(&canonical, &legacy).unwrap();
797
798 assert!(config.groups.is_empty());
799 assert!(warning.is_some_and(|warning| warning.contains("config parse error")));
800 assert_eq!(std::fs::read_to_string(canonical).unwrap(), "groups = [\n");
801 }
802
803 #[test]
804 fn canonical_serialization_has_only_group_fields() {
805 let config: GlobalConfig = toml::from_str(
806 "groups = [\"work\"]\n[[projects]]\nname = \"p\"\npath = \"/p\"\ngroups = [\"work\"]\n",
807 )
808 .unwrap();
809 let encoded = toml::to_string(&config).unwrap();
810 assert!(encoded.contains("groups = [\"work\"]"));
811 assert!(!encoded.contains("tab"));
812 assert!(!encoded.contains("tag"));
813 assert!(!encoded.contains("filter"));
814 }
815
816 #[test]
817 fn notification_timeout_defaults_and_rejects_zero() {
818 let defaulted: GlobalConfig = toml::from_str("").unwrap();
819 assert_eq!(defaulted.notification_timeout_seconds, 4);
820
821 let configured: GlobalConfig =
822 toml::from_str("notification_timeout_seconds = 9\n").unwrap();
823 assert_eq!(configured.notification_timeout_seconds, 9);
824
825 let error =
826 toml::from_str::<GlobalConfig>("notification_timeout_seconds = 0\n").unwrap_err();
827 assert!(error
828 .to_string()
829 .contains("notification_timeout_seconds must be at least 1"));
830 }
831
832 #[test]
833 fn presentation_settings_default_to_compact_sidebar_release_status_and_non_agentic_ports() {
834 let defaulted: GlobalConfig = toml::from_str("").unwrap();
835 assert!(defaulted.show_release_status);
836 assert!(defaulted.wake_mode);
837 assert_eq!(defaulted.terminal_sidebar, TerminalSidebar::Compact);
838 assert_eq!(defaulted.port_visibility, PortVisibility::NonAgentic);
839 assert!(!defaulted.port_visibility.shows_session(true));
840 assert!(defaulted.port_visibility.shows_session(false));
841
842 let configured: GlobalConfig = toml::from_str(
843 "show_release_status = false\nwake_mode = false\nterminal_sidebar = \"expanded\"\nport_visibility = \"all\"\n",
844 )
845 .unwrap();
846 assert!(!configured.show_release_status);
847 assert!(!configured.wake_mode);
848 assert_eq!(configured.terminal_sidebar, TerminalSidebar::Expanded);
849 assert_eq!(configured.port_visibility, PortVisibility::All);
850 assert!(configured.port_visibility.shows_session(true));
851
852 assert!(toml::from_str::<GlobalConfig>("port_visibility = \"sometimes\"\n").is_err());
853 assert!(toml::from_str::<GlobalConfig>("terminal_sidebar = \"sometimes\"\n").is_err());
854 }
855
856 #[test]
857 fn legacy_tabs_and_project_tab_migrate() {
858 let config: GlobalConfig = toml::from_str(
859 "tabs = [\"work\"]\n[[projects]]\nname = \"p\"\npath = \"/p\"\ntab = \"work\"\n",
860 )
861 .unwrap();
862 assert_eq!(config.groups, ["work"]);
863 assert_eq!(config.projects[0].groups, ["work"]);
864 assert!(stored_data_needs_migration(
865 "tabs = [\"work\"]\n[[projects]]\nname = \"p\"\npath = \"/p\"\ntab = \"work\"\n"
866 ));
867 }
868
869 #[test]
870 fn mixed_canonical_and_legacy_values_merge_canonical_first() {
871 let config: GlobalConfig = toml::from_str(
872 "groups = [\"new\", \"shared\"]\ntabs = [\"old\", \"shared\"]\n[[projects]]\nname = \"p\"\npath = \"/p\"\ngroups = [\"new\"]\ntab = \"old\"\n",
873 )
874 .unwrap();
875 assert_eq!(config.groups, ["new", "shared", "old"]);
876 assert_eq!(config.projects[0].groups, ["new", "old"]);
877 }
878
879 #[test]
880 fn recent_is_a_normal_named_group_but_reserved_names_remain_rejected() {
881 assert_eq!(
882 GroupKey::named("recent").unwrap(),
883 GroupKey::Named("recent".into())
884 );
885 assert_eq!(
886 GroupKey::named("Recent").unwrap(),
887 GroupKey::Named("Recent".into())
888 );
889 for name in ["ungrouped", "UNGROUPED", "default", "DeFaUlT"] {
890 assert!(GroupKey::named(name).is_err(), "{name} must be reserved");
891 assert!(serde_json::to_string(&GroupKey::Named(name.into())).is_err());
892 }
893
894 assert_eq!(
895 serde_json::from_str::<GroupKey>(r#""UNGROUPED""#).unwrap(),
896 GroupKey::Ungrouped
897 );
898 assert_eq!(
899 serde_json::from_str::<GroupKey>(r#""recent""#).unwrap(),
900 GroupKey::Named("recent".into())
901 );
902 assert!(serde_json::from_str::<GroupKey>(r#""default""#).is_err());
903 }
904
905 #[test]
906 fn legacy_recent_group_stays_named_while_default_is_migrated() {
907 let config: GlobalConfig = toml::from_str(
908 "tabs = [\"recent\", \"Default\"]\n[[projects]]\nname = \"p\"\npath = \"/p\"\ntab = \"recent\"\n",
909 )
910 .unwrap();
911
912 assert!(config.groups.iter().any(|group| group == "recent"));
913 assert!(!config
914 .groups
915 .iter()
916 .any(|group| group.eq_ignore_ascii_case("default")));
917 assert_eq!(config.projects[0].groups, ["recent"]);
918 }
919
920 #[test]
921 fn ordered_group_keys_start_with_ungrouped_and_preserve_configured_order() {
922 let config = GlobalConfig {
923 groups: vec!["work".into(), "recent".into()],
924 ..GlobalConfig::default()
925 };
926
927 assert_eq!(
928 config.ordered_group_keys(),
929 vec![
930 GroupKey::Ungrouped,
931 GroupKey::Named("work".into()),
932 GroupKey::Named("recent".into()),
933 ]
934 );
935 }
936
937 #[test]
938 fn group_matching_uses_only_memberships_and_exact_names() {
939 assert!(project_matches_group(&[], None));
940 assert!(project_matches_group(&["work".into()], None));
941 assert!(project_matches_group(&[], Some(&GroupKey::Ungrouped)));
942 assert!(!project_matches_group(
943 &["work".into()],
944 Some(&GroupKey::Ungrouped)
945 ));
946 assert!(!project_matches_group(
947 &[],
948 Some(&GroupKey::Named("recent".into()))
949 ));
950 assert!(project_matches_group(
951 &["other".into(), "recent".into()],
952 Some(&GroupKey::Named("recent".into()))
953 ));
954 assert!(!project_matches_group(
955 &["Recent".into()],
956 Some(&GroupKey::Named("recent".into()))
957 ));
958 assert!(!project_matches_group(
959 &["other".into()],
960 Some(&GroupKey::Named("work".into()))
961 ));
962 }
963}