1#[cfg(unix)]
11use std::os::unix::fs::symlink;
12
13use std::path::{Path, PathBuf};
14
15use super::alc_toml::{self, add_package_entry, validate_package_name, PackageDep};
16#[cfg(unix)]
17use super::resolve::packages_dir;
18use super::AppService;
19
20impl AppService {
21 pub async fn pkg_link(
32 &self,
33 path: String,
34 name: Option<String>,
35 force: Option<bool>,
36 scope: Option<String>,
37 project_root: Option<String>,
38 ) -> Result<String, String> {
39 let scope_str = scope.as_deref().unwrap_or("global");
40 match scope_str {
41 "global" => self.pkg_link_global(path, name, force).await,
42 "variant" => {
43 if force == Some(true) {
49 return Err(
50 "force is not supported with scope='variant' (variant scope writes \
51 alc.local.toml; there is no filesystem destination to overwrite)"
52 .to_string(),
53 );
54 }
55 self.pkg_link_variant(path, name, project_root).await
56 }
57 other => Err(format!(
58 "invalid scope: '{other}' (expected 'global' or 'variant')"
59 )),
60 }
61 }
62
63 async fn pkg_link_global(
65 &self,
66 path: String,
67 name: Option<String>,
68 force: Option<bool>,
69 ) -> Result<String, String> {
70 #[cfg(not(unix))]
71 {
72 let _ = (path, name, force);
73 return Err(
74 "pkg_link scope='global' is not supported on non-Unix platforms".to_string(),
75 );
76 }
77
78 #[cfg(unix)]
79 {
80 let force = force.unwrap_or(false);
81
82 let raw = Path::new(&path);
84 let source: PathBuf = if raw.is_absolute() {
85 raw.to_path_buf()
86 } else {
87 std::env::current_dir()
88 .map_err(|e| format!("Cannot determine cwd: {e}"))?
89 .join(raw)
90 };
91
92 if !source.is_dir() {
93 return Err(format!("Path is not a directory: {}", source.display()));
94 }
95
96 let mode = detect_mode(&source)?;
98
99 let pkgs = packages_dir(&self.log_config.app_dir());
101 std::fs::create_dir_all(&pkgs)
102 .map_err(|e| format!("Cannot create packages dir {}: {e}", pkgs.display()))?;
103
104 let mode_str;
106 let mut linked_names: Vec<String> = Vec::new();
107 let mut targets: serde_json::Map<String, serde_json::Value> = serde_json::Map::new();
108
109 match mode {
110 PackageMode::Single => {
111 mode_str = "single";
112 let pkg_name = if let Some(n) = name {
113 n
114 } else {
115 source
116 .file_name()
117 .ok_or_else(|| {
118 format!("Cannot determine package name from: {}", source.display())
119 })?
120 .to_string_lossy()
121 .to_string()
122 };
123 validate_package_name(&pkg_name)?;
124
125 let dest = pkgs.join(&pkg_name);
126 create_symlink(&source, &dest, force)?;
127
128 targets.insert(
129 pkg_name.clone(),
130 serde_json::Value::String(source.display().to_string()),
131 );
132 linked_names.push(pkg_name);
133 }
134 PackageMode::Collection => {
135 mode_str = "collection";
136 let entries = std::fs::read_dir(&source).map_err(|e| {
137 format!("Failed to read directory {}: {e}", source.display())
138 })?;
139
140 for entry in entries {
141 let entry =
142 entry.map_err(|e| format!("Failed to read directory entry: {e}"))?;
143 let pkg_path = entry.path();
144 if !pkg_path.is_dir() || !pkg_path.join("init.lua").exists() {
146 continue;
147 }
148 let pkg_name = entry.file_name().to_string_lossy().to_string();
149 validate_package_name(&pkg_name)?;
150
151 let dest = pkgs.join(&pkg_name);
152 create_symlink(&pkg_path, &dest, force)?;
153
154 targets.insert(
155 pkg_name.clone(),
156 serde_json::Value::String(pkg_path.display().to_string()),
157 );
158 linked_names.push(pkg_name);
159 }
160
161 if linked_names.is_empty() {
162 return Err(format!(
163 "No init.lua found in any subdirectory of: {}",
164 source.display()
165 ));
166 }
167
168 linked_names.sort();
169 }
170 }
171
172 Ok(serde_json::json!({
173 "linked": linked_names,
174 "mode": mode_str,
175 "targets": targets,
176 "scope": "global",
177 })
178 .to_string())
179 }
180 }
181
182 async fn pkg_link_variant(
184 &self,
185 path: String,
186 name: Option<String>,
187 project_root: Option<String>,
188 ) -> Result<String, String> {
189 let raw = Path::new(&path);
191 let source: PathBuf = if raw.is_absolute() {
192 raw.to_path_buf()
193 } else {
194 std::env::current_dir()
195 .map_err(|e| format!("Cannot determine cwd: {e}"))?
196 .join(raw)
197 };
198
199 if !source.is_dir() {
200 return Err(format!("Path is not a directory: {}", source.display()));
201 }
202
203 let mode = detect_mode(&source)?;
205
206 let root = self.resolve_root(project_root.as_deref()).ok_or_else(|| {
208 "No project root found. Pass project_root or activate via alc_session_new, set ALC_PROJECT_ROOT, or run from within a project containing alc.toml.".to_string()
209 })?;
210
211 let mut doc = match alc_toml::load_alc_local_toml_document(&root)? {
213 Some(d) => d,
214 None => "[packages]\n"
215 .parse::<toml_edit::DocumentMut>()
216 .map_err(|e| format!("Failed to create empty alc.local.toml document: {e}"))?,
217 };
218
219 let mode_str;
221 let mut linked_names: Vec<String> = Vec::new();
222 let mut targets: serde_json::Map<String, serde_json::Value> = serde_json::Map::new();
223
224 match mode {
225 PackageMode::Single => {
226 mode_str = "single";
227 let pkg_name = if let Some(n) = name {
228 n
229 } else {
230 source
231 .file_name()
232 .ok_or_else(|| {
233 format!("Cannot determine package name from: {}", source.display())
234 })?
235 .to_string_lossy()
236 .to_string()
237 };
238 validate_package_name(&pkg_name)?;
239
240 let abs = source.display().to_string();
241 let added = add_package_entry(
242 &mut doc,
243 &pkg_name,
244 &PackageDep::Path {
245 path: abs.clone(),
246 version: None,
247 },
248 );
249
250 targets.insert(pkg_name.clone(), serde_json::Value::String(abs));
251 if added {
252 linked_names.push(pkg_name);
253 }
254 }
255 PackageMode::Collection => {
256 mode_str = "collection";
257 let entries = std::fs::read_dir(&source)
258 .map_err(|e| format!("Failed to read directory {}: {e}", source.display()))?;
259
260 let mut candidates: Vec<(String, String)> = Vec::new();
261 for entry in entries {
262 let entry =
263 entry.map_err(|e| format!("Failed to read directory entry: {e}"))?;
264 let pkg_path = entry.path();
265 if !pkg_path.is_dir() || !pkg_path.join("init.lua").exists() {
266 continue;
267 }
268 let pkg_name = entry.file_name().to_string_lossy().to_string();
269 validate_package_name(&pkg_name)?;
270 candidates.push((pkg_name, pkg_path.display().to_string()));
271 }
272
273 if candidates.is_empty() {
274 return Err(format!(
275 "No init.lua found in any subdirectory of: {}",
276 source.display()
277 ));
278 }
279
280 candidates.sort();
281 for (pkg_name, abs) in candidates {
282 let added = add_package_entry(
283 &mut doc,
284 &pkg_name,
285 &PackageDep::Path {
286 path: abs.clone(),
287 version: None,
288 },
289 );
290 targets.insert(pkg_name.clone(), serde_json::Value::String(abs));
291 if added {
292 linked_names.push(pkg_name);
293 }
294 }
295 }
296 }
297
298 alc_toml::save_alc_local_toml(&root, &doc)?;
300
301 let alc_local_path = alc_toml::local_alc_toml_path(&root);
302
303 Ok(serde_json::json!({
304 "linked": linked_names,
305 "mode": mode_str,
306 "targets": targets,
307 "scope": "variant",
308 "alc_local_toml": alc_local_path.display().to_string(),
309 })
310 .to_string())
311 }
312}
313
314#[derive(Debug, Clone, Copy, PartialEq)]
317enum PackageMode {
318 Single,
319 Collection,
320}
321
322fn detect_mode(path: &Path) -> Result<PackageMode, String> {
324 if path.join("init.lua").exists() {
325 return Ok(PackageMode::Single);
326 }
327
328 let entries = std::fs::read_dir(path).map_err(|e| format!("Failed to read directory: {e}"))?;
329
330 for entry in entries {
331 let entry = entry.map_err(|e| format!("Failed to read directory entry: {e}"))?;
332 let sub = entry.path();
333 if sub.is_dir() && sub.join("init.lua").exists() {
334 return Ok(PackageMode::Collection);
335 }
336 }
337
338 Err(format!(
339 "No init.lua found in {} or any of its subdirectories",
340 path.display()
341 ))
342}
343
344#[cfg(unix)]
350fn create_symlink(source: &Path, dest: &Path, force: bool) -> Result<(), String> {
351 let meta = dest.symlink_metadata();
353
354 if let Ok(m) = meta {
355 if m.file_type().is_symlink() {
356 std::fs::remove_file(dest).map_err(|e| {
358 format!("Failed to remove existing symlink {}: {e}", dest.display())
359 })?;
360 } else if m.is_dir() {
361 if !force {
363 return Err(format!(
364 "Destination '{}' is a real directory. Use force=true to overwrite.",
365 dest.display()
366 ));
367 }
368 std::fs::remove_dir_all(dest)
369 .map_err(|e| format!("Failed to remove directory {}: {e}", dest.display()))?;
370 } else {
371 std::fs::remove_file(dest)
373 .map_err(|e| format!("Failed to remove {}: {e}", dest.display()))?;
374 }
375 }
376
377 symlink(source, dest).map_err(|e| {
378 format!(
379 "Failed to create symlink {} -> {}: {e}",
380 dest.display(),
381 source.display()
382 )
383 })
384}
385
386#[cfg(all(test, unix))]
389mod tests {
390 use super::*;
391 use crate::service::test_support::make_app_service_at;
392
393 #[tokio::test]
394 async fn pkg_link_single_creates_symlink() {
395 let tmp = tempfile::tempdir().unwrap();
396 let home = tmp.path();
397
398 let src = home.join("my_pkg");
399 std::fs::create_dir_all(&src).unwrap();
400 std::fs::write(src.join("init.lua"), "return {}").unwrap();
401
402 let svc = make_app_service_at(home.to_path_buf()).await;
403 let result = svc
404 .pkg_link(src.to_string_lossy().to_string(), None, None, None, None)
405 .await
406 .unwrap();
407
408 let json: serde_json::Value = serde_json::from_str(&result).unwrap();
409 assert_eq!(json["mode"], "single");
410 assert_eq!(json["linked"], serde_json::json!(["my_pkg"]));
411 assert_eq!(json["targets"]["my_pkg"], src.to_string_lossy().as_ref());
412
413 let dest = home.join("packages").join("my_pkg");
414 assert!(dest.symlink_metadata().unwrap().file_type().is_symlink());
415 assert_eq!(std::fs::read_link(&dest).unwrap(), src);
416 }
417
418 #[tokio::test]
419 async fn pkg_link_collection_creates_symlinks() {
420 let tmp = tempfile::tempdir().unwrap();
421 let home = tmp.path();
422
423 let coll = home.join("collection");
424 std::fs::create_dir_all(coll.join("pkg_a")).unwrap();
425 std::fs::create_dir_all(coll.join("pkg_b")).unwrap();
426 std::fs::write(coll.join("pkg_a").join("init.lua"), "return {}").unwrap();
427 std::fs::write(coll.join("pkg_b").join("init.lua"), "return {}").unwrap();
428
429 let svc = make_app_service_at(home.to_path_buf()).await;
430 let result = svc
431 .pkg_link(coll.to_string_lossy().to_string(), None, None, None, None)
432 .await
433 .unwrap();
434
435 let json: serde_json::Value = serde_json::from_str(&result).unwrap();
436 assert_eq!(json["mode"], "collection");
437
438 let linked = json["linked"].as_array().unwrap();
439 let mut names: Vec<&str> = linked.iter().map(|v| v.as_str().unwrap()).collect();
440 names.sort();
441 assert_eq!(names, ["pkg_a", "pkg_b"]);
442
443 let pkgs = home.join("packages");
444 assert!(pkgs
445 .join("pkg_a")
446 .symlink_metadata()
447 .unwrap()
448 .file_type()
449 .is_symlink());
450 assert!(pkgs
451 .join("pkg_b")
452 .symlink_metadata()
453 .unwrap()
454 .file_type()
455 .is_symlink());
456 }
457
458 #[tokio::test]
459 async fn pkg_link_overwrites_existing_symlink() {
460 let tmp = tempfile::tempdir().unwrap();
461 let home = tmp.path();
462
463 let src = home.join("my_pkg");
464 std::fs::create_dir_all(&src).unwrap();
465 std::fs::write(src.join("init.lua"), "return {}").unwrap();
466
467 let pkgs = home.join("packages");
468 std::fs::create_dir_all(&pkgs).unwrap();
469 let dest = pkgs.join("my_pkg");
470 symlink(&src, &dest).unwrap();
471
472 let svc = make_app_service_at(home.to_path_buf()).await;
473 let result = svc
474 .pkg_link(src.to_string_lossy().to_string(), None, None, None, None)
475 .await
476 .unwrap();
477
478 let json: serde_json::Value = serde_json::from_str(&result).unwrap();
479 assert_eq!(json["linked"], serde_json::json!(["my_pkg"]));
480 assert!(dest.symlink_metadata().unwrap().file_type().is_symlink());
481 }
482
483 #[tokio::test]
484 async fn pkg_link_real_dir_requires_force() {
485 let tmp = tempfile::tempdir().unwrap();
486 let home = tmp.path();
487
488 let src = home.join("my_pkg");
489 std::fs::create_dir_all(&src).unwrap();
490 std::fs::write(src.join("init.lua"), "return {}").unwrap();
491
492 let pkgs = home.join("packages");
493 let dest = pkgs.join("my_pkg");
494 std::fs::create_dir_all(&dest).unwrap();
495
496 let svc = make_app_service_at(home.to_path_buf()).await;
497
498 let err = svc
499 .pkg_link(src.to_string_lossy().to_string(), None, None, None, None)
500 .await
501 .unwrap_err();
502 assert!(
503 err.contains("real directory"),
504 "expected real directory error, got: {err}"
505 );
506
507 let result = svc
508 .pkg_link(
509 src.to_string_lossy().to_string(),
510 None,
511 Some(true),
512 None,
513 None,
514 )
515 .await
516 .unwrap();
517 let json: serde_json::Value = serde_json::from_str(&result).unwrap();
518 assert_eq!(json["linked"], serde_json::json!(["my_pkg"]));
519 assert!(dest.symlink_metadata().unwrap().file_type().is_symlink());
520 }
521
522 #[tokio::test]
523 async fn pkg_link_dangling_symlink_overwritten() {
524 let tmp = tempfile::tempdir().unwrap();
525 let home = tmp.path();
526
527 let src = home.join("my_pkg");
528 std::fs::create_dir_all(&src).unwrap();
529 std::fs::write(src.join("init.lua"), "return {}").unwrap();
530
531 let pkgs = home.join("packages");
532 std::fs::create_dir_all(&pkgs).unwrap();
533 let dest = pkgs.join("my_pkg");
534 symlink(home.join("nonexistent"), &dest).unwrap();
535 assert!(!dest.exists()); let svc = make_app_service_at(home.to_path_buf()).await;
538 let result = svc
539 .pkg_link(src.to_string_lossy().to_string(), None, None, None, None)
540 .await
541 .unwrap();
542
543 let json: serde_json::Value = serde_json::from_str(&result).unwrap();
544 assert_eq!(json["linked"], serde_json::json!(["my_pkg"]));
545 assert!(dest.symlink_metadata().unwrap().file_type().is_symlink());
546 assert!(dest.exists()); }
548
549 #[tokio::test]
550 async fn pkg_link_path_not_found_returns_error() {
551 let tmp = tempfile::tempdir().unwrap();
552 let home = tmp.path();
553 let nonexistent = home.join("does_not_exist");
554
555 let svc = make_app_service_at(home.to_path_buf()).await;
556 let err = svc
557 .pkg_link(
558 nonexistent.to_string_lossy().to_string(),
559 None,
560 None,
561 None,
562 None,
563 )
564 .await
565 .unwrap_err();
566 assert!(err.contains("not a directory"), "got: {err}");
567 }
568
569 #[tokio::test]
572 async fn pkg_link_scope_variant_appends_to_alc_local_toml() {
573 let tmp = tempfile::tempdir().unwrap();
574 let home = tmp.path();
575 let root = home.join("proj");
576 std::fs::create_dir_all(&root).unwrap();
577 let src = home.join("my_pkg");
579 std::fs::create_dir_all(&src).unwrap();
580 std::fs::write(src.join("init.lua"), "return {}").unwrap();
581
582 let svc = make_app_service_at(home.to_path_buf()).await;
583 let result = svc
584 .pkg_link(
585 src.to_string_lossy().to_string(),
586 None,
587 None,
588 Some("variant".to_string()),
589 Some(root.to_string_lossy().to_string()),
590 )
591 .await
592 .unwrap();
593
594 let json: serde_json::Value = serde_json::from_str(&result).unwrap();
595 assert_eq!(json["scope"], "variant");
596 assert_eq!(json["mode"], "single");
597 assert_eq!(json["linked"], serde_json::json!(["my_pkg"]));
598
599 let local = root.join("alc.local.toml");
601 assert!(local.exists());
602 let content = std::fs::read_to_string(&local).unwrap();
603 assert!(content.contains("my_pkg"));
604 assert!(content.contains(src.to_string_lossy().as_ref()));
605 }
606
607 #[tokio::test]
608 async fn pkg_link_scope_variant_no_symlink_created() {
609 let tmp = tempfile::tempdir().unwrap();
610 let home = tmp.path();
611 let root = home.join("proj");
612 std::fs::create_dir_all(&root).unwrap();
613 let src = home.join("my_pkg");
614 std::fs::create_dir_all(&src).unwrap();
615 std::fs::write(src.join("init.lua"), "return {}").unwrap();
616
617 let svc = make_app_service_at(home.to_path_buf()).await;
618 svc.pkg_link(
619 src.to_string_lossy().to_string(),
620 None,
621 None,
622 Some("variant".to_string()),
623 Some(root.to_string_lossy().to_string()),
624 )
625 .await
626 .unwrap();
627
628 let cache_link = home.join("packages").join("my_pkg");
629 assert!(
630 cache_link.symlink_metadata().is_err(),
631 "variant scope must not create a symlink in ~/.algocline/packages/"
632 );
633 }
634
635 #[tokio::test]
636 async fn pkg_link_scope_variant_second_call_is_noop_for_existing_entry() {
637 let tmp = tempfile::tempdir().unwrap();
638 let home = tmp.path();
639 let root = home.join("proj");
640 std::fs::create_dir_all(&root).unwrap();
641 let src = home.join("my_pkg");
642 std::fs::create_dir_all(&src).unwrap();
643 std::fs::write(src.join("init.lua"), "return {}").unwrap();
644
645 let svc = make_app_service_at(home.to_path_buf()).await;
646 svc.pkg_link(
647 src.to_string_lossy().to_string(),
648 None,
649 None,
650 Some("variant".to_string()),
651 Some(root.to_string_lossy().to_string()),
652 )
653 .await
654 .unwrap();
655
656 let result = svc
658 .pkg_link(
659 src.to_string_lossy().to_string(),
660 None,
661 None,
662 Some("variant".to_string()),
663 Some(root.to_string_lossy().to_string()),
664 )
665 .await
666 .unwrap();
667
668 let json: serde_json::Value = serde_json::from_str(&result).unwrap();
669 assert_eq!(json["linked"], serde_json::json!([]));
670 assert_eq!(json["targets"]["my_pkg"], src.to_string_lossy().as_ref());
672
673 let local = root.join("alc.local.toml");
678 let content = std::fs::read_to_string(&local).unwrap();
679 let doc: toml_edit::DocumentMut = content.parse().unwrap();
680 let pkgs = doc["packages"].as_table().unwrap();
681 let key_count = pkgs.iter().filter(|(k, _)| *k == "my_pkg").count();
682 assert_eq!(key_count, 1, "duplicate entry written: {content}");
683 }
684
685 #[tokio::test]
686 async fn pkg_link_scope_variant_requires_project_root() {
687 let tmp = tempfile::tempdir().unwrap();
688 let home = tmp.path();
689 let src = home.join("my_pkg");
690 std::fs::create_dir_all(&src).unwrap();
691 std::fs::write(src.join("init.lua"), "return {}").unwrap();
692
693 let svc = make_app_service_at(home.to_path_buf()).await;
694 let nonexistent = home.join("no_such_project_root_zzz");
698 let err = svc
699 .pkg_link(
700 src.to_string_lossy().to_string(),
701 None,
702 None,
703 Some("variant".to_string()),
704 Some(nonexistent.to_string_lossy().to_string()),
705 )
706 .await;
707 if let Err(e) = err {
712 assert!(e.contains("No project root found"), "unexpected err: {e}");
713 }
714 }
715
716 #[tokio::test]
717 async fn pkg_link_invalid_scope_returns_error() {
718 let tmp = tempfile::tempdir().unwrap();
719 let home = tmp.path();
720 let src = home.join("my_pkg");
721 std::fs::create_dir_all(&src).unwrap();
722 std::fs::write(src.join("init.lua"), "return {}").unwrap();
723
724 let svc = make_app_service_at(home.to_path_buf()).await;
725 let err = svc
726 .pkg_link(
727 src.to_string_lossy().to_string(),
728 None,
729 None,
730 Some("unknown".to_string()),
731 None,
732 )
733 .await
734 .unwrap_err();
735 assert!(err.contains("invalid scope"), "got: {err}");
736 }
737
738 #[tokio::test]
739 async fn pkg_link_scope_global_default_matches_existing_behavior() {
740 let tmp = tempfile::tempdir().unwrap();
743 let home = tmp.path();
744
745 let src = home.join("my_pkg");
746 std::fs::create_dir_all(&src).unwrap();
747 std::fs::write(src.join("init.lua"), "return {}").unwrap();
748
749 let svc = make_app_service_at(home.to_path_buf()).await;
750 let result = svc
751 .pkg_link(
752 src.to_string_lossy().to_string(),
753 None,
754 None,
755 Some("global".to_string()),
756 None,
757 )
758 .await
759 .unwrap();
760
761 let json: serde_json::Value = serde_json::from_str(&result).unwrap();
762 assert_eq!(json["scope"], "global");
763 assert_eq!(json["linked"], serde_json::json!(["my_pkg"]));
764 let dest = home.join("packages").join("my_pkg");
765 assert!(dest.symlink_metadata().unwrap().file_type().is_symlink());
766 }
767
768 #[tokio::test]
769 async fn pkg_link_scope_variant_collection_appends_all() {
770 let tmp = tempfile::tempdir().unwrap();
771 let home = tmp.path();
772 let root = home.join("proj");
773 std::fs::create_dir_all(&root).unwrap();
774 let coll = home.join("collection");
775 std::fs::create_dir_all(coll.join("pkg_a")).unwrap();
776 std::fs::create_dir_all(coll.join("pkg_b")).unwrap();
777 std::fs::write(coll.join("pkg_a").join("init.lua"), "return {}").unwrap();
778 std::fs::write(coll.join("pkg_b").join("init.lua"), "return {}").unwrap();
779
780 let svc = make_app_service_at(home.to_path_buf()).await;
781 let result = svc
782 .pkg_link(
783 coll.to_string_lossy().to_string(),
784 None,
785 None,
786 Some("variant".to_string()),
787 Some(root.to_string_lossy().to_string()),
788 )
789 .await
790 .unwrap();
791
792 let json: serde_json::Value = serde_json::from_str(&result).unwrap();
793 assert_eq!(json["scope"], "variant");
794 assert_eq!(json["mode"], "collection");
795 let linked = json["linked"].as_array().unwrap();
796 let names: Vec<&str> = linked.iter().map(|v| v.as_str().unwrap()).collect();
797 assert_eq!(names, ["pkg_a", "pkg_b"]);
798
799 let local = root.join("alc.local.toml");
800 let content = std::fs::read_to_string(&local).unwrap();
801 assert!(content.contains("pkg_a"));
802 assert!(content.contains("pkg_b"));
803 }
804
805 #[tokio::test]
809 async fn pkg_link_scope_variant_rejects_force() {
810 let tmp = tempfile::tempdir().unwrap();
811 let home = tmp.path();
812 let root = home.join("proj");
813 std::fs::create_dir_all(&root).unwrap();
814 let src = home.join("my_pkg");
815 std::fs::create_dir_all(&src).unwrap();
816 std::fs::write(src.join("init.lua"), "return {}").unwrap();
817
818 let svc = make_app_service_at(home.to_path_buf()).await;
819 let err = svc
820 .pkg_link(
821 src.to_string_lossy().to_string(),
822 None,
823 Some(true),
824 Some("variant".to_string()),
825 Some(root.to_string_lossy().to_string()),
826 )
827 .await
828 .unwrap_err();
829 assert!(
830 err.contains("force is not supported with scope='variant'"),
831 "got: {err}"
832 );
833
834 assert!(
836 !root.join("alc.local.toml").exists(),
837 "alc.local.toml must not be written when the call is rejected"
838 );
839 }
840
841 #[tokio::test]
843 async fn pkg_link_scope_variant_accepts_force_false() {
844 let tmp = tempfile::tempdir().unwrap();
845 let home = tmp.path();
846 let root = home.join("proj");
847 std::fs::create_dir_all(&root).unwrap();
848 let src = home.join("my_pkg");
849 std::fs::create_dir_all(&src).unwrap();
850 std::fs::write(src.join("init.lua"), "return {}").unwrap();
851
852 let svc = make_app_service_at(home.to_path_buf()).await;
853 let result = svc
854 .pkg_link(
855 src.to_string_lossy().to_string(),
856 None,
857 Some(false),
858 Some("variant".to_string()),
859 Some(root.to_string_lossy().to_string()),
860 )
861 .await
862 .unwrap();
863
864 let json: serde_json::Value = serde_json::from_str(&result).unwrap();
865 assert_eq!(json["scope"], "variant");
866 assert_eq!(json["linked"], serde_json::json!(["my_pkg"]));
867 }
868}