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 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 unenforced_rules: Vec::new(),
163 ownership: lockfile::TargetOwnership::Generated,
164 sha256: String::new(),
165 installed_path: String::new(),
166 },
167 )]),
168 implementation: None,
169 parameters: None,
170 workflow: None,
171 server: None,
172 },
173 );
174 }
175 lockfile::write_lockfile_at(path, &lf).unwrap();
176 }
177
178 #[test]
179 fn scope_from_str_valid() {
180 assert_eq!(Scope::parse("project"), Some(Scope::Project));
181 assert_eq!(Scope::parse("global"), Some(Scope::Global));
182 assert_eq!(Scope::parse("invalid"), None);
183 }
184
185 #[test]
186 fn scope_as_str() {
187 assert_eq!(Scope::Project.as_str(), "project");
188 assert_eq!(Scope::Global.as_str(), "global");
189 }
190
191 #[test]
192 fn resolve_entry_project_wins_over_global() {
193 let tmp = TempDir::new().unwrap();
194 let proj_lock = tmp.path().join("tuff.lock");
195 create_lockfile(&proj_lock, &[("test", "skill", "1.0-project")]);
196
197 let result = read_lockfile(Scope::Project, Some(tmp.path())).unwrap();
200 assert!(result.is_some());
201 let lf = result.unwrap();
202 assert_eq!(lf.capabilities["test"].version, "1.0-project");
203 }
204
205 #[test]
206 fn resolve_entry_not_found_returns_none() {
207 let tmp = TempDir::new().unwrap();
208 let proj_lock = tmp.path().join("tuff.lock");
209 create_lockfile(&proj_lock, &[("other", "skill", "1.0")]);
210
211 let result = resolve_entry("missing", tmp.path()).unwrap();
212 assert!(result.is_none());
213 }
214
215 #[test]
216 fn overrides_global_false_when_no_conflict() {
217 let tmp = TempDir::new().unwrap();
218 let proj_lock = tmp.path().join("tuff.lock");
219 create_lockfile(&proj_lock, &[("test", "skill", "1.0")]);
220
221 assert!(!overrides_global("test", tmp.path()).unwrap());
222 }
223
224 #[test]
225 fn check_collision_no_global_returns_none() {
226 let tmp = TempDir::new().unwrap();
227 let result = check_collision("test", tmp.path(), Some("https://github.com/a/b")).unwrap();
228 assert!(result.is_none());
229 }
230}