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    /// Override the workspace root. When omitted, the command walks up
43    /// from the current working directory to find it.
44    #[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
171/// Walk upward from `cwd` looking for the first ancestor that
172/// carries `.memstead/workspace.toml` — the post-rebuild workspace
173/// marker. Mirrors the resolver in `memstead-cli/src/setup.rs` and the
174/// MCP binary's walker; keep them in sync.
175fn 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        // Lay down the post-rebuild marker `.memstead/workspace.toml` so
216        // the link command's walk-up resolves. The legacy
217        // `.memstead/config.json` still holds the `deps` list and lands
218        // alongside via `write_workspace_config`.
219        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    /// Spin up a tiny axum server that serves a single fixture archive
232    /// at `/api/mem/<scope>/<name>.mem`. Returns the bound base
233    /// URL (e.g. `http://127.0.0.1:54321`) and a `JoinHandle` the
234    /// caller drops to shut the server down.
235    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        // Run on a blocking thread because `registry::download_mem`
280        // is `reqwest::blocking`.
281        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        // Cached archive lands at the expected path with the expected bytes.
303        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        // Workspace config records the dep.
317        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        // Server only knows about `anthropic/core`; we ask for a
368        // different name and expect a `NotFound` exit code.
369        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        // No .memstead/workspace.toml under tmp.
424        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}