Skip to main content

wsx_core/
ops.rs

1// Workspace operation functions — pure business logic, no App state.
2// These take explicit arguments rather than &mut App so they can be
3// tested and reasoned about independently of the TUI state machine.
4
5use std::collections::{HashMap, HashSet};
6use std::path::PathBuf;
7use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
8
9use anyhow::{bail, Result};
10
11use crate::{
12    cache::SessionSnapshot,
13    config::global::GlobalConfig,
14    git::{info as git_info, worktree as git_worktree},
15    hooks,
16    model::workspace::{
17        session_display_name_from_tmux, FetchFailReason, ForegroundKind, GitInfo, Project,
18        ProjectConfig, SessionInfo, WorkspaceState, WorktreeInfo,
19    },
20    tmux::{monitor::SessionStatus, session},
21};
22
23// (pane_capture, muted)
24type PaneSnap = HashMap<String, (Option<String>, bool)>;
25// session_order preserves user-defined sort across refresh
26type WorktreeSnap = HashMap<PathBuf, WorktreeSnapEntry>;
27
28struct WorktreeSnapEntry {
29    git_info: Option<GitInfo>,
30    git_info_fetched_at: Option<Instant>,
31    expanded: bool,
32    panes: PaneSnap,
33    session_order: Vec<String>,
34    last_fetched: Option<Instant>,
35    fetch_failed: bool,
36    fetch_fail_count: u32,
37    fetch_fail_reason: Option<FetchFailReason>,
38}
39
40pub const IDLE_SECS: u64 = 3;
41
42fn is_git_repo(path: &std::path::Path) -> bool {
43    path.exists() && path.join(".git").exists()
44}
45
46// ── Refresh helpers ───────────────────────────────────────────────────────────
47
48fn unix_ts_to_instant(unix_ts: u64) -> Option<Instant> {
49    let now_unix = SystemTime::now()
50        .duration_since(UNIX_EPOCH)
51        .unwrap_or_default()
52        .as_secs();
53    let secs_ago = now_unix.saturating_sub(unix_ts);
54    Instant::now().checked_sub(Duration::from_secs(secs_ago))
55}
56
57/// Rebuild all worktrees + sessions for every project from live data.
58/// Calls `list_worktrees` per project synchronously — use for user-triggered refreshes.
59pub fn refresh_workspace(
60    workspace: &mut WorkspaceState,
61    config: &GlobalConfig,
62    sessions_with_paths: &[(String, PathBuf)],
63    activity: &HashMap<String, SessionStatus>,
64) {
65    let worktrees: Vec<(PathBuf, Vec<git_worktree::WorktreeEntry>)> = workspace
66        .projects
67        .iter()
68        .map(|p| {
69            let entries = git_worktree::list_worktrees(&p.path).unwrap_or_default();
70            (p.path.clone(), entries)
71        })
72        .collect();
73    refresh_workspace_with_worktrees(workspace, config, sessions_with_paths, activity, worktrees);
74}
75
76/// Like `refresh_workspace` but with pre-computed worktree entries — avoids subprocess calls
77/// on the caller's thread. Used by the periodic background refresh path.
78pub fn refresh_workspace_with_worktrees(
79    workspace: &mut WorkspaceState,
80    config: &GlobalConfig,
81    sessions_with_paths: &[(String, PathBuf)],
82    activity: &HashMap<String, SessionStatus>,
83    worktrees: Vec<(PathBuf, Vec<git_worktree::WorktreeEntry>)>,
84) {
85    // Pre-index sessions by worktree path for O(1) lookup per worktree
86    let mut sessions_by_path: HashMap<&PathBuf, Vec<&str>> = HashMap::new();
87    for (name, path) in sessions_with_paths {
88        sessions_by_path
89            .entry(path)
90            .or_default()
91            .push(name.as_str());
92    }
93
94    let aliases_by_path: Vec<(PathBuf, HashMap<String, String>)> = config
95        .projects
96        .iter()
97        .map(|e| (e.path.clone(), e.aliases.clone()))
98        .collect();
99
100    let mut worktrees_map: HashMap<PathBuf, Vec<git_worktree::WorktreeEntry>> =
101        worktrees.into_iter().collect();
102
103    for i in 0..workspace.projects.len() {
104        let path = workspace.projects[i].path.clone();
105        let proj_name = workspace.projects[i].name.clone();
106        let aliases = aliases_by_path
107            .iter()
108            .find(|(p, _)| p == &path)
109            .map(|(_, a)| a.clone())
110            .unwrap_or_default();
111
112        let snapshot: WorktreeSnap = workspace.projects[i]
113            .worktrees
114            .iter()
115            .map(|w| {
116                let panes = w
117                    .sessions
118                    .iter()
119                    .map(|s| (s.name.clone(), (s.pane_capture.clone(), s.muted)))
120                    .collect();
121                let order = w.sessions.iter().map(|s| s.name.clone()).collect();
122                (
123                    w.path.clone(),
124                    WorktreeSnapEntry {
125                        git_info: w.git_info.clone(),
126                        git_info_fetched_at: w.git_info_fetched_at,
127                        expanded: w.expanded,
128                        panes,
129                        session_order: order,
130                        last_fetched: w.last_fetched,
131                        fetch_failed: w.fetch_failed,
132                        fetch_fail_count: w.fetch_fail_count,
133                        fetch_fail_reason: w.fetch_fail_reason.clone(),
134                    },
135                )
136            })
137            .collect();
138
139        let entries = worktrees_map.remove(&path).unwrap_or_default();
140        let mut new_worktrees = Vec::new();
141        for entry in entries
142            .into_iter()
143            .filter(|e| !config.is_worktree_excluded(&e.path))
144        {
145            let alias = aliases.get(&entry.branch).cloned();
146            let wt_path = entry.path.clone();
147            let prev = snapshot.get(&entry.path);
148
149            let prev_order: &[String] = prev
150                .map(|snap| snap.session_order.as_slice())
151                .unwrap_or(&[]);
152            // Index prev_order for O(1) sort-key lookup
153            let order_index: HashMap<&str, usize> = prev_order
154                .iter()
155                .enumerate()
156                .map(|(i, n)| (n.as_str(), i))
157                .collect();
158
159            let empty_names: Vec<&str> = Vec::new();
160            let session_names = sessions_by_path.get(&wt_path).unwrap_or(&empty_names);
161            let mut sessions: Vec<SessionInfo> = session_names
162                .iter()
163                .map(|&name| {
164                    let display_name = session_display_name_from_tmux(
165                        name,
166                        &proj_name,
167                        &wt_path,
168                        &entry.branch,
169                        alias.as_deref(),
170                    );
171                    let prev_pane = prev.and_then(|snap| snap.panes.get(name));
172                    let (pane_capture, prev_muted) = prev_pane
173                        .map(|(p, m)| (p.clone(), *m))
174                        .unwrap_or((None, false));
175                    let status = activity.get(name);
176                    // Prefer tmux-sourced muted flag over snapshot so all instances agree.
177                    let muted = status.map(|s| s.wsx_muted).unwrap_or(prev_muted);
178                    let last_activity = status
179                        .filter(|s| s.last_activity_ts > 0)
180                        .and_then(|s| unix_ts_to_instant(s.last_activity_ts));
181                    // Mute is sticky — only user interaction in wsx (attach, send, etc.)
182                    // unmutes a session. Background output no longer breaks it.
183                    // Muted sessions skip all activity tracking.
184                    let (has_activity, last_activity) = if muted {
185                        (false, None)
186                    } else {
187                        (status.map(|s| s.has_bell).unwrap_or(false), last_activity)
188                    };
189                    SessionInfo {
190                        name: name.to_string(),
191                        display_name,
192                        has_activity,
193                        pane_capture,
194                        last_activity,
195                        foreground: status
196                            .map(|s| s.foreground)
197                            .unwrap_or(ForegroundKind::Unknown),
198                        is_running_wsx: status.map(|s| s.is_running_wsx).unwrap_or(false),
199                        muted,
200                    }
201                })
202                .collect();
203            sessions.sort_by_key(|s| *order_index.get(s.name.as_str()).unwrap_or(&usize::MAX));
204
205            let (
206                git_info,
207                git_info_fetched_at,
208                expanded,
209                last_fetched,
210                fetch_failed,
211                fetch_fail_count,
212                fetch_fail_reason,
213            ) = prev
214                .map(|snap| {
215                    (
216                        snap.git_info.clone(),
217                        snap.git_info_fetched_at,
218                        snap.expanded,
219                        snap.last_fetched,
220                        snap.fetch_failed,
221                        snap.fetch_fail_count,
222                        snap.fetch_fail_reason.clone(),
223                    )
224                })
225                .unwrap_or((None, None, true, None, false, 0, None));
226
227            new_worktrees.push(WorktreeInfo {
228                name: entry.name,
229                branch: entry.branch,
230                path: entry.path,
231                is_main: entry.is_main,
232                alias,
233                sessions,
234                expanded,
235                git_info,
236                git_info_fetched_at,
237                fetch_failed,
238                fetch_fail_count,
239                fetch_fail_reason,
240                last_fetched,
241            });
242        }
243        workspace.projects[i].worktrees = new_worktrees;
244    }
245    // Drop projects that were already marked missing last cycle; mark newly-gone ones.
246    // This gives one refresh cycle (~3 s) of visual "(missing)" indication before removal.
247    workspace.projects.retain(|p| !p.missing);
248    for p in &mut workspace.projects {
249        p.missing = !is_git_repo(&p.path);
250    }
251}
252
253/// Update session activity state from live tmux data. Returns true if any field changed.
254pub fn update_activity(
255    workspace: &mut WorkspaceState,
256    activity: &HashMap<String, SessionStatus>,
257) -> bool {
258    let mut changed = false;
259    for project in &mut workspace.projects {
260        for wt in &mut project.worktrees {
261            for sess in &mut wt.sessions {
262                if sess.muted {
263                    continue;
264                }
265                let old_bell = sess.has_activity;
266                let old_foreground = sess.foreground;
267                if let Some(status) = activity.get(&sess.name) {
268                    sess.has_activity = status.has_bell;
269                    sess.foreground = status.foreground;
270                    sess.is_running_wsx = status.is_running_wsx;
271                    sess.last_activity = Some(status.last_activity_ts)
272                        .filter(|&ts| ts > 0)
273                        .and_then(|ts| unix_ts_to_instant(ts));
274                } else {
275                    sess.has_activity = false;
276                    sess.foreground = ForegroundKind::Unknown;
277                    sess.is_running_wsx = false;
278                }
279                if sess.has_activity != old_bell || sess.foreground != old_foreground {
280                    changed = true;
281                }
282            }
283        }
284    }
285    changed
286}
287
288// ── Workspace loading ─────────────────────────────────────────────────────────
289
290pub fn load_workspace(config: &GlobalConfig) -> WorkspaceState {
291    if config.projects.is_empty() {
292        return WorkspaceState::empty();
293    }
294
295    let projects = config
296        .projects
297        .iter()
298        .filter_map(|entry| {
299            let path = &entry.path;
300            if !is_git_repo(path) {
301                return None;
302            }
303
304            let default_branch = detect_default_branch(path);
305            let proj_config = crate::config::project::load_project_config(path);
306            let entries = git_worktree::list_worktrees(path).unwrap_or_default();
307            let entries = entries
308                .into_iter()
309                .filter(|e| !config.is_worktree_excluded(&e.path))
310                .collect();
311            let worktrees = git_worktree::to_worktree_infos(entries, &entry.aliases);
312
313            Some(Project {
314                name: entry.name.clone(),
315                path: path.clone(),
316                default_branch,
317                worktrees,
318                config: Some(proj_config),
319                expanded: true,
320                missing: false,
321            })
322        })
323        .collect();
324
325    WorkspaceState { projects }
326}
327
328pub fn expand_path(s: &str) -> PathBuf {
329    if s.starts_with("~/") {
330        if let Some(home) = dirs::home_dir() {
331            return home.join(&s[2..]);
332        }
333    }
334    PathBuf::from(s)
335}
336
337pub fn detect_default_branch(path: &std::path::Path) -> String {
338    git_info::current_branch(path).unwrap_or_else(|| "main".into())
339}
340
341// ── Project registration ──────────────────────────────────────────────────────
342
343/// Register a new project at `path`. Returns the constructed `Project` and
344/// mutates `config` (caller must call `config.save()`).
345pub fn register_project(path: PathBuf, config: &mut GlobalConfig) -> Result<Project> {
346    if path.as_os_str().is_empty() {
347        bail!("empty path");
348    }
349    // Normalize before any equality checks so the returned Project.path matches
350    // what config stores — otherwise delete/dedup/cache lookups silently miss and
351    // the tree shows duplicate or undeletable entries. Shared normalizer keeps
352    // this in lockstep with GlobalConfig::add_project / load.
353    let path = crate::config::global::normalize_project_path(&path);
354    if !path.exists() {
355        bail!("path does not exist: {}", path.display());
356    }
357    if !is_git_repo(&path) {
358        bail!("not a git repository: {}", path.display());
359    }
360    if config.projects.iter().any(|e| e.path == path) {
361        bail!("project already registered: {}", path.display());
362    }
363
364    let name = path
365        .file_name()
366        .map(|n| n.to_string_lossy().to_string())
367        .unwrap_or_else(|| "unknown".to_string());
368
369    let default_branch = detect_default_branch(&path);
370    let proj_config = crate::config::project::load_project_config(&path);
371    let entries = git_worktree::list_worktrees(&path).unwrap_or_default();
372    let aliases = config
373        .projects
374        .iter()
375        .find(|e| e.path == path)
376        .map(|e| e.aliases.clone())
377        .unwrap_or_default();
378    let worktrees = git_worktree::to_worktree_infos(entries, &aliases);
379
380    config.add_project(name.clone(), path.clone());
381
382    Ok(Project {
383        name,
384        path,
385        default_branch,
386        worktrees,
387        config: Some(proj_config),
388        expanded: true,
389        missing: false,
390    })
391}
392
393/// Remove a project by path from config. Caller must call `config.save()`.
394pub fn unregister_project(path: &PathBuf, config: &mut GlobalConfig) {
395    config.remove_project(path);
396}
397
398// ── Worktree operations ───────────────────────────────────────────────────────
399
400/// Create a new git worktree under `repo_path` for `branch`.
401/// Runs hooks (env copy, post_create) and returns the new worktree path.
402/// Returns a warning string if a hook failed (non-fatal).
403pub fn create_worktree(
404    repo_path: &PathBuf,
405    default_branch: &str,
406    proj_config: &ProjectConfig,
407    branch: &str,
408) -> Result<(PathBuf, Option<String>)> {
409    let wt_path = git_worktree::create_worktree(repo_path, branch, default_branch)?;
410
411    let mut warning: Option<String> = None;
412
413    if let Err(e) = hooks::copy_env_files(repo_path, &wt_path, proj_config) {
414        warning = Some(format!("Warning: .env copy: {}", e));
415    }
416    if let Some(ref cmd) = proj_config.post_create {
417        if let Err(e) = hooks::run_post_create(&wt_path, cmd) {
418            warning = Some(format!("Warning: postCreate: {}", e));
419        }
420    }
421
422    Ok((wt_path, warning))
423}
424
425/// Remove a git worktree and kill any associated tmux sessions.
426pub fn delete_worktree(
427    repo_path: &PathBuf,
428    wt_path: &PathBuf,
429    branch: &str,
430    session_names: &[String],
431) -> Result<()> {
432    git_worktree::remove_worktree(repo_path, wt_path, branch)?;
433    for sess in session_names {
434        let _ = session::kill_session(sess);
435    }
436    Ok(())
437}
438
439// ── Session operations ────────────────────────────────────────────────────────
440
441/// Create a named tmux session at `wt_path` and optionally send an initial command.
442/// Returns (tmux_name, display_name). Tmux name is prefixed with `{proj_name}-{wt_slug}-`;
443/// display_name is the user-visible part (what the user typed).
444pub fn create_session(
445    proj_name: &str,
446    wt_slug: &str,
447    wt_path: &PathBuf,
448    session_name: Option<String>,
449    command: Option<String>,
450) -> Result<(String, String)> {
451    // display name priority: explicit > command first word > proj_name
452    let base_display = match &session_name {
453        Some(n) if !n.is_empty() => n.clone(),
454        _ => match &command {
455            Some(cmd) => cmd
456                .split_whitespace()
457                .next()
458                .unwrap_or(proj_name)
459                .to_string(),
460            None => proj_name.to_string(),
461        },
462    };
463    let base_tmux = format!("{}-{}-{}", proj_name, wt_slug, base_display);
464    let tmux_name = session::unique_session_name(&base_tmux);
465    // strip "{proj_name}-{wt_slug}-" prefix to get display name
466    let prefix_len = proj_name.len() + 1 + wt_slug.len() + 1;
467    let display_name = tmux_name[prefix_len..].to_string();
468    session::create_session(&tmux_name, wt_path)?;
469    if let Some(cmd) = command {
470        session::send_keys(&tmux_name, &cmd)?;
471    }
472    Ok((tmux_name, display_name))
473}
474
475/// Rename a tmux session from `old_name` to `new_name`.
476pub fn rename_session(old_name: &str, new_name: &str) -> Result<()> {
477    session::rename_session(old_name, new_name)
478}
479
480/// Recreate tmux sessions after a server restart (reboot/crash).
481///
482/// ! Only restores when the tmux server PID has changed. If the same server is
483/// ! still running, missing sessions were intentionally killed — skip restore.
484///
485/// Uses the newest non-empty session source. Older versions always preferred
486/// sessions.toml, but that let a stale crash snapshot override a newer cache.
487///
488/// Returns the number of sessions recreated.
489pub fn restore_cached_sessions(workspace: &WorkspaceState, cached_pid: Option<u32>) -> usize {
490    let current_pid = session::server_pid();
491    if cached_pid.is_some() && cached_pid == current_pid {
492        return 0;
493    }
494
495    let live: HashSet<String> = session::list_sessions_with_paths()
496        .into_iter()
497        .map(|(name, _)| name)
498        .collect();
499
500    let source = choose_restore_source(
501        crate::cache::load_session_snapshot_with_meta(),
502        crate::cache::collect_session_names(workspace),
503        crate::cache::WorkspaceCache::load().written_at_unix_ms,
504    );
505
506    let mut restored = 0usize;
507    for (path_str, names) in &source {
508        let path = std::path::Path::new(path_str.as_str());
509        for name in names {
510            if !live.contains(name) && session::create_session(name, path).is_ok() {
511                restored += 1;
512            }
513        }
514    }
515    restored
516}
517
518fn choose_restore_source(
519    snapshot: SessionSnapshot,
520    workspace_sessions: HashMap<String, Vec<String>>,
521    workspace_written_at: Option<u64>,
522) -> HashMap<String, Vec<String>> {
523    let snapshot_is_newer = match (snapshot.written_at_unix_ms, workspace_written_at) {
524        // Legacy snapshots had no timestamp, so keep the old crash-restore
525        // behavior and prefer them when present.
526        (None, _) => true,
527        (Some(_), None) => true,
528        (Some(snapshot_ms), Some(workspace_ms)) => snapshot_ms >= workspace_ms,
529    };
530    if !snapshot.sessions.is_empty() && (workspace_sessions.is_empty() || snapshot_is_newer) {
531        snapshot.sessions
532    } else {
533        workspace_sessions
534    }
535}
536
537// ── Alias operations ──────────────────────────────────────────────────────────
538
539/// Persist an alias for a branch in the global config. Caller must call `config.save()`.
540pub fn set_alias(config: &mut GlobalConfig, proj_path: &PathBuf, branch: &str, alias: &str) {
541    config.set_alias(proj_path, branch, alias);
542}
543
544#[cfg(test)]
545mod tests {
546    use super::*;
547    use crate::model::workspace::{Project, WorkspaceState};
548    use std::collections::HashMap;
549
550    fn make_project(path: PathBuf) -> Project {
551        Project {
552            name: "test".to_string(),
553            path,
554            default_branch: "main".to_string(),
555            worktrees: vec![],
556            config: None,
557            expanded: true,
558            missing: false,
559        }
560    }
561
562    fn sessions(path: &str, names: &[&str]) -> HashMap<String, Vec<String>> {
563        HashMap::from([(
564            path.to_string(),
565            names.iter().map(|name| name.to_string()).collect(),
566        )])
567    }
568
569    #[test]
570    fn restore_source_uses_workspace_when_snapshot_is_older() {
571        let source = choose_restore_source(
572            SessionSnapshot {
573                sessions: sessions("/tmp/repo", &["old"]),
574                written_at_unix_ms: Some(10),
575            },
576            sessions("/tmp/repo", &["new"]),
577            Some(20),
578        );
579
580        assert_eq!(source["/tmp/repo"], vec!["new"]);
581    }
582
583    #[test]
584    fn restore_source_uses_snapshot_when_snapshot_is_newer() {
585        let source = choose_restore_source(
586            SessionSnapshot {
587                sessions: sessions("/tmp/repo", &["new"]),
588                written_at_unix_ms: Some(20),
589            },
590            sessions("/tmp/repo", &["old"]),
591            Some(10),
592        );
593
594        assert_eq!(source["/tmp/repo"], vec!["new"]);
595    }
596
597    #[test]
598    fn restore_source_keeps_legacy_snapshot_preference() {
599        let source = choose_restore_source(
600            SessionSnapshot {
601                sessions: sessions("/tmp/repo", &["legacy"]),
602                written_at_unix_ms: None,
603            },
604            sessions("/tmp/repo", &["workspace"]),
605            Some(20),
606        );
607
608        assert_eq!(source["/tmp/repo"], vec!["legacy"]);
609    }
610
611    #[test]
612    fn refresh_drops_project_whose_directory_was_deleted() {
613        let suffix = std::time::SystemTime::now()
614            .duration_since(std::time::UNIX_EPOCH)
615            .unwrap_or_default()
616            .as_nanos();
617        let base = std::env::temp_dir().join(format!("wsx-test-{}", suffix));
618        std::fs::create_dir_all(&base).unwrap();
619        let exists_path = base.join("real");
620        std::fs::create_dir_all(&exists_path).unwrap();
621        std::fs::create_dir(exists_path.join(".git")).unwrap();
622        let missing_path = base.join("ghost");
623
624        let config = GlobalConfig::default();
625        let activity: HashMap<String, crate::tmux::monitor::SessionStatus> = HashMap::new();
626
627        let mut workspace = WorkspaceState {
628            projects: vec![
629                make_project(exists_path.clone()),
630                make_project(missing_path.clone()),
631            ],
632        };
633
634        // First refresh: missing_path is newly gone — stays in tree, marked missing.
635        refresh_workspace_with_worktrees(
636            &mut workspace,
637            &config,
638            &[],
639            &activity,
640            vec![
641                (exists_path.clone(), vec![]),
642                (missing_path.clone(), vec![]),
643            ],
644        );
645        assert_eq!(workspace.projects.len(), 2);
646        assert!(workspace
647            .projects
648            .iter()
649            .any(|p| p.missing && p.path == missing_path));
650
651        // Second refresh: missing_path still gone — now dropped.
652        refresh_workspace_with_worktrees(
653            &mut workspace,
654            &config,
655            &[],
656            &activity,
657            vec![(exists_path.clone(), vec![]), (missing_path, vec![])],
658        );
659        assert_eq!(workspace.projects.len(), 1);
660        assert_eq!(workspace.projects[0].path, exists_path);
661        let _ = std::fs::remove_dir_all(&base);
662    }
663
664    fn unique_base() -> PathBuf {
665        let suffix = std::time::SystemTime::now()
666            .duration_since(std::time::UNIX_EPOCH)
667            .unwrap_or_default()
668            .as_nanos();
669        let base = std::env::temp_dir().join(format!("wsx-test-register-{}", suffix));
670        std::fs::create_dir_all(&base).unwrap();
671        base
672    }
673
674    fn make_repo_dir(base: &std::path::Path, name: &str) -> PathBuf {
675        let p = base.join(name);
676        std::fs::create_dir_all(p.join(".git")).unwrap();
677        p
678    }
679
680    #[test]
681    fn given_trailing_slash_path_when_registered_then_returned_path_has_no_trailing_slash() {
682        let base = unique_base();
683        let repo = make_repo_dir(&base, "myrepo");
684        let with_slash = PathBuf::from(format!("{}/", repo.to_string_lossy()));
685        let mut config = GlobalConfig::default();
686
687        let project = register_project(with_slash, &mut config).unwrap();
688
689        assert_eq!(project.path, repo);
690        let _ = std::fs::remove_dir_all(&base);
691    }
692
693    #[test]
694    fn given_valid_repo_when_registered_then_name_is_final_path_component() {
695        let base = unique_base();
696        let repo = make_repo_dir(&base, "coolproject");
697        let mut config = GlobalConfig::default();
698
699        let project = register_project(repo, &mut config).unwrap();
700
701        assert_eq!(project.name, "coolproject");
702        let _ = std::fs::remove_dir_all(&base);
703    }
704
705    #[test]
706    fn given_valid_repo_when_registered_then_appended_to_config() {
707        let base = unique_base();
708        let repo = make_repo_dir(&base, "myrepo");
709        let mut config = GlobalConfig::default();
710
711        let _ = register_project(repo, &mut config).unwrap();
712
713        assert_eq!(config.projects.len(), 1);
714        let _ = std::fs::remove_dir_all(&base);
715    }
716
717    #[test]
718    fn given_two_distinct_repos_when_both_registered_then_both_stored() {
719        let base = unique_base();
720        let repo_a = make_repo_dir(&base, "alpha");
721        let repo_b = make_repo_dir(&base, "beta");
722        let mut config = GlobalConfig::default();
723
724        register_project(repo_a, &mut config).unwrap();
725        register_project(repo_b, &mut config).unwrap();
726
727        assert_eq!(config.projects.len(), 2);
728        let _ = std::fs::remove_dir_all(&base);
729    }
730
731    #[test]
732    fn given_same_exact_path_registered_twice_when_second_call_then_returns_err() {
733        let base = unique_base();
734        let repo = make_repo_dir(&base, "myrepo");
735        let mut config = GlobalConfig::default();
736
737        register_project(repo.clone(), &mut config).unwrap();
738        let second = register_project(repo, &mut config);
739
740        assert!(second.is_err());
741        let _ = std::fs::remove_dir_all(&base);
742    }
743
744    #[test]
745    fn given_same_exact_path_registered_twice_when_second_call_then_projects_len_stays_one() {
746        let base = unique_base();
747        let repo = make_repo_dir(&base, "myrepo");
748        let mut config = GlobalConfig::default();
749
750        register_project(repo.clone(), &mut config).unwrap();
751        let _ = register_project(repo, &mut config);
752
753        assert_eq!(config.projects.len(), 1);
754        let _ = std::fs::remove_dir_all(&base);
755    }
756
757    #[test]
758    fn given_path_differing_only_by_trailing_slash_when_second_call_then_returns_err() {
759        let base = unique_base();
760        let repo = make_repo_dir(&base, "myrepo");
761        let with_slash = PathBuf::from(format!("{}/", repo.to_string_lossy()));
762        let mut config = GlobalConfig::default();
763
764        register_project(repo, &mut config).unwrap();
765        let second = register_project(with_slash, &mut config);
766
767        assert!(second.is_err());
768        let _ = std::fs::remove_dir_all(&base);
769    }
770
771    #[test]
772    fn given_path_differing_only_by_trailing_slash_when_second_call_then_projects_len_stays_one() {
773        let base = unique_base();
774        let repo = make_repo_dir(&base, "myrepo");
775        let with_slash = PathBuf::from(format!("{}/", repo.to_string_lossy()));
776        let mut config = GlobalConfig::default();
777
778        register_project(repo, &mut config).unwrap();
779        let _ = register_project(with_slash, &mut config);
780
781        assert_eq!(config.projects.len(), 1);
782        let _ = std::fs::remove_dir_all(&base);
783    }
784
785    #[test]
786    fn given_empty_path_when_registered_then_returns_err() {
787        let mut config = GlobalConfig::default();
788
789        let result = register_project(PathBuf::from(""), &mut config);
790
791        assert!(result.is_err());
792    }
793
794    #[test]
795    fn given_empty_path_when_registered_then_projects_stays_empty() {
796        let mut config = GlobalConfig::default();
797
798        let _ = register_project(PathBuf::from(""), &mut config);
799
800        assert_eq!(config.projects.len(), 0);
801    }
802
803    #[test]
804    fn given_nonexistent_path_when_registered_then_returns_err() {
805        let base = unique_base();
806        let missing = base.join("does-not-exist");
807        let mut config = GlobalConfig::default();
808
809        let result = register_project(missing, &mut config);
810
811        assert!(result.is_err());
812        let _ = std::fs::remove_dir_all(&base);
813    }
814
815    #[test]
816    fn given_existing_dir_without_git_when_registered_then_returns_err() {
817        let base = unique_base();
818        let plain = base.join("plaindir");
819        std::fs::create_dir_all(&plain).unwrap();
820        let mut config = GlobalConfig::default();
821
822        let result = register_project(plain, &mut config);
823
824        assert!(result.is_err());
825        let _ = std::fs::remove_dir_all(&base);
826    }
827
828    #[test]
829    fn given_file_at_path_instead_of_dir_when_registered_then_returns_err() {
830        let base = unique_base();
831        let file_path = base.join("notadir");
832        std::fs::write(&file_path, b"i am a file").unwrap();
833        let mut config = GlobalConfig::default();
834
835        let result = register_project(file_path, &mut config);
836
837        assert!(result.is_err());
838        let _ = std::fs::remove_dir_all(&base);
839    }
840}