Skip to main content

memstead_cli/commands/
link.rs

1//! `memstead link <scope/name>` — fetch a published mem from the
2//! registry, cache it locally, and record the dependency in the filesystem
3//! workspace config.
4//!
5//! Re-fetch is the refresh: invoking `memstead link <same-ref>` again
6//! re-downloads the archive and overwrites the cached file. The
7//! workspace-config dep entry is idempotent — `WorkspaceConfig::add_dep`
8//! deduplicates on `==`, so repeated invocations do not accumulate
9//! duplicate entries.
10//!
11//! Cache layout: `<workspace_root>/.memstead/memstead-io/<scope>/<name>.mem`.
12//! The Tier 3 wiki-link resolver consumes the cached archive at this
13//! exact path (criterion 7 of the plan, gated on filesystem engine
14//! context).
15
16use 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/// `memstead link` arguments.
28#[derive(Args, Debug)]
29pub struct LinkArgs {
30    /// Cross-mem dependency in `scope/name` form (no `@` prefix —
31    /// that is the `memstead install` shape). Tier 3 wiki-links use the
32    /// same form, so the input here matches what users will type
33    /// inside `[[scope/name:slug]]`.
34    #[arg(value_name = "SCOPE/NAME")]
35    pub dep: String,
36
37    /// Override the registry URL. Falls back to `MEMSTEAD_REGISTRY` then
38    /// the default `https://memstead.io`.
39    #[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
47/// Inner seam: `root_override` replaces the cwd walk — used by tests
48/// (the CLI-level workspace override is the ROOT command's global
49/// `--workspace` / `MEMSTEAD_WORKSPACE`, applied before dispatch; no
50/// subcommand-level flag exists).
51fn 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
163/// Walk upward from `cwd` looking for the first ancestor that
164/// carries `.memstead/workspace.toml` — the post-rebuild workspace
165/// marker. Mirrors the resolver in `memstead-cli/src/setup.rs` and the
166/// MCP binary's walker; keep them in sync.
167fn 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        // Lay down the post-rebuild marker `.memstead/workspace.toml` so
208        // the link command's walk-up resolves. The legacy
209        // `.memstead/config.json` still holds the `deps` list and lands
210        // alongside via `write_workspace_config`.
211        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    /// Spin up a tiny axum server that serves a single fixture archive
224    /// at `/api/mem/<scope>/<name>.mem`. Returns the bound base
225    /// URL (e.g. `http://127.0.0.1:54321`) and a `JoinHandle` the
226    /// caller drops to shut the server down.
227    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        // Run on a blocking thread because `registry::download_mem`
272        // is `reqwest::blocking`.
273        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        // Cached archive lands at the expected path with the expected bytes.
296        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        // Workspace config records the dep.
310        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        // Server only knows about `anthropic/core`; we ask for a
362        // different name and expect a `NotFound` exit code.
363        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    // The former `link_rejects_missing_workspace` test exercised the
417    // per-subcommand `--workspace` validation, which was folded into
418    // the root command's global override (validated in `main` before
419    // dispatch, refusing with the tried path). The binary-level
420    // refusal is covered by
421    // `read_commands::workspace_override_flag_env_precedence_and_refusal`.
422}