algocline_app/service/
pkg_link.rs1use 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 pub async fn pkg_link(
24 &self,
25 path: String,
26 project_root: Option<String>,
27 ) -> Result<String, String> {
28 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 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 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 let mode = detect_mode(&canon_path)?;
65
66 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 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 save_lockfile(&root, &lock)?;
130
131 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#[derive(Debug, Clone, Copy, PartialEq)]
149enum PackageMode {
150 Single,
151 Collection,
152}
153
154fn detect_mode(path: &Path) -> Result<PackageMode, String> {
156 if path.join("init.lua").exists() {
157 return Ok(PackageMode::Single);
158 }
159
160 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
177fn 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
189fn 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#[cfg(test)]
209mod tests {
210 use std::sync::Arc;
211
212 use super::*;
213 use crate::service::lockfile::load_lockfile;
214
215 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 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 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 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 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 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 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
327
328 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 assert_eq!(lock2.packages.len(), 1);
339 assert!(!lock2.packages[0].linked_at.is_empty());
344 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 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}