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