Skip to main content

wsx_core/config/
global.rs

1// ~/.config/wsx/config-v2.toml
2// ref: toml crate — https://docs.rs/toml/
3// ^ [[wsx Architecture]] Groups are the sole project organization and workspace selection contract.
4
5use 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
84/// Matches a project against the one active workspace group. No selection means all projects.
85pub 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// ^ [[Configuration Model]] Terminal presentation choices remain typed and default safely for older files.
121#[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
164/// Canonical form used for project-path identity. A trailing `/` is the only
165/// divergence we've seen between a user-typed path and its stored form, and an
166/// un-normalized duplicate silently breaks dedup / delete / cache lookups.
167/// Single source of truth — `load`, `add_project`, and `ops::register_project`
168/// must all route through this so the stored path and the in-memory path match.
169pub 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    /// Returns `(config, warning)`. The v2 path isolates wsx 0.20 from older
460    /// whole-file serializers; first load copies either legacy tabs or current
461    /// group data without modifying the old path.
462    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    /// Ensures the global config has editable content without replacing an
522    /// existing nonempty or nonregular path.
523    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}