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(&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            // 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_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()); // dangling
537
538        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()); // no longer dangling
548    }
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    // ── scope = variant ───────────────────────────────────────────────
571
572    #[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        // Source pkg.
579        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        // File was written.
601        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        // Second call — entry already exists, should be linked:[] (skipped).
658        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        // targets still records the current state.
672        assert_eq!(json["targets"]["my_pkg"], src.to_string_lossy().as_ref());
673
674        // File must still contain exactly one entry.
675        // Parse the TOML and count keys under [packages] rather than
676        // substring-match "my_pkg", because the path value also contains
677        // that literal ("/.../my_pkg").
678        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        // No project_root, no ALC_PROJECT_ROOT (test env doesn't set it).
696        // cwd walks up from test runner cwd — unlikely to find alc.toml ancestor.
697        // Use an invalid explicit path to force fallback + fail.
698        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        // Note: fallback may succeed via cwd walk-up to a real alc.toml;
709        // the best we can reliably assert is that EITHER:
710        // (a) the call errored with "No project root found"
711        // (b) the call succeeded with some valid root
712        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        // Explicit scope=Some("global") should behave exactly as scope=None
742        // (the default path).
743        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    /// `scope = variant` with `force = Some(true)` must be rejected — there is
807    /// no filesystem destination to overwrite, so silently ignoring `force`
808    /// would mask caller intent.
809    #[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        // alc.local.toml must not have been written.
836        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    /// `scope = variant` with `force = Some(false)` is allowed (same as None).
843    #[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}