Skip to main content

tuff_core/
resolver.rs

1use std::path::{Path, PathBuf};
2
3use crate::error::Result;
4use crate::lockfile;
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum Scope {
8    Project,
9    Global,
10}
11
12impl Scope {
13    pub fn as_str(&self) -> &'static str {
14        match self {
15            Self::Project => "project",
16            Self::Global => "global",
17        }
18    }
19
20    pub fn parse(s: &str) -> Option<Self> {
21        match s {
22            "project" => Some(Self::Project),
23            "global" => Some(Self::Global),
24            _ => None,
25        }
26    }
27}
28
29pub fn lockfile_path_for(scope: Scope, repo_root: Option<&Path>) -> Option<PathBuf> {
30    match scope {
31        Scope::Project => repo_root.map(|root| root.join("tuff.lock")),
32        Scope::Global => dirs_home().map(|home| crate::paths::global_lockfile(&home)),
33    }
34}
35
36pub fn read_lockfile(scope: Scope, repo_root: Option<&Path>) -> Result<Option<lockfile::Lockfile>> {
37    let path = match lockfile_path_for(scope, repo_root) {
38        Some(p) => p,
39        None => return Ok(None),
40    };
41    if path.exists() {
42        Ok(Some(lockfile::read_lockfile_at(&path)?))
43    } else {
44        Ok(None)
45    }
46}
47
48pub fn resolve_entry(
49    id: &str,
50    repo_root: &Path,
51) -> Result<Option<(Scope, lockfile::CapabilityLockEntry)>> {
52    if let Some(project_lf) = read_lockfile(Scope::Project, Some(repo_root))?
53        && let Some(entry) = project_lf.capabilities.get(id)
54    {
55        return Ok(Some((Scope::Project, entry.clone())));
56    }
57
58    if let Some(global_lf) = read_lockfile(Scope::Global, None)?
59        && let Some(entry) = global_lf.capabilities.get(id)
60    {
61        return Ok(Some((Scope::Global, entry.clone())));
62    }
63
64    Ok(None)
65}
66
67pub fn overrides_global(id: &str, repo_root: &Path) -> Result<bool> {
68    let project_exists = read_lockfile(Scope::Project, Some(repo_root))?
69        .map(|lf| lf.capabilities.contains_key(id))
70        .unwrap_or(false);
71
72    let global_exists = read_lockfile(Scope::Global, None)?
73        .map(|lf| lf.capabilities.contains_key(id))
74        .unwrap_or(false);
75
76    Ok(project_exists && global_exists)
77}
78
79pub fn check_collision(
80    id: &str,
81    _repo_root: &Path,
82    new_source_url: Option<&str>,
83) -> Result<Option<String>> {
84    let Some(global_lf) = read_lockfile(Scope::Global, None)? else {
85        return Ok(None);
86    };
87
88    let Some(global_entry) = global_lf.capabilities.get(id) else {
89        return Ok(None);
90    };
91
92    let global_source = global_entry.source.as_ref().map(|s| s.url.as_str());
93
94    match (new_source_url, global_source) {
95        (Some(new_url), Some(global_url)) if new_url != global_url => Ok(Some(format!(
96            "note: '{}' is already installed globally from a different source ({}). \
97                 The project copy will take precedence and the global copy will be shadowed.",
98            id, global_url
99        ))),
100        (None, Some(global_url)) => Ok(Some(format!(
101            "note: '{}' is already installed globally from {}. \
102                 The project copy will take precedence and the global copy will be shadowed.",
103            id, global_url
104        ))),
105        _ => Ok(None),
106    }
107}
108
109#[cfg(unix)]
110fn dirs_home() -> Option<PathBuf> {
111    std::env::var("HOME").ok().map(PathBuf::from)
112}
113
114#[cfg(windows)]
115fn dirs_home() -> Option<PathBuf> {
116    std::env::var("USERPROFILE")
117        .ok()
118        .map(PathBuf::from)
119        .or_else(|| {
120            let drive = std::env::var("HOMEDRIVE").ok()?;
121            let path = std::env::var("HOMEPATH").ok()?;
122            Some(PathBuf::from(format!("{}{}", drive, path)))
123        })
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129    use crate::lockfile;
130    use crate::manifest::CapabilityType;
131    use tempfile::TempDir;
132
133    fn create_lockfile(path: &std::path::Path, entries: &[(&str, &str, &str)]) {
134        let mut lf = lockfile::Lockfile {
135            version: lockfile::LOCKFILE_VERSION,
136            capabilities: std::collections::BTreeMap::new(),
137        };
138        for (id, capability_type, version) in entries {
139            let ct = CapabilityType::parse(capability_type).unwrap_or(CapabilityType::Skill);
140            lf.capabilities.insert(
141                id.to_string(),
142                lockfile::CapabilityLockEntry {
143                    capability_type: ct,
144                    installed_version: version.to_string(),
145                    description: String::new(),
146                    source_path: "".into(),
147                    targets: std::collections::BTreeMap::from([(
148                        "open-agents".to_string(),
149                        lockfile::TargetLockEntry {
150                            emitted_files: Vec::new(),
151                            managed_hooks: Vec::new(),
152                            ownership: lockfile::TargetOwnership::Generated,
153                            sha256: String::new(),
154                            installed_path: String::new(),
155                        },
156                    )]),
157                    source: None,
158                    scope: "project".into(),
159                    pack: None,
160                },
161            );
162        }
163        lockfile::write_lockfile_at(path, &lf).unwrap();
164    }
165
166    #[test]
167    fn scope_from_str_valid() {
168        assert_eq!(Scope::parse("project"), Some(Scope::Project));
169        assert_eq!(Scope::parse("global"), Some(Scope::Global));
170        assert_eq!(Scope::parse("invalid"), None);
171    }
172
173    #[test]
174    fn scope_as_str() {
175        assert_eq!(Scope::Project.as_str(), "project");
176        assert_eq!(Scope::Global.as_str(), "global");
177    }
178
179    #[test]
180    fn resolve_entry_project_wins_over_global() {
181        let tmp = TempDir::new().unwrap();
182        let proj_lock = tmp.path().join("tuff.lock");
183        create_lockfile(&proj_lock, &[("test", "skill", "1.0-project")]);
184
185        // Create a fake global lockfile that won't be checked
186        // since we can't easily control HOME in tests, test project-only path
187        let result = read_lockfile(Scope::Project, Some(tmp.path())).unwrap();
188        assert!(result.is_some());
189        let lf = result.unwrap();
190        assert_eq!(lf.capabilities["test"].installed_version, "1.0-project");
191    }
192
193    #[test]
194    fn resolve_entry_not_found_returns_none() {
195        let tmp = TempDir::new().unwrap();
196        let proj_lock = tmp.path().join("tuff.lock");
197        create_lockfile(&proj_lock, &[("other", "skill", "1.0")]);
198
199        let result = resolve_entry("missing", tmp.path()).unwrap();
200        assert!(result.is_none());
201    }
202
203    #[test]
204    fn overrides_global_false_when_no_conflict() {
205        let tmp = TempDir::new().unwrap();
206        let proj_lock = tmp.path().join("tuff.lock");
207        create_lockfile(&proj_lock, &[("test", "skill", "1.0")]);
208
209        assert!(!overrides_global("test", tmp.path()).unwrap());
210    }
211
212    #[test]
213    fn check_collision_no_global_returns_none() {
214        let tmp = TempDir::new().unwrap();
215        let result = check_collision("test", tmp.path(), Some("https://github.com/a/b")).unwrap();
216        assert!(result.is_none());
217    }
218}