algocline_app/service/
pkg_link.rs1#[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 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 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 let mode = detect_mode(&source)?;
56
57 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 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 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#[derive(Debug, Clone, Copy, PartialEq)]
143enum PackageMode {
144 Single,
145 Collection,
146}
147
148fn 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#[cfg(unix)]
176fn create_symlink(source: &Path, dest: &Path, force: bool) -> Result<(), String> {
177 let meta = dest.symlink_metadata();
179
180 if let Ok(m) = meta {
181 if m.file_type().is_symlink() {
182 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 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 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#[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()); 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()); }
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}