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::resolve::{
13 install_scenarios_from_dir, packages_dir, scenarios_dir, DirEntryFailures, AUTO_INSTALL_SOURCES,
14};
15use super::super::source::PackageSource;
16use super::super::{AppService, ProjectFilesError};
17
18#[derive(Debug, Clone)]
23pub(crate) enum InstallSource {
24 LocalPath(PathBuf),
26 GitUrl(String),
28}
29
30fn classify_install_url(url: &str) -> InstallSource {
42 let local_path = Path::new(url);
43 if local_path.is_absolute() {
44 return InstallSource::LocalPath(local_path.to_path_buf());
45 }
46
47 InstallSource::GitUrl(prefix_git_scheme_if_missing(url))
48}
49
50pub(super) fn prefix_git_scheme_if_missing(url: &str) -> String {
58 if url.starts_with("http://")
59 || url.starts_with("https://")
60 || url.starts_with("file://")
61 || url.starts_with("git@")
62 {
63 url.to_string()
64 } else {
65 format!("https://{url}")
66 }
67}
68
69impl AppService {
70 pub async fn pkg_install(
77 &self,
78 url: String,
79 name: Option<String>,
80 force: Option<bool>,
81 ) -> Result<String, String> {
82 let source = classify_install_url(&url);
83 self.pkg_install_typed(source, name, force).await
84 }
85
86 pub(crate) async fn pkg_install_typed(
89 &self,
90 source: InstallSource,
91 name: Option<String>,
92 force: Option<bool>,
93 ) -> Result<String, String> {
94 let app_dir = self.log_config.app_dir();
95 let pkg_dir = packages_dir(&app_dir);
96 std::fs::create_dir_all(&pkg_dir)
97 .map_err(|e| ProjectFilesError::PackagesDir {
98 path: pkg_dir.display().to_string(),
99 source: e,
100 })
101 .map_err(|e| e.to_string())?;
102
103 let git_url = match source {
104 InstallSource::LocalPath(path) => {
105 return self.install_from_local_path(&path, &pkg_dir, name).await;
106 }
107 InstallSource::GitUrl(u) => u,
108 };
109 let url = git_url.clone();
113
114 let staging = tempfile::tempdir().map_err(|e| format!("Failed to create temp dir: {e}"))?;
116
117 let clone_future = tokio::process::Command::new("git")
122 .args([
123 "clone",
124 "--depth",
125 "1",
126 &git_url,
127 &staging.path().to_string_lossy(),
128 ])
129 .output();
130 let output = tokio::time::timeout(std::time::Duration::from_secs(60), clone_future)
131 .await
132 .map_err(|_| format!("git clone timed out after 60s: {git_url}"))?
133 .map_err(|e| format!("Failed to run git: {e}"))?;
134
135 if !output.status.success() {
136 let stderr = String::from_utf8_lossy(&output.stderr);
137 return Err(format!("git clone failed: {stderr}"));
138 }
139
140 if let Err(e) = std::fs::remove_dir_all(staging.path().join(".git")) {
143 if e.kind() != std::io::ErrorKind::NotFound {
144 tracing::warn!(
145 "pkg_install: failed to strip .git from staging {}: {e}",
146 staging.path().display()
147 );
148 }
149 }
150
151 if staging.path().join("init.lua").exists() {
153 let name = name.unwrap_or_else(|| {
155 url.trim_end_matches('/')
156 .rsplit('/')
157 .next()
158 .unwrap_or("unknown")
159 .trim_end_matches(".git")
160 .to_string()
161 });
162
163 let dest = ContainedPath::child(&pkg_dir, &name)?;
164 if dest.as_ref().exists() {
165 return Err(format!(
166 "Package '{name}' already exists at {}. Remove it first.",
167 dest.as_ref().display()
168 ));
169 }
170
171 copy_dir(staging.path(), dest.as_ref())
172 .map_err(|e| format!("Failed to copy package: {e}"))?;
173
174 let mut storage_warnings: Vec<String> = Vec::new();
179 if let Err(e) = manifest::record_install(
180 &app_dir,
181 &name,
182 None,
183 super::super::source::PackageSource::Git {
184 url: url.clone(),
185 rev: None,
186 },
187 ) {
188 storage_warnings.push(format!("manifest record_install: {e}"));
189 }
190 if let Err(e) = hub::register_source(&app_dir, &url, "pkg_install") {
191 storage_warnings.push(format!("hub register_source: {e}"));
192 }
193
194 let project_files_warnings = match self
198 .update_project_files_for_install(std::slice::from_ref(&name))
199 .await
200 {
201 Ok(ws) => ws,
202 Err(e) => vec![e.to_string()],
203 };
204
205 let mut response = serde_json::json!({
206 "installed": [name],
207 "mode": "single",
208 });
209 if let Some(tp) = super::super::resolve::types_stub_path(&app_dir) {
210 response["types_path"] = serde_json::Value::String(tp);
211 }
212 if let Some(tp) = super::super::resolve::alc_shapes_types_stub_path(&app_dir) {
213 response["alc_shapes_types_path"] = serde_json::Value::String(tp);
214 }
215 if !storage_warnings.is_empty() {
216 response["storage_warnings"] = serde_json::json!(storage_warnings);
217 }
218 if !project_files_warnings.is_empty() {
219 response["project_files_warnings"] = serde_json::json!(project_files_warnings);
220 }
221 Ok(response.to_string())
222 } else {
223 if name.is_some() {
225 return Err(
227 "The 'name' parameter is only supported for single-package repos (init.lua at root). \
228 This repository is a collection (subdirs with init.lua)."
229 .to_string(),
230 );
231 }
232
233 let force = force.unwrap_or(false);
234 let mut installed = Vec::new();
235 let mut skipped = Vec::new();
236 let mut skipped_symlinks: Vec<String> = Vec::new();
243
244 let entries = std::fs::read_dir(staging.path())
245 .map_err(|e| format!("Failed to read staging dir: {e}"))?;
246
247 for entry in entries {
248 let entry = entry.map_err(|e| format!("Failed to read entry: {e}"))?;
249 let path = entry.path();
250 if !path.is_dir() {
251 continue;
252 }
253 if !path.join("init.lua").exists() {
254 continue;
255 }
256 let pkg_name = entry.file_name().to_string_lossy().to_string();
257
258 let candidate = pkg_dir.join(&pkg_name);
264 if candidate
265 .symlink_metadata()
266 .map(|m| m.file_type().is_symlink())
267 .unwrap_or(false)
268 {
269 tracing::warn!(
270 "pkg_install: skipping '{pkg_name}' — destination is an existing symlink \
271 (likely a `pkg_link` dev link); run `pkg_unlink {pkg_name}` to replace it"
272 );
273 skipped_symlinks.push(pkg_name);
274 continue;
275 }
276
277 let dest = ContainedPath::child(&pkg_dir, &pkg_name)?;
281 if dest.as_ref().exists() {
282 if !force {
283 skipped.push(pkg_name);
284 continue;
285 }
286 std::fs::remove_dir_all(dest.as_ref()).map_err(|e| {
288 format!("Failed to remove existing package '{pkg_name}': {e}")
289 })?;
290 }
291 copy_dir(&path, dest.as_ref())
292 .map_err(|e| format!("Failed to copy package '{pkg_name}': {e}"))?;
293 installed.push(pkg_name);
294 }
295
296 let mut cards_installed: Vec<String> = Vec::new();
298 for pkg_name in installed.iter().chain(skipped.iter()) {
299 let cards_subdir = staging.path().join(pkg_name).join("cards");
300 if cards_subdir.is_dir() {
301 let imported = self.import_pkg_bundled_cards(pkg_name, &cards_subdir);
302 cards_installed.extend(imported);
303 }
304 }
305
306 let scenarios_subdir = staging.path().join("scenarios");
308 let mut scenarios_installed: Vec<String> = Vec::new();
309 let mut scenarios_failures: DirEntryFailures = Vec::new();
310 if scenarios_subdir.is_dir() {
311 let sc_dir = scenarios_dir(&app_dir);
312 std::fs::create_dir_all(&sc_dir)
313 .map_err(|e| format!("Failed to create scenarios dir: {e}"))?;
314 {
315 if let Ok(result) = install_scenarios_from_dir(&scenarios_subdir, &sc_dir) {
316 if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&result) {
317 if let Some(arr) = parsed.get("installed").and_then(|v| v.as_array()) {
318 scenarios_installed = arr
319 .iter()
320 .filter_map(|v| v.as_str().map(String::from))
321 .collect();
322 }
323 if let Some(arr) = parsed.get("failures").and_then(|v| v.as_array()) {
324 scenarios_failures = arr
325 .iter()
326 .filter_map(|v| v.as_str().map(String::from))
327 .collect();
328 }
329 }
330 }
331 }
332 }
333
334 if installed.is_empty() && skipped.is_empty() && skipped_symlinks.is_empty() {
335 return Err(
336 "No packages found. Expected init.lua at root (single) or */init.lua (collection)."
337 .to_string(),
338 );
339 }
340
341 let mut storage_warnings: Vec<String> = Vec::new();
344 if let Err(e) = manifest::record_install_batch(
345 &app_dir,
346 &installed,
347 super::super::source::PackageSource::Git {
348 url: url.clone(),
349 rev: None,
350 },
351 ) {
352 storage_warnings.push(format!("manifest record_install_batch: {e}"));
353 }
354 if let Err(e) = hub::register_source(&app_dir, &url, "pkg_install") {
355 storage_warnings.push(format!("hub register_source: {e}"));
356 }
357
358 let project_files_warnings =
362 match self.update_project_files_for_install(&installed).await {
363 Ok(ws) => ws,
364 Err(e) => vec![e.to_string()],
365 };
366
367 let mut response = serde_json::json!({
368 "installed": installed,
369 "skipped": skipped,
370 "skipped_symlinks": skipped_symlinks,
371 "cards_installed": cards_installed,
372 "scenarios_installed": scenarios_installed,
373 "scenarios_failures": scenarios_failures,
374 "mode": "collection",
375 });
376 if let Some(tp) = super::super::resolve::types_stub_path(&app_dir) {
377 response["types_path"] = serde_json::Value::String(tp);
378 }
379 if let Some(tp) = super::super::resolve::alc_shapes_types_stub_path(&app_dir) {
380 response["alc_shapes_types_path"] = serde_json::Value::String(tp);
381 }
382 if !storage_warnings.is_empty() {
383 response["storage_warnings"] = serde_json::json!(storage_warnings);
384 }
385 if !project_files_warnings.is_empty() {
386 response["project_files_warnings"] = serde_json::json!(project_files_warnings);
387 }
388 Ok(response.to_string())
389 }
390 }
391
392 async fn install_from_local_path(
394 &self,
395 source: &Path,
396 pkg_dir: &Path,
397 name: Option<String>,
398 ) -> Result<String, String> {
399 let app_dir = self.log_config.app_dir();
400 if !source.exists() {
406 return Err(format!(
407 "Source directory does not exist: {}",
408 source.display()
409 ));
410 }
411 if source.join("init.lua").exists() {
412 let name = name.unwrap_or_else(|| {
414 source
415 .file_name()
416 .map(|n| n.to_string_lossy().to_string())
417 .unwrap_or_else(|| "unknown".to_string())
418 });
419
420 let dest = ContainedPath::child(pkg_dir, &name)?;
421 if dest.as_ref().exists() {
422 if let Err(e) = std::fs::remove_dir_all(&dest) {
427 tracing::warn!(
428 "pkg_install: failed to remove existing dest {} before overwrite: {e}",
429 dest.as_ref().display()
430 );
431 }
432 }
433
434 copy_dir(source, dest.as_ref()).map_err(|e| format!("Failed to copy package: {e}"))?;
435 if let Err(e) = std::fs::remove_dir_all(dest.as_ref().join(".git")) {
437 if e.kind() != std::io::ErrorKind::NotFound {
438 tracing::warn!(
439 "pkg_install: failed to strip .git from {}: {e}",
440 dest.as_ref().display()
441 );
442 }
443 }
444
445 let source_str_local = source.display().to_string();
456 let mut storage_warnings: Vec<String> = Vec::new();
457 if let Err(e) = manifest::record_install(
458 &app_dir,
459 &name,
460 None,
461 super::super::source::PackageSource::Path {
462 path: source_str_local.clone(),
463 },
464 ) {
465 storage_warnings.push(format!("manifest record_install: {e}"));
466 }
467 if let Err(e) = hub::register_source(&app_dir, &source_str_local, "pkg_install") {
468 storage_warnings.push(format!("hub register_source: {e}"));
469 }
470
471 let project_files_warnings = match self
475 .update_project_files_for_install(std::slice::from_ref(&name))
476 .await
477 {
478 Ok(ws) => ws,
479 Err(e) => vec![e.to_string()],
480 };
481
482 let mut response = serde_json::json!({
483 "installed": [name],
484 "mode": "local_single",
485 });
486 if let Some(tp) = super::super::resolve::types_stub_path(&app_dir) {
487 response["types_path"] = serde_json::Value::String(tp);
488 }
489 if let Some(tp) = super::super::resolve::alc_shapes_types_stub_path(&app_dir) {
490 response["alc_shapes_types_path"] = serde_json::Value::String(tp);
491 }
492 if !storage_warnings.is_empty() {
493 response["storage_warnings"] = serde_json::json!(storage_warnings);
494 }
495 if !project_files_warnings.is_empty() {
496 response["project_files_warnings"] = serde_json::json!(project_files_warnings);
497 }
498 Ok(response.to_string())
499 } else {
500 if name.is_some() {
502 return Err(
503 "The 'name' parameter is only supported for single-package dirs (init.lua at root)."
504 .to_string(),
505 );
506 }
507
508 let mut installed = Vec::new();
509 let mut updated = Vec::new();
510
511 let entries =
512 std::fs::read_dir(source).map_err(|e| format!("Failed to read source dir: {e}"))?;
513
514 for entry in entries {
515 let entry = entry.map_err(|e| format!("Failed to read entry: {e}"))?;
516 let path = entry.path();
517 if !path.is_dir() || !path.join("init.lua").exists() {
518 continue;
519 }
520 let pkg_name = entry.file_name().to_string_lossy().to_string();
521 let dest = ContainedPath::child(pkg_dir, &pkg_name)?;
524 let existed = dest.as_ref().exists();
525 if existed {
526 if let Err(e) = std::fs::remove_dir_all(dest.as_ref()) {
527 tracing::warn!(
528 "pkg_install: failed to remove existing dest {} before overwrite: {e}",
529 dest.as_ref().display()
530 );
531 }
532 }
533 copy_dir(&path, dest.as_ref())
534 .map_err(|e| format!("Failed to copy package '{pkg_name}': {e}"))?;
535 if let Err(e) = std::fs::remove_dir_all(dest.as_ref().join(".git")) {
536 if e.kind() != std::io::ErrorKind::NotFound {
537 tracing::warn!(
538 "pkg_install: failed to strip .git from {}: {e}",
539 dest.as_ref().display()
540 );
541 }
542 }
543 if existed {
544 updated.push(pkg_name);
545 } else {
546 installed.push(pkg_name);
547 }
548 }
549
550 if installed.is_empty() && updated.is_empty() {
551 return Err(
552 "No packages found. Expected init.lua at root (single) or */init.lua (collection)."
553 .to_string(),
554 );
555 }
556
557 let mut cards_installed: Vec<String> = Vec::new();
559 for pkg_name in installed.iter().chain(updated.iter()) {
560 let cards_subdir = source.join(pkg_name).join("cards");
561 if cards_subdir.is_dir() {
562 let imported = self.import_pkg_bundled_cards(pkg_name, &cards_subdir);
563 cards_installed.extend(imported);
564 }
565 }
566
567 let source_str = source.display().to_string();
572 let all_names: Vec<String> = installed.iter().chain(updated.iter()).cloned().collect();
573 let mut storage_warnings: Vec<String> = Vec::new();
574 if let Err(e) = manifest::record_install_batch(
575 &app_dir,
576 &all_names,
577 super::super::source::PackageSource::Path {
578 path: source_str.clone(),
579 },
580 ) {
581 storage_warnings.push(format!("manifest record_install_batch: {e}"));
582 }
583 if let Err(e) = hub::register_source(&app_dir, &source_str, "pkg_install") {
584 storage_warnings.push(format!("hub register_source: {e}"));
585 }
586
587 let project_files_warnings =
591 match self.update_project_files_for_install(&installed).await {
592 Ok(ws) => ws,
593 Err(e) => vec![e.to_string()],
594 };
595
596 let mut response = serde_json::json!({
597 "installed": installed,
598 "updated": updated,
599 "cards_installed": cards_installed,
600 "mode": "local_collection",
601 });
602 if let Some(tp) = super::super::resolve::types_stub_path(&app_dir) {
603 response["types_path"] = serde_json::Value::String(tp);
604 }
605 if let Some(tp) = super::super::resolve::alc_shapes_types_stub_path(&app_dir) {
606 response["alc_shapes_types_path"] = serde_json::Value::String(tp);
607 }
608 if !storage_warnings.is_empty() {
609 response["storage_warnings"] = serde_json::json!(storage_warnings);
610 }
611 if !project_files_warnings.is_empty() {
612 response["project_files_warnings"] = serde_json::json!(project_files_warnings);
613 }
614 Ok(response.to_string())
615 }
616 }
617
618 async fn update_project_files_for_install(
623 &self,
624 names: &[String],
625 ) -> Result<Vec<String>, ProjectFilesError> {
626 let root = match self.resolve_root(None) {
627 Some(r) => r,
628 None => return Ok(Vec::new()), };
630
631 let mut resolved: Vec<(String, Option<String>)> = Vec::with_capacity(names.len());
636 for name in names {
637 let version = self.fetch_pkg_version(name).await;
638 resolved.push((name.clone(), version));
639 }
640
641 let lock_path = project_files_lock_path(&root);
652 super::super::lock::with_exclusive_lock(&lock_path, move || {
653 let mut warnings: Vec<String> = Vec::new();
654
655 let mut doc = match load_alc_toml_document(&root) {
658 Ok(Some(d)) => d,
659 Ok(None) => return Ok(Vec::new()), Err(e) => return Err(ProjectFilesError::AlcTomlLoad(e)),
661 };
662
663 let mut lock = match load_lockfile(&root) {
667 Ok(Some(l)) => l,
668 Ok(None) => LockFile {
669 version: 1,
670 packages: Vec::new(),
671 },
672 Err(e) => return Err(ProjectFilesError::AlcLockLoad(e)),
673 };
674
675 for (name, version) in &resolved {
676 add_package_entry(&mut doc, name, &PackageDep::Version("*".to_string()));
678 upsert_lock_entry(
680 &mut lock,
681 name.clone(),
682 version.clone(),
683 PackageSource::Installed,
684 );
685 }
686
687 if let Err(e) = save_alc_toml(&root, &doc) {
691 warnings.push(ProjectFilesError::AlcTomlSave(e).to_string());
692 }
693 if let Err(e) = save_lockfile(&root, &lock) {
694 warnings.push(ProjectFilesError::AlcLockSave(e).to_string());
695 }
696 Ok(warnings)
697 })
698 }
699
700 async fn fetch_pkg_version(&self, name: &str) -> Option<String> {
702 if !is_safe_pkg_name(name) {
703 return None;
704 }
705 let code = format!(
706 r#"package.loaded["{name}"] = nil
707local pkg = require("{name}")
708return (pkg.meta or {{}}).version"#
709 );
710 match self.executor.eval_simple(code).await {
711 Ok(serde_json::Value::String(v)) if !v.is_empty() => Some(v),
712 _ => None,
713 }
714 }
715
716 pub(in crate::service) async fn auto_install_bundled_packages(&self) -> Result<(), String> {
718 let mut errors: Vec<String> = Vec::new();
719 for url in AUTO_INSTALL_SOURCES {
720 tracing::info!("auto-installing from {url}");
721 if let Err(e) = self.pkg_install(url.to_string(), None, None).await {
722 tracing::warn!("failed to auto-install from {url}: {e}");
723 errors.push(format!("{url}: {e}"));
724 }
725 }
726 if errors.len() == AUTO_INSTALL_SOURCES.len() {
728 return Err(format!(
729 "Failed to auto-install bundled packages: {}",
730 errors.join("; ")
731 ));
732 }
733 Ok(())
734 }
735}
736
737fn project_files_lock_path(root: &std::path::Path) -> std::path::PathBuf {
749 root.join(".alc-install.lock")
750}
751
752fn is_safe_pkg_name(name: &str) -> bool {
754 !name.is_empty()
755 && name
756 .bytes()
757 .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
758}
759
760fn upsert_lock_entry(
762 lock: &mut LockFile,
763 name: String,
764 version: Option<String>,
765 source: PackageSource,
766) {
767 if let Some(existing) = lock.packages.iter_mut().find(|p| p.name == name) {
768 existing.version = version;
769 existing.source = source;
770 } else {
771 lock.packages.push(LockPackage {
772 name,
773 version,
774 source,
775 });
776 }
777}
778
779#[cfg(test)]
780mod tests {
781 use super::super::super::alc_toml::save_alc_toml;
782 use super::super::super::lock::with_exclusive_lock;
783 use super::super::super::lockfile::save_lockfile;
784 use super::*;
785
786 #[test]
791 fn load_alc_toml_corrupt_yields_fatal_err() {
792 let tmp = tempfile::tempdir().unwrap();
793 let root = tmp.path();
794 std::fs::write(root.join("alc.toml"), b"[[not valid toml = {").unwrap();
796
797 let lock_path = root.join(".alc-install.lock");
798 let result: Result<Vec<String>, String> =
799 with_exclusive_lock(&lock_path, move || match load_alc_toml_document(root) {
800 Ok(Some(_d)) => Ok(Vec::new()),
801 Ok(None) => Ok(Vec::new()),
802 Err(e) => Err(format!("alc.toml load: {e}")),
803 });
804
805 assert!(
806 result.is_err(),
807 "Expected Err on corrupt alc.toml, got: {result:?}"
808 );
809 let msg = result.unwrap_err();
810 assert!(
811 msg.contains("alc.toml load:"),
812 "Error should contain 'alc.toml load:', got: {msg}"
813 );
814 }
815
816 #[test]
818 fn load_alc_lock_corrupt_yields_fatal_err() {
819 let tmp = tempfile::tempdir().unwrap();
820 let root = tmp.path();
821 std::fs::write(root.join("alc.toml"), b"[packages]\n").unwrap();
823 std::fs::write(root.join("alc.lock"), b"version = 999\n[[package]]\n").unwrap();
825
826 let lock_path = root.join(".alc-install.lock");
827 let result: Result<Vec<String>, String> = with_exclusive_lock(&lock_path, move || {
828 let _doc = match load_alc_toml_document(root) {
829 Ok(Some(d)) => d,
830 Ok(None) => return Ok(Vec::new()),
831 Err(e) => return Err(format!("alc.toml load: {e}")),
832 };
833 match load_lockfile(root) {
834 Ok(Some(_l)) => Ok(Vec::new()),
835 Ok(None) => Ok(Vec::new()),
836 Err(e) => Err(format!("alc.lock load: {e}")),
837 }
838 });
839
840 assert!(
841 result.is_err(),
842 "Expected Err on corrupt alc.lock, got: {result:?}"
843 );
844 let msg = result.unwrap_err();
845 assert!(
846 msg.contains("alc.lock load:"),
847 "Error should contain 'alc.lock load:', got: {msg}"
848 );
849 }
850
851 #[test]
856 fn save_failure_produces_warning_not_fatal_err() {
857 let tmp = tempfile::tempdir().unwrap();
858 let root = tmp.path();
859 std::fs::write(root.join("alc.toml"), b"[packages]\n").unwrap();
861
862 let bad_root = root.join("blocked_subdir");
867 std::fs::write(&bad_root, b"this is a file, not a dir").unwrap();
868
869 let lock_path = root.join(".alc-install.lock");
870 let root_owned = root.to_path_buf();
871 let bad_root_owned = bad_root.clone();
872 let result: Result<Vec<String>, String> = with_exclusive_lock(&lock_path, move || {
873 let mut warnings: Vec<String> = Vec::new();
874 let doc = match load_alc_toml_document(&root_owned) {
875 Ok(Some(d)) => d,
876 Ok(None) => return Ok(Vec::new()),
877 Err(e) => return Err(format!("alc.toml load: {e}")),
878 };
879 if let Err(e) = save_alc_toml(&bad_root_owned, &doc) {
880 warnings.push(format!("alc.toml save: {e}"));
881 }
882 let lock = LockFile {
883 version: 1,
884 packages: Vec::new(),
885 };
886 if let Err(e) = save_lockfile(&bad_root_owned, &lock) {
887 warnings.push(format!("alc.lock save: {e}"));
888 }
889 Ok(warnings)
890 });
891
892 assert!(
893 result.is_ok(),
894 "Expected Ok even with save failures, got: {result:?}"
895 );
896 let warnings = result.unwrap();
897 assert!(
898 !warnings.is_empty(),
899 "Expected at least one save warning, got empty warnings"
900 );
901 assert!(
902 warnings.iter().any(|w| w.contains("alc.toml save:")),
903 "Expected 'alc.toml save:' warning, got: {warnings:?}"
904 );
905 }
906
907 #[test]
913 fn caller_degrades_fatal_err_to_project_files_warnings() {
914 let update_result: Result<Vec<String>, String> =
916 Err("alc.toml load: TOML parse error at line 1".to_string());
917
918 let project_files_warnings = match update_result {
920 Ok(ws) => ws,
921 Err(e) => vec![e],
922 };
923
924 assert_eq!(project_files_warnings.len(), 1);
925 assert!(
926 project_files_warnings[0].contains("alc.toml load:"),
927 "Warning should contain the original error message"
928 );
929 }
930
931 #[test]
933 fn caller_passes_through_ok_warnings() {
934 let update_result: Result<Vec<String>, String> = Ok(vec![
935 "alc.toml save: permission denied".to_string(),
936 "alc.lock save: no space left".to_string(),
937 ]);
938
939 let project_files_warnings = match update_result {
940 Ok(ws) => ws,
941 Err(e) => vec![e],
942 };
943
944 assert_eq!(project_files_warnings.len(), 2);
945 }
946
947 #[test]
950 fn empty_warnings_are_not_added_to_response() {
951 let update_result: Result<Vec<String>, String> = Ok(Vec::new());
952
953 let project_files_warnings = match update_result {
954 Ok(ws) => ws,
955 Err(e) => vec![e],
956 };
957
958 let mut response = serde_json::json!({ "installed": ["mypkg"], "mode": "single" });
960 if !project_files_warnings.is_empty() {
961 response["project_files_warnings"] = serde_json::json!(project_files_warnings);
962 }
963
964 assert!(
965 response.get("project_files_warnings").is_none(),
966 "project_files_warnings should not appear when warnings are empty"
967 );
968 }
969
970 #[test]
973 fn upsert_lock_entry_inserts_new_package() {
974 let mut lock = LockFile {
975 version: 1,
976 packages: Vec::new(),
977 };
978 upsert_lock_entry(
979 &mut lock,
980 "mypkg".to_string(),
981 Some("1.0.0".to_string()),
982 PackageSource::Installed,
983 );
984 assert_eq!(lock.packages.len(), 1);
985 assert_eq!(lock.packages[0].name, "mypkg");
986 assert_eq!(lock.packages[0].version, Some("1.0.0".to_string()));
987 }
988
989 #[test]
990 fn upsert_lock_entry_updates_existing_package() {
991 let mut lock = LockFile {
992 version: 1,
993 packages: Vec::new(),
994 };
995 upsert_lock_entry(
996 &mut lock,
997 "mypkg".to_string(),
998 Some("1.0.0".to_string()),
999 PackageSource::Installed,
1000 );
1001 upsert_lock_entry(
1002 &mut lock,
1003 "mypkg".to_string(),
1004 Some("2.0.0".to_string()),
1005 PackageSource::Installed,
1006 );
1007 assert_eq!(lock.packages.len(), 1);
1008 assert_eq!(lock.packages[0].version, Some("2.0.0".to_string()));
1009 }
1010
1011 #[test]
1012 fn is_safe_pkg_name_accepts_valid_names() {
1013 assert!(is_safe_pkg_name("my_pkg"));
1014 assert!(is_safe_pkg_name("my-pkg"));
1015 assert!(is_safe_pkg_name("mypkg123"));
1016 }
1017
1018 #[test]
1019 fn is_safe_pkg_name_rejects_invalid_names() {
1020 assert!(!is_safe_pkg_name(""));
1021 assert!(!is_safe_pkg_name("my pkg"));
1022 assert!(!is_safe_pkg_name("../escape"));
1023 assert!(!is_safe_pkg_name("pkg;rm -rf /"));
1024 }
1025}