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/// Canonical form used for project-path identity. A trailing `/` is the only
157/// divergence we've seen between a user-typed path and its stored form, and an
158/// un-normalized duplicate silently breaks dedup / delete / cache lookups.
159/// Single source of truth — `load`, `add_project`, and `ops::register_project`
160/// must all route through this so the stored path and the in-memory path match.
161pub 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    /// Returns `(config, warning)`. The v2 path isolates wsx 0.20 from older
446    /// whole-file serializers; first load copies either legacy tabs or current
447    /// group data without modifying the old path.
448    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    /// Ensures the global config has editable content without replacing an
508    /// existing nonempty or nonregular path.
509    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}