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 engine = crate::setup::full_engine(ctx)?;
54
55 if args.source.starts_with('@') {
59 return Err(CliError::new(
60 ExitKind::Validation,
61 "INVALID_INPUT",
62 "the `@scope/name` syntax is no longer supported — use \
63 `github:<handle>/<name>`, `<domain>/<name>`, or a bare `<handle>/<name>`",
64 )
65 .into());
66 }
67
68 if let Some((scope, name)) = registry::parse_ref(&args.source) {
70 let base = registry::registry_base(args.registry.as_deref());
71 let client = registry::build_http()?;
72
73 let tmp = tempfile::NamedTempFile::new().map_err(|e| {
79 CliError::new(
80 ExitKind::Generic,
81 "INTERNAL_IO_ERROR",
82 format!(
83 "could not create a temporary file to download into ({e}) — check that the \
84 system temp directory is writable and has free space"
85 ),
86 )
87 })?;
88 registry::download_mem(&client, &base, &scope, &name, tmp.path()).map_err(|e| {
89 let msg = match &e {
90 DownloadError::NotFound => {
91 format!("{scope}/{name} not found on {base}")
92 }
93 DownloadError::Gone => {
94 format!("{scope}/{name} has been taken down")
95 }
96 _ => format!("download failed: {e}"),
97 };
98 let code: &'static str = match &e {
99 DownloadError::NotFound => "REGISTRY_NOT_FOUND",
100 DownloadError::Gone => "GONE",
101 _ => "REGISTRY_ERROR",
102 };
103 CliError::new(
104 match e {
105 DownloadError::NotFound => ExitKind::NotFound,
106 _ => ExitKind::Generic,
107 },
108 code,
109 msg,
110 )
111 })?;
112
113 let source_url = format!(
114 "{base}/api/mem/{scope}/{name}.mem",
115 base = base,
116 scope = scope,
117 name = name
118 );
119 return install_archive(ctx, &mut engine, tmp.path(), Some(source_url));
120 }
121
122 let path = PathBuf::from(&args.source);
124 install_archive(ctx, &mut engine, &path, None)
125}
126
127fn install_archive(
130 ctx: &CliContext,
131 engine: &mut memstead_base::Engine,
132 archive: &Path,
133 source_url: Option<String>,
134) -> anyhow::Result<()> {
135 let writable: Vec<String> = engine
139 .mem_router()
140 .writable_mems()
141 .iter()
142 .map(|n| n.to_string())
143 .collect();
144 let writable_refs: Vec<&str> = writable.iter().map(String::as_str).collect();
145
146 let outcome =
147 mem_cache::install_to_cache(archive, &writable_refs).map_err(install_err_to_cli)?;
148
149 let mount_state = mem_cache::register_cached_archive(engine, &outcome, "memstead install")
150 .map_err(engine_err_to_cli)?;
151 if mount_state != mem_cache::MountRegistration::AlreadyRegistered {
152 engine.persist_state().map_err(engine_err_to_cli)?;
153 }
154
155 emit_outcome(ctx, outcome, mount_state, source_url)
156}
157
158fn emit_outcome(
159 ctx: &CliContext,
160 outcome: CacheInstallOutcome,
161 mount_state: MountRegistration,
162 source_url: Option<String>,
163) -> anyhow::Result<()> {
164 let mount_status_wire = match mount_state {
165 MountRegistration::Registered => "registered",
166 MountRegistration::AlreadyRegistered => "already_registered",
167 MountRegistration::Refreshed => "refreshed",
168 };
169 if ctx.json {
170 print_json(&json!({
171 "mem_name": outcome.mem_name,
172 "copied_to_cache": outcome.copied_to_cache,
173 "mount": mount_status_wire,
174 "cache_path": outcome.cache_path.to_string_lossy(),
175 "source_url": source_url,
176 "warnings": outcome.warnings,
179 }))?;
180 } else {
181 let cache_status = if outcome.copied_to_cache {
182 "copied into cache"
183 } else {
184 "already in cache (unchanged)"
185 };
186 let mount_status = match mount_state {
187 MountRegistration::Registered => {
188 "registered as a workspace-level read-only mount".to_string()
189 }
190 MountRegistration::AlreadyRegistered => {
191 "already registered as a read-mem mount (unchanged)".to_string()
192 }
193 MountRegistration::Refreshed => {
194 "read-mem mount refreshed to the new archive content".to_string()
195 }
196 };
197 let mut body = format!(
198 "# Installed `{}`\n\n- Archive: {}\n- Mount: {}",
199 outcome.mem_name, cache_status, mount_status,
200 );
201 if let Some(url) = source_url {
202 body.push_str(&format!("\n- Source: {url}"));
203 }
204 if !outcome.warnings.is_empty() {
205 body.push_str("\n\n## Warnings\n");
206 for w in &outcome.warnings {
207 body.push_str(&format!("\n- **{}**: {}", w.code(), w.message()));
208 }
209 }
210 print_markdown(&body);
211 }
212 Ok(())
213}
214
215fn install_err_to_cli(e: memstead_git_branch::mem_cache::InstallError) -> anyhow::Error {
223 use memstead_base::validator::ValidationError;
224 use memstead_git_branch::mem_cache::InstallError;
225 if let InstallError::Validation(
232 ValidationError::EmbeddedSchemaInvalid { .. }
233 | ValidationError::EmbeddedSchemaMismatch { .. },
234 ) = &e
235 {
236 return CliError::new(
237 ExitKind::Validation,
238 "EMBEDDED_SCHEMA_INVALID",
239 e.to_string(),
240 )
241 .into();
242 }
243 if let InstallError::ShadowsWritable {
244 archive_name,
245 shadows_writable,
246 } = &e
247 {
248 return CliError::new(
249 ExitKind::Validation,
250 "READ_MEM_SHADOWS_WRITABLE",
251 e.to_string(),
252 )
253 .with_details(json!({
254 "archive_name": archive_name,
255 "shadows_writable": shadows_writable,
256 }))
257 .into();
258 }
259 CliError::new(
269 ExitKind::Generic,
270 crate::ARCHIVE_VALIDATION_FAILED_CODE,
271 e.to_string(),
272 )
273 .into()
274}
275
276fn engine_err_to_cli(e: memstead_base::EngineError) -> anyhow::Error {
278 CliError::from_engine_op(e).into()
279}
280
281#[cfg(test)]
282mod tests {
283 use crate::registry::parse_ref;
284
285 #[test]
286 fn parse_ref_accepts_three_scope_forms() {
287 assert_eq!(
288 parse_ref("memstead/knowledge"),
289 Some(("memstead".into(), "knowledge".into()))
290 );
291 assert_eq!(
292 parse_ref("github:alice/foo"),
293 Some(("github:alice".into(), "foo".into()))
294 );
295 assert_eq!(
296 parse_ref("acme.com:payments/foo"),
297 Some(("acme.com:payments".into(), "foo".into()))
298 );
299 }
300
301 #[test]
302 fn parse_ref_rejects_local_paths() {
303 assert!(parse_ref("/tmp/foo.mem").is_none());
304 assert!(parse_ref("./foo.mem").is_none());
305 assert!(parse_ref("foo.mem").is_none());
306 }
307
308 #[test]
309 fn parse_ref_rejects_legacy_at_and_malformed() {
310 assert!(parse_ref("@memstead/knowledge").is_none());
312 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()); }
318}