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