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