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