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                },
160            );
161        }
162        lockfile::write_lockfile_at(path, &lf).unwrap();
163    }
164
165    #[test]
166    fn scope_from_str_valid() {
167        assert_eq!(Scope::parse("project"), Some(Scope::Project));
168        assert_eq!(Scope::parse("global"), Some(Scope::Global));
169        assert_eq!(Scope::parse("invalid"), None);
170    }
171
172    #[test]
173    fn scope_as_str() {
174        assert_eq!(Scope::Project.as_str(), "project");
175        assert_eq!(Scope::Global.as_str(), "global");
176    }
177
178    #[test]
179    fn resolve_entry_project_wins_over_global() {
180        let tmp = TempDir::new().unwrap();
181        let proj_lock = tmp.path().join("tuff.lock");
182        create_lockfile(&proj_lock, &[("test", "skill", "1.0-project")]);
183
184        // Create a fake global lockfile that won't be checked
185        // since we can't easily control HOME in tests, test project-only path
186        let result = read_lockfile(Scope::Project, Some(tmp.path())).unwrap();
187        assert!(result.is_some());
188        let lf = result.unwrap();
189        assert_eq!(lf.capabilities["test"].installed_version, "1.0-project");
190    }
191
192    #[test]
193    fn resolve_entry_not_found_returns_none() {
194        let tmp = TempDir::new().unwrap();
195        let proj_lock = tmp.path().join("tuff.lock");
196        create_lockfile(&proj_lock, &[("other", "skill", "1.0")]);
197
198        let result = resolve_entry("missing", tmp.path()).unwrap();
199        assert!(result.is_none());
200    }
201
202    #[test]
203    fn overrides_global_false_when_no_conflict() {
204        let tmp = TempDir::new().unwrap();
205        let proj_lock = tmp.path().join("tuff.lock");
206        create_lockfile(&proj_lock, &[("test", "skill", "1.0")]);
207
208        assert!(!overrides_global("test", tmp.path()).unwrap());
209    }
210
211    #[test]
212    fn check_collision_no_global_returns_none() {
213        let tmp = TempDir::new().unwrap();
214        let result = check_collision("test", tmp.path(), Some("https://github.com/a/b")).unwrap();
215        assert!(result.is_none());
216    }
217}