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