Skip to main content

algocline_app/service/
pkg_link.rs

1//! `alc_pkg_link` — link a local directory as a project-local package.
2//!
3//! Unlike `pkg_install` (copy-based), `pkg_link` records the directory path in
4//! `alc.lock` without copying any files. The linked directory is resolved at
5//! `alc_run` time via `FsResolver` using `extra_lib_paths`.
6
7use std::path::Path;
8
9use super::lockfile::{load_lockfile, lockfile_path, save_lockfile, LockFile, LockPackage};
10use super::manifest::now_iso8601;
11use super::project::resolve_project_root;
12use super::source::PackageSource;
13use super::AppService;
14
15impl AppService {
16    /// Link a local directory as a project-local package (no copy).
17    ///
18    /// `path`: source directory — single package (has `init.lua`) or collection
19    /// (subdirectories have `init.lua`). May be absolute or relative to `project_root`.
20    ///
21    /// `project_root`: optional explicit project root (where `alc.lock` lives).
22    /// Falls back to `ALC_PROJECT_ROOT` env or ancestor walk from cwd.
23    pub async fn pkg_link(
24        &self,
25        path: String,
26        project_root: Option<String>,
27    ) -> Result<String, String> {
28        // 1. Resolve project root.
29        let root = resolve_project_root(project_root.as_deref()).ok_or_else(|| {
30            "Cannot determine project root: provide project_root or set ALC_PROJECT_ROOT"
31                .to_string()
32        })?;
33
34        // 2. Resolve path (absolute: use as-is, relative: join with project_root).
35        let raw_path = Path::new(&path);
36        let canon_path = if raw_path.is_absolute() {
37            raw_path.to_path_buf()
38        } else {
39            root.join(raw_path)
40        };
41
42        if !canon_path.is_dir() {
43            return Err(format!("Path is not a directory: {}", canon_path.display()));
44        }
45
46        // Containment check: the linked directory must live under the project
47        // root. Symlinks are resolved via `canonicalize` so an in-tree symlink
48        // pointing outside the project is also rejected. This prevents an
49        // `alc.lock` entry from exposing arbitrary filesystem paths (e.g.
50        // `/etc`, `../../..`) as Lua package search locations.
51        let canon_root = std::fs::canonicalize(&root)
52            .map_err(|e| format!("Cannot canonicalize project_root {}: {e}", root.display()))?;
53        let canon_path = std::fs::canonicalize(&canon_path)
54            .map_err(|e| format!("Cannot canonicalize path {}: {e}", canon_path.display()))?;
55        if !canon_path.starts_with(&canon_root) {
56            return Err(format!(
57                "Path must be inside project_root ({}): {}",
58                canon_root.display(),
59                canon_path.display()
60            ));
61        }
62
63        // 3. Determine mode: single (init.lua at root) or collection (subdirs with init.lua).
64        let mode = detect_mode(&canon_path)?;
65
66        // 4. Load or create alc.lock.
67        let mut lock = match load_lockfile(&root)? {
68            Some(existing) => existing,
69            None => LockFile {
70                version: 1,
71                packages: Vec::new(),
72            },
73        };
74
75        // 5. Build entries and upsert into lock.
76        let now = now_iso8601();
77        let linked_names = match mode {
78            PackageMode::Single => {
79                let name = canon_path
80                    .file_name()
81                    .ok_or_else(|| {
82                        format!(
83                            "Cannot determine package name from path: {}",
84                            canon_path.display()
85                        )
86                    })?
87                    .to_string_lossy()
88                    .to_string();
89
90                let stored_path = relative_or_absolute_path(&canon_path, &canon_root);
91                upsert_lock_entry(&mut lock, name.clone(), stored_path, now);
92                vec![name]
93            }
94            PackageMode::Collection => {
95                let entries = std::fs::read_dir(&canon_path).map_err(|e| {
96                    format!("Failed to read directory {}: {e}", canon_path.display())
97                })?;
98
99                let mut names = Vec::new();
100                for entry in entries {
101                    let entry =
102                        entry.map_err(|e| format!("Failed to read directory entry: {e}"))?;
103                    let pkg_path = entry.path();
104                    if !pkg_path.is_dir() {
105                        continue;
106                    }
107                    if !pkg_path.join("init.lua").exists() {
108                        continue;
109                    }
110                    let name = entry.file_name().to_string_lossy().to_string();
111                    let stored_path = relative_or_absolute_path(&pkg_path, &canon_root);
112                    upsert_lock_entry(&mut lock, name.clone(), stored_path, now.clone());
113                    names.push(name);
114                }
115
116                if names.is_empty() {
117                    return Err(format!(
118                        "No init.lua found in any subdirectory of: {}",
119                        canon_path.display()
120                    ));
121                }
122
123                names.sort();
124                names
125            }
126        };
127
128        // 6. Save alc.lock.
129        save_lockfile(&root, &lock)?;
130
131        // 7. Return result.
132        let mode_str = match mode {
133            PackageMode::Single => "single",
134            PackageMode::Collection => "collection",
135        };
136
137        Ok(serde_json::json!({
138            "linked": linked_names,
139            "mode": mode_str,
140            "lockfile": lockfile_path(&root).display().to_string(),
141        })
142        .to_string())
143    }
144}
145
146// ─── Internal helpers ────────────────────────────────────────────
147
148#[derive(Debug, Clone, Copy, PartialEq)]
149enum PackageMode {
150    Single,
151    Collection,
152}
153
154/// Determine whether `path` is a single package or a collection.
155fn detect_mode(path: &Path) -> Result<PackageMode, String> {
156    if path.join("init.lua").exists() {
157        return Ok(PackageMode::Single);
158    }
159
160    // Check if any subdirectory has an init.lua.
161    let entries = std::fs::read_dir(path).map_err(|e| format!("Failed to read directory: {e}"))?;
162
163    for entry in entries {
164        let entry = entry.map_err(|e| format!("Failed to read directory entry: {e}"))?;
165        let sub = entry.path();
166        if sub.is_dir() && sub.join("init.lua").exists() {
167            return Ok(PackageMode::Collection);
168        }
169    }
170
171    Err(format!(
172        "No init.lua found in {} or any of its subdirectories",
173        path.display()
174    ))
175}
176
177/// Return `path` as a relative string from `base` if possible, otherwise absolute.
178///
179/// Uses `strip_prefix` to relativize. If the paths cannot be made relative
180/// (e.g. different mount points, or canonicalization introduced symlink
181/// resolution mismatch), falls back to the absolute string.
182fn relative_or_absolute_path(path: &Path, base: &Path) -> String {
183    match path.strip_prefix(base) {
184        Ok(rel) => rel.to_string_lossy().to_string(),
185        Err(_) => path.to_string_lossy().to_string(),
186    }
187}
188
189/// Insert or update a `LockPackage` entry.
190///
191/// If an entry with the same `name` already exists, updates `linked_at` and
192/// the `path` inside `PackageSource::LocalDir`. Otherwise appends a new entry.
193fn upsert_lock_entry(lock: &mut LockFile, name: String, path: String, linked_at: String) {
194    if let Some(existing) = lock.packages.iter_mut().find(|p| p.name == name) {
195        existing.source = PackageSource::LocalDir { path };
196        existing.linked_at = linked_at;
197    } else {
198        lock.packages.push(LockPackage {
199            name,
200            source: PackageSource::LocalDir { path },
201            linked_at,
202        });
203    }
204}
205
206// ─── Tests ───────────────────────────────────────────────────────
207
208#[cfg(test)]
209mod tests {
210    use std::sync::Arc;
211
212    use super::*;
213    use crate::service::lockfile::load_lockfile;
214
215    /// Build a minimal AppService for tests.
216    async fn make_app_service() -> AppService {
217        let executor = Arc::new(
218            algocline_engine::Executor::new(vec![])
219                .await
220                .expect("executor"),
221        );
222        AppService {
223            executor,
224            registry: Arc::new(algocline_engine::SessionRegistry::new()),
225            log_config: crate::service::config::AppConfig {
226                log_dir: None,
227                log_dir_source: crate::service::config::LogDirSource::None,
228                log_enabled: false,
229            },
230            search_paths: vec![],
231            eval_sessions: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
232            session_strategies: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
233        }
234    }
235
236    #[tokio::test]
237    async fn pkg_link_single() {
238        let tmp = tempfile::tempdir().unwrap();
239        let project_root = tmp.path();
240
241        // Create a single-package dir.
242        let pkg_dir = project_root.join("my_pkg");
243        std::fs::create_dir_all(&pkg_dir).unwrap();
244        std::fs::write(pkg_dir.join("init.lua"), "return {}").unwrap();
245
246        let svc = make_app_service().await;
247        let result = svc
248            .pkg_link(
249                pkg_dir.to_string_lossy().to_string(),
250                Some(project_root.to_string_lossy().to_string()),
251            )
252            .await
253            .unwrap();
254
255        let json: serde_json::Value = serde_json::from_str(&result).unwrap();
256        assert_eq!(json["mode"], "single");
257        assert_eq!(json["linked"], serde_json::json!(["my_pkg"]));
258
259        // Verify alc.lock was written.
260        let lock = load_lockfile(project_root).unwrap().unwrap();
261        assert_eq!(lock.packages.len(), 1);
262        assert_eq!(lock.packages[0].name, "my_pkg");
263        assert!(matches!(
264            &lock.packages[0].source,
265            PackageSource::LocalDir { .. }
266        ));
267    }
268
269    #[tokio::test]
270    async fn pkg_link_collection() {
271        let tmp = tempfile::tempdir().unwrap();
272        let project_root = tmp.path();
273
274        // Create a collection dir with two packages.
275        let collection = project_root.join("collection");
276        std::fs::create_dir_all(collection.join("pkg_a")).unwrap();
277        std::fs::create_dir_all(collection.join("pkg_b")).unwrap();
278        std::fs::write(collection.join("pkg_a").join("init.lua"), "return {}").unwrap();
279        std::fs::write(collection.join("pkg_b").join("init.lua"), "return {}").unwrap();
280
281        let svc = make_app_service().await;
282        let result = svc
283            .pkg_link(
284                collection.to_string_lossy().to_string(),
285                Some(project_root.to_string_lossy().to_string()),
286            )
287            .await
288            .unwrap();
289
290        let json: serde_json::Value = serde_json::from_str(&result).unwrap();
291        assert_eq!(json["mode"], "collection");
292
293        let linked = json["linked"].as_array().unwrap();
294        let mut names: Vec<&str> = linked.iter().map(|v| v.as_str().unwrap()).collect();
295        names.sort();
296        assert_eq!(names, ["pkg_a", "pkg_b"]);
297
298        // Verify alc.lock has both entries.
299        let lock = load_lockfile(project_root).unwrap().unwrap();
300        assert_eq!(lock.packages.len(), 2);
301    }
302
303    #[tokio::test]
304    async fn pkg_link_idempotent() {
305        let tmp = tempfile::tempdir().unwrap();
306        let project_root = tmp.path();
307
308        let pkg_dir = project_root.join("my_pkg");
309        std::fs::create_dir_all(&pkg_dir).unwrap();
310        std::fs::write(pkg_dir.join("init.lua"), "return {}").unwrap();
311
312        let svc = make_app_service().await;
313
314        // Link once.
315        svc.pkg_link(
316            pkg_dir.to_string_lossy().to_string(),
317            Some(project_root.to_string_lossy().to_string()),
318        )
319        .await
320        .unwrap();
321
322        let lock1 = load_lockfile(project_root).unwrap().unwrap();
323        let first_linked_at = lock1.packages[0].linked_at.clone();
324
325        // Small sleep to ensure timestamp can differ.
326        tokio::time::sleep(std::time::Duration::from_millis(10)).await;
327
328        // Link again (same path).
329        svc.pkg_link(
330            pkg_dir.to_string_lossy().to_string(),
331            Some(project_root.to_string_lossy().to_string()),
332        )
333        .await
334        .unwrap();
335
336        let lock2 = load_lockfile(project_root).unwrap().unwrap();
337        // Only one entry (no duplicate).
338        assert_eq!(lock2.packages.len(), 1);
339        // linked_at must be updated.
340        // (In practice the timestamp has 1-second resolution; we just verify
341        // the field exists and is non-empty. A precise comparison would be
342        // flaky depending on system clock resolution.)
343        assert!(!lock2.packages[0].linked_at.is_empty());
344        // The field should be >= first_linked_at (monotonic).
345        assert!(lock2.packages[0].linked_at >= first_linked_at);
346    }
347
348    #[tokio::test]
349    async fn pkg_link_no_project_root_returns_error() {
350        // When no project_root is given AND there is no ALC_PROJECT_ROOT env
351        // AND cwd has no alc.lock ancestors, resolve_project_root may return
352        // Some(cwd). We explicitly pass an invalid dir to ensure we hit Err.
353        let tmp = tempfile::tempdir().unwrap();
354        let non_dir = tmp.path().join("does_not_exist");
355
356        let svc = make_app_service().await;
357        let result = svc
358            .pkg_link(
359                non_dir.to_string_lossy().to_string(),
360                Some(tmp.path().to_string_lossy().to_string()),
361            )
362            .await;
363
364        assert!(result.is_err());
365        assert!(result.unwrap_err().contains("not a directory"));
366    }
367}