1use std::path::PathBuf;
11
12use clap::Parser;
13use indexmap::IndexMap;
14use serde::Deserialize;
15
16use memstead_base::CreateEntityArgs;
17#[cfg(feature = "mem-repo")]
18use memstead_base::EntityId;
19#[cfg(feature = "mem-repo")]
20use memstead_base::ops::RelateArg;
21use memstead_base::vcs::Actor;
22
23use crate::CliError;
24use crate::output::{ExitKind, print_json, print_markdown};
25use crate::setup::{CliContext, CliEngine};
26
27#[derive(Parser, Debug)]
28#[command(after_long_help = super::create_after_long_help())]
29pub struct Args {
30 #[arg(long)]
32 pub title: Option<String>,
33
34 #[arg(long = "type")]
37 pub entity_type: Option<String>,
38
39 #[arg(long)]
41 pub mem: Option<String>,
42
43 #[arg(long = "section", value_name = "KEY=VALUE")]
48 pub sections: Vec<String>,
49
50 #[arg(long = "metadata", value_name = "KEY=VALUE")]
52 pub metadata: Vec<String>,
53
54 #[arg(long = "relation", value_name = "TYPE:TARGET")]
58 pub relations: Vec<String>,
59
60 #[arg(long = "anchor", value_name = "JSON")]
67 pub anchors: Vec<String>,
68
69 #[arg(long = "from", value_name = "FILE")]
79 pub from: Option<PathBuf>,
80
81 #[arg(long = "dry-run")]
87 pub dry_run: bool,
88
89 #[arg(long)]
97 pub note: Option<String>,
98}
99
100#[derive(Debug, Deserialize)]
107#[serde(deny_unknown_fields)]
108struct CreatePayload {
109 title: String,
110 entity_type: String,
111 mem: Option<String>,
112 #[serde(default)]
113 sections: IndexMap<String, String>,
114 #[serde(default)]
115 metadata: IndexMap<String, String>,
116 #[serde(default)]
117 relations: Vec<RelationPayload>,
118 #[serde(default)]
122 anchors: Vec<memstead_base::anchor::AnchorInput>,
123 #[serde(default)]
127 note: Option<String>,
128 #[serde(default)]
135 dry_run: bool,
136 #[serde(default)]
142 id: Option<String>,
143 #[serde(default)]
147 #[allow(dead_code)]
148 expected_hash: Option<String>,
149}
150
151#[derive(Debug, Deserialize)]
152#[serde(deny_unknown_fields)]
153#[cfg_attr(not(feature = "mem-repo"), allow(dead_code))]
154struct RelationPayload {
155 to: String,
156 #[serde(rename = "type")]
157 rel_type: String,
158 #[serde(default)]
159 description: Option<String>,
160}
161
162pub fn run(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
163 let payload = if let Some(ref file) = args.from {
164 let bytes = std::fs::read(file).map_err(|e| {
165 CliError::new(
166 ExitKind::Generic,
167 "INVALID_INPUT",
168 format!("failed to read {}: {e}", file.display()),
169 )
170 })?;
171 let mut parsed: CreatePayload = serde_json::from_slice(&bytes).map_err(|e| {
172 CliError::new(
173 ExitKind::Validation,
174 "INVALID_INPUT",
175 format!("invalid JSON in {}: {e}", file.display()),
176 )
177 .with_details(serde_json::json!({
178 "path": file.display().to_string(),
179 "parser_error": e.to_string(),
180 }))
181 })?;
182 parsed.dry_run |= args.dry_run;
185 parsed
186 } else {
187 let title = args.title.clone().ok_or_else(|| {
188 CliError::new(
189 ExitKind::Validation,
190 "INVALID_INPUT",
191 "missing --title (or pass --from <file.json>)",
192 )
193 })?;
194 let entity_type = args.entity_type.clone().ok_or_else(|| {
195 CliError::new(
196 ExitKind::Validation,
197 "INVALID_INPUT",
198 "missing --type (or pass --from <file.json>)",
199 )
200 })?;
201 CreatePayload {
202 title,
203 entity_type,
204 mem: args.mem.clone(),
205 sections: parse_kv_list(&args.sections, "--section")?,
206 metadata: parse_kv_list(&args.metadata, "--metadata")?,
207 relations: parse_relation_list(&args.relations)?,
208 anchors: parse_anchor_list(&args.anchors)?,
209 note: None,
210 dry_run: args.dry_run,
211 id: None,
212 expected_hash: None,
213 }
214 };
215
216 let dry_run = payload.dry_run;
220
221 if let Some(template_id) = payload.id.as_deref() {
227 let derived = memstead_base::entity::id::validate_and_derive_slug(&payload.title)
228 .map_err(|e| CliError::new(ExitKind::Validation, "INVALID_TITLE", e.to_string()))?;
229 let derived_slug = derived.slug;
230 let slug_part = template_id
231 .rsplit_once("--")
232 .map(|(_, s)| s)
233 .unwrap_or(template_id);
234 if slug_part != derived_slug {
235 return Err(CliError::new(
236 ExitKind::Validation,
237 "INVALID_INPUT",
238 format!(
239 "template `id` {template_id:?} does not match the id derived from the \
240 title (slug {derived_slug:?}) — create derives identity from the title; \
241 fix the template's id or title"
242 ),
243 )
244 .into());
245 }
246 }
247
248 let note = args.note.clone().or_else(|| payload.note.clone());
251
252 match ctx.cli_engine()? {
253 #[cfg(feature = "mem-repo")]
254 CliEngine::MemRepo(mut engine) => {
255 let mem = match payload.mem {
256 Some(v) => v,
257 None => first_writable_mem(&engine)?,
258 };
259
260 let create_args = CreateEntityArgs {
261 anchors: payload.anchors,
262 title: payload.title,
263 mem,
264 entity_type: payload.entity_type,
265 sections: payload.sections,
266 metadata: payload.metadata,
267 relations: payload
268 .relations
269 .into_iter()
270 .map(|r| RelateArg {
271 to: EntityId::canonical(&r.to),
272 rel_type: r.rel_type,
273 description: r.description,
274 })
275 .collect(),
276 dry_run,
277 };
278
279 let result = engine
280 .create_entity_with_ctx(create_args, &crate::setup::cli_ctx_with_note(note.clone()))
281 .map_err(CliError::from_engine_op)?;
282 let mem_changed = engine.take_mem_changed_notices();
283
284 if ctx.json {
285 let mut body = serde_json::to_value(&result).unwrap_or(serde_json::Value::Null);
286 super::merge_mem_changed_json(&mut body, &mem_changed);
287 print_json(&body)?;
288 } else {
289 let warnings = if result.warnings.is_empty() {
290 String::new()
291 } else {
292 let rendered: Vec<String> =
293 result.warnings.iter().map(ToString::to_string).collect();
294 let warnings_block =
295 format!("\n\n> warnings:\n> - {}", rendered.join("\n> - "),);
296 let guidance_block = super::render_type_guidance_block(&result.type_guidance);
297 format!("{warnings_block}{guidance_block}")
298 };
299 let incoming_block = if result.incoming.is_empty() {
300 String::new()
301 } else {
302 let heading = if dry_run {
303 format!("Would adopt incoming edges ({})", result.incoming.len())
304 } else {
305 format!("Adopted incoming edges ({})", result.incoming.len())
306 };
307 let rows: Vec<String> = result
308 .incoming
309 .iter()
310 .map(|r| {
311 format!("- {} --[{}]--> (this) [{}]", r.from, r.rel_type, r.source)
312 })
313 .collect();
314 format!("\n\n## {}\n\n{}", heading, rows.join("\n"))
315 };
316 let title_heading = if dry_run {
317 format!("Dry run — would create `{}`", result.id)
318 } else {
319 format!("Created `{}`", result.id)
320 };
321 let mem_changed_block = super::render_mem_changed_block(&mem_changed);
322 print_markdown(&format!(
323 "# {}\n\n- Title: {}\n- Mem: {}\n- File: {}\n- Hash: `{}`{}{}{}",
324 title_heading,
325 result.title,
326 result.mem,
327 result.file_path,
328 result.content_hash,
329 warnings,
330 incoming_block,
331 mem_changed_block,
332 ));
333 }
334 }
335 CliEngine::Filesystem(mut engine) => {
336 let workspace_mem = engine
342 .mem_names()
343 .into_iter()
344 .next()
345 .map(String::from)
346 .unwrap_or_default();
347 if let Some(requested) = payload.mem.as_deref()
348 && requested != workspace_mem
349 {
350 return Err(CliError::new(
351 ExitKind::NotFound,
352 "UNKNOWN_MEM",
353 format!(
354 "filesystem-mem is single-mem: workspace mem is `{workspace_mem}`, request specified `{requested}`"
355 ),
356 )
357 .into());
358 }
359 if !payload.relations.is_empty() {
364 return Err(CliError::new(
365 ExitKind::Validation,
366 "INVALID_INPUT",
367 "--relation is not yet supported on filesystem-mem `memstead create` — use `memstead relate` after creation",
368 )
369 .into());
370 }
371 if dry_run {
372 return Err(CliError::new(
373 ExitKind::Validation,
374 "INVALID_INPUT",
375 "--dry-run is not yet supported on filesystem-mem `memstead create`",
376 )
377 .into());
378 }
379
380 let create_args = CreateEntityArgs {
381 anchors: payload.anchors,
382 mem: workspace_mem,
383 title: payload.title.clone(),
384 entity_type: payload.entity_type,
385 sections: payload.sections,
386 metadata: payload.metadata,
387 relations: Vec::new(),
388 dry_run: false,
389 };
390 let outcome = engine
391 .create_entity(
392 create_args,
393 Actor::Cli,
394 Some(&crate::setup::cli_client_id()),
395 note.as_deref(),
396 )
397 .map_err(CliError::from_engine_op)?;
398
399 if ctx.json {
400 print_json(&serde_json::json!({
404 "id": outcome.id.as_ref(),
405 "title": payload.title,
406 "file_path": outcome.file_path,
407 "_hash": outcome.content_hash,
408 "warnings": outcome.warnings,
409 "type_guidance": outcome.type_guidance,
410 }))?;
411 } else {
412 let warnings = if outcome.warnings.is_empty() {
413 String::new()
414 } else {
415 let rendered: Vec<String> =
418 outcome.warnings.iter().map(|w| w.to_string()).collect();
419 let warnings_block =
420 format!("\n\n> warnings:\n> - {}", rendered.join("\n> - "),);
421 let guidance_block = super::render_type_guidance_block(&outcome.type_guidance);
422 format!("{warnings_block}{guidance_block}")
423 };
424 print_markdown(&format!(
425 "# Created `{}`\n\n- Title: {}\n- Mem: {}\n- File: {}\n- Hash: `{}`{}",
426 outcome.id,
427 payload.title,
428 outcome.id.mem(),
429 outcome.file_path,
430 outcome.content_hash,
431 warnings,
432 ));
433 }
434 }
435 }
436 Ok(())
437}
438
439fn parse_kv_list(items: &[String], flag: &str) -> anyhow::Result<IndexMap<String, String>> {
440 let mut out = IndexMap::with_capacity(items.len());
441 for raw in items {
442 let (k, v) = raw.split_once('=').ok_or_else(|| {
443 CliError::new(
444 ExitKind::Validation,
445 "INVALID_INPUT",
446 format!("{flag}: expected KEY=VALUE, got `{raw}`"),
447 )
448 })?;
449 out.insert(k.to_string(), v.to_string());
450 }
451 Ok(out)
452}
453
454pub(crate) fn parse_anchor_list(
460 items: &[String],
461) -> anyhow::Result<Vec<memstead_base::anchor::AnchorInput>> {
462 let mut out = Vec::with_capacity(items.len());
463 for raw in items {
464 let anchor: memstead_base::anchor::AnchorInput =
465 serde_json::from_str(raw).map_err(|e| {
466 CliError::new(
467 ExitKind::Validation,
468 "INVALID_INPUT",
469 format!("--anchor: expected a JSON anchor object, got `{raw}`: {e}"),
470 )
471 })?;
472 out.push(anchor);
473 }
474 Ok(out)
475}
476
477fn parse_relation_list(items: &[String]) -> anyhow::Result<Vec<RelationPayload>> {
478 let mut out = Vec::with_capacity(items.len());
479 for raw in items {
480 let (rel_type, to) = raw.split_once(':').ok_or_else(|| {
481 CliError::new(
482 ExitKind::Validation,
483 "INVALID_INPUT",
484 format!("--relation: expected TYPE:target-id, got `{raw}`"),
485 )
486 })?;
487 out.push(RelationPayload {
488 rel_type: rel_type.to_string(),
489 to: to.to_string(),
490 description: None,
491 });
492 }
493 Ok(out)
494}
495
496#[cfg(feature = "mem-repo")]
497fn first_writable_mem(engine: &memstead_base::Engine) -> anyhow::Result<String> {
498 match engine.default_writable_mem() {
503 Some(name) => Ok(name.to_string()),
504 None => Err(CliError::new(
505 ExitKind::Generic,
506 "NO_WRITABLE_MEM",
507 "no writable mem loaded — pass --mem <name>",
508 )
509 .into()),
510 }
511}
512
513#[cfg(test)]
514mod tests {
515 use super::*;
516
517 #[test]
521 fn create_payload_accepts_optional_note() {
522 let with_note: CreatePayload =
523 serde_json::from_str(r#"{"title":"X","entity_type":"spec","note":"why this landed"}"#)
524 .expect("payload with note must parse");
525 assert_eq!(with_note.note.as_deref(), Some("why this landed"));
526
527 let without: CreatePayload = serde_json::from_str(r#"{"title":"X","entity_type":"spec"}"#)
528 .expect("note-less payload must still parse");
529 assert!(without.note.is_none());
530 }
531
532 #[test]
535 fn cli_note_takes_precedence_over_file_note() {
536 let cli = Some("from-flag".to_string());
537 let file = Some("from-file".to_string());
538 assert_eq!(
539 cli.clone().or_else(|| file.clone()).as_deref(),
540 Some("from-flag")
541 );
542 assert_eq!(None.or_else(|| file.clone()).as_deref(), Some("from-file"));
543 }
544}