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")]
76 pub from: Option<PathBuf>,
77
78 #[arg(long = "dry-run")]
82 pub dry_run: bool,
83
84 #[arg(long)]
92 pub note: Option<String>,
93}
94
95#[derive(Debug, Deserialize)]
102#[serde(deny_unknown_fields)]
103struct CreatePayload {
104 title: String,
105 entity_type: String,
106 mem: Option<String>,
107 #[serde(default)]
108 sections: IndexMap<String, String>,
109 #[serde(default)]
110 metadata: IndexMap<String, String>,
111 #[serde(default)]
112 relations: Vec<RelationPayload>,
113 #[serde(default)]
117 anchors: Vec<memstead_base::anchor::AnchorInput>,
118 #[serde(default)]
122 note: Option<String>,
123}
124
125#[derive(Debug, Deserialize)]
126#[serde(deny_unknown_fields)]
127#[cfg_attr(not(feature = "mem-repo"), allow(dead_code))]
128struct RelationPayload {
129 to: String,
130 #[serde(rename = "type")]
131 rel_type: String,
132 #[serde(default)]
133 description: Option<String>,
134}
135
136pub fn run(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
137 let payload = if let Some(ref file) = args.from {
138 let bytes = std::fs::read(file).map_err(|e| {
139 CliError::new(
140 ExitKind::Generic,
141 "INVALID_INPUT",
142 format!("failed to read {}: {e}", file.display()),
143 )
144 })?;
145 let parsed: CreatePayload = serde_json::from_slice(&bytes).map_err(|e| {
146 CliError::new(
147 ExitKind::Validation,
148 "INVALID_INPUT",
149 format!("invalid JSON in {}: {e}", file.display()),
150 )
151 .with_details(serde_json::json!({
152 "path": file.display().to_string(),
153 "parser_error": e.to_string(),
154 }))
155 })?;
156 parsed
157 } else {
158 let title = args.title.clone().ok_or_else(|| {
159 CliError::new(
160 ExitKind::Validation,
161 "INVALID_INPUT",
162 "missing --title (or pass --from <file.json>)",
163 )
164 })?;
165 let entity_type = args.entity_type.clone().ok_or_else(|| {
166 CliError::new(
167 ExitKind::Validation,
168 "INVALID_INPUT",
169 "missing --type (or pass --from <file.json>)",
170 )
171 })?;
172 CreatePayload {
173 title,
174 entity_type,
175 mem: args.mem.clone(),
176 sections: parse_kv_list(&args.sections, "--section")?,
177 metadata: parse_kv_list(&args.metadata, "--metadata")?,
178 relations: parse_relation_list(&args.relations)?,
179 anchors: parse_anchor_list(&args.anchors)?,
180 note: None,
181 }
182 };
183
184 let note = args.note.clone().or_else(|| payload.note.clone());
187
188 match ctx.cli_engine()? {
189 #[cfg(feature = "mem-repo")]
190 CliEngine::MemRepo(mut engine) => {
191 let mem = match payload.mem {
192 Some(v) => v,
193 None => first_writable_mem(&engine)?,
194 };
195
196 let create_args = CreateEntityArgs {
197 anchors: payload.anchors,
198 title: payload.title,
199 mem,
200 entity_type: payload.entity_type,
201 sections: payload.sections,
202 metadata: payload.metadata,
203 relations: payload
204 .relations
205 .into_iter()
206 .map(|r| RelateArg {
207 to: EntityId::canonical(&r.to),
208 rel_type: r.rel_type,
209 description: r.description,
210 })
211 .collect(),
212 dry_run: args.dry_run,
213 };
214
215 let result = engine
216 .create_entity_with_ctx(create_args, &crate::setup::cli_ctx_with_note(note.clone()))
217 .map_err(CliError::from_engine_op)?;
218 let mem_changed = engine.take_mem_changed_notices();
219
220 if ctx.json {
221 let mut body = serde_json::to_value(&result).unwrap_or(serde_json::Value::Null);
222 super::merge_mem_changed_json(&mut body, &mem_changed);
223 print_json(&body)?;
224 } else {
225 let warnings = if result.warnings.is_empty() {
226 String::new()
227 } else {
228 let rendered: Vec<String> =
229 result.warnings.iter().map(ToString::to_string).collect();
230 let warnings_block =
231 format!("\n\n> warnings:\n> - {}", rendered.join("\n> - "),);
232 let guidance_block = super::render_type_guidance_block(&result.type_guidance);
233 format!("{warnings_block}{guidance_block}")
234 };
235 let incoming_block = if result.incoming.is_empty() {
236 String::new()
237 } else {
238 let heading = if args.dry_run {
239 format!("Would adopt incoming edges ({})", result.incoming.len())
240 } else {
241 format!("Adopted incoming edges ({})", result.incoming.len())
242 };
243 let rows: Vec<String> = result
244 .incoming
245 .iter()
246 .map(|r| {
247 format!("- {} --[{}]--> (this) [{}]", r.from, r.rel_type, r.source)
248 })
249 .collect();
250 format!("\n\n## {}\n\n{}", heading, rows.join("\n"))
251 };
252 let title_heading = if args.dry_run {
253 format!("Dry run — would create `{}`", result.id)
254 } else {
255 format!("Created `{}`", result.id)
256 };
257 let mem_changed_block = super::render_mem_changed_block(&mem_changed);
258 print_markdown(&format!(
259 "# {}\n\n- Title: {}\n- Mem: {}\n- File: {}\n- Hash: `{}`{}{}{}",
260 title_heading,
261 result.title,
262 result.mem,
263 result.file_path,
264 result.content_hash,
265 warnings,
266 incoming_block,
267 mem_changed_block,
268 ));
269 }
270 }
271 CliEngine::Filesystem(mut engine) => {
272 let workspace_mem = engine
278 .mem_names()
279 .into_iter()
280 .next()
281 .map(String::from)
282 .unwrap_or_default();
283 if let Some(requested) = payload.mem.as_deref()
284 && requested != workspace_mem
285 {
286 return Err(CliError::new(
287 ExitKind::NotFound,
288 "UNKNOWN_MEM",
289 format!(
290 "filesystem-mem is single-mem: workspace mem is `{workspace_mem}`, request specified `{requested}`"
291 ),
292 )
293 .into());
294 }
295 if !payload.relations.is_empty() {
300 return Err(CliError::new(
301 ExitKind::Validation,
302 "INVALID_INPUT",
303 "--relation is not yet supported on filesystem-mem `memstead create` — use `memstead relate` after creation",
304 )
305 .into());
306 }
307 if args.dry_run {
308 return Err(CliError::new(
309 ExitKind::Validation,
310 "INVALID_INPUT",
311 "--dry-run is not yet supported on filesystem-mem `memstead create`",
312 )
313 .into());
314 }
315
316 let create_args = CreateEntityArgs {
317 anchors: payload.anchors,
318 mem: workspace_mem,
319 title: payload.title.clone(),
320 entity_type: payload.entity_type,
321 sections: payload.sections,
322 metadata: payload.metadata,
323 relations: Vec::new(),
324 dry_run: false,
325 };
326 let outcome = engine
327 .create_entity(create_args, Actor::Cli, None, note.as_deref())
328 .map_err(CliError::from_engine_op)?;
329
330 if ctx.json {
331 print_json(&serde_json::json!({
335 "id": outcome.id.as_ref(),
336 "title": payload.title,
337 "file_path": outcome.file_path,
338 "_hash": outcome.content_hash,
339 "warnings": outcome.warnings,
340 "type_guidance": outcome.type_guidance,
341 }))?;
342 } else {
343 let warnings = if outcome.warnings.is_empty() {
344 String::new()
345 } else {
346 let rendered: Vec<String> =
349 outcome.warnings.iter().map(|w| w.to_string()).collect();
350 let warnings_block =
351 format!("\n\n> warnings:\n> - {}", rendered.join("\n> - "),);
352 let guidance_block = super::render_type_guidance_block(&outcome.type_guidance);
353 format!("{warnings_block}{guidance_block}")
354 };
355 print_markdown(&format!(
356 "# Created `{}`\n\n- Title: {}\n- Mem: {}\n- File: {}\n- Hash: `{}`{}",
357 outcome.id,
358 payload.title,
359 outcome.id.mem(),
360 outcome.file_path,
361 outcome.content_hash,
362 warnings,
363 ));
364 }
365 }
366 }
367 Ok(())
368}
369
370fn parse_kv_list(items: &[String], flag: &str) -> anyhow::Result<IndexMap<String, String>> {
371 let mut out = IndexMap::with_capacity(items.len());
372 for raw in items {
373 let (k, v) = raw.split_once('=').ok_or_else(|| {
374 CliError::new(
375 ExitKind::Validation,
376 "INVALID_INPUT",
377 format!("{flag}: expected KEY=VALUE, got `{raw}`"),
378 )
379 })?;
380 out.insert(k.to_string(), v.to_string());
381 }
382 Ok(out)
383}
384
385pub(crate) fn parse_anchor_list(
391 items: &[String],
392) -> anyhow::Result<Vec<memstead_base::anchor::AnchorInput>> {
393 let mut out = Vec::with_capacity(items.len());
394 for raw in items {
395 let anchor: memstead_base::anchor::AnchorInput =
396 serde_json::from_str(raw).map_err(|e| {
397 CliError::new(
398 ExitKind::Validation,
399 "INVALID_INPUT",
400 format!("--anchor: expected a JSON anchor object, got `{raw}`: {e}"),
401 )
402 })?;
403 out.push(anchor);
404 }
405 Ok(out)
406}
407
408fn parse_relation_list(items: &[String]) -> anyhow::Result<Vec<RelationPayload>> {
409 let mut out = Vec::with_capacity(items.len());
410 for raw in items {
411 let (rel_type, to) = raw.split_once(':').ok_or_else(|| {
412 CliError::new(
413 ExitKind::Validation,
414 "INVALID_INPUT",
415 format!("--relation: expected TYPE:target-id, got `{raw}`"),
416 )
417 })?;
418 out.push(RelationPayload {
419 rel_type: rel_type.to_string(),
420 to: to.to_string(),
421 description: None,
422 });
423 }
424 Ok(out)
425}
426
427#[cfg(feature = "mem-repo")]
428fn first_writable_mem(engine: &memstead_base::Engine) -> anyhow::Result<String> {
429 match engine.default_writable_mem() {
434 Some(name) => Ok(name.to_string()),
435 None => Err(CliError::new(
436 ExitKind::Generic,
437 "NO_WRITABLE_MEM",
438 "no writable mem loaded — pass --mem <name>",
439 )
440 .into()),
441 }
442}
443
444#[cfg(test)]
445mod tests {
446 use super::*;
447
448 #[test]
452 fn create_payload_accepts_optional_note() {
453 let with_note: CreatePayload =
454 serde_json::from_str(r#"{"title":"X","entity_type":"spec","note":"why this landed"}"#)
455 .expect("payload with note must parse");
456 assert_eq!(with_note.note.as_deref(), Some("why this landed"));
457
458 let without: CreatePayload = serde_json::from_str(r#"{"title":"X","entity_type":"spec"}"#)
459 .expect("note-less payload must still parse");
460 assert!(without.note.is_none());
461 }
462
463 #[test]
466 fn cli_note_takes_precedence_over_file_note() {
467 let cli = Some("from-flag".to_string());
468 let file = Some("from-file".to_string());
469 assert_eq!(
470 cli.clone().or_else(|| file.clone()).as_deref(),
471 Some("from-flag")
472 );
473 assert_eq!(None.or_else(|| file.clone()).as_deref(), Some("from-file"));
474 }
475}