Skip to main content

algocline_app/service/
pkg_link.rs

1//! `alc_pkg_link` — link a local directory as a package via symlink.
2//!
3//! Creates a symlink in `~/.algocline/packages/{name}` pointing to the source
4//! directory. Equivalent to `npm link`. No files are copied; no alc.lock is
5//! written. Changes in the source directory are reflected immediately on the
6//! next `alc_run`.
7
8#[cfg(unix)]
9use std::os::unix::fs::symlink;
10
11use std::path::{Path, PathBuf};
12
13use super::alc_toml::validate_package_name;
14use super::resolve::packages_dir;
15use super::AppService;
16
17impl AppService {
18    /// Link a local directory as a package by creating a symlink in the cache.
19    ///
20    /// `path`: source directory to link (absolute or cwd-relative).
21    /// `name`: optional package name override (single package mode only).
22    /// `force`: if `true`, overwrite an existing real directory at the destination.
23    ///          Existing symlinks (including dangling) are always overwritten.
24    pub async fn pkg_link(
25        &self,
26        path: String,
27        name: Option<String>,
28        force: Option<bool>,
29    ) -> Result<String, String> {
30        #[cfg(not(unix))]
31        {
32            let _ = (path, name, force);
33            return Err("pkg_link is not supported on non-Unix platforms".to_string());
34        }
35
36        #[cfg(unix)]
37        {
38            let force = force.unwrap_or(false);
39
40            // 1. Resolve source path (absolute: use as-is; relative: join with cwd).
41            let raw = Path::new(&path);
42            let source: PathBuf = if raw.is_absolute() {
43                raw.to_path_buf()
44            } else {
45                std::env::current_dir()
46                    .map_err(|e| format!("Cannot determine cwd: {e}"))?
47                    .join(raw)
48            };
49
50            if !source.is_dir() {
51                return Err(format!("Path is not a directory: {}", source.display()));
52            }
53
54            // 2. Detect mode: single package (init.lua at root) or collection.
55            let mode = detect_mode(&source)?;
56
57            // 3. Get packages_dir.
58            let pkgs = packages_dir()?;
59            std::fs::create_dir_all(&pkgs)
60                .map_err(|e| format!("Cannot create packages dir {}: {e}", pkgs.display()))?;
61
62            // 4. Link packages.
63            let mode_str;
64            let mut linked_names: Vec<String> = Vec::new();
65            let mut targets: serde_json::Map<String, serde_json::Value> = serde_json::Map::new();
66
67            match mode {
68                PackageMode::Single => {
69                    mode_str = "single";
70                    let pkg_name = if let Some(n) = name {
71                        n
72                    } else {
73                        source
74                            .file_name()
75                            .ok_or_else(|| {
76                                format!("Cannot determine package name from: {}", source.display())
77                            })?
78                            .to_string_lossy()
79                            .to_string()
80                    };
81                    validate_package_name(&pkg_name)?;
82
83                    let dest = pkgs.join(&pkg_name);
84                    create_symlink(&source, &dest, force)?;
85
86                    targets.insert(
87                        pkg_name.clone(),
88                        serde_json::Value::String(source.display().to_string()),
89                    );
90                    linked_names.push(pkg_name);
91                }
92                PackageMode::Collection => {
93                    mode_str = "collection";
94                    let entries = std::fs::read_dir(&source).map_err(|e| {
95                        format!("Failed to read directory {}: {e}", source.display())
96                    })?;
97
98                    for entry in entries {
99                        let entry =
100                            entry.map_err(|e| format!("Failed to read directory entry: {e}"))?;
101                        let pkg_path = entry.path();
102                        // Skip non-dirs and dirs without init.lua.
103                        if !pkg_path.is_dir() || !pkg_path.join("init.lua").exists() {
104                            continue;
105                        }
106                        let pkg_name = entry.file_name().to_string_lossy().to_string();
107                        validate_package_name(&pkg_name)?;
108
109                        let dest = pkgs.join(&pkg_name);
110                        create_symlink(&pkg_path, &dest, force)?;
111
112                        targets.insert(
113                            pkg_name.clone(),
114                            serde_json::Value::String(pkg_path.display().to_string()),
115                        );
116                        linked_names.push(pkg_name);
117                    }
118
119                    if linked_names.is_empty() {
120                        return Err(format!(
121                            "No init.lua found in any subdirectory of: {}",
122                            source.display()
123                        ));
124                    }
125
126                    linked_names.sort();
127                }
128            }
129
130            Ok(serde_json::json!({
131                "linked": linked_names,
132                "mode": mode_str,
133                "targets": targets,
134            })
135            .to_string())
136        }
137    }
138}
139
140// ─── Internal helpers ────────────────────────────────────────────
141
142#[derive(Debug, Clone, Copy, PartialEq)]
143enum PackageMode {
144    Single,
145    Collection,
146}
147
148/// Determine whether `path` is a single package or a collection.
149fn detect_mode(path: &Path) -> Result<PackageMode, String> {
150    if path.join("init.lua").exists() {
151        return Ok(PackageMode::Single);
152    }
153
154    let entries = std::fs::read_dir(path).map_err(|e| format!("Failed to read directory: {e}"))?;
155
156    for entry in entries {
157        let entry = entry.map_err(|e| format!("Failed to read directory entry: {e}"))?;
158        let sub = entry.path();
159        if sub.is_dir() && sub.join("init.lua").exists() {
160            return Ok(PackageMode::Collection);
161        }
162    }
163
164    Err(format!(
165        "No init.lua found in {} or any of its subdirectories",
166        path.display()
167    ))
168}
169
170/// Create a symlink at `dest` pointing to `source`.
171///
172/// - If `dest` is a symlink (including dangling): always overwrite (remove + recreate).
173/// - If `dest` is a real directory: require `force == true`; remove with `remove_dir_all`.
174/// - If `dest` does not exist: create directly.
175#[cfg(unix)]
176fn create_symlink(source: &Path, dest: &Path, force: bool) -> Result<(), String> {
177    // Check symlink status first (symlink_metadata does not follow symlinks).
178    let meta = dest.symlink_metadata();
179
180    if let Ok(m) = meta {
181        if m.file_type().is_symlink() {
182            // Existing symlink (live or dangling) — always overwrite.
183            std::fs::remove_file(dest).map_err(|e| {
184                format!("Failed to remove existing symlink {}: {e}", dest.display())
185            })?;
186        } else if m.is_dir() {
187            // Real directory — require force.
188            if !force {
189                return Err(format!(
190                    "Destination '{}' is a real directory. Use force=true to overwrite.",
191                    dest.display()
192                ));
193            }
194            std::fs::remove_dir_all(dest)
195                .map_err(|e| format!("Failed to remove directory {}: {e}", dest.display()))?;
196        } else {
197            // Regular file or other — overwrite regardless.
198            std::fs::remove_file(dest)
199                .map_err(|e| format!("Failed to remove {}: {e}", dest.display()))?;
200        }
201    }
202
203    symlink(source, dest).map_err(|e| {
204        format!(
205            "Failed to create symlink {} -> {}: {e}",
206            dest.display(),
207            source.display()
208        )
209    })
210}
211
212// ─── Tests ───────────────────────────────────────────────────────
213
214#[cfg(all(test, unix))]
215mod tests {
216    use super::*;
217    use crate::service::test_support::{make_app_service, FakeHome};
218
219    #[tokio::test]
220    async fn pkg_link_single_creates_symlink() {
221        let env = FakeHome::new();
222        let home = &env.home;
223
224        let src = home.join("my_pkg");
225        std::fs::create_dir_all(&src).unwrap();
226        std::fs::write(src.join("init.lua"), "return {}").unwrap();
227
228        let svc = make_app_service().await;
229        let result = svc
230            .pkg_link(src.to_string_lossy().to_string(), None, None)
231            .await
232            .unwrap();
233
234        let json: serde_json::Value = serde_json::from_str(&result).unwrap();
235        assert_eq!(json["mode"], "single");
236        assert_eq!(json["linked"], serde_json::json!(["my_pkg"]));
237        assert_eq!(json["targets"]["my_pkg"], src.to_string_lossy().as_ref());
238
239        let dest = home.join(".algocline").join("packages").join("my_pkg");
240        assert!(dest.symlink_metadata().unwrap().file_type().is_symlink());
241        assert_eq!(std::fs::read_link(&dest).unwrap(), src);
242    }
243
244    #[tokio::test]
245    async fn pkg_link_collection_creates_symlinks() {
246        let env = FakeHome::new();
247        let home = &env.home;
248
249        let coll = home.join("collection");
250        std::fs::create_dir_all(coll.join("pkg_a")).unwrap();
251        std::fs::create_dir_all(coll.join("pkg_b")).unwrap();
252        std::fs::write(coll.join("pkg_a").join("init.lua"), "return {}").unwrap();
253        std::fs::write(coll.join("pkg_b").join("init.lua"), "return {}").unwrap();
254
255        let svc = make_app_service().await;
256        let result = svc
257            .pkg_link(coll.to_string_lossy().to_string(), None, None)
258            .await
259            .unwrap();
260
261        let json: serde_json::Value = serde_json::from_str(&result).unwrap();
262        assert_eq!(json["mode"], "collection");
263
264        let linked = json["linked"].as_array().unwrap();
265        let mut names: Vec<&str> = linked.iter().map(|v| v.as_str().unwrap()).collect();
266        names.sort();
267        assert_eq!(names, ["pkg_a", "pkg_b"]);
268
269        let pkgs = home.join(".algocline").join("packages");
270        assert!(pkgs
271            .join("pkg_a")
272            .symlink_metadata()
273            .unwrap()
274            .file_type()
275            .is_symlink());
276        assert!(pkgs
277            .join("pkg_b")
278            .symlink_metadata()
279            .unwrap()
280            .file_type()
281            .is_symlink());
282    }
283
284    #[tokio::test]
285    async fn pkg_link_overwrites_existing_symlink() {
286        let env = FakeHome::new();
287        let home = &env.home;
288
289        let src = home.join("my_pkg");
290        std::fs::create_dir_all(&src).unwrap();
291        std::fs::write(src.join("init.lua"), "return {}").unwrap();
292
293        let pkgs = home.join(".algocline").join("packages");
294        std::fs::create_dir_all(&pkgs).unwrap();
295        let dest = pkgs.join("my_pkg");
296        symlink(&src, &dest).unwrap();
297
298        let svc = make_app_service().await;
299        let result = svc
300            .pkg_link(src.to_string_lossy().to_string(), None, None)
301            .await
302            .unwrap();
303
304        let json: serde_json::Value = serde_json::from_str(&result).unwrap();
305        assert_eq!(json["linked"], serde_json::json!(["my_pkg"]));
306        assert!(dest.symlink_metadata().unwrap().file_type().is_symlink());
307    }
308
309    #[tokio::test]
310    async fn pkg_link_real_dir_requires_force() {
311        let env = FakeHome::new();
312        let home = &env.home;
313
314        let src = home.join("my_pkg");
315        std::fs::create_dir_all(&src).unwrap();
316        std::fs::write(src.join("init.lua"), "return {}").unwrap();
317
318        let pkgs = home.join(".algocline").join("packages");
319        let dest = pkgs.join("my_pkg");
320        std::fs::create_dir_all(&dest).unwrap();
321
322        let svc = make_app_service().await;
323
324        let err = svc
325            .pkg_link(src.to_string_lossy().to_string(), None, None)
326            .await
327            .unwrap_err();
328        assert!(
329            err.contains("real directory"),
330            "expected real directory error, got: {err}"
331        );
332
333        let result = svc
334            .pkg_link(src.to_string_lossy().to_string(), None, Some(true))
335            .await
336            .unwrap();
337        let json: serde_json::Value = serde_json::from_str(&result).unwrap();
338        assert_eq!(json["linked"], serde_json::json!(["my_pkg"]));
339        assert!(dest.symlink_metadata().unwrap().file_type().is_symlink());
340    }
341
342    #[tokio::test]
343    async fn pkg_link_dangling_symlink_overwritten() {
344        let env = FakeHome::new();
345        let home = &env.home;
346
347        let src = home.join("my_pkg");
348        std::fs::create_dir_all(&src).unwrap();
349        std::fs::write(src.join("init.lua"), "return {}").unwrap();
350
351        let pkgs = home.join(".algocline").join("packages");
352        std::fs::create_dir_all(&pkgs).unwrap();
353        let dest = pkgs.join("my_pkg");
354        symlink(home.join("nonexistent"), &dest).unwrap();
355        assert!(!dest.exists()); // dangling
356
357        let svc = make_app_service().await;
358        let result = svc
359            .pkg_link(src.to_string_lossy().to_string(), None, None)
360            .await
361            .unwrap();
362
363        let json: serde_json::Value = serde_json::from_str(&result).unwrap();
364        assert_eq!(json["linked"], serde_json::json!(["my_pkg"]));
365        assert!(dest.symlink_metadata().unwrap().file_type().is_symlink());
366        assert!(dest.exists()); // no longer dangling
367    }
368
369    #[tokio::test]
370    async fn pkg_link_path_not_found_returns_error() {
371        let env = FakeHome::new();
372        let nonexistent = env.home.join("does_not_exist");
373
374        let svc = make_app_service().await;
375        let err = svc
376            .pkg_link(nonexistent.to_string_lossy().to_string(), None, None)
377            .await
378            .unwrap_err();
379        assert!(err.contains("not a directory"), "got: {err}");
380    }
381}