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