1use std::path::{Path, PathBuf};
4
5use super::super::alc_toml::{
6 add_package_entry, load_alc_toml_document, save_alc_toml, PackageDep,
7};
8use super::super::hub;
9use super::super::lockfile::{load_lockfile, save_lockfile, LockFile, LockPackage};
10use super::super::manifest;
11use super::super::path::{copy_dir, ContainedPath};
12use super::super::project::resolve_project_root;
13use super::super::resolve::{
14 install_scenarios_from_dir, packages_dir, scenarios_dir, DirEntryFailures, AUTO_INSTALL_SOURCES,
15};
16use super::super::source::PackageSource;
17use super::super::{AppService, ProjectFilesError};
18
19#[derive(Debug, Clone)]
24pub(crate) enum InstallSource {
25 LocalPath(PathBuf),
27 GitUrl(String),
29}
30
31fn classify_install_url(url: &str) -> InstallSource {
43 let local_path = Path::new(url);
44 if local_path.is_absolute() {
45 return InstallSource::LocalPath(local_path.to_path_buf());
46 }
47
48 InstallSource::GitUrl(prefix_git_scheme_if_missing(url))
49}
50
51pub(super) fn prefix_git_scheme_if_missing(url: &str) -> String {
59 if url.starts_with("http://")
60 || url.starts_with("https://")
61 || url.starts_with("file://")
62 || url.starts_with("git@")
63 {
64 url.to_string()
65 } else {
66 format!("https://{url}")
67 }
68}
69
70impl AppService {
71 pub async fn pkg_install(&self, url: String, name: Option<String>) -> Result<String, String> {
78 let source = classify_install_url(&url);
79 self.pkg_install_typed(source, name).await
80 }
81
82 pub(crate) async fn pkg_install_typed(
85 &self,
86 source: InstallSource,
87 name: Option<String>,
88 ) -> Result<String, String> {
89 let app_dir = self.log_config.app_dir();
90 let pkg_dir = packages_dir(&app_dir);
91 std::fs::create_dir_all(&pkg_dir)
92 .map_err(|e| ProjectFilesError::PackagesDir {
93 path: pkg_dir.display().to_string(),
94 source: e,
95 })
96 .map_err(|e| e.to_string())?;
97
98 let git_url = match source {
99 InstallSource::LocalPath(path) => {
100 return self.install_from_local_path(&path, &pkg_dir, name).await;
101 }
102 InstallSource::GitUrl(u) => u,
103 };
104 let url = git_url.clone();
108
109 let staging = tempfile::tempdir().map_err(|e| format!("Failed to create temp dir: {e}"))?;
111
112 let clone_future = tokio::process::Command::new("git")
117 .args([
118 "clone",
119 "--depth",
120 "1",
121 &git_url,
122 &staging.path().to_string_lossy(),
123 ])
124 .output();
125 let output = tokio::time::timeout(std::time::Duration::from_secs(60), clone_future)
126 .await
127 .map_err(|_| format!("git clone timed out after 60s: {git_url}"))?
128 .map_err(|e| format!("Failed to run git: {e}"))?;
129
130 if !output.status.success() {
131 let stderr = String::from_utf8_lossy(&output.stderr);
132 return Err(format!("git clone failed: {stderr}"));
133 }
134
135 if let Err(e) = std::fs::remove_dir_all(staging.path().join(".git")) {
138 if e.kind() != std::io::ErrorKind::NotFound {
139 tracing::warn!(
140 "pkg_install: failed to strip .git from staging {}: {e}",
141 staging.path().display()
142 );
143 }
144 }
145
146 if staging.path().join("init.lua").exists() {
148 let name = name.unwrap_or_else(|| {
150 url.trim_end_matches('/')
151 .rsplit('/')
152 .next()
153 .unwrap_or("unknown")
154 .trim_end_matches(".git")
155 .to_string()
156 });
157
158 let dest = ContainedPath::child(&pkg_dir, &name)?;
159 if dest.as_ref().exists() {
160 return Err(format!(
161 "Package '{name}' already exists at {}. Remove it first.",
162 dest.as_ref().display()
163 ));
164 }
165
166 copy_dir(staging.path(), dest.as_ref())
167 .map_err(|e| format!("Failed to copy package: {e}"))?;
168
169 let mut storage_warnings: Vec<String> = Vec::new();
174 if let Err(e) = manifest::record_install(
175 &app_dir,
176 &name,
177 None,
178 super::super::source::PackageSource::Git {
179 url: url.clone(),
180 rev: None,
181 },
182 ) {
183 storage_warnings.push(format!("manifest record_install: {e}"));
184 }
185 if let Err(e) = hub::register_source(&app_dir, &url, "pkg_install") {
186 storage_warnings.push(format!("hub register_source: {e}"));
187 }
188
189 let project_files_warnings = match self
193 .update_project_files_for_install(std::slice::from_ref(&name))
194 .await
195 {
196 Ok(ws) => ws,
197 Err(e) => vec![e.to_string()],
198 };
199
200 let mut response = serde_json::json!({
201 "installed": [name],
202 "mode": "single",
203 });
204 if let Some(tp) = super::super::resolve::types_stub_path(&app_dir) {
205 response["types_path"] = serde_json::Value::String(tp);
206 }
207 if let Some(tp) = super::super::resolve::alc_shapes_types_stub_path(&app_dir) {
208 response["alc_shapes_types_path"] = serde_json::Value::String(tp);
209 }
210 if !storage_warnings.is_empty() {
211 response["storage_warnings"] = serde_json::json!(storage_warnings);
212 }
213 if !project_files_warnings.is_empty() {
214 response["project_files_warnings"] = serde_json::json!(project_files_warnings);
215 }
216 Ok(response.to_string())
217 } else {
218 if name.is_some() {
220 return Err(
222 "The 'name' parameter is only supported for single-package repos (init.lua at root). \
223 This repository is a collection (subdirs with init.lua)."
224 .to_string(),
225 );
226 }
227
228 let mut installed = Vec::new();
229 let mut skipped = Vec::new();
230 let mut skipped_symlinks: Vec<String> = Vec::new();
237
238 let entries = std::fs::read_dir(staging.path())
239 .map_err(|e| format!("Failed to read staging dir: {e}"))?;
240
241 for entry in entries {
242 let entry = entry.map_err(|e| format!("Failed to read entry: {e}"))?;
243 let path = entry.path();
244 if !path.is_dir() {
245 continue;
246 }
247 if !path.join("init.lua").exists() {
248 continue;
249 }
250 let pkg_name = entry.file_name().to_string_lossy().to_string();
251
252 let candidate = pkg_dir.join(&pkg_name);
258 if candidate
259 .symlink_metadata()
260 .map(|m| m.file_type().is_symlink())
261 .unwrap_or(false)
262 {
263 tracing::warn!(
264 "pkg_install: skipping '{pkg_name}' — destination is an existing symlink \
265 (likely a `pkg_link` dev link); run `pkg_unlink {pkg_name}` to replace it"
266 );
267 skipped_symlinks.push(pkg_name);
268 continue;
269 }
270
271 let dest = ContainedPath::child(&pkg_dir, &pkg_name)?;
275 if dest.as_ref().exists() {
276 skipped.push(pkg_name);
277 continue;
278 }
279 copy_dir(&path, dest.as_ref())
280 .map_err(|e| format!("Failed to copy package '{pkg_name}': {e}"))?;
281 installed.push(pkg_name);
282 }
283
284 let mut cards_installed: Vec<String> = Vec::new();
286 for pkg_name in installed.iter().chain(skipped.iter()) {
287 let cards_subdir = staging.path().join(pkg_name).join("cards");
288 if cards_subdir.is_dir() {
289 let imported = self.import_pkg_bundled_cards(pkg_name, &cards_subdir);
290 cards_installed.extend(imported);
291 }
292 }
293
294 let scenarios_subdir = staging.path().join("scenarios");
296 let mut scenarios_installed: Vec<String> = Vec::new();
297 let mut scenarios_failures: DirEntryFailures = Vec::new();
298 if scenarios_subdir.is_dir() {
299 let sc_dir = scenarios_dir(&app_dir);
300 std::fs::create_dir_all(&sc_dir)
301 .map_err(|e| format!("Failed to create scenarios dir: {e}"))?;
302 {
303 if let Ok(result) = install_scenarios_from_dir(&scenarios_subdir, &sc_dir) {
304 if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&result) {
305 if let Some(arr) = parsed.get("installed").and_then(|v| v.as_array()) {
306 scenarios_installed = arr
307 .iter()
308 .filter_map(|v| v.as_str().map(String::from))
309 .collect();
310 }
311 if let Some(arr) = parsed.get("failures").and_then(|v| v.as_array()) {
312 scenarios_failures = arr
313 .iter()
314 .filter_map(|v| v.as_str().map(String::from))
315 .collect();
316 }
317 }
318 }
319 }
320 }
321
322 if installed.is_empty() && skipped.is_empty() && skipped_symlinks.is_empty() {
323 return Err(
324 "No packages found. Expected init.lua at root (single) or */init.lua (collection)."
325 .to_string(),
326 );
327 }
328
329 let mut storage_warnings: Vec<String> = Vec::new();
332 if let Err(e) = manifest::record_install_batch(
333 &app_dir,
334 &installed,
335 super::super::source::PackageSource::Git {
336 url: url.clone(),
337 rev: None,
338 },
339 ) {
340 storage_warnings.push(format!("manifest record_install_batch: {e}"));
341 }
342 if let Err(e) = hub::register_source(&app_dir, &url, "pkg_install") {
343 storage_warnings.push(format!("hub register_source: {e}"));
344 }
345
346 let project_files_warnings =
350 match self.update_project_files_for_install(&installed).await {
351 Ok(ws) => ws,
352 Err(e) => vec![e.to_string()],
353 };
354
355 let mut response = serde_json::json!({
356 "installed": installed,
357 "skipped": skipped,
358 "skipped_symlinks": skipped_symlinks,
359 "cards_installed": cards_installed,
360 "scenarios_installed": scenarios_installed,
361 "scenarios_failures": scenarios_failures,
362 "mode": "collection",
363 });
364 if let Some(tp) = super::super::resolve::types_stub_path(&app_dir) {
365 response["types_path"] = serde_json::Value::String(tp);
366 }
367 if let Some(tp) = super::super::resolve::alc_shapes_types_stub_path(&app_dir) {
368 response["alc_shapes_types_path"] = serde_json::Value::String(tp);
369 }
370 if !storage_warnings.is_empty() {
371 response["storage_warnings"] = serde_json::json!(storage_warnings);
372 }
373 if !project_files_warnings.is_empty() {
374 response["project_files_warnings"] = serde_json::json!(project_files_warnings);
375 }
376 Ok(response.to_string())
377 }
378 }
379
380 async fn install_from_local_path(
382 &self,
383 source: &Path,
384 pkg_dir: &Path,
385 name: Option<String>,
386 ) -> Result<String, String> {
387 let app_dir = self.log_config.app_dir();
388 if !source.exists() {
394 return Err(format!(
395 "Source directory does not exist: {}",
396 source.display()
397 ));
398 }
399 if source.join("init.lua").exists() {
400 let name = name.unwrap_or_else(|| {
402 source
403 .file_name()
404 .map(|n| n.to_string_lossy().to_string())
405 .unwrap_or_else(|| "unknown".to_string())
406 });
407
408 let dest = ContainedPath::child(pkg_dir, &name)?;
409 if dest.as_ref().exists() {
410 if let Err(e) = std::fs::remove_dir_all(&dest) {
415 tracing::warn!(
416 "pkg_install: failed to remove existing dest {} before overwrite: {e}",
417 dest.as_ref().display()
418 );
419 }
420 }
421
422 copy_dir(source, dest.as_ref()).map_err(|e| format!("Failed to copy package: {e}"))?;
423 if let Err(e) = std::fs::remove_dir_all(dest.as_ref().join(".git")) {
425 if e.kind() != std::io::ErrorKind::NotFound {
426 tracing::warn!(
427 "pkg_install: failed to strip .git from {}: {e}",
428 dest.as_ref().display()
429 );
430 }
431 }
432
433 let source_str_local = source.display().to_string();
444 let mut storage_warnings: Vec<String> = Vec::new();
445 if let Err(e) = manifest::record_install(
446 &app_dir,
447 &name,
448 None,
449 super::super::source::PackageSource::Path {
450 path: source_str_local.clone(),
451 },
452 ) {
453 storage_warnings.push(format!("manifest record_install: {e}"));
454 }
455 if let Err(e) = hub::register_source(&app_dir, &source_str_local, "pkg_install") {
456 storage_warnings.push(format!("hub register_source: {e}"));
457 }
458
459 let project_files_warnings = match self
463 .update_project_files_for_install(std::slice::from_ref(&name))
464 .await
465 {
466 Ok(ws) => ws,
467 Err(e) => vec![e.to_string()],
468 };
469
470 let mut response = serde_json::json!({
471 "installed": [name],
472 "mode": "local_single",
473 });
474 if let Some(tp) = super::super::resolve::types_stub_path(&app_dir) {
475 response["types_path"] = serde_json::Value::String(tp);
476 }
477 if let Some(tp) = super::super::resolve::alc_shapes_types_stub_path(&app_dir) {
478 response["alc_shapes_types_path"] = serde_json::Value::String(tp);
479 }
480 if !storage_warnings.is_empty() {
481 response["storage_warnings"] = serde_json::json!(storage_warnings);
482 }
483 if !project_files_warnings.is_empty() {
484 response["project_files_warnings"] = serde_json::json!(project_files_warnings);
485 }
486 Ok(response.to_string())
487 } else {
488 if name.is_some() {
490 return Err(
491 "The 'name' parameter is only supported for single-package dirs (init.lua at root)."
492 .to_string(),
493 );
494 }
495
496 let mut installed = Vec::new();
497 let mut updated = Vec::new();
498
499 let entries =
500 std::fs::read_dir(source).map_err(|e| format!("Failed to read source dir: {e}"))?;
501
502 for entry in entries {
503 let entry = entry.map_err(|e| format!("Failed to read entry: {e}"))?;
504 let path = entry.path();
505 if !path.is_dir() || !path.join("init.lua").exists() {
506 continue;
507 }
508 let pkg_name = entry.file_name().to_string_lossy().to_string();
509 let dest = ContainedPath::child(pkg_dir, &pkg_name)?;
512 let existed = dest.as_ref().exists();
513 if existed {
514 if let Err(e) = std::fs::remove_dir_all(dest.as_ref()) {
515 tracing::warn!(
516 "pkg_install: failed to remove existing dest {} before overwrite: {e}",
517 dest.as_ref().display()
518 );
519 }
520 }
521 copy_dir(&path, dest.as_ref())
522 .map_err(|e| format!("Failed to copy package '{pkg_name}': {e}"))?;
523 if let Err(e) = std::fs::remove_dir_all(dest.as_ref().join(".git")) {
524 if e.kind() != std::io::ErrorKind::NotFound {
525 tracing::warn!(
526 "pkg_install: failed to strip .git from {}: {e}",
527 dest.as_ref().display()
528 );
529 }
530 }
531 if existed {
532 updated.push(pkg_name);
533 } else {
534 installed.push(pkg_name);
535 }
536 }
537
538 if installed.is_empty() && updated.is_empty() {
539 return Err(
540 "No packages found. Expected init.lua at root (single) or */init.lua (collection)."
541 .to_string(),
542 );
543 }
544
545 let mut cards_installed: Vec<String> = Vec::new();
547 for pkg_name in installed.iter().chain(updated.iter()) {
548 let cards_subdir = source.join(pkg_name).join("cards");
549 if cards_subdir.is_dir() {
550 let imported = self.import_pkg_bundled_cards(pkg_name, &cards_subdir);
551 cards_installed.extend(imported);
552 }
553 }
554
555 let source_str = source.display().to_string();
560 let all_names: Vec<String> = installed.iter().chain(updated.iter()).cloned().collect();
561 let mut storage_warnings: Vec<String> = Vec::new();
562 if let Err(e) = manifest::record_install_batch(
563 &app_dir,
564 &all_names,
565 super::super::source::PackageSource::Path {
566 path: source_str.clone(),
567 },
568 ) {
569 storage_warnings.push(format!("manifest record_install_batch: {e}"));
570 }
571 if let Err(e) = hub::register_source(&app_dir, &source_str, "pkg_install") {
572 storage_warnings.push(format!("hub register_source: {e}"));
573 }
574
575 let project_files_warnings =
579 match self.update_project_files_for_install(&installed).await {
580 Ok(ws) => ws,
581 Err(e) => vec![e.to_string()],
582 };
583
584 let mut response = serde_json::json!({
585 "installed": installed,
586 "updated": updated,
587 "cards_installed": cards_installed,
588 "mode": "local_collection",
589 });
590 if let Some(tp) = super::super::resolve::types_stub_path(&app_dir) {
591 response["types_path"] = serde_json::Value::String(tp);
592 }
593 if let Some(tp) = super::super::resolve::alc_shapes_types_stub_path(&app_dir) {
594 response["alc_shapes_types_path"] = serde_json::Value::String(tp);
595 }
596 if !storage_warnings.is_empty() {
597 response["storage_warnings"] = serde_json::json!(storage_warnings);
598 }
599 if !project_files_warnings.is_empty() {
600 response["project_files_warnings"] = serde_json::json!(project_files_warnings);
601 }
602 Ok(response.to_string())
603 }
604 }
605
606 async fn update_project_files_for_install(
611 &self,
612 names: &[String],
613 ) -> Result<Vec<String>, ProjectFilesError> {
614 let root = match resolve_project_root(None) {
615 Some(r) => r,
616 None => return Ok(Vec::new()), };
618
619 let mut resolved: Vec<(String, Option<String>)> = Vec::with_capacity(names.len());
624 for name in names {
625 let version = self.fetch_pkg_version(name).await;
626 resolved.push((name.clone(), version));
627 }
628
629 let lock_path = project_files_lock_path(&root);
640 super::super::lock::with_exclusive_lock(&lock_path, move || {
641 let mut warnings: Vec<String> = Vec::new();
642
643 let mut doc = match load_alc_toml_document(&root) {
646 Ok(Some(d)) => d,
647 Ok(None) => return Ok(Vec::new()), Err(e) => return Err(ProjectFilesError::AlcTomlLoad(e)),
649 };
650
651 let mut lock = match load_lockfile(&root) {
655 Ok(Some(l)) => l,
656 Ok(None) => LockFile {
657 version: 1,
658 packages: Vec::new(),
659 },
660 Err(e) => return Err(ProjectFilesError::AlcLockLoad(e)),
661 };
662
663 for (name, version) in &resolved {
664 add_package_entry(&mut doc, name, &PackageDep::Version("*".to_string()));
666 upsert_lock_entry(
668 &mut lock,
669 name.clone(),
670 version.clone(),
671 PackageSource::Installed,
672 );
673 }
674
675 if let Err(e) = save_alc_toml(&root, &doc) {
679 warnings.push(ProjectFilesError::AlcTomlSave(e).to_string());
680 }
681 if let Err(e) = save_lockfile(&root, &lock) {
682 warnings.push(ProjectFilesError::AlcLockSave(e).to_string());
683 }
684 Ok(warnings)
685 })
686 }
687
688 async fn fetch_pkg_version(&self, name: &str) -> Option<String> {
690 if !is_safe_pkg_name(name) {
691 return None;
692 }
693 let code = format!(
694 r#"package.loaded["{name}"] = nil
695local pkg = require("{name}")
696return (pkg.meta or {{}}).version"#
697 );
698 match self.executor.eval_simple(code).await {
699 Ok(serde_json::Value::String(v)) if !v.is_empty() => Some(v),
700 _ => None,
701 }
702 }
703
704 pub(in crate::service) async fn auto_install_bundled_packages(&self) -> Result<(), String> {
706 let mut errors: Vec<String> = Vec::new();
707 for url in AUTO_INSTALL_SOURCES {
708 tracing::info!("auto-installing from {url}");
709 if let Err(e) = self.pkg_install(url.to_string(), None).await {
710 tracing::warn!("failed to auto-install from {url}: {e}");
711 errors.push(format!("{url}: {e}"));
712 }
713 }
714 if errors.len() == AUTO_INSTALL_SOURCES.len() {
716 return Err(format!(
717 "Failed to auto-install bundled packages: {}",
718 errors.join("; ")
719 ));
720 }
721 Ok(())
722 }
723}
724
725fn project_files_lock_path(root: &std::path::Path) -> std::path::PathBuf {
737 root.join(".alc-install.lock")
738}
739
740fn is_safe_pkg_name(name: &str) -> bool {
742 !name.is_empty()
743 && name
744 .bytes()
745 .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
746}
747
748fn upsert_lock_entry(
750 lock: &mut LockFile,
751 name: String,
752 version: Option<String>,
753 source: PackageSource,
754) {
755 if let Some(existing) = lock.packages.iter_mut().find(|p| p.name == name) {
756 existing.version = version;
757 existing.source = source;
758 } else {
759 lock.packages.push(LockPackage {
760 name,
761 version,
762 source,
763 });
764 }
765}
766
767#[cfg(test)]
768mod tests {
769 use super::super::super::alc_toml::save_alc_toml;
770 use super::super::super::lock::with_exclusive_lock;
771 use super::super::super::lockfile::save_lockfile;
772 use super::*;
773
774 #[test]
779 fn load_alc_toml_corrupt_yields_fatal_err() {
780 let tmp = tempfile::tempdir().unwrap();
781 let root = tmp.path();
782 std::fs::write(root.join("alc.toml"), b"[[not valid toml = {").unwrap();
784
785 let lock_path = root.join(".alc-install.lock");
786 let result: Result<Vec<String>, String> =
787 with_exclusive_lock(&lock_path, move || match load_alc_toml_document(root) {
788 Ok(Some(_d)) => Ok(Vec::new()),
789 Ok(None) => Ok(Vec::new()),
790 Err(e) => Err(format!("alc.toml load: {e}")),
791 });
792
793 assert!(
794 result.is_err(),
795 "Expected Err on corrupt alc.toml, got: {result:?}"
796 );
797 let msg = result.unwrap_err();
798 assert!(
799 msg.contains("alc.toml load:"),
800 "Error should contain 'alc.toml load:', got: {msg}"
801 );
802 }
803
804 #[test]
806 fn load_alc_lock_corrupt_yields_fatal_err() {
807 let tmp = tempfile::tempdir().unwrap();
808 let root = tmp.path();
809 std::fs::write(root.join("alc.toml"), b"[packages]\n").unwrap();
811 std::fs::write(root.join("alc.lock"), b"version = 999\n[[package]]\n").unwrap();
813
814 let lock_path = root.join(".alc-install.lock");
815 let result: Result<Vec<String>, String> = with_exclusive_lock(&lock_path, move || {
816 let _doc = match load_alc_toml_document(root) {
817 Ok(Some(d)) => d,
818 Ok(None) => return Ok(Vec::new()),
819 Err(e) => return Err(format!("alc.toml load: {e}")),
820 };
821 match load_lockfile(root) {
822 Ok(Some(_l)) => Ok(Vec::new()),
823 Ok(None) => Ok(Vec::new()),
824 Err(e) => Err(format!("alc.lock load: {e}")),
825 }
826 });
827
828 assert!(
829 result.is_err(),
830 "Expected Err on corrupt alc.lock, got: {result:?}"
831 );
832 let msg = result.unwrap_err();
833 assert!(
834 msg.contains("alc.lock load:"),
835 "Error should contain 'alc.lock load:', got: {msg}"
836 );
837 }
838
839 #[test]
844 fn save_failure_produces_warning_not_fatal_err() {
845 let tmp = tempfile::tempdir().unwrap();
846 let root = tmp.path();
847 std::fs::write(root.join("alc.toml"), b"[packages]\n").unwrap();
849
850 let bad_root = root.join("blocked_subdir");
855 std::fs::write(&bad_root, b"this is a file, not a dir").unwrap();
856
857 let lock_path = root.join(".alc-install.lock");
858 let root_owned = root.to_path_buf();
859 let bad_root_owned = bad_root.clone();
860 let result: Result<Vec<String>, String> = with_exclusive_lock(&lock_path, move || {
861 let mut warnings: Vec<String> = Vec::new();
862 let doc = match load_alc_toml_document(&root_owned) {
863 Ok(Some(d)) => d,
864 Ok(None) => return Ok(Vec::new()),
865 Err(e) => return Err(format!("alc.toml load: {e}")),
866 };
867 if let Err(e) = save_alc_toml(&bad_root_owned, &doc) {
868 warnings.push(format!("alc.toml save: {e}"));
869 }
870 let lock = LockFile {
871 version: 1,
872 packages: Vec::new(),
873 };
874 if let Err(e) = save_lockfile(&bad_root_owned, &lock) {
875 warnings.push(format!("alc.lock save: {e}"));
876 }
877 Ok(warnings)
878 });
879
880 assert!(
881 result.is_ok(),
882 "Expected Ok even with save failures, got: {result:?}"
883 );
884 let warnings = result.unwrap();
885 assert!(
886 !warnings.is_empty(),
887 "Expected at least one save warning, got empty warnings"
888 );
889 assert!(
890 warnings.iter().any(|w| w.contains("alc.toml save:")),
891 "Expected 'alc.toml save:' warning, got: {warnings:?}"
892 );
893 }
894
895 #[test]
901 fn caller_degrades_fatal_err_to_project_files_warnings() {
902 let update_result: Result<Vec<String>, String> =
904 Err("alc.toml load: TOML parse error at line 1".to_string());
905
906 let project_files_warnings = match update_result {
908 Ok(ws) => ws,
909 Err(e) => vec![e],
910 };
911
912 assert_eq!(project_files_warnings.len(), 1);
913 assert!(
914 project_files_warnings[0].contains("alc.toml load:"),
915 "Warning should contain the original error message"
916 );
917 }
918
919 #[test]
921 fn caller_passes_through_ok_warnings() {
922 let update_result: Result<Vec<String>, String> = Ok(vec![
923 "alc.toml save: permission denied".to_string(),
924 "alc.lock save: no space left".to_string(),
925 ]);
926
927 let project_files_warnings = match update_result {
928 Ok(ws) => ws,
929 Err(e) => vec![e],
930 };
931
932 assert_eq!(project_files_warnings.len(), 2);
933 }
934
935 #[test]
938 fn empty_warnings_are_not_added_to_response() {
939 let update_result: Result<Vec<String>, String> = Ok(Vec::new());
940
941 let project_files_warnings = match update_result {
942 Ok(ws) => ws,
943 Err(e) => vec![e],
944 };
945
946 let mut response = serde_json::json!({ "installed": ["mypkg"], "mode": "single" });
948 if !project_files_warnings.is_empty() {
949 response["project_files_warnings"] = serde_json::json!(project_files_warnings);
950 }
951
952 assert!(
953 response.get("project_files_warnings").is_none(),
954 "project_files_warnings should not appear when warnings are empty"
955 );
956 }
957
958 #[test]
961 fn upsert_lock_entry_inserts_new_package() {
962 let mut lock = LockFile {
963 version: 1,
964 packages: Vec::new(),
965 };
966 upsert_lock_entry(
967 &mut lock,
968 "mypkg".to_string(),
969 Some("1.0.0".to_string()),
970 PackageSource::Installed,
971 );
972 assert_eq!(lock.packages.len(), 1);
973 assert_eq!(lock.packages[0].name, "mypkg");
974 assert_eq!(lock.packages[0].version, Some("1.0.0".to_string()));
975 }
976
977 #[test]
978 fn upsert_lock_entry_updates_existing_package() {
979 let mut lock = LockFile {
980 version: 1,
981 packages: Vec::new(),
982 };
983 upsert_lock_entry(
984 &mut lock,
985 "mypkg".to_string(),
986 Some("1.0.0".to_string()),
987 PackageSource::Installed,
988 );
989 upsert_lock_entry(
990 &mut lock,
991 "mypkg".to_string(),
992 Some("2.0.0".to_string()),
993 PackageSource::Installed,
994 );
995 assert_eq!(lock.packages.len(), 1);
996 assert_eq!(lock.packages[0].version, Some("2.0.0".to_string()));
997 }
998
999 #[test]
1000 fn is_safe_pkg_name_accepts_valid_names() {
1001 assert!(is_safe_pkg_name("my_pkg"));
1002 assert!(is_safe_pkg_name("my-pkg"));
1003 assert!(is_safe_pkg_name("mypkg123"));
1004 }
1005
1006 #[test]
1007 fn is_safe_pkg_name_rejects_invalid_names() {
1008 assert!(!is_safe_pkg_name(""));
1009 assert!(!is_safe_pkg_name("my pkg"));
1010 assert!(!is_safe_pkg_name("../escape"));
1011 assert!(!is_safe_pkg_name("pkg;rm -rf /"));
1012 }
1013}