Skip to main content

ai_agent/utils/plugins/
reconciler.rs

1// Source: ~/claudecode/openclaudecode/src/utils/plugins/reconciler.ts
2#![allow(dead_code)]
3
4use std::collections::HashMap;
5
6use super::marketplace_manager::{
7    DeclaredMarketplace, add_marketplace_source, get_declared_marketplaces,
8    load_known_marketplaces_config,
9};
10use super::schemas::{MarketplaceSource, is_local_marketplace_source};
11
12/// Result of comparing declared vs materialized marketplaces.
13pub struct MarketplaceDiff {
14    pub missing: Vec<String>,
15    pub source_changed: Vec<SourceChangedEntry>,
16    pub up_to_date: Vec<String>,
17}
18
19pub struct SourceChangedEntry {
20    pub name: String,
21    pub declared_source: MarketplaceSource,
22    pub materialized_source: MarketplaceSource,
23}
24
25/// Compare declared intent (settings) against materialized state (JSON).
26pub fn diff_marketplaces(
27    declared: &HashMap<String, DeclaredMarketplace>,
28    materialized: &HashMap<String, super::types::KnownMarketplace>,
29    _project_root: Option<&str>,
30) -> MarketplaceDiff {
31    let mut missing = Vec::new();
32    let mut source_changed = Vec::new();
33    let mut up_to_date = Vec::new();
34
35    for (name, intent) in declared {
36        let state = materialized.get(name);
37
38        match state {
39            None => missing.push(name.clone()),
40            Some(state_entry) => {
41                if intent.source_is_fallback.unwrap_or(false) {
42                    up_to_date.push(name.clone());
43                } else if sources_equal_serialized(&intent.source, &state_entry.source) {
44                    up_to_date.push(name.clone());
45                } else {
46                    let materialized_source =
47                        plugin_source_to_marketplace_source(&state_entry.source);
48                    source_changed.push(SourceChangedEntry {
49                        name: name.clone(),
50                        declared_source: intent.source.clone(),
51                        materialized_source,
52                    });
53                }
54            }
55        }
56    }
57
58    MarketplaceDiff {
59        missing,
60        source_changed,
61        up_to_date,
62    }
63}
64
65/// Compare two sources by serializing to JSON for deep equality.
66fn sources_equal_serialized(a: &MarketplaceSource, b: &super::types::PluginSource) -> bool {
67    let a_json = serde_json::to_value(a).unwrap_or_default();
68    let b_json = serde_json::to_value(b).unwrap_or_default();
69    a_json == b_json
70}
71
72/// Convert a PluginSource to a MarketplaceSource for diff reporting.
73fn plugin_source_to_marketplace_source(source: &super::types::PluginSource) -> MarketplaceSource {
74    match source {
75        super::types::PluginSource::Relative(path) => MarketplaceSource::Directory {
76            path: path.clone(),
77        },
78        super::types::PluginSource::Github {
79            repo,
80            ref_,
81            path,
82            ..
83        } => MarketplaceSource::Github {
84            repo: repo.clone(),
85            ref_: ref_.clone(),
86            path: path.clone(),
87        },
88        super::types::PluginSource::Git { url, ref_, .. } => MarketplaceSource::Git {
89            url: url.clone(),
90            ref_: ref_.clone(),
91            path: None,
92        },
93        super::types::PluginSource::GitSubdir {
94            repo, subdir, ref_, ..
95        } => MarketplaceSource::GitSubdir {
96            url: repo.clone(),
97            path: subdir.clone(),
98            ref_: ref_.clone(),
99        },
100        super::types::PluginSource::Url { url, .. } => MarketplaceSource::Url {
101            url: url.clone(),
102        },
103        super::types::PluginSource::Npm { package, .. } => MarketplaceSource::Url {
104            url: format!("npm:{}", package),
105        },
106        super::types::PluginSource::Pip { package, .. } => MarketplaceSource::Url {
107            url: format!("pip:{}", package),
108        },
109        super::types::PluginSource::Settings { .. } => MarketplaceSource::Settings {
110            name: String::new(),
111            plugins: Vec::new(),
112        },
113    }
114}
115
116fn _sources_equal(a: &MarketplaceSource, b: &MarketplaceSource) -> bool {
117    a == b
118}
119
120fn _normalize_source(source: &MarketplaceSource, _project_root: Option<&str>) -> MarketplaceSource {
121    source.clone()
122}
123
124fn _find_canonical_git_root(base: &str) -> Option<String> {
125    Some(base.to_string())
126}
127
128/// Progress event for reconciliation.
129pub enum ReconcileProgressEvent {
130    Installing {
131        name: String,
132        action: String,
133        index: usize,
134        total: usize,
135    },
136    Installed {
137        name: String,
138        already_materialized: bool,
139    },
140    Failed {
141        name: String,
142        error: String,
143    },
144}
145
146/// Options for reconciliation.
147pub struct ReconcileOptions {
148    pub skip: Option<Box<dyn Fn(&str, &MarketplaceSource) -> bool>>,
149    pub on_progress: Option<Box<dyn Fn(ReconcileProgressEvent)>>,
150}
151
152/// Result of marketplace reconciliation.
153pub struct ReconcileResult {
154    pub installed: Vec<String>,
155    pub updated: Vec<String>,
156    pub failed: Vec<(String, String)>,
157    pub up_to_date: Vec<String>,
158    pub skipped: Vec<String>,
159}
160
161/// Make known_marketplaces.json consistent with declared intent.
162pub async fn reconcile_marketplaces(
163    opts: Option<ReconcileOptions>,
164) -> Result<ReconcileResult, Box<dyn std::error::Error + Send + Sync>> {
165    let declared = get_declared_marketplaces();
166    if declared.is_empty() {
167        return Ok(ReconcileResult {
168            installed: Vec::new(),
169            updated: Vec::new(),
170            failed: Vec::new(),
171            up_to_date: Vec::new(),
172            skipped: Vec::new(),
173        });
174    }
175
176    let materialized = match load_known_marketplaces_config().await {
177        Ok(m) => m,
178        Err(e) => {
179            log::error!("Failed to load known marketplaces config: {}", e);
180            HashMap::new()
181        }
182    };
183
184    let diff = diff_marketplaces(&declared, &materialized, None);
185
186    let mut work = Vec::new();
187
188    for name in &diff.missing {
189        if let Some(intent) = declared.get(name) {
190            work.push((name.clone(), intent.source.clone(), "install".to_string()));
191        }
192    }
193
194    for entry in diff.source_changed {
195        work.push((
196            entry.name.clone(),
197            entry.declared_source,
198            "update".to_string(),
199        ));
200    }
201
202    let mut skipped = Vec::new();
203    let mut to_process = Vec::new();
204
205    for (name, source, action) in work {
206        if let Some(skip_fn) = opts.as_ref().and_then(|o| o.skip.as_ref()) {
207            if skip_fn(&name, &source) {
208                skipped.push(name);
209                continue;
210            }
211        }
212
213        if action == "update" && is_local_marketplace_source(&source) {
214            skipped.push(name);
215            continue;
216        }
217
218        to_process.push((name, source, action));
219    }
220
221    if to_process.is_empty() {
222        return Ok(ReconcileResult {
223            installed: Vec::new(),
224            updated: Vec::new(),
225            failed: Vec::new(),
226            up_to_date: diff.up_to_date,
227            skipped,
228        });
229    }
230
231    let mut installed = Vec::new();
232    let mut updated = Vec::new();
233    let mut failed = Vec::new();
234
235    for (i, (name, source, action)) in to_process.iter().enumerate() {
236        if let Some(on_progress) = opts.as_ref().and_then(|o| o.on_progress.as_ref()) {
237            on_progress(ReconcileProgressEvent::Installing {
238                name: name.clone(),
239                action: action.clone(),
240                index: i + 1,
241                total: to_process.len(),
242            });
243        }
244
245        match add_marketplace_source(source).await {
246            Ok(result) => {
247                if action == "install" {
248                    installed.push(name.clone());
249                } else {
250                    updated.push(name.clone());
251                }
252
253                if let Some(on_progress) = opts.as_ref().and_then(|o| o.on_progress.as_ref()) {
254                    on_progress(ReconcileProgressEvent::Installed {
255                        name: name.clone(),
256                        already_materialized: result.already_materialized,
257                    });
258                }
259            }
260            Err(e) => {
261                let error = e.to_string();
262                failed.push((name.clone(), error.clone()));
263
264                if let Some(on_progress) = opts.as_ref().and_then(|o| o.on_progress.as_ref()) {
265                    on_progress(ReconcileProgressEvent::Failed {
266                        name: name.clone(),
267                        error,
268                    });
269                }
270
271                log::error!("Failed to reconcile marketplace {}: {}", name, e);
272            }
273        }
274    }
275
276    Ok(ReconcileResult {
277        installed,
278        updated,
279        failed,
280        up_to_date: diff.up_to_date,
281        skipped,
282    })
283}
284
285async fn _path_exists(source: &MarketplaceSource) -> bool {
286    if let MarketplaceSource::Directory { path } | MarketplaceSource::File { path } = source {
287        tokio::fs::metadata(path).await.is_ok()
288    } else {
289        true
290    }
291}