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    ///
65    /// Only collection layout (`<dir>/<name>/init.lua`) is supported. Single-package
66    /// layout (root-level `init.lua`) was removed in v0.36.0; reorganise the source
67    /// directory into a collection before calling this function.
68    ///
69    /// # Arguments
70    ///
71    /// * `path` — local directory path (absolute or relative to cwd)
72    /// * `name` — must be `None`; the `name` parameter is not supported in collection
73    ///   mode (package names are derived from subdirectory names)
74    /// * `force` — overwrite an existing real directory at the destination
75    ///
76    /// # Returns
77    ///
78    /// JSON string with `linked`, `mode`, `targets`, and `scope` fields on success.
79    ///
80    /// # Errors
81    ///
82    /// Returns `Err` if the path is not a directory, if `name` is provided, if no
83    /// `*/init.lua` subdirectories are found, or if a symlink operation fails.
84    async fn pkg_link_global(
85        &self,
86        path: String,
87        name: Option<String>,
88        force: Option<bool>,
89    ) -> Result<String, String> {
90        #[cfg(not(unix))]
91        {
92            let _ = (path, name, force);
93            return Err(
94                "pkg_link scope='global' is not supported on non-Unix platforms".to_string(),
95            );
96        }
97
98        #[cfg(unix)]
99        {
100            // Reject 'name' parameter — collection mode derives names from subdirectory names.
101            if name.is_some() {
102                return Err(
103                    "The 'name' parameter is no longer supported. Single-mode link was removed \
104                     in v0.36.0; package names are derived from subdirectory names in collection \
105                     layout (<dir>/<name>/init.lua)."
106                        .to_string(),
107                );
108            }
109
110            let force = force.unwrap_or(false);
111
112            // 1. Resolve source path (absolute: use as-is; relative: join with cwd).
113            let raw = Path::new(&path);
114            let source: PathBuf = if raw.is_absolute() {
115                raw.to_path_buf()
116            } else {
117                std::env::current_dir()
118                    .map_err(|e| format!("Cannot determine cwd: {e}"))?
119                    .join(raw)
120            };
121
122            if !source.is_dir() {
123                return Err(format!("Path is not a directory: {}", source.display()));
124            }
125
126            // 2. Get packages_dir.
127            let pkgs = packages_dir(&self.log_config.app_dir());
128            std::fs::create_dir_all(&pkgs)
129                .map_err(|e| format!("Cannot create packages dir {}: {e}", pkgs.display()))?;
130
131            // 3. Link packages (collection layout only: scan */init.lua).
132            let mut linked_names: Vec<String> = Vec::new();
133            let mut targets: serde_json::Map<String, serde_json::Value> = serde_json::Map::new();
134
135            let entries = std::fs::read_dir(&source)
136                .map_err(|e| format!("Failed to read directory {}: {e}", source.display()))?;
137
138            for entry in entries {
139                let entry = entry.map_err(|e| format!("Failed to read directory entry: {e}"))?;
140                let pkg_path = entry.path();
141                // Skip non-dirs and dirs without init.lua.
142                if !pkg_path.is_dir() || !pkg_path.join("init.lua").exists() {
143                    continue;
144                }
145                let pkg_name = entry.file_name().to_string_lossy().to_string();
146                validate_package_name(&pkg_name)?;
147
148                let dest = pkgs.join(&pkg_name);
149                create_symlink(&pkg_path, &dest, force)?;
150
151                targets.insert(
152                    pkg_name.clone(),
153                    serde_json::Value::String(pkg_path.display().to_string()),
154                );
155                linked_names.push(pkg_name);
156            }
157
158            if linked_names.is_empty() {
159                return Err(format!(
160                    "No init.lua found in any subdirectory of: {} \
161                     (expected */init.lua collection layout)",
162                    source.display()
163                ));
164            }
165
166            linked_names.sort();
167
168            Ok(serde_json::json!({
169                "linked": linked_names,
170                "mode": "collection",
171                "targets": targets,
172                "scope": "global",
173            })
174            .to_string())
175        }
176    }
177
178    /// `scope = variant` — record the path in `alc.local.toml`.
179    ///
180    /// Only collection layout (`<dir>/<name>/init.lua`) is supported. Single-package
181    /// layout (root-level `init.lua`) was removed in v0.36.0.
182    ///
183    /// # Arguments
184    ///
185    /// * `path` — local directory path containing one or more `<name>/init.lua` subdirs
186    /// * `name` — must be `None`; package names are derived from subdirectory names
187    /// * `project_root` — root directory for `alc.local.toml`; falls back to walk-up
188    ///   / env if `None`
189    ///
190    /// # Returns
191    ///
192    /// JSON string with `linked`, `mode`, `targets`, `scope`, and `alc_local_toml` on success.
193    ///
194    /// # Errors
195    ///
196    /// Returns `Err` if the path is not a directory, if `name` is provided, if no
197    /// `*/init.lua` subdirectories are found, or if writing `alc.local.toml` fails.
198    async fn pkg_link_variant(
199        &self,
200        path: String,
201        name: Option<String>,
202        project_root: Option<String>,
203    ) -> Result<String, String> {
204        // Reject 'name' parameter — collection mode derives names from subdirectory names.
205        if name.is_some() {
206            return Err(
207                "The 'name' parameter is no longer supported. Single-mode link was removed \
208                 in v0.36.0; package names are derived from subdirectory names in collection \
209                 layout (<dir>/<name>/init.lua)."
210                    .to_string(),
211            );
212        }
213
214        // 1. Resolve source path.
215        let raw = Path::new(&path);
216        let source: PathBuf = if raw.is_absolute() {
217            raw.to_path_buf()
218        } else {
219            std::env::current_dir()
220                .map_err(|e| format!("Cannot determine cwd: {e}"))?
221                .join(raw)
222        };
223
224        if !source.is_dir() {
225            return Err(format!("Path is not a directory: {}", source.display()));
226        }
227
228        // 2. Resolve project root (P > S > E > W).
229        let root = self.resolve_root(project_root.as_deref()).ok_or_else(|| {
230            "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()
231        })?;
232
233        // 3. Load or create alc.local.toml document.
234        let mut doc = match alc_toml::load_alc_local_toml_document(&root)? {
235            Some(d) => d,
236            None => "[packages]\n"
237                .parse::<toml_edit::DocumentMut>()
238                .map_err(|e| format!("Failed to create empty alc.local.toml document: {e}"))?,
239        };
240
241        // 4. Build entries to add (collection layout only: scan */init.lua).
242        let mut linked_names: Vec<String> = Vec::new();
243        let mut targets: serde_json::Map<String, serde_json::Value> = serde_json::Map::new();
244
245        let entries = std::fs::read_dir(&source)
246            .map_err(|e| format!("Failed to read directory {}: {e}", source.display()))?;
247
248        let mut candidates: Vec<(String, String)> = Vec::new();
249        for entry in entries {
250            let entry = entry.map_err(|e| format!("Failed to read directory entry: {e}"))?;
251            let pkg_path = entry.path();
252            if !pkg_path.is_dir() || !pkg_path.join("init.lua").exists() {
253                continue;
254            }
255            let pkg_name = entry.file_name().to_string_lossy().to_string();
256            validate_package_name(&pkg_name)?;
257            candidates.push((pkg_name, pkg_path.display().to_string()));
258        }
259
260        if candidates.is_empty() {
261            return Err(format!(
262                "No init.lua found in any subdirectory of: {} \
263                 (expected */init.lua collection layout)",
264                source.display()
265            ));
266        }
267
268        candidates.sort();
269        for (pkg_name, abs) in candidates {
270            let added = add_package_entry(
271                &mut doc,
272                &pkg_name,
273                &PackageDep::Path {
274                    path: abs.clone(),
275                    version: None,
276                },
277            );
278            targets.insert(pkg_name.clone(), serde_json::Value::String(abs));
279            if added {
280                linked_names.push(pkg_name);
281            }
282        }
283
284        // 5. Save.
285        alc_toml::save_alc_local_toml(&root, &doc)?;
286
287        let alc_local_path = alc_toml::local_alc_toml_path(&root);
288
289        Ok(serde_json::json!({
290            "linked": linked_names,
291            "mode": "collection",
292            "targets": targets,
293            "scope": "variant",
294            "alc_local_toml": alc_local_path.display().to_string(),
295        })
296        .to_string())
297    }
298}
299
300// ─── Internal helpers ────────────────────────────────────────────
301
302/// Create a symlink at `dest` pointing to `source`.
303///
304/// - If `dest` is a symlink (including dangling): always overwrite (remove + recreate).
305/// - If `dest` is a real directory: require `force == true`; remove with `remove_dir_all`.
306/// - If `dest` does not exist: create directly.
307#[cfg(unix)]
308fn create_symlink(source: &Path, dest: &Path, force: bool) -> Result<(), String> {
309    // Check symlink status first (symlink_metadata does not follow symlinks).
310    let meta = dest.symlink_metadata();
311
312    if let Ok(m) = meta {
313        if m.file_type().is_symlink() {
314            // Existing symlink (live or dangling) — always overwrite.
315            std::fs::remove_file(dest).map_err(|e| {
316                format!("Failed to remove existing symlink {}: {e}", dest.display())
317            })?;
318        } else if m.is_dir() {
319            // Real directory — require force.
320            if !force {
321                return Err(format!(
322                    "Destination '{}' is a real directory. Use force=true to overwrite.",
323                    dest.display()
324                ));
325            }
326            std::fs::remove_dir_all(dest)
327                .map_err(|e| format!("Failed to remove directory {}: {e}", dest.display()))?;
328        } else {
329            // Regular file or other — overwrite regardless.
330            std::fs::remove_file(dest)
331                .map_err(|e| format!("Failed to remove {}: {e}", dest.display()))?;
332        }
333    }
334
335    symlink(source, dest).map_err(|e| {
336        format!(
337            "Failed to create symlink {} -> {}: {e}",
338            dest.display(),
339            source.display()
340        )
341    })
342}
343
344// ─── Tests ───────────────────────────────────────────────────────
345
346#[cfg(all(test, unix))]
347mod tests {
348    use super::*;
349    use crate::service::test_support::make_app_service_at;
350
351    #[tokio::test]
352    async fn pkg_link_creates_symlink_in_collection_mode() {
353        let tmp = tempfile::tempdir().unwrap();
354        let home = tmp.path();
355
356        // Collection 1-entry layout: <coll>/<name>/init.lua
357        let coll = home.join("my_coll");
358        let pkg_dir = coll.join("my_pkg");
359        std::fs::create_dir_all(&pkg_dir).unwrap();
360        std::fs::write(pkg_dir.join("init.lua"), "return {}").unwrap();
361
362        let svc = make_app_service_at(home.to_path_buf()).await;
363        let result = svc
364            .pkg_link(coll.to_string_lossy().to_string(), None, None, None, None)
365            .await
366            .unwrap();
367
368        let json: serde_json::Value = serde_json::from_str(&result).unwrap();
369        assert_eq!(json["mode"], "collection");
370        assert_eq!(json["linked"], serde_json::json!(["my_pkg"]));
371        assert_eq!(
372            json["targets"]["my_pkg"],
373            pkg_dir.to_string_lossy().as_ref()
374        );
375
376        let dest = home.join("packages").join("my_pkg");
377        assert!(dest.symlink_metadata().unwrap().file_type().is_symlink());
378        assert_eq!(std::fs::read_link(&dest).unwrap(), pkg_dir);
379    }
380
381    #[tokio::test]
382    async fn pkg_link_collection_creates_symlinks() {
383        let tmp = tempfile::tempdir().unwrap();
384        let home = tmp.path();
385
386        let coll = home.join("collection");
387        std::fs::create_dir_all(coll.join("pkg_a")).unwrap();
388        std::fs::create_dir_all(coll.join("pkg_b")).unwrap();
389        std::fs::write(coll.join("pkg_a").join("init.lua"), "return {}").unwrap();
390        std::fs::write(coll.join("pkg_b").join("init.lua"), "return {}").unwrap();
391
392        let svc = make_app_service_at(home.to_path_buf()).await;
393        let result = svc
394            .pkg_link(coll.to_string_lossy().to_string(), None, None, None, None)
395            .await
396            .unwrap();
397
398        let json: serde_json::Value = serde_json::from_str(&result).unwrap();
399        assert_eq!(json["mode"], "collection");
400
401        let linked = json["linked"].as_array().unwrap();
402        let mut names: Vec<&str> = linked.iter().map(|v| v.as_str().unwrap()).collect();
403        names.sort();
404        assert_eq!(names, ["pkg_a", "pkg_b"]);
405
406        let pkgs = home.join("packages");
407        assert!(pkgs
408            .join("pkg_a")
409            .symlink_metadata()
410            .unwrap()
411            .file_type()
412            .is_symlink());
413        assert!(pkgs
414            .join("pkg_b")
415            .symlink_metadata()
416            .unwrap()
417            .file_type()
418            .is_symlink());
419    }
420
421    #[tokio::test]
422    async fn pkg_link_overwrites_existing_symlink() {
423        let tmp = tempfile::tempdir().unwrap();
424        let home = tmp.path();
425
426        // Collection 1-entry layout.
427        let coll = home.join("my_coll");
428        let pkg_dir = coll.join("my_pkg");
429        std::fs::create_dir_all(&pkg_dir).unwrap();
430        std::fs::write(pkg_dir.join("init.lua"), "return {}").unwrap();
431
432        // Pre-existing symlink at destination.
433        let pkgs = home.join("packages");
434        std::fs::create_dir_all(&pkgs).unwrap();
435        let dest = pkgs.join("my_pkg");
436        symlink(&pkg_dir, &dest).unwrap();
437
438        let svc = make_app_service_at(home.to_path_buf()).await;
439        let result = svc
440            .pkg_link(coll.to_string_lossy().to_string(), None, None, None, None)
441            .await
442            .unwrap();
443
444        let json: serde_json::Value = serde_json::from_str(&result).unwrap();
445        assert_eq!(json["linked"], serde_json::json!(["my_pkg"]));
446        assert!(dest.symlink_metadata().unwrap().file_type().is_symlink());
447    }
448
449    #[tokio::test]
450    async fn pkg_link_real_dir_requires_force() {
451        let tmp = tempfile::tempdir().unwrap();
452        let home = tmp.path();
453
454        // Collection 1-entry layout.
455        let coll = home.join("my_coll");
456        let pkg_dir = coll.join("my_pkg");
457        std::fs::create_dir_all(&pkg_dir).unwrap();
458        std::fs::write(pkg_dir.join("init.lua"), "return {}").unwrap();
459
460        // Pre-existing real directory at destination.
461        let pkgs = home.join("packages");
462        let dest = pkgs.join("my_pkg");
463        std::fs::create_dir_all(&dest).unwrap();
464
465        let svc = make_app_service_at(home.to_path_buf()).await;
466
467        let err = svc
468            .pkg_link(coll.to_string_lossy().to_string(), None, None, None, None)
469            .await
470            .unwrap_err();
471        assert!(
472            err.contains("real directory"),
473            "expected real directory error, got: {err}"
474        );
475
476        let result = svc
477            .pkg_link(
478                coll.to_string_lossy().to_string(),
479                None,
480                Some(true),
481                None,
482                None,
483            )
484            .await
485            .unwrap();
486        let json: serde_json::Value = serde_json::from_str(&result).unwrap();
487        assert_eq!(json["linked"], serde_json::json!(["my_pkg"]));
488        assert!(dest.symlink_metadata().unwrap().file_type().is_symlink());
489    }
490
491    #[tokio::test]
492    async fn pkg_link_dangling_symlink_overwritten() {
493        let tmp = tempfile::tempdir().unwrap();
494        let home = tmp.path();
495
496        // Collection 1-entry layout.
497        let coll = home.join("my_coll");
498        let pkg_dir = coll.join("my_pkg");
499        std::fs::create_dir_all(&pkg_dir).unwrap();
500        std::fs::write(pkg_dir.join("init.lua"), "return {}").unwrap();
501
502        // Pre-existing dangling symlink at destination.
503        let pkgs = home.join("packages");
504        std::fs::create_dir_all(&pkgs).unwrap();
505        let dest = pkgs.join("my_pkg");
506        symlink(home.join("nonexistent"), &dest).unwrap();
507        assert!(!dest.exists()); // dangling
508
509        let svc = make_app_service_at(home.to_path_buf()).await;
510        let result = svc
511            .pkg_link(coll.to_string_lossy().to_string(), None, None, None, None)
512            .await
513            .unwrap();
514
515        let json: serde_json::Value = serde_json::from_str(&result).unwrap();
516        assert_eq!(json["linked"], serde_json::json!(["my_pkg"]));
517        assert!(dest.symlink_metadata().unwrap().file_type().is_symlink());
518        assert!(dest.exists()); // no longer dangling
519    }
520
521    #[tokio::test]
522    async fn pkg_link_path_not_found_returns_error() {
523        let tmp = tempfile::tempdir().unwrap();
524        let home = tmp.path();
525        let nonexistent = home.join("does_not_exist");
526
527        let svc = make_app_service_at(home.to_path_buf()).await;
528        let err = svc
529            .pkg_link(
530                nonexistent.to_string_lossy().to_string(),
531                None,
532                None,
533                None,
534                None,
535            )
536            .await
537            .unwrap_err();
538        assert!(err.contains("not a directory"), "got: {err}");
539    }
540
541    // ── scope = variant ───────────────────────────────────────────────
542
543    #[tokio::test]
544    async fn pkg_link_scope_variant_appends_to_alc_local_toml() {
545        let tmp = tempfile::tempdir().unwrap();
546        let home = tmp.path();
547        let root = home.join("proj");
548        std::fs::create_dir_all(&root).unwrap();
549        // Collection 1-entry layout.
550        let coll = home.join("my_coll");
551        let pkg_dir = coll.join("my_pkg");
552        std::fs::create_dir_all(&pkg_dir).unwrap();
553        std::fs::write(pkg_dir.join("init.lua"), "return {}").unwrap();
554
555        let svc = make_app_service_at(home.to_path_buf()).await;
556        let result = svc
557            .pkg_link(
558                coll.to_string_lossy().to_string(),
559                None,
560                None,
561                Some("variant".to_string()),
562                Some(root.to_string_lossy().to_string()),
563            )
564            .await
565            .unwrap();
566
567        let json: serde_json::Value = serde_json::from_str(&result).unwrap();
568        assert_eq!(json["scope"], "variant");
569        assert_eq!(json["mode"], "collection");
570        assert_eq!(json["linked"], serde_json::json!(["my_pkg"]));
571
572        // File was written.
573        let local = root.join("alc.local.toml");
574        assert!(local.exists());
575        let content = std::fs::read_to_string(&local).unwrap();
576        assert!(content.contains("my_pkg"));
577        assert!(content.contains(pkg_dir.to_string_lossy().as_ref()));
578    }
579
580    #[tokio::test]
581    async fn pkg_link_scope_variant_no_symlink_created() {
582        let tmp = tempfile::tempdir().unwrap();
583        let home = tmp.path();
584        let root = home.join("proj");
585        std::fs::create_dir_all(&root).unwrap();
586        // Collection 1-entry layout.
587        let coll = home.join("my_coll");
588        let pkg_dir = coll.join("my_pkg");
589        std::fs::create_dir_all(&pkg_dir).unwrap();
590        std::fs::write(pkg_dir.join("init.lua"), "return {}").unwrap();
591
592        let svc = make_app_service_at(home.to_path_buf()).await;
593        svc.pkg_link(
594            coll.to_string_lossy().to_string(),
595            None,
596            None,
597            Some("variant".to_string()),
598            Some(root.to_string_lossy().to_string()),
599        )
600        .await
601        .unwrap();
602
603        let cache_link = home.join("packages").join("my_pkg");
604        assert!(
605            cache_link.symlink_metadata().is_err(),
606            "variant scope must not create a symlink in ~/.algocline/packages/"
607        );
608    }
609
610    #[tokio::test]
611    async fn pkg_link_scope_variant_second_call_is_noop_for_existing_entry() {
612        let tmp = tempfile::tempdir().unwrap();
613        let home = tmp.path();
614        let root = home.join("proj");
615        std::fs::create_dir_all(&root).unwrap();
616        // Collection 1-entry layout.
617        let coll = home.join("my_coll");
618        let pkg_dir = coll.join("my_pkg");
619        std::fs::create_dir_all(&pkg_dir).unwrap();
620        std::fs::write(pkg_dir.join("init.lua"), "return {}").unwrap();
621
622        let svc = make_app_service_at(home.to_path_buf()).await;
623        svc.pkg_link(
624            coll.to_string_lossy().to_string(),
625            None,
626            None,
627            Some("variant".to_string()),
628            Some(root.to_string_lossy().to_string()),
629        )
630        .await
631        .unwrap();
632
633        // Second call — entry already exists, should be linked:[] (skipped).
634        let result = svc
635            .pkg_link(
636                coll.to_string_lossy().to_string(),
637                None,
638                None,
639                Some("variant".to_string()),
640                Some(root.to_string_lossy().to_string()),
641            )
642            .await
643            .unwrap();
644
645        let json: serde_json::Value = serde_json::from_str(&result).unwrap();
646        assert_eq!(json["linked"], serde_json::json!([]));
647        // targets still records the current state.
648        assert_eq!(
649            json["targets"]["my_pkg"],
650            pkg_dir.to_string_lossy().as_ref()
651        );
652
653        // File must still contain exactly one entry.
654        // Parse the TOML and count keys under [packages] rather than
655        // substring-match "my_pkg", because the path value also contains
656        // that literal ("/.../my_pkg").
657        let local = root.join("alc.local.toml");
658        let content = std::fs::read_to_string(&local).unwrap();
659        let doc: toml_edit::DocumentMut = content.parse().unwrap();
660        let pkgs = doc["packages"].as_table().unwrap();
661        let key_count = pkgs.iter().filter(|(k, _)| *k == "my_pkg").count();
662        assert_eq!(key_count, 1, "duplicate entry written: {content}");
663    }
664
665    #[tokio::test]
666    async fn pkg_link_scope_variant_requires_project_root() {
667        let tmp = tempfile::tempdir().unwrap();
668        let home = tmp.path();
669        // Collection 1-entry layout.
670        let coll = home.join("my_coll");
671        let pkg_dir = coll.join("my_pkg");
672        std::fs::create_dir_all(&pkg_dir).unwrap();
673        std::fs::write(pkg_dir.join("init.lua"), "return {}").unwrap();
674
675        let svc = make_app_service_at(home.to_path_buf()).await;
676        // No project_root, no ALC_PROJECT_ROOT (test env doesn't set it).
677        // cwd walks up from test runner cwd — unlikely to find alc.toml ancestor.
678        // Use an invalid explicit path to force fallback + fail.
679        let nonexistent = home.join("no_such_project_root_zzz");
680        let err = svc
681            .pkg_link(
682                coll.to_string_lossy().to_string(),
683                None,
684                None,
685                Some("variant".to_string()),
686                Some(nonexistent.to_string_lossy().to_string()),
687            )
688            .await;
689        // Note: fallback may succeed via cwd walk-up to a real alc.toml;
690        // the best we can reliably assert is that EITHER:
691        // (a) the call errored with "No project root found"
692        // (b) the call succeeded with some valid root
693        if let Err(e) = err {
694            assert!(e.contains("No project root found"), "unexpected err: {e}");
695        }
696    }
697
698    #[tokio::test]
699    async fn pkg_link_invalid_scope_returns_error() {
700        let tmp = tempfile::tempdir().unwrap();
701        let home = tmp.path();
702        // Collection 1-entry layout (scope error fires before layout check).
703        let coll = home.join("my_coll");
704        let pkg_dir = coll.join("my_pkg");
705        std::fs::create_dir_all(&pkg_dir).unwrap();
706        std::fs::write(pkg_dir.join("init.lua"), "return {}").unwrap();
707
708        let svc = make_app_service_at(home.to_path_buf()).await;
709        let err = svc
710            .pkg_link(
711                coll.to_string_lossy().to_string(),
712                None,
713                None,
714                Some("unknown".to_string()),
715                None,
716            )
717            .await
718            .unwrap_err();
719        assert!(err.contains("invalid scope"), "got: {err}");
720    }
721
722    #[tokio::test]
723    async fn pkg_link_scope_global_default_matches_existing_behavior() {
724        // Explicit scope=Some("global") should behave exactly as scope=None
725        // (the default path).
726        let tmp = tempfile::tempdir().unwrap();
727        let home = tmp.path();
728
729        // Collection 1-entry layout.
730        let coll = home.join("my_coll");
731        let pkg_dir = coll.join("my_pkg");
732        std::fs::create_dir_all(&pkg_dir).unwrap();
733        std::fs::write(pkg_dir.join("init.lua"), "return {}").unwrap();
734
735        let svc = make_app_service_at(home.to_path_buf()).await;
736        let result = svc
737            .pkg_link(
738                coll.to_string_lossy().to_string(),
739                None,
740                None,
741                Some("global".to_string()),
742                None,
743            )
744            .await
745            .unwrap();
746
747        let json: serde_json::Value = serde_json::from_str(&result).unwrap();
748        assert_eq!(json["scope"], "global");
749        assert_eq!(json["mode"], "collection");
750        assert_eq!(json["linked"], serde_json::json!(["my_pkg"]));
751        let dest = home.join("packages").join("my_pkg");
752        assert!(dest.symlink_metadata().unwrap().file_type().is_symlink());
753    }
754
755    #[tokio::test]
756    async fn pkg_link_scope_variant_collection_appends_all() {
757        let tmp = tempfile::tempdir().unwrap();
758        let home = tmp.path();
759        let root = home.join("proj");
760        std::fs::create_dir_all(&root).unwrap();
761        let coll = home.join("collection");
762        std::fs::create_dir_all(coll.join("pkg_a")).unwrap();
763        std::fs::create_dir_all(coll.join("pkg_b")).unwrap();
764        std::fs::write(coll.join("pkg_a").join("init.lua"), "return {}").unwrap();
765        std::fs::write(coll.join("pkg_b").join("init.lua"), "return {}").unwrap();
766
767        let svc = make_app_service_at(home.to_path_buf()).await;
768        let result = svc
769            .pkg_link(
770                coll.to_string_lossy().to_string(),
771                None,
772                None,
773                Some("variant".to_string()),
774                Some(root.to_string_lossy().to_string()),
775            )
776            .await
777            .unwrap();
778
779        let json: serde_json::Value = serde_json::from_str(&result).unwrap();
780        assert_eq!(json["scope"], "variant");
781        assert_eq!(json["mode"], "collection");
782        let linked = json["linked"].as_array().unwrap();
783        let names: Vec<&str> = linked.iter().map(|v| v.as_str().unwrap()).collect();
784        assert_eq!(names, ["pkg_a", "pkg_b"]);
785
786        let local = root.join("alc.local.toml");
787        let content = std::fs::read_to_string(&local).unwrap();
788        assert!(content.contains("pkg_a"));
789        assert!(content.contains("pkg_b"));
790    }
791
792    /// `scope = variant` with `force = Some(true)` must be rejected — there is
793    /// no filesystem destination to overwrite, so silently ignoring `force`
794    /// would mask caller intent.
795    #[tokio::test]
796    async fn pkg_link_scope_variant_rejects_force() {
797        let tmp = tempfile::tempdir().unwrap();
798        let home = tmp.path();
799        let root = home.join("proj");
800        std::fs::create_dir_all(&root).unwrap();
801        // Collection 1-entry layout (force check fires before layout scan).
802        let coll = home.join("my_coll");
803        let pkg_dir = coll.join("my_pkg");
804        std::fs::create_dir_all(&pkg_dir).unwrap();
805        std::fs::write(pkg_dir.join("init.lua"), "return {}").unwrap();
806
807        let svc = make_app_service_at(home.to_path_buf()).await;
808        let err = svc
809            .pkg_link(
810                coll.to_string_lossy().to_string(),
811                None,
812                Some(true),
813                Some("variant".to_string()),
814                Some(root.to_string_lossy().to_string()),
815            )
816            .await
817            .unwrap_err();
818        assert!(
819            err.contains("force is not supported with scope='variant'"),
820            "got: {err}"
821        );
822
823        // alc.local.toml must not have been written.
824        assert!(
825            !root.join("alc.local.toml").exists(),
826            "alc.local.toml must not be written when the call is rejected"
827        );
828    }
829
830    /// `scope = variant` with `force = Some(false)` is allowed (same as None).
831    #[tokio::test]
832    async fn pkg_link_scope_variant_accepts_force_false() {
833        let tmp = tempfile::tempdir().unwrap();
834        let home = tmp.path();
835        let root = home.join("proj");
836        std::fs::create_dir_all(&root).unwrap();
837        // Collection 1-entry layout.
838        let coll = home.join("my_coll");
839        let pkg_dir = coll.join("my_pkg");
840        std::fs::create_dir_all(&pkg_dir).unwrap();
841        std::fs::write(pkg_dir.join("init.lua"), "return {}").unwrap();
842
843        let svc = make_app_service_at(home.to_path_buf()).await;
844        let result = svc
845            .pkg_link(
846                coll.to_string_lossy().to_string(),
847                None,
848                Some(false),
849                Some("variant".to_string()),
850                Some(root.to_string_lossy().to_string()),
851            )
852            .await
853            .unwrap();
854
855        let json: serde_json::Value = serde_json::from_str(&result).unwrap();
856        assert_eq!(json["scope"], "variant");
857        assert_eq!(json["mode"], "collection");
858        assert_eq!(json["linked"], serde_json::json!(["my_pkg"]));
859    }
860}