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};
16use super::project::resolve_project_root;
17#[cfg(unix)]
18use super::resolve::packages_dir;
19use super::AppService;
20
21impl AppService {
22    /// Link a local directory as a package.
23    ///
24    /// - `scope = None | Some("global")`: create a symlink in
25    ///   `~/.algocline/packages/{name}`. Unix-only (symlink).
26    /// - `scope = Some("variant")`: record the path in `alc.local.toml`
27    ///   at the project root. Works on all platforms (no symlink).
28    /// - Any other scope value → `Err`.
29    ///
30    /// `force` is only meaningful in `global` scope (overwrite real dir).
31    /// `project_root` is only consulted in `variant` scope.
32    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                // `force` has no meaning under variant scope — the op is a
45                // `[packages.{name}]` upsert into `alc.local.toml`, not a
46                // filesystem overwrite. Silently ignoring a caller-supplied
47                // `force=true` masks intent (user expected overwrite but got
48                // a no-op on an existing entry), so reject it explicitly.
49                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    /// `scope = global` — create a symlink in `~/.algocline/packages/{name}`.
65    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            // 1. Resolve source path (absolute: use as-is; relative: join with cwd).
84            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            // 2. Detect mode: single package (init.lua at root) or collection.
98            let mode = detect_mode(&source)?;
99
100            // 3. Get packages_dir.
101            let pkgs = packages_dir()?;
102            std::fs::create_dir_all(&pkgs)
103                .map_err(|e| format!("Cannot create packages dir {}: {e}", pkgs.display()))?;
104
105            // 4. Link packages.
106            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                        // Skip non-dirs and dirs without init.lua.
146                        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    /// `scope = variant` — record the path in `alc.local.toml`.
184    async fn pkg_link_variant(
185        &self,
186        path: String,
187        name: Option<String>,
188        project_root: Option<String>,
189    ) -> Result<String, String> {
190        // 1. Resolve source path.
191        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        // 2. Detect mode.
205        let mode = detect_mode(&source)?;
206
207        // 3. Resolve project root.
208        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        // 4. Load or create alc.local.toml document.
213        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        // 5. Build entries to add.
221        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        // 6. Save.
300        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// ─── Internal helpers ────────────────────────────────────────────
316
317#[derive(Debug, Clone, Copy, PartialEq)]
318enum PackageMode {
319    Single,
320    Collection,
321}
322
323/// Determine whether `path` is a single package or a collection.
324fn 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/// Create a symlink at `dest` pointing to `source`.
346///
347/// - If `dest` is a symlink (including dangling): always overwrite (remove + recreate).
348/// - If `dest` is a real directory: require `force == true`; remove with `remove_dir_all`.
349/// - If `dest` does not exist: create directly.
350#[cfg(unix)]
351fn create_symlink(source: &Path, dest: &Path, force: bool) -> Result<(), String> {
352    // Check symlink status first (symlink_metadata does not follow symlinks).
353    let meta = dest.symlink_metadata();
354
355    if let Ok(m) = meta {
356        if m.file_type().is_symlink() {
357            // Existing symlink (live or dangling) — always overwrite.
358            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            // Real directory — require force.
363            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            // Regular file or other — overwrite regardless.
373            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// ─── Tests ───────────────────────────────────────────────────────
388
389#[cfg(all(test, unix))]
390mod tests {
391    use super::*;
392    use crate::service::test_support::{make_app_service, FakeHome};
393
394    #[tokio::test]
395    async fn pkg_link_single_creates_symlink() {
396        let env = FakeHome::new();
397        let home = &env.home;
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().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(".algocline").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 env = FakeHome::new();
422        let home = &env.home;
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().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(".algocline").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 env = FakeHome::new();
462        let home = &env.home;
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(".algocline").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().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 env = FakeHome::new();
487        let home = &env.home;
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(".algocline").join("packages");
494        let dest = pkgs.join("my_pkg");
495        std::fs::create_dir_all(&dest).unwrap();
496
497        let svc = make_app_service().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 env = FakeHome::new();
526        let home = &env.home;
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(".algocline").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()); // dangling
537
538        let svc = make_app_service().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()); // no longer dangling
548    }
549
550    #[tokio::test]
551    async fn pkg_link_path_not_found_returns_error() {
552        let env = FakeHome::new();
553        let nonexistent = env.home.join("does_not_exist");
554
555        let svc = make_app_service().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 env = FakeHome::new();
574        let root = env.home.join("proj");
575        std::fs::create_dir_all(&root).unwrap();
576        // Source pkg.
577        let src = env.home.join("my_pkg");
578        std::fs::create_dir_all(&src).unwrap();
579        std::fs::write(src.join("init.lua"), "return {}").unwrap();
580
581        let svc = make_app_service().await;
582        let result = svc
583            .pkg_link(
584                src.to_string_lossy().to_string(),
585                None,
586                None,
587                Some("variant".to_string()),
588                Some(root.to_string_lossy().to_string()),
589            )
590            .await
591            .unwrap();
592
593        let json: serde_json::Value = serde_json::from_str(&result).unwrap();
594        assert_eq!(json["scope"], "variant");
595        assert_eq!(json["mode"], "single");
596        assert_eq!(json["linked"], serde_json::json!(["my_pkg"]));
597
598        // File was written.
599        let local = root.join("alc.local.toml");
600        assert!(local.exists());
601        let content = std::fs::read_to_string(&local).unwrap();
602        assert!(content.contains("my_pkg"));
603        assert!(content.contains(src.to_string_lossy().as_ref()));
604    }
605
606    #[tokio::test]
607    async fn pkg_link_scope_variant_no_symlink_created() {
608        let env = FakeHome::new();
609        let root = env.home.join("proj");
610        std::fs::create_dir_all(&root).unwrap();
611        let src = env.home.join("my_pkg");
612        std::fs::create_dir_all(&src).unwrap();
613        std::fs::write(src.join("init.lua"), "return {}").unwrap();
614
615        let svc = make_app_service().await;
616        svc.pkg_link(
617            src.to_string_lossy().to_string(),
618            None,
619            None,
620            Some("variant".to_string()),
621            Some(root.to_string_lossy().to_string()),
622        )
623        .await
624        .unwrap();
625
626        let cache_link = env.home.join(".algocline").join("packages").join("my_pkg");
627        assert!(
628            cache_link.symlink_metadata().is_err(),
629            "variant scope must not create a symlink in ~/.algocline/packages/"
630        );
631    }
632
633    #[tokio::test]
634    async fn pkg_link_scope_variant_second_call_is_noop_for_existing_entry() {
635        let env = FakeHome::new();
636        let root = env.home.join("proj");
637        std::fs::create_dir_all(&root).unwrap();
638        let src = env.home.join("my_pkg");
639        std::fs::create_dir_all(&src).unwrap();
640        std::fs::write(src.join("init.lua"), "return {}").unwrap();
641
642        let svc = make_app_service().await;
643        svc.pkg_link(
644            src.to_string_lossy().to_string(),
645            None,
646            None,
647            Some("variant".to_string()),
648            Some(root.to_string_lossy().to_string()),
649        )
650        .await
651        .unwrap();
652
653        // Second call — entry already exists, should be linked:[] (skipped).
654        let result = svc
655            .pkg_link(
656                src.to_string_lossy().to_string(),
657                None,
658                None,
659                Some("variant".to_string()),
660                Some(root.to_string_lossy().to_string()),
661            )
662            .await
663            .unwrap();
664
665        let json: serde_json::Value = serde_json::from_str(&result).unwrap();
666        assert_eq!(json["linked"], serde_json::json!([]));
667        // targets still records the current state.
668        assert_eq!(json["targets"]["my_pkg"], src.to_string_lossy().as_ref());
669
670        // File must still contain exactly one entry.
671        // Parse the TOML and count keys under [packages] rather than
672        // substring-match "my_pkg", because the path value also contains
673        // that literal ("/.../my_pkg").
674        let local = root.join("alc.local.toml");
675        let content = std::fs::read_to_string(&local).unwrap();
676        let doc: toml_edit::DocumentMut = content.parse().unwrap();
677        let pkgs = doc["packages"].as_table().unwrap();
678        let key_count = pkgs.iter().filter(|(k, _)| *k == "my_pkg").count();
679        assert_eq!(key_count, 1, "duplicate entry written: {content}");
680    }
681
682    #[tokio::test]
683    async fn pkg_link_scope_variant_requires_project_root() {
684        let env = FakeHome::new();
685        let src = env.home.join("my_pkg");
686        std::fs::create_dir_all(&src).unwrap();
687        std::fs::write(src.join("init.lua"), "return {}").unwrap();
688
689        let svc = make_app_service().await;
690        // No project_root, no ALC_PROJECT_ROOT (test env doesn't set it).
691        // cwd walks up from test runner cwd — unlikely to find alc.toml ancestor.
692        // Use an invalid explicit path to force fallback + fail.
693        let nonexistent = env.home.join("no_such_project_root_zzz");
694        let err = svc
695            .pkg_link(
696                src.to_string_lossy().to_string(),
697                None,
698                None,
699                Some("variant".to_string()),
700                Some(nonexistent.to_string_lossy().to_string()),
701            )
702            .await;
703        // Note: fallback may succeed via cwd walk-up to a real alc.toml;
704        // the best we can reliably assert is that EITHER:
705        // (a) the call errored with "No project root found"
706        // (b) the call succeeded with some valid root
707        if let Err(e) = err {
708            assert!(e.contains("No project root found"), "unexpected err: {e}");
709        }
710    }
711
712    #[tokio::test]
713    async fn pkg_link_invalid_scope_returns_error() {
714        let env = FakeHome::new();
715        let src = env.home.join("my_pkg");
716        std::fs::create_dir_all(&src).unwrap();
717        std::fs::write(src.join("init.lua"), "return {}").unwrap();
718
719        let svc = make_app_service().await;
720        let err = svc
721            .pkg_link(
722                src.to_string_lossy().to_string(),
723                None,
724                None,
725                Some("unknown".to_string()),
726                None,
727            )
728            .await
729            .unwrap_err();
730        assert!(err.contains("invalid scope"), "got: {err}");
731    }
732
733    #[tokio::test]
734    async fn pkg_link_scope_global_default_matches_existing_behavior() {
735        // Explicit scope=Some("global") should behave exactly as scope=None
736        // (the default path).
737        let env = FakeHome::new();
738        let home = &env.home;
739
740        let src = home.join("my_pkg");
741        std::fs::create_dir_all(&src).unwrap();
742        std::fs::write(src.join("init.lua"), "return {}").unwrap();
743
744        let svc = make_app_service().await;
745        let result = svc
746            .pkg_link(
747                src.to_string_lossy().to_string(),
748                None,
749                None,
750                Some("global".to_string()),
751                None,
752            )
753            .await
754            .unwrap();
755
756        let json: serde_json::Value = serde_json::from_str(&result).unwrap();
757        assert_eq!(json["scope"], "global");
758        assert_eq!(json["linked"], serde_json::json!(["my_pkg"]));
759        let dest = home.join(".algocline").join("packages").join("my_pkg");
760        assert!(dest.symlink_metadata().unwrap().file_type().is_symlink());
761    }
762
763    #[tokio::test]
764    async fn pkg_link_scope_variant_collection_appends_all() {
765        let env = FakeHome::new();
766        let root = env.home.join("proj");
767        std::fs::create_dir_all(&root).unwrap();
768        let coll = env.home.join("collection");
769        std::fs::create_dir_all(coll.join("pkg_a")).unwrap();
770        std::fs::create_dir_all(coll.join("pkg_b")).unwrap();
771        std::fs::write(coll.join("pkg_a").join("init.lua"), "return {}").unwrap();
772        std::fs::write(coll.join("pkg_b").join("init.lua"), "return {}").unwrap();
773
774        let svc = make_app_service().await;
775        let result = svc
776            .pkg_link(
777                coll.to_string_lossy().to_string(),
778                None,
779                None,
780                Some("variant".to_string()),
781                Some(root.to_string_lossy().to_string()),
782            )
783            .await
784            .unwrap();
785
786        let json: serde_json::Value = serde_json::from_str(&result).unwrap();
787        assert_eq!(json["scope"], "variant");
788        assert_eq!(json["mode"], "collection");
789        let linked = json["linked"].as_array().unwrap();
790        let names: Vec<&str> = linked.iter().map(|v| v.as_str().unwrap()).collect();
791        assert_eq!(names, ["pkg_a", "pkg_b"]);
792
793        let local = root.join("alc.local.toml");
794        let content = std::fs::read_to_string(&local).unwrap();
795        assert!(content.contains("pkg_a"));
796        assert!(content.contains("pkg_b"));
797    }
798
799    /// `scope = variant` with `force = Some(true)` must be rejected — there is
800    /// no filesystem destination to overwrite, so silently ignoring `force`
801    /// would mask caller intent.
802    #[tokio::test]
803    async fn pkg_link_scope_variant_rejects_force() {
804        let env = FakeHome::new();
805        let root = env.home.join("proj");
806        std::fs::create_dir_all(&root).unwrap();
807        let src = env.home.join("my_pkg");
808        std::fs::create_dir_all(&src).unwrap();
809        std::fs::write(src.join("init.lua"), "return {}").unwrap();
810
811        let svc = make_app_service().await;
812        let err = svc
813            .pkg_link(
814                src.to_string_lossy().to_string(),
815                None,
816                Some(true),
817                Some("variant".to_string()),
818                Some(root.to_string_lossy().to_string()),
819            )
820            .await
821            .unwrap_err();
822        assert!(
823            err.contains("force is not supported with scope='variant'"),
824            "got: {err}"
825        );
826
827        // alc.local.toml must not have been written.
828        assert!(
829            !root.join("alc.local.toml").exists(),
830            "alc.local.toml must not be written when the call is rejected"
831        );
832    }
833
834    /// `scope = variant` with `force = Some(false)` is allowed (same as None).
835    #[tokio::test]
836    async fn pkg_link_scope_variant_accepts_force_false() {
837        let env = FakeHome::new();
838        let root = env.home.join("proj");
839        std::fs::create_dir_all(&root).unwrap();
840        let src = env.home.join("my_pkg");
841        std::fs::create_dir_all(&src).unwrap();
842        std::fs::write(src.join("init.lua"), "return {}").unwrap();
843
844        let svc = make_app_service().await;
845        let result = svc
846            .pkg_link(
847                src.to_string_lossy().to_string(),
848                None,
849                Some(false),
850                Some("variant".to_string()),
851                Some(root.to_string_lossy().to_string()),
852            )
853            .await
854            .unwrap();
855
856        let json: serde_json::Value = serde_json::from_str(&result).unwrap();
857        assert_eq!(json["scope"], "variant");
858        assert_eq!(json["linked"], serde_json::json!(["my_pkg"]));
859    }
860}