use std::path::{Path, PathBuf};
use clap::Parser;
use serde_json::json;
use memstead_git_branch::mem_cache::{self, TargetMem};
use memstead_git_branch::mem_repo_config;
use crate::CliError;
use crate::output::{ExitKind, print_json, print_markdown};
use crate::registry::{self, DownloadError};
use crate::setup::CliContext;
use crate::setup::cli_ctx;
#[derive(Parser, Debug)]
pub struct Args {
#[arg(value_name = "PATH or SCOPE/NAME")]
pub source: String,
#[arg(long = "mem", value_name = "NAME")]
pub mem_name: Option<String>,
#[arg(long, value_name = "URL")]
pub registry: Option<String>,
}
pub fn run(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
let engine = crate::setup::pro_engine(ctx)?;
let mem_name = resolve_mem_name(&engine, args.mem_name.clone())?;
let mem_disk_dir = engine
.mem_router()
.dir_for_mem(&mem_name)
.map(|p| p.to_path_buf());
let workspace_root = engine
.workspace_root()
.map(|p| p.to_path_buf())
.unwrap_or_default();
let writable: Vec<String> = engine
.mem_router()
.writable_mems()
.iter()
.map(|n| n.to_string())
.collect();
if args.source.starts_with('@') {
anyhow::bail!(
"the `@scope/name` syntax is no longer supported — use \
`github:<handle>/<name>`, `<domain>/<name>`, or a bare `<handle>/<name>`"
);
}
if let Some((scope, name)) = registry::parse_ref(&args.source) {
return install_from_registry(
ctx,
&mem_name,
mem_disk_dir.as_deref(),
&workspace_root,
&scope,
&name,
args.registry.as_deref(),
&writable,
);
}
install_from_local(
ctx,
&mem_name,
mem_disk_dir.as_deref(),
&workspace_root,
&PathBuf::from(&args.source),
&writable,
)
}
fn install_from_local(
ctx: &CliContext,
mem_name: &str,
mem_disk_dir: Option<&Path>,
workspace_root: &Path,
archive: &Path,
writable: &[String],
) -> anyhow::Result<()> {
let writable_refs: Vec<&str> = writable.iter().map(String::as_str).collect();
let target = build_target(mem_name, mem_disk_dir, workspace_root);
let commit_ctx = cli_ctx();
let message = format!("memstead: install (read-mem registration into {mem_name})");
let outcome =
mem_cache::install_read_mem(archive, target, &commit_ctx, &message, &writable_refs)
.map_err(install_err_to_cli)?;
emit_outcome(ctx, mem_name, outcome, None)
}
#[allow(clippy::too_many_arguments)]
fn install_from_registry(
ctx: &CliContext,
mem_name: &str,
mem_disk_dir: Option<&Path>,
workspace_root: &Path,
scope: &str,
name: &str,
registry_arg: Option<&str>,
writable: &[String],
) -> anyhow::Result<()> {
let base = registry::registry_base(registry_arg);
let client = registry::build_http()?;
let tmp = tempfile::NamedTempFile::new().map_err(|e| {
CliError::new(
ExitKind::Generic,
crate::INTERNAL_CODE,
format!("tempfile: {e}"),
)
})?;
registry::download_mem(&client, &base, scope, name, tmp.path()).map_err(|e| {
let msg = match &e {
DownloadError::NotFound => {
format!("{scope}/{name} not found on {base}")
}
DownloadError::Gone => {
format!("{scope}/{name} has been taken down")
}
_ => format!("download failed: {e}"),
};
let code: &'static str = match &e {
DownloadError::NotFound => "REGISTRY_NOT_FOUND",
DownloadError::Gone => "GONE",
_ => "REGISTRY_ERROR",
};
CliError::new(
match e {
DownloadError::NotFound => ExitKind::NotFound,
_ => ExitKind::Generic,
},
code,
msg,
)
})?;
let writable_refs: Vec<&str> = writable.iter().map(String::as_str).collect();
let target = build_target(mem_name, mem_disk_dir, workspace_root);
let commit_ctx = cli_ctx();
let message = format!("memstead: install (read-mem registration into {mem_name})");
let outcome =
mem_cache::install_read_mem(tmp.path(), target, &commit_ctx, &message, &writable_refs)
.map_err(install_err_to_cli)?;
let source_url = format!(
"{base}/api/mem/{scope}/{name}.mem",
base = base,
scope = scope,
name = name
);
update_source_to_url(
mem_name,
mem_disk_dir,
workspace_root,
&outcome.mem_name,
&source_url,
)?;
emit_outcome(ctx, mem_name, outcome, Some(source_url))
}
fn build_target<'a>(
mem_name: &'a str,
mem_disk_dir: Option<&'a Path>,
workspace_root: &'a Path,
) -> TargetMem<'a> {
match mem_disk_dir {
Some(p) => TargetMem::Disk(p),
None => TargetMem::MemRepo {
workspace_root,
mem_name,
},
}
}
fn update_source_to_url(
host_mem_name: &str,
mem_disk_dir: Option<&Path>,
workspace_root: &Path,
read_mem_name: &str,
source_url: &str,
) -> anyhow::Result<()> {
use serde_json::{Map, Value, json};
let url_entry = |existing: Option<&Value>| -> Value {
let mut obj = Map::new();
obj.insert("source".into(), json!({ "type": "url", "url": source_url }));
if let Some(key) = existing
.and_then(|e| e.get("cacheKey"))
.and_then(|k| k.as_str())
{
obj.insert("cacheKey".into(), json!(key));
}
Value::Object(obj)
};
match mem_disk_dir {
Some(mem_dir) => {
let (mut config, config_path) =
memstead_schema::config::load_config(mem_dir).map_err(|e| {
CliError::new(
ExitKind::Generic,
"WORKSPACE_CONFIG_READ_FAILED",
format!("reading config: {e}"),
)
})?;
let root = config.as_object_mut().ok_or_else(|| {
CliError::new(
ExitKind::Generic,
"WORKSPACE_CONFIG_INVALID",
"config root must be a JSON object",
)
})?;
let read_mems = root
.entry("readMems")
.or_insert_with(|| Value::Object(Map::new()))
.as_object_mut()
.ok_or_else(|| {
CliError::new(
ExitKind::Generic,
"WORKSPACE_CONFIG_INVALID",
"readMems must be a JSON object",
)
})?;
let existing_is_url_same = read_mems
.get(read_mem_name)
.and_then(|v| v.get("source"))
.and_then(|s| s.get("type"))
.and_then(|t| t.as_str())
.is_some_and(|t| t == "url");
if existing_is_url_same {
return Ok(());
}
let entry = url_entry(read_mems.get(read_mem_name));
read_mems.insert(read_mem_name.to_string(), entry);
let body = serde_json::to_string_pretty(&config).map_err(|e| {
CliError::new(
ExitKind::Generic,
crate::INTERNAL_CODE,
format!("serializing config: {e}"),
)
})?;
std::fs::write(&config_path, body + "\n").map_err(|e| {
CliError::new(
ExitKind::Generic,
crate::INTERNAL_CODE,
format!("writing config: {e}"),
)
})?;
Ok(())
}
None => {
let config =
mem_repo_config::read_config(workspace_root, host_mem_name).map_err(|e| {
CliError::new(
ExitKind::Generic,
"WORKSPACE_CONFIG_READ_FAILED",
format!("reading configs/{host_mem_name}.json from mem-repo-git:main: {e}"),
)
})?;
let mut value = serde_json::to_value(&config).map_err(|e| {
CliError::new(
ExitKind::Generic,
crate::INTERNAL_CODE,
format!("re-serialize MemConfig: {e}"),
)
})?;
let root = value.as_object_mut().ok_or_else(|| {
CliError::new(
ExitKind::Generic,
"WORKSPACE_CONFIG_INVALID",
"config root must be a JSON object",
)
})?;
let read_mems = root
.entry("readMems")
.or_insert_with(|| Value::Object(Map::new()))
.as_object_mut()
.ok_or_else(|| {
CliError::new(
ExitKind::Generic,
"WORKSPACE_CONFIG_INVALID",
"readMems must be a JSON object",
)
})?;
let existing_is_url_same = read_mems
.get(read_mem_name)
.and_then(|v| v.get("source"))
.and_then(|s| s.get("type"))
.and_then(|t| t.as_str())
.is_some_and(|t| t == "url");
if existing_is_url_same {
return Ok(());
}
let entry = url_entry(read_mems.get(read_mem_name));
read_mems.insert(read_mem_name.to_string(), entry);
let updated_bytes = serde_json::to_vec_pretty(&value).map_err(|e| {
CliError::new(
ExitKind::Generic,
crate::INTERNAL_CODE,
format!("serializing updated config: {e}"),
)
})?;
let commit_ctx = cli_ctx();
let message = format!(
"memstead: install (rewrite source URL for {read_mem_name} in {host_mem_name})"
);
mem_repo_config::commit_config(
workspace_root,
host_mem_name,
&updated_bytes,
&commit_ctx,
&message,
)
.map_err(|e| {
CliError::new(
ExitKind::Generic,
"WORKSPACE_CONFIG_WRITE_FAILED",
format!("commit configs/{host_mem_name}.json: {e}"),
)
})?;
Ok(())
}
}
}
fn emit_outcome(
ctx: &CliContext,
target_mem: &str,
outcome: mem_cache::InstallOutcome,
source_url: Option<String>,
) -> anyhow::Result<()> {
if ctx.json {
print_json(&json!({
"mem_name": outcome.mem_name,
"copied_to_cache": outcome.copied_to_cache,
"registered_in_config": outcome.registered_in_config,
"target_mem": target_mem,
"source_url": source_url,
"warnings": outcome.warnings,
}))?;
} else {
let cache_status = if outcome.copied_to_cache {
"copied into cache"
} else {
"already in cache (unchanged)"
};
let config_status = if outcome.registered_in_config {
format!("registered as a read-mem on `{target_mem}`'s workspace config")
} else {
format!("already registered as a read-mem on `{target_mem}`'s workspace config")
};
let mut body = format!(
"# Installed `{}`\n\n- Archive: {}\n- Config: {}",
outcome.mem_name, cache_status, config_status,
);
if let Some(url) = source_url {
body.push_str(&format!("\n- Source: {url}"));
}
if !outcome.warnings.is_empty() {
body.push_str("\n\n## Warnings\n");
for w in &outcome.warnings {
body.push_str(&format!("\n- **{}**: {}", w.code(), w.message()));
}
}
print_markdown(&body);
}
Ok(())
}
fn install_err_to_cli(e: memstead_git_branch::mem_cache::InstallError) -> anyhow::Error {
use memstead_git_branch::mem_cache::InstallError;
if let InstallError::ShadowsWritable {
archive_name,
shadows_writable,
} = &e
{
return CliError::new(
ExitKind::Validation,
"READ_MEM_SHADOWS_WRITABLE",
e.to_string(),
)
.with_details(json!({
"archive_name": archive_name,
"shadows_writable": shadows_writable,
}))
.into();
}
CliError::new(
ExitKind::Generic,
crate::ARCHIVE_VALIDATION_FAILED_CODE,
e.to_string(),
)
.into()
}
fn resolve_mem_name(
engine: &memstead_base::Engine,
explicit: Option<String>,
) -> anyhow::Result<String> {
let writable: Vec<String> = engine
.mem_configs_named()
.filter(|(name, _)| engine.mem_router().is_writable(name))
.map(|(name, _)| name.to_string())
.collect();
if let Some(name) = explicit {
if !writable.iter().any(|v| v == &name) {
return Err(CliError::new(
ExitKind::Validation,
"HOST_MEM_NOT_REGISTERED",
format!(
"host mem `{name}` is not a registered writable mem — \
run `memstead mem init {name}` first OR pass `--mem <existing>`",
),
)
.with_details(json!({
"requested": name,
"known_mems": writable,
}))
.into());
}
return Ok(name);
}
match writable.len() {
0 => Err(CliError::new(
ExitKind::Generic,
"NO_WRITABLE_MEM",
"no writable mem loaded — nothing to install into",
)
.into()),
1 => Ok(writable.into_iter().next().unwrap()),
_ => Err(CliError::new(
ExitKind::Validation,
"AMBIGUOUS_MEM",
format!(
"multiple writable mems loaded ({}); pass --mem <name> \
to pick the install target",
writable.join(", ")
),
)
.with_details(json!({ "mems": writable }))
.into()),
}
}
#[cfg(test)]
mod tests {
use crate::registry::parse_ref;
#[test]
fn parse_ref_accepts_three_scope_forms() {
assert_eq!(
parse_ref("memstead/knowledge"),
Some(("memstead".into(), "knowledge".into()))
);
assert_eq!(
parse_ref("github:alice/foo"),
Some(("github:alice".into(), "foo".into()))
);
assert_eq!(
parse_ref("acme.com:payments/foo"),
Some(("acme.com:payments".into(), "foo".into()))
);
}
#[test]
fn parse_ref_rejects_local_paths() {
assert!(parse_ref("/tmp/foo.mem").is_none());
assert!(parse_ref("./foo.mem").is_none());
assert!(parse_ref("foo.mem").is_none());
}
#[test]
fn parse_ref_rejects_legacy_at_and_malformed() {
assert!(parse_ref("@memstead/knowledge").is_none());
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()); }
}