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