Skip to main content

git_same/config/
workspace_policy.rs

1//! Workspace resolution rules (policy concern only).
2
3use super::parser::Config;
4use super::workspace::tilde_collapse_path;
5use super::workspace::WorkspaceConfig;
6use super::workspace_store::WorkspaceStore;
7use crate::errors::AppError;
8use std::path::Path;
9
10/// Workspace policy helpers.
11pub struct WorkspacePolicy;
12
13impl WorkspacePolicy {
14    /// Walk up from `start` to find the nearest `.git-same/config.toml`.
15    ///
16    /// Returns the workspace root (parent of `.git-same/`) if found.
17    pub fn detect_from_cwd(start: &Path) -> Option<std::path::PathBuf> {
18        let mut current = start.to_path_buf();
19        loop {
20            let config = WorkspaceStore::config_path(&current);
21            if config.exists() {
22                return Some(current);
23            }
24            if !current.pop() {
25                break;
26            }
27        }
28        None
29    }
30
31    /// Resolve which workspace to use.
32    ///
33    /// Priority:
34    /// 1. Explicit `--workspace <path|name>` argument
35    /// 2. CWD auto-detection (walk up looking for `.git-same/`)
36    /// 3. Global `default_workspace` path
37    /// 4. Single-workspace auto-select
38    /// 5. Error
39    pub fn resolve(name: Option<&str>, config: &Config) -> Result<WorkspaceConfig, AppError> {
40        // 1. Explicit selector (path or unique folder name)
41        if let Some(value) = name {
42            let expanded = shellexpand::tilde(value);
43            let root = Path::new(expanded.as_ref());
44            match WorkspaceStore::load(root) {
45                Ok(ws) => return Ok(ws),
46                Err(path_err) => {
47                    let workspaces = WorkspaceStore::list()?;
48                    match Self::resolve_selector_from_list(value, workspaces) {
49                        Ok(ws) => return Ok(ws),
50                        Err(selector_err) => {
51                            let is_ambiguous = selector_err.to_string().contains("ambiguous");
52                            if is_ambiguous {
53                                return Err(selector_err);
54                            }
55                            if Self::looks_like_path(value) {
56                                return Err(path_err);
57                            }
58                            return Err(AppError::config(format!(
59                                "No workspace matched selector '{}'. Use 'gisa workspace list' and \
60                                 pass a workspace folder name or path.",
61                                value
62                            )));
63                        }
64                    }
65                }
66            }
67        }
68
69        // 2. CWD auto-detection
70        if let Ok(cwd) = std::env::current_dir() {
71            if let Some(root) = Self::detect_from_cwd(&cwd) {
72                return WorkspaceStore::load(&root);
73            }
74        }
75
76        // 3. Global default_workspace
77        if let Some(ref default_path) = config.default_workspace {
78            let expanded = shellexpand::tilde(default_path);
79            let root = Path::new(expanded.as_ref());
80            return WorkspaceStore::load(root);
81        }
82
83        // 4. Single-workspace auto-select (or error)
84        let workspaces = WorkspaceStore::list()?;
85        Self::resolve_from_list(workspaces)
86    }
87
88    /// Resolve from an already-loaded list of workspaces (no filesystem access).
89    pub fn resolve_from_list(
90        workspaces: Vec<WorkspaceConfig>,
91    ) -> Result<WorkspaceConfig, AppError> {
92        match workspaces.len() {
93            0 => Err(AppError::config(
94                "No workspaces configured. Run 'gisa setup' first.",
95            )),
96            1 => Ok(workspaces
97                .into_iter()
98                .next()
99                .expect("single workspace exists")),
100            _ => {
101                let labels: Vec<String> = workspaces.iter().map(|w| w.display_label()).collect();
102                Err(AppError::config(format!(
103                    "Multiple workspaces configured. Use --workspace <path|name> to select one, \
104                     or set a default with 'gisa workspace default <path|name>': {}",
105                    labels.join(", ")
106                )))
107            }
108        }
109    }
110
111    fn resolve_selector_from_list(
112        selector: &str,
113        workspaces: Vec<WorkspaceConfig>,
114    ) -> Result<WorkspaceConfig, AppError> {
115        let matches: Vec<WorkspaceConfig> = workspaces
116            .into_iter()
117            .filter(|ws| {
118                let folder_name_matches = ws
119                    .root_path
120                    .file_name()
121                    .and_then(|name| name.to_str())
122                    .map(|name| name == selector)
123                    .unwrap_or(false);
124                let path_matches = ws.root_path.to_string_lossy() == selector;
125                let tilde_path_matches = tilde_collapse_path(&ws.root_path) == selector;
126                folder_name_matches || path_matches || tilde_path_matches
127            })
128            .collect();
129
130        match matches.len() {
131            1 => Ok(matches
132                .into_iter()
133                .next()
134                .expect("single selector match exists")),
135            0 => Err(AppError::config("No workspace matched selector")),
136            _ => {
137                let labels: Vec<String> = matches.iter().map(|ws| ws.display_label()).collect();
138                Err(AppError::config(format!(
139                    "Workspace selector '{}' is ambiguous. Use an explicit path instead: {}",
140                    selector,
141                    labels.join(", ")
142                )))
143            }
144        }
145    }
146
147    fn looks_like_path(value: &str) -> bool {
148        value.contains(std::path::MAIN_SEPARATOR)
149            || value.contains('/')
150            || value.starts_with('.')
151            || value.starts_with('~')
152    }
153}
154
155#[cfg(test)]
156#[path = "workspace_policy_tests.rs"]
157mod tests;