Skip to main content

algocline_app/service/
pkg_link.rs

1//! `alc_pkg_link` — link a local directory as a package.
2//!
3//! Two scopes:
4//! - **global** (default): creates a symlink in `~/.algocline/packages/{name}`.
5//!   Equivalent to `npm link`. No files are copied; no alc.lock is written.
6//! - **variant**: appends a `[packages.{name}]` entry to `alc.local.toml`
7//!   at the project root. Worktree-scoped override (git-ignored, loaded
8//!   every `alc_run`). No symlink is created.
9
10#[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    /// Link a local directory as a package.
22    ///
23    /// - `scope = None | Some("global")`: create a symlink in
24    ///   `~/.algocline/packages/{name}`. Unix-only (symlink).
25    /// - `scope = Some("variant")`: record the path in `alc.local.toml`
26    ///   at the project root. Works on all platforms (no symlink).
27    /// - Any other scope value → `Err`.
28    ///
29    /// `force` is only meaningful in `global` scope (overwrite real dir).
30    /// `project_root` is only consulted in `variant` scope.
31    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                // `force` has no meaning under variant scope — the op is a
44                // `[packages.{name}]` upsert into `alc.local.toml`, not a
45                // filesystem overwrite. Silently ignoring a caller-supplied
46                // `force=true` masks intent (user expected overwrite but got
47                // a no-op on an existing entry), so reject it explicitly.
48                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    /// `scope = global` — create a symlink in `~/.algocline/packages/{name}`.
64    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            // 1. Resolve source path (absolute: use as-is; relative: join with cwd).
83            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            // 2. Detect mode: single package (init.lua at root) or collection.
97            let mode = detect_mode(&source)?;
98
99            // 3. Get packages_dir.
100            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            // 4. Link packages.
105            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                        // Skip non-dirs and dirs without init.lua.
145                        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    /// `scope = variant` — record the path in `alc.local.toml`.
183    async fn pkg_link_variant(
184        &self,
185        path: String,
186        name: Option<String>,
187        project_root: Option<String>,
188    ) -> Result<String, String> {
189        // 1. Resolve source path.
190        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        // 2. Detect mode.
204        let mode = detect_mode(&source)?;
205
206        // 3. Resolve project root (P > S > E > W).
207        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        // 4. Load or create alc.local.toml document.
212        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        // 5. Build entries to add.
220        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        // 6. Save.
299        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// ─── Internal helpers ────────────────────────────────────────────
315
316#[derive(Debug, Clone, Copy, PartialEq)]
317enum PackageMode {
318    Single,
319    Collection,
320}
321
322/// Determine whether `path` is a single package or a collection.
323fn 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/// Create a symlink at `dest` pointing to `source`.
345///
346/// - If `dest` is a symlink (including dangling): always overwrite (remove + recreate).
347/// - If `dest` is a real directory: require `force == true`; remove with `remove_dir_all`.
348/// - If `dest` does not exist: create directly.
349#[cfg(unix)]
350fn create_symlink(source: &Path, dest: &Path, force: bool) -> Result<(), String> {
351    // Check symlink status first (symlink_metadata does not follow symlinks).
352    let meta = dest.symlink_metadata();
353
354    if let Ok(m) = meta {
355        if m.file_type().is_symlink() {
356            // Existing symlink (live or dangling) — always overwrite.
357            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            // Real directory — require force.
362            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            // Regular file or other — overwrite regardless.
372            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// ─── Tests ───────────────────────────────────────────────────────
387
388#[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()); // dangling
536
537        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()); // no longer dangling
547    }
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    // ── scope = variant ───────────────────────────────────────────────
570
571    #[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        // Source pkg.
578        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        // File was written.
600        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        // Second call — entry already exists, should be linked:[] (skipped).
657        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        // targets still records the current state.
671        assert_eq!(json["targets"]["my_pkg"], src.to_string_lossy().as_ref());
672
673        // File must still contain exactly one entry.
674        // Parse the TOML and count keys under [packages] rather than
675        // substring-match "my_pkg", because the path value also contains
676        // that literal ("/.../my_pkg").
677        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        // No project_root, no ALC_PROJECT_ROOT (test env doesn't set it).
695        // cwd walks up from test runner cwd — unlikely to find alc.toml ancestor.
696        // Use an invalid explicit path to force fallback + fail.
697        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        // Note: fallback may succeed via cwd walk-up to a real alc.toml;
708        // the best we can reliably assert is that EITHER:
709        // (a) the call errored with "No project root found"
710        // (b) the call succeeded with some valid root
711        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        // Explicit scope=Some("global") should behave exactly as scope=None
741        // (the default path).
742        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    /// `scope = variant` with `force = Some(true)` must be rejected — there is
806    /// no filesystem destination to overwrite, so silently ignoring `force`
807    /// would mask caller intent.
808    #[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        // alc.local.toml must not have been written.
835        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    /// `scope = variant` with `force = Some(false)` is allowed (same as None).
842    #[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}