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
79/// The identity `check_collision` uses for a catalog install: there is no
80/// URL, and one catalog entry installed twice is the same source.
81pub const CATALOG_SOURCE_IDENTITY: &str = "catalog";
82
83pub fn check_collision(
84    id: &str,
85    _repo_root: &Path,
86    new_source_url: Option<&str>,
87) -> Result<Option<String>> {
88    let Some(global_lf) = read_lockfile(Scope::Global, None)? else {
89        return Ok(None);
90    };
91
92    let Some(global_entry) = global_lf.capabilities.get(id) else {
93        return Ok(None);
94    };
95
96    let global_source = match &global_entry.source {
97        lockfile::CapabilitySource::Git(git) => Some(git.url.as_str()),
98        lockfile::CapabilitySource::Catalog(_) => Some(CATALOG_SOURCE_IDENTITY),
99        lockfile::CapabilitySource::Local(_) | lockfile::CapabilitySource::Pack(_) => None,
100    };
101
102    match (new_source_url, global_source) {
103        (Some(new_url), Some(global_url)) if new_url != global_url => Ok(Some(format!(
104            "note: '{}' is already installed globally from a different source ({}). \
105                 The project copy will take precedence and the global copy will be shadowed.",
106            id, global_url
107        ))),
108        (None, Some(global_url)) => Ok(Some(format!(
109            "note: '{}' is already installed globally from {}. \
110                 The project copy will take precedence and the global copy will be shadowed.",
111            id, global_url
112        ))),
113        _ => Ok(None),
114    }
115}
116
117#[cfg(unix)]
118fn dirs_home() -> Option<PathBuf> {
119    std::env::var("HOME").ok().map(PathBuf::from)
120}
121
122#[cfg(windows)]
123fn dirs_home() -> Option<PathBuf> {
124    std::env::var("USERPROFILE")
125        .ok()
126        .map(PathBuf::from)
127        .or_else(|| {
128            let drive = std::env::var("HOMEDRIVE").ok()?;
129            let path = std::env::var("HOMEPATH").ok()?;
130            Some(PathBuf::from(format!("{}{}", drive, path)))
131        })
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137    use crate::lockfile;
138    use crate::manifest::CapabilityType;
139    use tempfile::TempDir;
140
141    fn create_lockfile(path: &std::path::Path, entries: &[(&str, &str, &str)]) {
142        let mut lf = lockfile::Lockfile {
143            version: lockfile::LOCKFILE_VERSION,
144            capabilities: std::collections::BTreeMap::new(),
145        };
146        for (id, capability_type, version) in entries {
147            let ct = CapabilityType::parse(capability_type).unwrap_or(CapabilityType::Skill);
148            lf.capabilities.insert(
149                id.to_string(),
150                lockfile::CapabilityLockEntry {
151                    capability_type: ct,
152                    version: version.to_string(),
153                    version_scheme: lockfile::VersionScheme::Declared,
154                    description: String::new(),
155                    source: lockfile::CapabilitySource::local(""),
156                    targets: std::collections::BTreeMap::from([(
157                        "open-agents".to_string(),
158                        lockfile::TargetLockEntry {
159                            managed_hooks: Vec::new(),
160                            managed_mcp_entry: None,
161                            managed_permissions: Vec::new(),
162                            ownership: lockfile::TargetOwnership::Generated,
163                            sha256: String::new(),
164                            installed_path: String::new(),
165                        },
166                    )]),
167                    implementation: None,
168                    parameters: None,
169                    workflow: None,
170                    server: None,
171                },
172            );
173        }
174        lockfile::write_lockfile_at(path, &lf).unwrap();
175    }
176
177    #[test]
178    fn scope_from_str_valid() {
179        assert_eq!(Scope::parse("project"), Some(Scope::Project));
180        assert_eq!(Scope::parse("global"), Some(Scope::Global));
181        assert_eq!(Scope::parse("invalid"), None);
182    }
183
184    #[test]
185    fn scope_as_str() {
186        assert_eq!(Scope::Project.as_str(), "project");
187        assert_eq!(Scope::Global.as_str(), "global");
188    }
189
190    #[test]
191    fn resolve_entry_project_wins_over_global() {
192        let tmp = TempDir::new().unwrap();
193        let proj_lock = tmp.path().join("tuff.lock");
194        create_lockfile(&proj_lock, &[("test", "skill", "1.0-project")]);
195
196        // Create a fake global lockfile that won't be checked
197        // since we can't easily control HOME in tests, test project-only path
198        let result = read_lockfile(Scope::Project, Some(tmp.path())).unwrap();
199        assert!(result.is_some());
200        let lf = result.unwrap();
201        assert_eq!(lf.capabilities["test"].version, "1.0-project");
202    }
203
204    #[test]
205    fn resolve_entry_not_found_returns_none() {
206        let tmp = TempDir::new().unwrap();
207        let proj_lock = tmp.path().join("tuff.lock");
208        create_lockfile(&proj_lock, &[("other", "skill", "1.0")]);
209
210        let result = resolve_entry("missing", tmp.path()).unwrap();
211        assert!(result.is_none());
212    }
213
214    #[test]
215    fn overrides_global_false_when_no_conflict() {
216        let tmp = TempDir::new().unwrap();
217        let proj_lock = tmp.path().join("tuff.lock");
218        create_lockfile(&proj_lock, &[("test", "skill", "1.0")]);
219
220        assert!(!overrides_global("test", tmp.path()).unwrap());
221    }
222
223    #[test]
224    fn check_collision_no_global_returns_none() {
225        let tmp = TempDir::new().unwrap();
226        let result = check_collision("test", tmp.path(), Some("https://github.com/a/b")).unwrap();
227        assert!(result.is_none());
228    }
229}