Skip to main content

algocline_app/service/pkg/
install.rs

1//! `pkg_install` — install a package from a Git URL or local path.
2
3use std::path::Path;
4
5use super::super::manifest;
6use super::super::path::{copy_dir, ContainedPath};
7use super::super::resolve::{
8    install_scenarios_from_dir, packages_dir, scenarios_dir, DirEntryFailures, AUTO_INSTALL_SOURCES,
9};
10use super::super::AppService;
11
12impl AppService {
13    /// Install a package from a Git URL or local path.
14    pub async fn pkg_install(&self, url: String, name: Option<String>) -> Result<String, String> {
15        let pkg_dir = packages_dir()?;
16        let _ = std::fs::create_dir_all(&pkg_dir);
17
18        // Local path: copy directly (supports uncommitted/dirty working trees)
19        let local_path = Path::new(&url);
20        if local_path.is_absolute() && local_path.is_dir() {
21            return self.install_from_local_path(local_path, &pkg_dir, name);
22        }
23
24        // Normalize URL: add https:// only for bare domain-style URLs
25        let git_url = if url.starts_with("http://")
26            || url.starts_with("https://")
27            || url.starts_with("file://")
28            || url.starts_with("git@")
29        {
30            url.clone()
31        } else {
32            format!("https://{url}")
33        };
34
35        // Clone to temp directory first to detect single vs collection
36        let staging = tempfile::tempdir().map_err(|e| format!("Failed to create temp dir: {e}"))?;
37
38        let output = tokio::process::Command::new("git")
39            .args([
40                "clone",
41                "--depth",
42                "1",
43                &git_url,
44                &staging.path().to_string_lossy(),
45            ])
46            .output()
47            .await
48            .map_err(|e| format!("Failed to run git: {e}"))?;
49
50        if !output.status.success() {
51            let stderr = String::from_utf8_lossy(&output.stderr);
52            return Err(format!("git clone failed: {stderr}"));
53        }
54
55        // Remove .git dir from staging
56        let _ = std::fs::remove_dir_all(staging.path().join(".git"));
57
58        // Detect: single package (init.lua at root) vs collection (subdirs with init.lua)
59        if staging.path().join("init.lua").exists() {
60            // Single package mode
61            let name = name.unwrap_or_else(|| {
62                url.trim_end_matches('/')
63                    .rsplit('/')
64                    .next()
65                    .unwrap_or("unknown")
66                    .trim_end_matches(".git")
67                    .to_string()
68            });
69
70            let dest = ContainedPath::child(&pkg_dir, &name)?;
71            if dest.as_ref().exists() {
72                return Err(format!(
73                    "Package '{name}' already exists at {}. Remove it first.",
74                    dest.as_ref().display()
75                ));
76            }
77
78            copy_dir(staging.path(), dest.as_ref())
79                .map_err(|e| format!("Failed to copy package: {e}"))?;
80
81            // Record in manifest (best-effort; install itself already succeeded)
82            let _ = manifest::record_install(&name, None, &url);
83
84            let mut response = serde_json::json!({
85                "installed": [name],
86                "mode": "single",
87            });
88            if let Some(tp) = super::super::resolve::types_stub_path() {
89                response["types_path"] = serde_json::Value::String(tp);
90            }
91            Ok(response.to_string())
92        } else {
93            // Collection mode: scan for subdirs containing init.lua
94            if name.is_some() {
95                // name parameter is only meaningful for single-package repos
96                return Err(
97                    "The 'name' parameter is only supported for single-package repos (init.lua at root). \
98                     This repository is a collection (subdirs with init.lua)."
99                        .to_string(),
100                );
101            }
102
103            let mut installed = Vec::new();
104            let mut skipped = Vec::new();
105
106            let entries = std::fs::read_dir(staging.path())
107                .map_err(|e| format!("Failed to read staging dir: {e}"))?;
108
109            for entry in entries {
110                let entry = entry.map_err(|e| format!("Failed to read entry: {e}"))?;
111                let path = entry.path();
112                if !path.is_dir() {
113                    continue;
114                }
115                if !path.join("init.lua").exists() {
116                    continue;
117                }
118                let pkg_name = entry.file_name().to_string_lossy().to_string();
119                let dest = pkg_dir.join(&pkg_name);
120                if dest.exists() {
121                    skipped.push(pkg_name);
122                    continue;
123                }
124                copy_dir(&path, &dest)
125                    .map_err(|e| format!("Failed to copy package '{pkg_name}': {e}"))?;
126                installed.push(pkg_name);
127            }
128
129            // Install bundled scenarios only when an explicit `scenarios/` subdir exists.
130            // Unlike `scenario_install` (which falls back to root via `resolve_scenario_source`),
131            // bundled scenarios are optional — we don't scan the package root for .lua files.
132            let scenarios_subdir = staging.path().join("scenarios");
133            let mut scenarios_installed: Vec<String> = Vec::new();
134            let mut scenarios_failures: DirEntryFailures = Vec::new();
135            if scenarios_subdir.is_dir() {
136                if let Ok(sc_dir) = scenarios_dir() {
137                    std::fs::create_dir_all(&sc_dir)
138                        .map_err(|e| format!("Failed to create scenarios dir: {e}"))?;
139                    if let Ok(result) = install_scenarios_from_dir(&scenarios_subdir, &sc_dir) {
140                        if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&result) {
141                            if let Some(arr) = parsed.get("installed").and_then(|v| v.as_array()) {
142                                scenarios_installed = arr
143                                    .iter()
144                                    .filter_map(|v| v.as_str().map(String::from))
145                                    .collect();
146                            }
147                            if let Some(arr) = parsed.get("failures").and_then(|v| v.as_array()) {
148                                scenarios_failures = arr
149                                    .iter()
150                                    .filter_map(|v| v.as_str().map(String::from))
151                                    .collect();
152                            }
153                        }
154                    }
155                }
156            }
157
158            if installed.is_empty() && skipped.is_empty() {
159                return Err(
160                    "No packages found. Expected init.lua at root (single) or */init.lua (collection)."
161                        .to_string(),
162                );
163            }
164
165            // Record in manifest (best-effort)
166            let _ = manifest::record_install_batch(&installed, &url);
167
168            let mut response = serde_json::json!({
169                "installed": installed,
170                "skipped": skipped,
171                "scenarios_installed": scenarios_installed,
172                "scenarios_failures": scenarios_failures,
173                "mode": "collection",
174            });
175            if let Some(tp) = super::super::resolve::types_stub_path() {
176                response["types_path"] = serde_json::Value::String(tp);
177            }
178            Ok(response.to_string())
179        }
180    }
181
182    /// Install from a local directory path (supports dirty/uncommitted files).
183    fn install_from_local_path(
184        &self,
185        source: &Path,
186        pkg_dir: &Path,
187        name: Option<String>,
188    ) -> Result<String, String> {
189        if source.join("init.lua").exists() {
190            // Single package
191            let name = name.unwrap_or_else(|| {
192                source
193                    .file_name()
194                    .map(|n| n.to_string_lossy().to_string())
195                    .unwrap_or_else(|| "unknown".to_string())
196            });
197
198            let dest = ContainedPath::child(pkg_dir, &name)?;
199            if dest.as_ref().exists() {
200                // Overwrite for local installs (dev workflow)
201                let _ = std::fs::remove_dir_all(&dest);
202            }
203
204            copy_dir(source, dest.as_ref()).map_err(|e| format!("Failed to copy package: {e}"))?;
205            // Remove .git if copied
206            let _ = std::fs::remove_dir_all(dest.as_ref().join(".git"));
207
208            // Record in manifest (best-effort)
209            let _ = manifest::record_install(&name, None, &source.display().to_string());
210
211            let mut response = serde_json::json!({
212                "installed": [name],
213                "mode": "local_single",
214            });
215            if let Some(tp) = super::super::resolve::types_stub_path() {
216                response["types_path"] = serde_json::Value::String(tp);
217            }
218            Ok(response.to_string())
219        } else {
220            // Collection mode
221            if name.is_some() {
222                return Err(
223                    "The 'name' parameter is only supported for single-package dirs (init.lua at root)."
224                        .to_string(),
225                );
226            }
227
228            let mut installed = Vec::new();
229            let mut updated = Vec::new();
230
231            let entries =
232                std::fs::read_dir(source).map_err(|e| format!("Failed to read source dir: {e}"))?;
233
234            for entry in entries {
235                let entry = entry.map_err(|e| format!("Failed to read entry: {e}"))?;
236                let path = entry.path();
237                if !path.is_dir() || !path.join("init.lua").exists() {
238                    continue;
239                }
240                let pkg_name = entry.file_name().to_string_lossy().to_string();
241                let dest = pkg_dir.join(&pkg_name);
242                let existed = dest.exists();
243                if existed {
244                    let _ = std::fs::remove_dir_all(&dest);
245                }
246                copy_dir(&path, &dest)
247                    .map_err(|e| format!("Failed to copy package '{pkg_name}': {e}"))?;
248                let _ = std::fs::remove_dir_all(dest.join(".git"));
249                if existed {
250                    updated.push(pkg_name);
251                } else {
252                    installed.push(pkg_name);
253                }
254            }
255
256            if installed.is_empty() && updated.is_empty() {
257                return Err(
258                    "No packages found. Expected init.lua at root (single) or */init.lua (collection)."
259                        .to_string(),
260                );
261            }
262
263            // Record in manifest (best-effort)
264            let source_str = source.display().to_string();
265            let all_names: Vec<String> = installed.iter().chain(updated.iter()).cloned().collect();
266            let _ = manifest::record_install_batch(&all_names, &source_str);
267
268            let mut response = serde_json::json!({
269                "installed": installed,
270                "updated": updated,
271                "mode": "local_collection",
272            });
273            if let Some(tp) = super::super::resolve::types_stub_path() {
274                response["types_path"] = serde_json::Value::String(tp);
275            }
276            Ok(response.to_string())
277        }
278    }
279
280    /// Install all bundled sources (collections + single packages).
281    pub(in crate::service) async fn auto_install_bundled_packages(&self) -> Result<(), String> {
282        let mut errors: Vec<String> = Vec::new();
283        for url in AUTO_INSTALL_SOURCES {
284            tracing::info!("auto-installing from {url}");
285            if let Err(e) = self.pkg_install(url.to_string(), None).await {
286                tracing::warn!("failed to auto-install from {url}: {e}");
287                errors.push(format!("{url}: {e}"));
288            }
289        }
290        // Fail only if ALL sources failed
291        if errors.len() == AUTO_INSTALL_SOURCES.len() {
292            return Err(format!(
293                "Failed to auto-install bundled packages: {}",
294                errors.join("; ")
295            ));
296        }
297        Ok(())
298    }
299}