1use std::path::{Path, PathBuf};
20
21use clap::Parser;
22use serde_json::json;
23
24use memstead_git_branch::mem_cache::{self, TargetMem};
25use memstead_git_branch::mem_repo_config;
26
27use crate::CliError;
28use crate::output::{ExitKind, print_json, print_markdown};
29use crate::registry::{self, DownloadError};
30use crate::setup::CliContext;
31use crate::setup::cli_ctx;
32
33#[derive(Parser, Debug)]
38pub struct Args {
39 #[arg(value_name = "PATH or SCOPE/NAME")]
42 pub source: String,
43
44 #[arg(long = "mem", value_name = "NAME")]
53 pub mem_name: Option<String>,
54
55 #[arg(long, value_name = "URL")]
58 pub registry: Option<String>,
59}
60
61pub fn run(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
62 let engine = crate::setup::pro_engine(ctx)?;
63
64 let mem_name = resolve_mem_name(&engine, args.mem_name.clone())?;
65 let mem_disk_dir = engine
70 .mem_router()
71 .dir_for_mem(&mem_name)
72 .map(|p| p.to_path_buf());
73 let workspace_root = engine
74 .workspace_root()
75 .map(|p| p.to_path_buf())
76 .unwrap_or_default();
77 let writable: Vec<String> = engine
81 .mem_router()
82 .writable_mems()
83 .iter()
84 .map(|n| n.to_string())
85 .collect();
86
87 if args.source.starts_with('@') {
90 anyhow::bail!(
91 "the `@scope/name` syntax is no longer supported — use \
92 `github:<handle>/<name>`, `<domain>/<name>`, or a bare `<handle>/<name>`"
93 );
94 }
95 if let Some((scope, name)) = registry::parse_ref(&args.source) {
97 return install_from_registry(
98 ctx,
99 &mem_name,
100 mem_disk_dir.as_deref(),
101 &workspace_root,
102 &scope,
103 &name,
104 args.registry.as_deref(),
105 &writable,
106 );
107 }
108
109 install_from_local(
111 ctx,
112 &mem_name,
113 mem_disk_dir.as_deref(),
114 &workspace_root,
115 &PathBuf::from(&args.source),
116 &writable,
117 )
118}
119
120fn install_from_local(
121 ctx: &CliContext,
122 mem_name: &str,
123 mem_disk_dir: Option<&Path>,
124 workspace_root: &Path,
125 archive: &Path,
126 writable: &[String],
127) -> anyhow::Result<()> {
128 let writable_refs: Vec<&str> = writable.iter().map(String::as_str).collect();
129 let target = build_target(mem_name, mem_disk_dir, workspace_root);
130 let commit_ctx = cli_ctx();
131 let message = format!("memstead: install (read-mem registration into {mem_name})");
132 let outcome =
133 mem_cache::install_read_mem(archive, target, &commit_ctx, &message, &writable_refs)
134 .map_err(install_err_to_cli)?;
135 emit_outcome(ctx, mem_name, outcome, None)
136}
137
138#[allow(clippy::too_many_arguments)]
141fn install_from_registry(
142 ctx: &CliContext,
143 mem_name: &str,
144 mem_disk_dir: Option<&Path>,
145 workspace_root: &Path,
146 scope: &str,
147 name: &str,
148 registry_arg: Option<&str>,
149 writable: &[String],
150) -> anyhow::Result<()> {
151 let base = registry::registry_base(registry_arg);
152 let client = registry::build_http()?;
153
154 let tmp = tempfile::NamedTempFile::new().map_err(|e| {
157 CliError::new(
158 ExitKind::Generic,
159 crate::INTERNAL_CODE,
160 format!("tempfile: {e}"),
161 )
162 })?;
163 registry::download_mem(&client, &base, scope, name, tmp.path()).map_err(|e| {
164 let msg = match &e {
165 DownloadError::NotFound => {
166 format!("{scope}/{name} not found on {base}")
167 }
168 DownloadError::Gone => {
169 format!("{scope}/{name} has been taken down")
170 }
171 _ => format!("download failed: {e}"),
172 };
173 let code: &'static str = match &e {
174 DownloadError::NotFound => "REGISTRY_NOT_FOUND",
175 DownloadError::Gone => "GONE",
176 _ => "REGISTRY_ERROR",
177 };
178 CliError::new(
179 match e {
180 DownloadError::NotFound => ExitKind::NotFound,
181 _ => ExitKind::Generic,
182 },
183 code,
184 msg,
185 )
186 })?;
187
188 let writable_refs: Vec<&str> = writable.iter().map(String::as_str).collect();
193 let target = build_target(mem_name, mem_disk_dir, workspace_root);
194 let commit_ctx = cli_ctx();
195 let message = format!("memstead: install (read-mem registration into {mem_name})");
196 let outcome =
197 mem_cache::install_read_mem(tmp.path(), target, &commit_ctx, &message, &writable_refs)
198 .map_err(install_err_to_cli)?;
199
200 let source_url = format!(
201 "{base}/api/mem/{scope}/{name}.mem",
202 base = base,
203 scope = scope,
204 name = name
205 );
206 update_source_to_url(
207 mem_name,
208 mem_disk_dir,
209 workspace_root,
210 &outcome.mem_name,
211 &source_url,
212 )?;
213
214 emit_outcome(ctx, mem_name, outcome, Some(source_url))
215}
216
217fn build_target<'a>(
221 mem_name: &'a str,
222 mem_disk_dir: Option<&'a Path>,
223 workspace_root: &'a Path,
224) -> TargetMem<'a> {
225 match mem_disk_dir {
226 Some(p) => TargetMem::Disk(p),
227 None => TargetMem::MemRepo {
228 workspace_root,
229 mem_name,
230 },
231 }
232}
233
234fn update_source_to_url(
245 host_mem_name: &str,
246 mem_disk_dir: Option<&Path>,
247 workspace_root: &Path,
248 read_mem_name: &str,
249 source_url: &str,
250) -> anyhow::Result<()> {
251 use serde_json::{Map, Value, json};
252
253 let url_entry = |existing: Option<&Value>| -> Value {
258 let mut obj = Map::new();
259 obj.insert("source".into(), json!({ "type": "url", "url": source_url }));
260 if let Some(key) = existing
261 .and_then(|e| e.get("cacheKey"))
262 .and_then(|k| k.as_str())
263 {
264 obj.insert("cacheKey".into(), json!(key));
265 }
266 Value::Object(obj)
267 };
268
269 match mem_disk_dir {
270 Some(mem_dir) => {
271 let (mut config, config_path) =
272 memstead_schema::config::load_config(mem_dir).map_err(|e| {
273 CliError::new(
274 ExitKind::Generic,
275 "WORKSPACE_CONFIG_READ_FAILED",
276 format!("reading config: {e}"),
277 )
278 })?;
279
280 let root = config.as_object_mut().ok_or_else(|| {
281 CliError::new(
282 ExitKind::Generic,
283 "WORKSPACE_CONFIG_INVALID",
284 "config root must be a JSON object",
285 )
286 })?;
287 let read_mems = root
288 .entry("readMems")
289 .or_insert_with(|| Value::Object(Map::new()))
290 .as_object_mut()
291 .ok_or_else(|| {
292 CliError::new(
293 ExitKind::Generic,
294 "WORKSPACE_CONFIG_INVALID",
295 "readMems must be a JSON object",
296 )
297 })?;
298
299 let existing_is_url_same = read_mems
300 .get(read_mem_name)
301 .and_then(|v| v.get("source"))
302 .and_then(|s| s.get("type"))
303 .and_then(|t| t.as_str())
304 .is_some_and(|t| t == "url");
305 if existing_is_url_same {
306 return Ok(());
307 }
308
309 let entry = url_entry(read_mems.get(read_mem_name));
310 read_mems.insert(read_mem_name.to_string(), entry);
311
312 let body = serde_json::to_string_pretty(&config).map_err(|e| {
313 CliError::new(
314 ExitKind::Generic,
315 crate::INTERNAL_CODE,
316 format!("serializing config: {e}"),
317 )
318 })?;
319 std::fs::write(&config_path, body + "\n").map_err(|e| {
320 CliError::new(
321 ExitKind::Generic,
322 crate::INTERNAL_CODE,
323 format!("writing config: {e}"),
324 )
325 })?;
326 Ok(())
327 }
328 None => {
329 let config =
331 mem_repo_config::read_config(workspace_root, host_mem_name).map_err(|e| {
332 CliError::new(
333 ExitKind::Generic,
334 "WORKSPACE_CONFIG_READ_FAILED",
335 format!("reading configs/{host_mem_name}.json from mem-repo-git:main: {e}"),
336 )
337 })?;
338 let mut value = serde_json::to_value(&config).map_err(|e| {
339 CliError::new(
340 ExitKind::Generic,
341 crate::INTERNAL_CODE,
342 format!("re-serialize MemConfig: {e}"),
343 )
344 })?;
345 let root = value.as_object_mut().ok_or_else(|| {
346 CliError::new(
347 ExitKind::Generic,
348 "WORKSPACE_CONFIG_INVALID",
349 "config root must be a JSON object",
350 )
351 })?;
352 let read_mems = root
353 .entry("readMems")
354 .or_insert_with(|| Value::Object(Map::new()))
355 .as_object_mut()
356 .ok_or_else(|| {
357 CliError::new(
358 ExitKind::Generic,
359 "WORKSPACE_CONFIG_INVALID",
360 "readMems must be a JSON object",
361 )
362 })?;
363
364 let existing_is_url_same = read_mems
365 .get(read_mem_name)
366 .and_then(|v| v.get("source"))
367 .and_then(|s| s.get("type"))
368 .and_then(|t| t.as_str())
369 .is_some_and(|t| t == "url");
370 if existing_is_url_same {
371 return Ok(());
372 }
373
374 let entry = url_entry(read_mems.get(read_mem_name));
375 read_mems.insert(read_mem_name.to_string(), entry);
376
377 let updated_bytes = serde_json::to_vec_pretty(&value).map_err(|e| {
378 CliError::new(
379 ExitKind::Generic,
380 crate::INTERNAL_CODE,
381 format!("serializing updated config: {e}"),
382 )
383 })?;
384 let commit_ctx = cli_ctx();
385 let message = format!(
386 "memstead: install (rewrite source URL for {read_mem_name} in {host_mem_name})"
387 );
388 mem_repo_config::commit_config(
389 workspace_root,
390 host_mem_name,
391 &updated_bytes,
392 &commit_ctx,
393 &message,
394 )
395 .map_err(|e| {
396 CliError::new(
397 ExitKind::Generic,
398 "WORKSPACE_CONFIG_WRITE_FAILED",
399 format!("commit configs/{host_mem_name}.json: {e}"),
400 )
401 })?;
402 Ok(())
403 }
404 }
405}
406
407fn emit_outcome(
408 ctx: &CliContext,
409 target_mem: &str,
410 outcome: mem_cache::InstallOutcome,
411 source_url: Option<String>,
412) -> anyhow::Result<()> {
413 if ctx.json {
414 print_json(&json!({
415 "mem_name": outcome.mem_name,
416 "copied_to_cache": outcome.copied_to_cache,
417 "registered_in_config": outcome.registered_in_config,
418 "target_mem": target_mem,
419 "source_url": source_url,
420 "warnings": outcome.warnings,
423 }))?;
424 } else {
425 let cache_status = if outcome.copied_to_cache {
426 "copied into cache"
427 } else {
428 "already in cache (unchanged)"
429 };
430 let config_status = if outcome.registered_in_config {
437 format!("registered as a read-mem on `{target_mem}`'s workspace config")
438 } else {
439 format!("already registered as a read-mem on `{target_mem}`'s workspace config")
440 };
441 let mut body = format!(
442 "# Installed `{}`\n\n- Archive: {}\n- Config: {}",
443 outcome.mem_name, cache_status, config_status,
444 );
445 if let Some(url) = source_url {
446 body.push_str(&format!("\n- Source: {url}"));
447 }
448 if !outcome.warnings.is_empty() {
449 body.push_str("\n\n## Warnings\n");
450 for w in &outcome.warnings {
451 body.push_str(&format!("\n- **{}**: {}", w.code(), w.message()));
452 }
453 }
454 print_markdown(&body);
455 }
456 Ok(())
457}
458
459fn install_err_to_cli(e: memstead_git_branch::mem_cache::InstallError) -> anyhow::Error {
467 use memstead_git_branch::mem_cache::InstallError;
468 if let InstallError::ShadowsWritable {
469 archive_name,
470 shadows_writable,
471 } = &e
472 {
473 return CliError::new(
474 ExitKind::Validation,
475 "READ_MEM_SHADOWS_WRITABLE",
476 e.to_string(),
477 )
478 .with_details(json!({
479 "archive_name": archive_name,
480 "shadows_writable": shadows_writable,
481 }))
482 .into();
483 }
484 CliError::new(
494 ExitKind::Generic,
495 crate::ARCHIVE_VALIDATION_FAILED_CODE,
496 e.to_string(),
497 )
498 .into()
499}
500
501fn resolve_mem_name(
502 engine: &memstead_base::Engine,
503 explicit: Option<String>,
504) -> anyhow::Result<String> {
505 let writable: Vec<String> = engine
506 .mem_configs_named()
507 .filter(|(name, _)| engine.mem_router().is_writable(name))
508 .map(|(name, _)| name.to_string())
509 .collect();
510
511 if let Some(name) = explicit {
512 if !writable.iter().any(|v| v == &name) {
521 return Err(CliError::new(
522 ExitKind::Validation,
523 "HOST_MEM_NOT_REGISTERED",
524 format!(
525 "host mem `{name}` is not a registered writable mem — \
526 run `memstead mem init {name}` first OR pass `--mem <existing>`",
527 ),
528 )
529 .with_details(json!({
530 "requested": name,
531 "known_mems": writable,
532 }))
533 .into());
534 }
535 return Ok(name);
536 }
537
538 match writable.len() {
539 0 => Err(CliError::new(
540 ExitKind::Generic,
541 "NO_WRITABLE_MEM",
542 "no writable mem loaded — nothing to install into",
543 )
544 .into()),
545 1 => Ok(writable.into_iter().next().unwrap()),
546 _ => Err(CliError::new(
547 ExitKind::Validation,
548 "AMBIGUOUS_MEM",
549 format!(
550 "multiple writable mems loaded ({}); pass --mem <name> \
551 to pick the install target",
552 writable.join(", ")
553 ),
554 )
555 .with_details(json!({ "mems": writable }))
556 .into()),
557 }
558}
559
560#[cfg(test)]
561mod tests {
562 use crate::registry::parse_ref;
563
564 #[test]
565 fn parse_ref_accepts_three_scope_forms() {
566 assert_eq!(
567 parse_ref("memstead/knowledge"),
568 Some(("memstead".into(), "knowledge".into()))
569 );
570 assert_eq!(
571 parse_ref("github:alice/foo"),
572 Some(("github:alice".into(), "foo".into()))
573 );
574 assert_eq!(
575 parse_ref("acme.com:payments/foo"),
576 Some(("acme.com:payments".into(), "foo".into()))
577 );
578 }
579
580 #[test]
581 fn parse_ref_rejects_local_paths() {
582 assert!(parse_ref("/tmp/foo.mem").is_none());
583 assert!(parse_ref("./foo.mem").is_none());
584 assert!(parse_ref("foo.mem").is_none());
585 }
586
587 #[test]
588 fn parse_ref_rejects_legacy_at_and_malformed() {
589 assert!(parse_ref("@memstead/knowledge").is_none());
591 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()); }
597}