1use std::path::{Path, PathBuf};
17
18use clap::Args;
19use memstead_base::filesystem::config::{DepRef, read_workspace_config, write_workspace_config};
20use serde_json::json;
21
22use crate::CliError;
23use crate::output::{ExitKind, print_json, print_markdown};
24use crate::registry::{self, DownloadError};
25use crate::setup::CliContext;
26
27#[derive(Args, Debug)]
29pub struct LinkArgs {
30 #[arg(value_name = "SCOPE/NAME")]
35 pub dep: String,
36
37 #[arg(long, value_name = "URL")]
40 pub registry: Option<String>,
41
42 #[arg(long, value_name = "PATH")]
45 pub workspace: Option<PathBuf>,
46}
47
48pub fn run(ctx: &CliContext, args: LinkArgs) -> anyhow::Result<()> {
49 let dep: DepRef = args.dep.parse().map_err(|e: String| CliError {
50 code: "INVALID_INPUT",
51 message: format!(
52 "invalid dependency reference {value:?}: {e} (expected 'scope/name')",
53 value = args.dep
54 ),
55 kind: ExitKind::Validation,
56 details: None,
57 })?;
58
59 let workspace_root = match args.workspace.clone() {
60 Some(p) => {
61 let canon = p.canonicalize().unwrap_or(p);
62 if !memstead_base::is_workspace_root(&canon) {
63 return Err(CliError {
64 code: "WORKSPACE_NOT_INITIALISED",
65 message: format!(
66 "no workspace at {} (missing .memstead/workspace.toml)",
67 canon.display()
68 ),
69 kind: ExitKind::NotFound,
70 details: None,
71 }
72 .into());
73 }
74 canon
75 }
76 None => find_filesystem_workspace_root()?,
77 };
78
79 let mut config = read_workspace_config(&workspace_root).map_err(|e| CliError {
80 code: "WORKSPACE_CONFIG_READ_FAILED",
81 message: format!("read workspace config: {e}"),
82 kind: ExitKind::Generic,
83 details: None,
84 })?;
85
86 let cache_dir = workspace_root
87 .join(memstead_base::WORKSPACE_STORE_DIR)
88 .join("memstead-io")
89 .join(&dep.scope);
90 std::fs::create_dir_all(&cache_dir).map_err(|e| CliError {
91 code: crate::INTERNAL_CODE,
92 message: format!("create cache dir {}: {e}", cache_dir.display()),
93 kind: ExitKind::Generic,
94 details: None,
95 })?;
96 let cache_path = cache_dir.join(format!(
97 "{}.{}",
98 dep.name,
99 memstead_schema::ARCHIVE_EXTENSION
100 ));
101
102 let base = registry::registry_base(args.registry.as_deref());
103 let client = registry::build_http()?;
104 let bytes = registry::download_mem(&client, &base, &dep.scope, &dep.name, &cache_path)
105 .map_err(|e| {
106 let (msg, kind, code): (String, ExitKind, &'static str) = match e {
107 DownloadError::NotFound => (
108 format!(
109 "registry has no mem {}/{} — check the spelling or `memstead publish` it first",
110 dep.scope, dep.name
111 ),
112 ExitKind::NotFound,
113 "REGISTRY_NOT_FOUND",
114 ),
115 DownloadError::Gone => (
116 format!(
117 "mem {}/{} has been unpublished from the registry",
118 dep.scope, dep.name
119 ),
120 ExitKind::NotFound,
121 "GONE",
122 ),
123 other => (
124 format!("download from registry: {other}"),
125 ExitKind::Generic,
126 "REGISTRY_ERROR",
127 ),
128 };
129 CliError {
130 code,
131 message: msg,
132 kind,
133 details: None,
134 }
135 })?;
136
137 let added = config.add_dep(dep.clone());
138 write_workspace_config(&workspace_root, &config).map_err(|e| CliError {
139 code: crate::INTERNAL_CODE,
140 message: format!("update workspace config: {e}"),
141 kind: ExitKind::Generic,
142 details: None,
143 })?;
144
145 if ctx.json {
146 let payload = json!({
147 "scope": dep.scope,
148 "name": dep.name,
149 "cached_at": cache_path.display().to_string(),
150 "bytes": bytes,
151 "registry": base,
152 "newly_recorded": added,
153 "deps_total": config.deps.len(),
154 });
155 return print_json(&payload);
156 }
157
158 let action = if added { "Linked" } else { "Re-fetched" };
159 let lines = [
160 format!("# {} `{}`", action, dep.as_display()),
161 String::new(),
162 format!("- Cached: `{}`", cache_path.display()),
163 format!("- Bytes: {bytes}"),
164 format!("- Registry: {base}"),
165 format!("- Total deps in this workspace: {}", config.deps.len()),
166 ];
167 print_markdown(&lines.join("\n"));
168 Ok(())
169}
170
171fn find_filesystem_workspace_root() -> Result<PathBuf, CliError> {
176 let cwd = std::env::current_dir().map_err(|e| CliError {
177 code: crate::INTERNAL_CODE,
178 message: format!("read cwd: {e}"),
179 kind: ExitKind::Generic,
180 details: None,
181 })?;
182
183 let mut current: &Path = &cwd;
184 loop {
185 if memstead_base::is_workspace_root(current) {
186 return Ok(current.to_path_buf());
187 }
188 match current.parent() {
189 Some(p) => current = p,
190 None => {
191 return Err(CliError {
192 code: "WORKSPACE_NOT_INITIALISED",
193 message: format!(
194 "no workspace found from {} or any ancestor (missing \
195 .memstead/workspace.toml) — run `memstead init` first or \
196 pass --workspace <path>",
197 cwd.display()
198 ),
199 kind: ExitKind::NotFound,
200 details: None,
201 });
202 }
203 }
204 }
205}
206
207#[cfg(test)]
208mod tests {
209 use super::*;
210 use memstead_base::filesystem::config::{FILESYSTEM_WORKSPACE_FORMAT, WorkspaceConfig};
211 use memstead_schema::SchemaRef;
212 use tempfile::TempDir;
213
214 fn write_minimal_workspace(tmp: &TempDir) {
215 let memstead_dir = tmp.path().join(".memstead");
220 std::fs::create_dir_all(&memstead_dir).unwrap();
221 std::fs::write(
222 memstead_dir.join("workspace.toml"),
223 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
224 )
225 .unwrap();
226 let pin: SchemaRef = "default@1.0.0".parse().unwrap();
227 let cfg = WorkspaceConfig::new("demo", pin);
228 write_workspace_config(tmp.path(), &cfg).unwrap();
229 }
230
231 async fn spawn_fixture_registry(
236 scope: &'static str,
237 name: &'static str,
238 body: Vec<u8>,
239 ) -> (String, tokio::task::JoinHandle<()>) {
240 use axum::{Router, extract::Path as AxumPath, http::StatusCode, routing::get};
241 use std::sync::Arc;
242
243 let body = Arc::new(body);
244 let app: Router = Router::new().route(
245 "/api/mem/{scope_at}/{name_memstead}",
246 get({
247 let body = body.clone();
248 move |AxumPath((scope_at, name_memstead)): AxumPath<(String, String)>| {
249 let body = body.clone();
250 async move {
251 let want_scope = scope.to_string();
252 let want_name = format!("{name}.mem");
253 if scope_at == want_scope && name_memstead == want_name {
254 (StatusCode::OK, (*body).clone())
255 } else {
256 (StatusCode::NOT_FOUND, vec![])
257 }
258 }
259 }
260 }),
261 );
262 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
263 let addr = listener.local_addr().unwrap();
264 let handle = tokio::spawn(async move {
265 axum::serve(listener, app).await.unwrap();
266 });
267 (format!("http://{addr}"), handle)
268 }
269
270 #[tokio::test(flavor = "multi_thread")]
271 async fn link_downloads_archive_and_records_dep() {
272 let tmp = TempDir::new().unwrap();
273 write_minimal_workspace(&tmp);
274
275 let archive_bytes = b"fake-memstead-archive-bytes".to_vec();
276 let (base, handle) =
277 spawn_fixture_registry("anthropic", "core", archive_bytes.clone()).await;
278
279 let workspace = tmp.path().to_path_buf();
282 let base_clone = base.clone();
283 let result = tokio::task::spawn_blocking(move || {
284 let ctx = CliContext {
285 json: false,
286 quiet: false,
287 };
288 run(
289 &ctx,
290 LinkArgs {
291 dep: "anthropic/core".to_string(),
292 registry: Some(base_clone),
293 workspace: Some(workspace),
294 },
295 )
296 })
297 .await
298 .unwrap();
299 handle.abort();
300 result.unwrap();
301
302 let cache = tmp
304 .path()
305 .join(".memstead")
306 .join("memstead-io")
307 .join("anthropic")
308 .join("core.mem");
309 assert!(
310 cache.is_file(),
311 "cached archive must exist at {}",
312 cache.display()
313 );
314 assert_eq!(std::fs::read(&cache).unwrap(), archive_bytes);
315
316 let cfg = read_workspace_config(tmp.path()).unwrap();
318 assert_eq!(cfg.format, FILESYSTEM_WORKSPACE_FORMAT);
319 assert_eq!(cfg.deps.len(), 1);
320 assert_eq!(cfg.deps[0].as_display(), "anthropic/core");
321 }
322
323 #[tokio::test(flavor = "multi_thread")]
324 async fn link_is_idempotent_on_repeat() {
325 let tmp = TempDir::new().unwrap();
326 write_minimal_workspace(&tmp);
327
328 let archive_bytes = b"v1".to_vec();
329 let (base, handle) =
330 spawn_fixture_registry("anthropic", "core", archive_bytes.clone()).await;
331
332 let workspace = tmp.path().to_path_buf();
333 let base_clone = base.clone();
334 for _ in 0..2 {
335 let workspace = workspace.clone();
336 let base_clone = base_clone.clone();
337 tokio::task::spawn_blocking(move || {
338 let ctx = CliContext {
339 json: false,
340 quiet: false,
341 };
342 run(
343 &ctx,
344 LinkArgs {
345 dep: "anthropic/core".to_string(),
346 registry: Some(base_clone),
347 workspace: Some(workspace),
348 },
349 )
350 .unwrap();
351 })
352 .await
353 .unwrap();
354 }
355 handle.abort();
356
357 let cfg = read_workspace_config(tmp.path()).unwrap();
358 assert_eq!(
359 cfg.deps.len(),
360 1,
361 "repeated link must not duplicate the dep entry"
362 );
363 }
364
365 #[tokio::test(flavor = "multi_thread")]
366 async fn link_404_is_typed_and_actionable() {
367 let tmp = TempDir::new().unwrap();
370 write_minimal_workspace(&tmp);
371
372 let (base, handle) = spawn_fixture_registry("anthropic", "core", b"".to_vec()).await;
373 let workspace = tmp.path().to_path_buf();
374 let base_clone = base.clone();
375 let err = tokio::task::spawn_blocking(move || {
376 let ctx = CliContext {
377 json: false,
378 quiet: false,
379 };
380 run(
381 &ctx,
382 LinkArgs {
383 dep: "anthropic/missing".to_string(),
384 registry: Some(base_clone),
385 workspace: Some(workspace),
386 },
387 )
388 .unwrap_err()
389 })
390 .await
391 .unwrap();
392 handle.abort();
393 let msg = err.to_string();
394 assert!(
395 msg.contains("registry has no mem"),
396 "expected actionable 404 message, got: {msg}"
397 );
398 }
399
400 #[test]
401 fn link_rejects_invalid_dep_ref() {
402 let tmp = TempDir::new().unwrap();
403 write_minimal_workspace(&tmp);
404 let ctx = CliContext {
405 json: false,
406 quiet: false,
407 };
408 let err = run(
409 &ctx,
410 LinkArgs {
411 dep: "not-a-scope-name".to_string(),
412 registry: None,
413 workspace: Some(tmp.path().to_path_buf()),
414 },
415 )
416 .unwrap_err();
417 assert!(err.to_string().contains("invalid dependency reference"));
418 }
419
420 #[test]
421 fn link_rejects_missing_workspace() {
422 let tmp = TempDir::new().unwrap();
423 let ctx = CliContext {
425 json: false,
426 quiet: false,
427 };
428 let err = run(
429 &ctx,
430 LinkArgs {
431 dep: "anthropic/core".to_string(),
432 registry: None,
433 workspace: Some(tmp.path().to_path_buf()),
434 },
435 )
436 .unwrap_err();
437 assert!(err.to_string().contains("no workspace"));
438 }
439}