algocline_app/service/
pkg.rs1use std::collections::HashMap;
2use std::path::Path;
3
4use super::path::{copy_dir, ContainedPath};
5use super::resolve::{
6 install_scenarios_from_dir, is_system_package, packages_dir, scenarios_dir, DirEntryFailures,
7 AUTO_INSTALL_SOURCES,
8};
9use super::AppService;
10
11impl AppService {
12 pub async fn pkg_list(&self) -> Result<String, String> {
19 let mut seen: HashMap<String, Vec<(usize, String)>> = HashMap::new();
22 let mut all_packages: Vec<serde_json::Value> = Vec::new();
23
24 for (idx, sp) in self.search_paths.iter().enumerate() {
25 if !sp.path.is_dir() {
26 continue;
27 }
28 let entries = match std::fs::read_dir(&sp.path) {
29 Ok(e) => e,
30 Err(_) => continue,
31 };
32
33 for entry in entries.flatten() {
34 let path = entry.path();
35 if !path.is_dir() {
36 continue;
37 }
38 if !path.join("init.lua").exists() {
39 continue;
40 }
41 let name = entry.file_name().to_string_lossy().to_string();
42 if is_system_package(&name) {
43 continue;
44 }
45
46 let source_display = sp.path.display().to_string();
47 seen.entry(name.clone())
48 .or_default()
49 .push((idx, source_display.clone()));
50
51 let occurrences = &seen[&name];
52 let active = occurrences.len() == 1; let code = format!(
55 r#"local pkg = require("{name}")
56return pkg.meta or {{ name = "{name}" }}"#
57 );
58 let mut pkg_json = match self.executor.eval_simple(code).await {
59 Ok(meta) => meta,
60 Err(_) => serde_json::json!({ "name": name, "error": "failed to load meta" }),
61 };
62
63 if let Some(obj) = pkg_json.as_object_mut() {
64 obj.insert(
65 "source".to_string(),
66 serde_json::Value::String(source_display),
67 );
68 obj.insert("active".to_string(), serde_json::Value::Bool(active));
69 }
70
71 all_packages.push(pkg_json);
72 }
73 }
74
75 for pkg in &mut all_packages {
77 let Some(obj) = pkg.as_object_mut() else {
78 continue;
79 };
80 let is_active = obj.get("active").and_then(|v| v.as_bool()).unwrap_or(false);
81 if !is_active {
82 continue;
83 }
84 let Some(name) = obj.get("name").and_then(|v| v.as_str()) else {
85 continue;
86 };
87 if let Some(occurrences) = seen.get(name) {
88 if occurrences.len() > 1 {
89 let overridden: Vec<&str> = occurrences
91 .iter()
92 .skip(1)
93 .map(|(_, s)| s.as_str())
94 .collect();
95 obj.insert("overrides".to_string(), serde_json::json!(overridden));
96 }
97 }
98 }
99
100 let search_paths_json: Vec<serde_json::Value> = self
101 .search_paths
102 .iter()
103 .map(|sp| {
104 serde_json::json!({
105 "path": sp.path.display().to_string(),
106 "source": sp.source.to_string(),
107 })
108 })
109 .collect();
110
111 Ok(serde_json::json!({
112 "packages": all_packages,
113 "search_paths": search_paths_json,
114 })
115 .to_string())
116 }
117
118 pub async fn pkg_install(&self, url: String, name: Option<String>) -> Result<String, String> {
120 let pkg_dir = packages_dir()?;
121 let _ = std::fs::create_dir_all(&pkg_dir);
122
123 let local_path = Path::new(&url);
125 if local_path.is_absolute() && local_path.is_dir() {
126 return self.install_from_local_path(local_path, &pkg_dir, name);
127 }
128
129 let git_url = if url.starts_with("http://")
131 || url.starts_with("https://")
132 || url.starts_with("file://")
133 || url.starts_with("git@")
134 {
135 url.clone()
136 } else {
137 format!("https://{url}")
138 };
139
140 let staging = tempfile::tempdir().map_err(|e| format!("Failed to create temp dir: {e}"))?;
142
143 let output = tokio::process::Command::new("git")
144 .args([
145 "clone",
146 "--depth",
147 "1",
148 &git_url,
149 &staging.path().to_string_lossy(),
150 ])
151 .output()
152 .await
153 .map_err(|e| format!("Failed to run git: {e}"))?;
154
155 if !output.status.success() {
156 let stderr = String::from_utf8_lossy(&output.stderr);
157 return Err(format!("git clone failed: {stderr}"));
158 }
159
160 let _ = std::fs::remove_dir_all(staging.path().join(".git"));
162
163 if staging.path().join("init.lua").exists() {
165 let name = name.unwrap_or_else(|| {
167 url.trim_end_matches('/')
168 .rsplit('/')
169 .next()
170 .unwrap_or("unknown")
171 .trim_end_matches(".git")
172 .to_string()
173 });
174
175 let dest = ContainedPath::child(&pkg_dir, &name)?;
176 if dest.as_ref().exists() {
177 return Err(format!(
178 "Package '{name}' already exists at {}. Remove it first.",
179 dest.as_ref().display()
180 ));
181 }
182
183 copy_dir(staging.path(), dest.as_ref())
184 .map_err(|e| format!("Failed to copy package: {e}"))?;
185
186 Ok(serde_json::json!({
187 "installed": [name],
188 "mode": "single",
189 })
190 .to_string())
191 } else {
192 if name.is_some() {
194 return Err(
196 "The 'name' parameter is only supported for single-package repos (init.lua at root). \
197 This repository is a collection (subdirs with init.lua)."
198 .to_string(),
199 );
200 }
201
202 let mut installed = Vec::new();
203 let mut skipped = Vec::new();
204
205 let entries = std::fs::read_dir(staging.path())
206 .map_err(|e| format!("Failed to read staging dir: {e}"))?;
207
208 for entry in entries {
209 let entry = entry.map_err(|e| format!("Failed to read entry: {e}"))?;
210 let path = entry.path();
211 if !path.is_dir() {
212 continue;
213 }
214 if !path.join("init.lua").exists() {
215 continue;
216 }
217 let pkg_name = entry.file_name().to_string_lossy().to_string();
218 let dest = pkg_dir.join(&pkg_name);
219 if dest.exists() {
220 skipped.push(pkg_name);
221 continue;
222 }
223 copy_dir(&path, &dest)
224 .map_err(|e| format!("Failed to copy package '{pkg_name}': {e}"))?;
225 installed.push(pkg_name);
226 }
227
228 let scenarios_subdir = staging.path().join("scenarios");
232 let mut scenarios_installed: Vec<String> = Vec::new();
233 let mut scenarios_failures: DirEntryFailures = Vec::new();
234 if scenarios_subdir.is_dir() {
235 if let Ok(sc_dir) = scenarios_dir() {
236 std::fs::create_dir_all(&sc_dir)
237 .map_err(|e| format!("Failed to create scenarios dir: {e}"))?;
238 if let Ok(result) = install_scenarios_from_dir(&scenarios_subdir, &sc_dir) {
239 if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&result) {
240 if let Some(arr) = parsed.get("installed").and_then(|v| v.as_array()) {
241 scenarios_installed = arr
242 .iter()
243 .filter_map(|v| v.as_str().map(String::from))
244 .collect();
245 }
246 if let Some(arr) = parsed.get("failures").and_then(|v| v.as_array()) {
247 scenarios_failures = arr
248 .iter()
249 .filter_map(|v| v.as_str().map(String::from))
250 .collect();
251 }
252 }
253 }
254 }
255 }
256
257 if installed.is_empty() && skipped.is_empty() {
258 return Err(
259 "No packages found. Expected init.lua at root (single) or */init.lua (collection)."
260 .to_string(),
261 );
262 }
263
264 Ok(serde_json::json!({
265 "installed": installed,
266 "skipped": skipped,
267 "scenarios_installed": scenarios_installed,
268 "scenarios_failures": scenarios_failures,
269 "mode": "collection",
270 })
271 .to_string())
272 }
273 }
274
275 fn install_from_local_path(
277 &self,
278 source: &Path,
279 pkg_dir: &Path,
280 name: Option<String>,
281 ) -> Result<String, String> {
282 if source.join("init.lua").exists() {
283 let name = name.unwrap_or_else(|| {
285 source
286 .file_name()
287 .map(|n| n.to_string_lossy().to_string())
288 .unwrap_or_else(|| "unknown".to_string())
289 });
290
291 let dest = ContainedPath::child(pkg_dir, &name)?;
292 if dest.as_ref().exists() {
293 let _ = std::fs::remove_dir_all(&dest);
295 }
296
297 copy_dir(source, dest.as_ref()).map_err(|e| format!("Failed to copy package: {e}"))?;
298 let _ = std::fs::remove_dir_all(dest.as_ref().join(".git"));
300
301 Ok(serde_json::json!({
302 "installed": [name],
303 "mode": "local_single",
304 })
305 .to_string())
306 } else {
307 if name.is_some() {
309 return Err(
310 "The 'name' parameter is only supported for single-package dirs (init.lua at root)."
311 .to_string(),
312 );
313 }
314
315 let mut installed = Vec::new();
316 let mut updated = Vec::new();
317
318 let entries =
319 std::fs::read_dir(source).map_err(|e| format!("Failed to read source dir: {e}"))?;
320
321 for entry in entries {
322 let entry = entry.map_err(|e| format!("Failed to read entry: {e}"))?;
323 let path = entry.path();
324 if !path.is_dir() || !path.join("init.lua").exists() {
325 continue;
326 }
327 let pkg_name = entry.file_name().to_string_lossy().to_string();
328 let dest = pkg_dir.join(&pkg_name);
329 let existed = dest.exists();
330 if existed {
331 let _ = std::fs::remove_dir_all(&dest);
332 }
333 copy_dir(&path, &dest)
334 .map_err(|e| format!("Failed to copy package '{pkg_name}': {e}"))?;
335 let _ = std::fs::remove_dir_all(dest.join(".git"));
336 if existed {
337 updated.push(pkg_name);
338 } else {
339 installed.push(pkg_name);
340 }
341 }
342
343 if installed.is_empty() && updated.is_empty() {
344 return Err(
345 "No packages found. Expected init.lua at root (single) or */init.lua (collection)."
346 .to_string(),
347 );
348 }
349
350 Ok(serde_json::json!({
351 "installed": installed,
352 "updated": updated,
353 "mode": "local_collection",
354 })
355 .to_string())
356 }
357 }
358
359 pub async fn pkg_remove(&self, name: &str) -> Result<String, String> {
361 let pkg_dir = packages_dir()?;
362 let dest = ContainedPath::child(&pkg_dir, name)?;
363
364 if !dest.as_ref().exists() {
365 return Err(format!("Package '{name}' not found"));
366 }
367
368 std::fs::remove_dir_all(&dest).map_err(|e| format!("Failed to remove '{name}': {e}"))?;
369
370 Ok(serde_json::json!({ "removed": name }).to_string())
371 }
372
373 pub(super) async fn auto_install_bundled_packages(&self) -> Result<(), String> {
375 let mut errors: Vec<String> = Vec::new();
376 for url in AUTO_INSTALL_SOURCES {
377 tracing::info!("auto-installing from {url}");
378 if let Err(e) = self.pkg_install(url.to_string(), None).await {
379 tracing::warn!("failed to auto-install from {url}: {e}");
380 errors.push(format!("{url}: {e}"));
381 }
382 }
383 if errors.len() == AUTO_INSTALL_SOURCES.len() {
385 return Err(format!(
386 "Failed to auto-install bundled packages: {}",
387 errors.join("; ")
388 ));
389 }
390 Ok(())
391 }
392}