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