Skip to main content

ai_agent/utils/plugins/
dependency_resolver.rs

1// Source: ~/claudecode/openclaudecode/src/utils/plugins/dependencyResolver.ts
2#![allow(dead_code)]
3
4use std::collections::{HashMap, HashSet};
5
6use super::plugin_identifier::parse_plugin_identifier;
7use super::types::PluginId;
8
9/// Synthetic marketplace sentinel for --plugin-dir plugins.
10const INLINE_MARKETPLACE: &str = "inline";
11
12/// Normalize a dependency reference to fully-qualified "name@marketplace" form.
13pub fn qualify_dependency(dep: &str, declaring_plugin_id: &str) -> String {
14    let parsed = parse_plugin_identifier(dep);
15    if parsed.marketplace.is_some() {
16        return dep.to_string();
17    }
18    let declaring = parse_plugin_identifier(declaring_plugin_id);
19    match declaring.marketplace {
20        Some(ref m) if m == INLINE_MARKETPLACE => dep.to_string(),
21        None => dep.to_string(),
22        Some(ref mkt) => format!("{}@{}", dep, mkt),
23    }
24}
25
26/// Minimal shape the resolver needs from a marketplace lookup.
27pub struct DependencyLookupResult {
28    pub dependencies: Option<Vec<String>>,
29}
30
31/// Result of dependency resolution.
32pub enum ResolutionResult {
33    Ok {
34        closure: Vec<PluginId>,
35    },
36    Cycle {
37        chain: Vec<PluginId>,
38    },
39    NotFound {
40        missing: PluginId,
41        required_by: PluginId,
42    },
43    CrossMarketplace {
44        dependency: PluginId,
45        required_by: PluginId,
46    },
47}
48
49impl ResolutionResult {
50    pub fn is_ok(&self) -> bool {
51        matches!(self, ResolutionResult::Ok { .. })
52    }
53}
54
55/// Walk the transitive dependency closure of `root_id` via DFS.
56pub async fn resolve_dependency_closure<F, Fut>(
57    root_id: &PluginId,
58    lookup: F,
59    already_enabled: &HashSet<PluginId>,
60    allowed_cross_marketplaces: &HashSet<String>,
61) -> ResolutionResult
62where
63    F: Fn(PluginId) -> Fut,
64    Fut: std::future::Future<Output = Option<DependencyLookupResult>>,
65{
66    let root_marketplace = parse_plugin_identifier(root_id).marketplace;
67    let mut closure: Vec<PluginId> = Vec::new();
68    let mut visited: HashSet<PluginId> = HashSet::new();
69    let mut stack: Vec<PluginId> = Vec::new();
70
71    async fn walk<F, Fut>(
72        id: PluginId,
73        required_by: PluginId,
74        root_id: &PluginId,
75        root_marketplace: Option<&str>,
76        already_enabled: &HashSet<PluginId>,
77        allowed_cross_marketplaces: &HashSet<String>,
78        visited: &mut HashSet<PluginId>,
79        stack: &mut Vec<PluginId>,
80        closure: &mut Vec<PluginId>,
81        lookup: &F,
82    ) -> Option<ResolutionResult>
83    where
84        F: Fn(PluginId) -> Fut,
85        Fut: std::future::Future<Output = Option<DependencyLookupResult>>,
86    {
87        // Skip already-enabled dependencies, but never skip the root
88        if id != *root_id && already_enabled.contains(&id) {
89            return None;
90        }
91
92        // Security: block auto-install across marketplace boundaries
93        let id_marketplace = parse_plugin_identifier(&id).marketplace;
94        if let (Some(id_mkt), Some(root_mkt)) = (id_marketplace.as_deref(), root_marketplace) {
95            if id_mkt != root_mkt && !allowed_cross_marketplaces.contains(id_mkt) {
96                return Some(ResolutionResult::CrossMarketplace {
97                    dependency: id.clone(),
98                    required_by,
99                });
100            }
101        }
102
103        if stack.contains(&id) {
104            return Some(ResolutionResult::Cycle {
105                chain: {
106                    let mut c = stack.clone();
107                    c.push(id.clone());
108                    c
109                },
110            });
111        }
112
113        if visited.contains(&id) {
114            return None;
115        }
116        visited.insert(id.clone());
117
118        let entry = lookup(id.clone()).await;
119        let entry = match entry {
120            Some(e) => e,
121            None => {
122                return Some(ResolutionResult::NotFound {
123                    missing: id,
124                    required_by,
125                });
126            }
127        };
128
129        stack.push(id.clone());
130        for raw_dep in entry.dependencies.unwrap_or_default() {
131            let dep = qualify_dependency(&raw_dep, &id);
132            if let Some(err) = walk(
133                dep,
134                id.clone(),
135                root_id,
136                root_marketplace,
137                already_enabled,
138                allowed_cross_marketplaces,
139                visited,
140                stack,
141                closure,
142                lookup,
143            )
144            .await
145            {
146                return Some(err);
147            }
148        }
149        stack.pop();
150
151        closure.push(id);
152        None
153    }
154
155    let result = walk(
156        root_id.clone(),
157        root_id.clone(),
158        root_id,
159        root_marketplace.as_deref(),
160        already_enabled,
161        allowed_cross_marketplaces,
162        &mut visited,
163        &mut stack,
164        &mut closure,
165        &lookup,
166    )
167    .await;
168
169    match result {
170        Some(err) => err,
171        None => ResolutionResult::Ok { closure },
172    }
173}
174
175/// Result from verify_and_demote: demoted plugins and their errors.
176pub struct VerifyAndDemoteResult {
177    pub demoted: HashSet<String>,
178    pub errors: Vec<PluginError>,
179}
180
181/// Plugin error types.
182pub enum PluginError {
183    DependencyUnsatisfied {
184        source: String,
185        plugin: String,
186        dependency: String,
187        reason: String, // "not-enabled" or "not-found"
188    },
189    // Other error variants omitted for brevity
190}
191
192pub struct LoadedPlugin {
193    pub source: String,
194    pub enabled: bool,
195    pub name: String,
196    pub manifest: PluginManifest,
197}
198
199pub struct PluginManifest {
200    pub dependencies: Option<Vec<String>>,
201}
202
203/// Load-time safety net: verify all manifest dependencies are also in the enabled set.
204pub fn verify_and_demote(plugins: &[LoadedPlugin]) -> VerifyAndDemoteResult {
205    let known: HashSet<_> = plugins.iter().map(|p| p.source.clone()).collect();
206    let enabled: HashSet<_> = plugins
207        .iter()
208        .filter(|p| p.enabled)
209        .map(|p| p.source.clone())
210        .collect();
211
212    let known_by_name: HashSet<_> = plugins
213        .iter()
214        .map(|p| parse_plugin_identifier(&p.source).name.clone())
215        .collect();
216
217    let mut enabled_by_name: HashMap<String, i32> = HashMap::new();
218    for id in &enabled {
219        let n = parse_plugin_identifier(id).name;
220        *enabled_by_name.entry(n).or_insert(0) += 1;
221    }
222
223    let mut errors = Vec::new();
224    let mut current_enabled = enabled.clone();
225    let mut changed = true;
226
227    while changed {
228        changed = false;
229        for p in plugins {
230            if !current_enabled.contains(&p.source) {
231                continue;
232            }
233            for raw_dep in p.manifest.dependencies.iter().flatten() {
234                let dep = qualify_dependency(raw_dep, &p.source);
235                let is_bare = parse_plugin_identifier(&dep).marketplace.is_none();
236                let satisfied = if is_bare {
237                    enabled_by_name.get(&dep).copied().unwrap_or(0) > 0
238                } else {
239                    current_enabled.contains(&dep)
240                };
241
242                if !satisfied {
243                    current_enabled.remove(&p.source);
244                    let count = enabled_by_name.get(&p.name).copied().unwrap_or(0);
245                    if count <= 1 {
246                        enabled_by_name.remove(&p.name);
247                    } else {
248                        enabled_by_name.insert(p.name.clone(), count - 1);
249                    }
250                    errors.push(PluginError::DependencyUnsatisfied {
251                        source: p.source.clone(),
252                        plugin: p.name.clone(),
253                        dependency: dep.clone(),
254                        reason: if (is_bare && known_by_name.contains(&dep)) || known.contains(&dep)
255                        {
256                            "not-enabled".to_string()
257                        } else {
258                            "not-found".to_string()
259                        },
260                    });
261                    changed = true;
262                    break;
263                }
264            }
265        }
266    }
267
268    let demoted: HashSet<_> = plugins
269        .iter()
270        .filter(|p| p.enabled && !current_enabled.contains(&p.source))
271        .map(|p| p.source.clone())
272        .collect();
273
274    VerifyAndDemoteResult { demoted, errors }
275}
276
277/// Find all enabled plugins that declare `plugin_id` as a dependency.
278pub fn find_reverse_dependents(plugin_id: &PluginId, plugins: &[LoadedPlugin]) -> Vec<String> {
279    let target_name = parse_plugin_identifier(plugin_id).name;
280    plugins
281        .iter()
282        .filter(|p| {
283            p.enabled
284                && p.source != *plugin_id
285                && p.manifest.dependencies.iter().flatten().any(|d| {
286                    let qualified = qualify_dependency(d, &p.source);
287                    if parse_plugin_identifier(&qualified).marketplace.is_some() {
288                        qualified == *plugin_id
289                    } else {
290                        qualified == target_name
291                    }
292                })
293        })
294        .map(|p| p.name.clone())
295        .collect()
296}
297
298/// Format the "(+ N dependencies)" suffix for install success messages.
299pub fn format_dependency_count_suffix(installed_deps: &[String]) -> String {
300    if installed_deps.is_empty() {
301        return String::new();
302    }
303    let n = installed_deps.len();
304    format!(
305        " (+ {} {})",
306        n,
307        if n == 1 { "dependency" } else { "dependencies" }
308    )
309}
310
311/// Format the "warning: required by X, Y" suffix.
312pub fn format_reverse_dependents_suffix(rdeps: Option<&[String]>) -> String {
313    match rdeps {
314        Some(d) if !d.is_empty() => {
315            format!(" — warning: required by {}", d.join(", "))
316        }
317        _ => String::new(),
318    }
319}