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| {
76 CliError::new(
77 ExitKind::Generic,
78 crate::INTERNAL_CODE,
79 format!("tempfile: {e}"),
80 )
81 })?;
82 registry::download_mem(&client, &base, &scope, &name, tmp.path()).map_err(|e| {
83 let msg = match &e {
84 DownloadError::NotFound => {
85 format!("{scope}/{name} not found on {base}")
86 }
87 DownloadError::Gone => {
88 format!("{scope}/{name} has been taken down")
89 }
90 _ => format!("download failed: {e}"),
91 };
92 let code: &'static str = match &e {
93 DownloadError::NotFound => "REGISTRY_NOT_FOUND",
94 DownloadError::Gone => "GONE",
95 _ => "REGISTRY_ERROR",
96 };
97 CliError::new(
98 match e {
99 DownloadError::NotFound => ExitKind::NotFound,
100 _ => ExitKind::Generic,
101 },
102 code,
103 msg,
104 )
105 })?;
106
107 let source_url = format!(
108 "{base}/api/mem/{scope}/{name}.mem",
109 base = base,
110 scope = scope,
111 name = name
112 );
113 return install_archive(ctx, &mut engine, tmp.path(), Some(source_url));
114 }
115
116 let path = PathBuf::from(&args.source);
118 install_archive(ctx, &mut engine, &path, None)
119}
120
121fn install_archive(
124 ctx: &CliContext,
125 engine: &mut memstead_base::Engine,
126 archive: &Path,
127 source_url: Option<String>,
128) -> anyhow::Result<()> {
129 let writable: Vec<String> = engine
133 .mem_router()
134 .writable_mems()
135 .iter()
136 .map(|n| n.to_string())
137 .collect();
138 let writable_refs: Vec<&str> = writable.iter().map(String::as_str).collect();
139
140 let outcome =
141 mem_cache::install_to_cache(archive, &writable_refs).map_err(install_err_to_cli)?;
142
143 let mount_state = mem_cache::register_cached_archive(engine, &outcome, "memstead install")
144 .map_err(engine_err_to_cli)?;
145 if mount_state != mem_cache::MountRegistration::AlreadyRegistered {
146 engine.persist_state().map_err(engine_err_to_cli)?;
147 }
148
149 emit_outcome(ctx, outcome, mount_state, source_url)
150}
151
152fn emit_outcome(
153 ctx: &CliContext,
154 outcome: CacheInstallOutcome,
155 mount_state: MountRegistration,
156 source_url: Option<String>,
157) -> anyhow::Result<()> {
158 let mount_status_wire = match mount_state {
159 MountRegistration::Registered => "registered",
160 MountRegistration::AlreadyRegistered => "already_registered",
161 MountRegistration::Refreshed => "refreshed",
162 };
163 if ctx.json {
164 print_json(&json!({
165 "mem_name": outcome.mem_name,
166 "copied_to_cache": outcome.copied_to_cache,
167 "mount": mount_status_wire,
168 "cache_path": outcome.cache_path.to_string_lossy(),
169 "source_url": source_url,
170 "warnings": outcome.warnings,
173 }))?;
174 } else {
175 let cache_status = if outcome.copied_to_cache {
176 "copied into cache"
177 } else {
178 "already in cache (unchanged)"
179 };
180 let mount_status = match mount_state {
181 MountRegistration::Registered => {
182 "registered as a workspace-level read-only mount".to_string()
183 }
184 MountRegistration::AlreadyRegistered => {
185 "already registered as a read-mem mount (unchanged)".to_string()
186 }
187 MountRegistration::Refreshed => {
188 "read-mem mount refreshed to the new archive content".to_string()
189 }
190 };
191 let mut body = format!(
192 "# Installed `{}`\n\n- Archive: {}\n- Mount: {}",
193 outcome.mem_name, cache_status, mount_status,
194 );
195 if let Some(url) = source_url {
196 body.push_str(&format!("\n- Source: {url}"));
197 }
198 if !outcome.warnings.is_empty() {
199 body.push_str("\n\n## Warnings\n");
200 for w in &outcome.warnings {
201 body.push_str(&format!("\n- **{}**: {}", w.code(), w.message()));
202 }
203 }
204 print_markdown(&body);
205 }
206 Ok(())
207}
208
209fn install_err_to_cli(e: memstead_git_branch::mem_cache::InstallError) -> anyhow::Error {
217 use memstead_git_branch::mem_cache::InstallError;
218 if let InstallError::ShadowsWritable {
219 archive_name,
220 shadows_writable,
221 } = &e
222 {
223 return CliError::new(
224 ExitKind::Validation,
225 "READ_MEM_SHADOWS_WRITABLE",
226 e.to_string(),
227 )
228 .with_details(json!({
229 "archive_name": archive_name,
230 "shadows_writable": shadows_writable,
231 }))
232 .into();
233 }
234 CliError::new(
244 ExitKind::Generic,
245 crate::ARCHIVE_VALIDATION_FAILED_CODE,
246 e.to_string(),
247 )
248 .into()
249}
250
251fn engine_err_to_cli(e: memstead_base::EngineError) -> anyhow::Error {
253 CliError::from_engine_op(e).into()
254}
255
256#[cfg(test)]
257mod tests {
258 use crate::registry::parse_ref;
259
260 #[test]
261 fn parse_ref_accepts_three_scope_forms() {
262 assert_eq!(
263 parse_ref("memstead/knowledge"),
264 Some(("memstead".into(), "knowledge".into()))
265 );
266 assert_eq!(
267 parse_ref("github:alice/foo"),
268 Some(("github:alice".into(), "foo".into()))
269 );
270 assert_eq!(
271 parse_ref("acme.com:payments/foo"),
272 Some(("acme.com:payments".into(), "foo".into()))
273 );
274 }
275
276 #[test]
277 fn parse_ref_rejects_local_paths() {
278 assert!(parse_ref("/tmp/foo.mem").is_none());
279 assert!(parse_ref("./foo.mem").is_none());
280 assert!(parse_ref("foo.mem").is_none());
281 }
282
283 #[test]
284 fn parse_ref_rejects_legacy_at_and_malformed() {
285 assert!(parse_ref("@memstead/knowledge").is_none());
287 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()); }
293}