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