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