1use std::path::{Path, PathBuf};
22
23use clap::Parser;
24use serde_json::json;
25
26use memstead_git_branch::mem_cache::{self, CacheInstallOutcome, MountRegistration};
27
28use crate::CliError;
29use crate::output::{ExitKind, print_json, print_markdown};
30use crate::registry::{self, DownloadError};
31use crate::setup::CliContext;
32
33#[derive(Parser, Debug)]
40pub struct Args {
41 #[arg(value_name = "PATH or SCOPE/NAME")]
44 pub source: String,
45
46 #[arg(long, value_name = "URL")]
49 pub registry: Option<String>,
50}
51
52pub fn run(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
53 let mut cli_engine = ctx.cli_engine()?;
61 let engine = cli_engine.base_mut();
62
63 if args.source.starts_with('@') {
67 return Err(CliError::new(
68 ExitKind::Validation,
69 "INVALID_INPUT",
70 "the `@scope/name` syntax is no longer supported — use \
71 `github:<handle>/<name>`, `<domain>/<name>`, or a bare `<handle>/<name>`",
72 )
73 .into());
74 }
75
76 if let Some((scope, name)) = registry::parse_ref(&args.source) {
78 let fetched = fetch_registry_archive(&scope, &name, args.registry.as_deref())?;
79 return install_archive(
80 ctx,
81 engine,
82 fetched.file.path(),
83 Some(fetched.source_url),
84 "Installed",
85 "memstead install",
86 );
87 }
88
89 let path = PathBuf::from(&args.source);
91 install_archive(ctx, engine, &path, None, "Installed", "memstead install")
92}
93
94pub(crate) struct FetchedArchive {
98 pub file: tempfile::NamedTempFile,
99 pub source_url: String,
100}
101
102pub(crate) fn fetch_registry_archive(
106 scope: &str,
107 name: &str,
108 registry_override: Option<&str>,
109) -> anyhow::Result<FetchedArchive> {
110 let base = registry::registry_base(registry_override);
111 let client = registry::build_http()?;
112
113 let tmp = tempfile::NamedTempFile::new().map_err(|e| {
119 CliError::new(
120 ExitKind::Generic,
121 "INTERNAL_IO_ERROR",
122 format!(
123 "could not create a temporary file to download into ({e}) — check that the \
124 system temp directory is writable and has free space"
125 ),
126 )
127 })?;
128 registry::download_mem(&client, &base, scope, name, tmp.path()).map_err(|e| {
129 let msg = match &e {
130 DownloadError::NotFound => {
131 format!("{scope}/{name} not found on {base}")
132 }
133 DownloadError::Gone => {
134 format!("{scope}/{name} has been taken down")
135 }
136 _ => format!("download failed: {e}"),
137 };
138 let code: &'static str = match &e {
139 DownloadError::NotFound => "REGISTRY_NOT_FOUND",
140 DownloadError::Gone => "GONE",
141 _ => "REGISTRY_ERROR",
142 };
143 CliError::new(
144 match e {
145 DownloadError::NotFound => ExitKind::NotFound,
146 _ => ExitKind::Generic,
147 },
148 code,
149 msg,
150 )
151 })?;
152
153 Ok(FetchedArchive {
154 file: tmp,
155 source_url: format!("{base}/api/mem/{scope}/{name}.mem"),
156 })
157}
158
159pub(crate) fn install_archive(
162 ctx: &CliContext,
163 engine: &mut memstead_base::Engine,
164 archive: &Path,
165 source_url: Option<String>,
166 verb: &str,
167 by_tool: &'static str,
168) -> anyhow::Result<()> {
169 let writable: Vec<String> = engine
173 .mem_router()
174 .writable_mems()
175 .iter()
176 .map(|n| n.to_string())
177 .collect();
178 let writable_refs: Vec<&str> = writable.iter().map(String::as_str).collect();
179
180 let outcome =
181 mem_cache::install_to_cache(archive, &writable_refs).map_err(install_err_to_cli)?;
182
183 let mount_state =
184 mem_cache::register_cached_archive(engine, &outcome, by_tool).map_err(engine_err_to_cli)?;
185 if mount_state != mem_cache::MountRegistration::AlreadyRegistered {
186 engine.persist_state().map_err(engine_err_to_cli)?;
187 }
188
189 emit_outcome(ctx, outcome, mount_state, source_url, verb)
190}
191
192fn emit_outcome(
193 ctx: &CliContext,
194 outcome: CacheInstallOutcome,
195 mount_state: MountRegistration,
196 source_url: Option<String>,
197 verb: &str,
198) -> anyhow::Result<()> {
199 let mount_status_wire = match mount_state {
200 MountRegistration::Registered => "registered",
201 MountRegistration::AlreadyRegistered => "already_registered",
202 MountRegistration::Refreshed => "refreshed",
203 };
204 if ctx.json {
205 print_json(&json!({
206 "mem_name": outcome.mem_name,
207 "copied_to_cache": outcome.copied_to_cache,
208 "mount": mount_status_wire,
209 "cache_path": outcome.cache_path.to_string_lossy(),
210 "source_url": source_url,
211 "warnings": outcome.warnings,
214 }))?;
215 } else {
216 let cache_status = if outcome.copied_to_cache {
217 "copied into cache"
218 } else {
219 "already in cache (unchanged)"
220 };
221 let mount_status = match mount_state {
222 MountRegistration::Registered => {
223 "registered as a workspace-level read-only mount".to_string()
224 }
225 MountRegistration::AlreadyRegistered => {
226 "already registered as a read-mem mount (unchanged)".to_string()
227 }
228 MountRegistration::Refreshed => {
229 "read-mem mount refreshed to the new archive content".to_string()
230 }
231 };
232 let mut body = format!(
233 "# {} `{}`\n\n- Archive: {}\n- Mount: {}",
234 verb, outcome.mem_name, cache_status, mount_status,
235 );
236 if let Some(url) = source_url {
237 body.push_str(&format!("\n- Source: {url}"));
238 }
239 if !outcome.warnings.is_empty() {
240 body.push_str("\n\n## Warnings\n");
241 for w in &outcome.warnings {
242 body.push_str(&format!("\n- **{}**: {}", w.code(), w.message()));
243 }
244 }
245 print_markdown(&body);
246 }
247 Ok(())
248}
249
250fn install_err_to_cli(e: memstead_git_branch::mem_cache::InstallError) -> anyhow::Error {
258 use memstead_base::validator::ValidationError;
259 use memstead_git_branch::mem_cache::InstallError;
260 if let InstallError::Validation(
267 ValidationError::EmbeddedSchemaInvalid { .. }
268 | ValidationError::EmbeddedSchemaMismatch { .. },
269 ) = &e
270 {
271 return CliError::new(
272 ExitKind::Validation,
273 "EMBEDDED_SCHEMA_INVALID",
274 e.to_string(),
275 )
276 .into();
277 }
278 if let InstallError::ShadowsWritable {
279 archive_name,
280 shadows_writable,
281 } = &e
282 {
283 return CliError::new(
284 ExitKind::Validation,
285 "READ_MEM_SHADOWS_WRITABLE",
286 e.to_string(),
287 )
288 .with_details(json!({
289 "archive_name": archive_name,
290 "shadows_writable": shadows_writable,
291 }))
292 .into();
293 }
294 CliError::new(
304 ExitKind::Generic,
305 crate::ARCHIVE_VALIDATION_FAILED_CODE,
306 e.to_string(),
307 )
308 .into()
309}
310
311fn engine_err_to_cli(e: memstead_base::EngineError) -> anyhow::Error {
313 CliError::from_engine_op(e).into()
314}
315
316#[cfg(test)]
317mod tests {
318 use crate::registry::parse_ref;
319
320 #[test]
321 fn parse_ref_accepts_three_scope_forms() {
322 assert_eq!(
323 parse_ref("memstead/knowledge"),
324 Some(("memstead".into(), "knowledge".into()))
325 );
326 assert_eq!(
327 parse_ref("github:alice/foo"),
328 Some(("github:alice".into(), "foo".into()))
329 );
330 assert_eq!(
331 parse_ref("acme.com:payments/foo"),
332 Some(("acme.com:payments".into(), "foo".into()))
333 );
334 }
335
336 #[test]
337 fn parse_ref_rejects_local_paths() {
338 assert!(parse_ref("/tmp/foo.mem").is_none());
339 assert!(parse_ref("./foo.mem").is_none());
340 assert!(parse_ref("foo.mem").is_none());
341 }
342
343 #[test]
344 fn parse_ref_rejects_legacy_at_and_malformed() {
345 assert!(parse_ref("@memstead/knowledge").is_none());
347 assert!(parse_ref("memstead").is_none()); assert!(parse_ref("/knowledge").is_none()); assert!(parse_ref("memstead/").is_none()); assert!(parse_ref("memstead/knowledge.mem").is_none()); assert!(parse_ref("memstead/subdir/knowledge").is_none()); }
353}