Skip to main content

memstead_cli/commands/
mod.rs

1pub mod anchors;
2pub mod changes;
3pub mod context;
4pub mod create;
5
6/// `--help` text describing the title→slug pipeline. Shared by
7/// `memstead create` and `memstead rename` so an agent reading either
8/// command's help can predict what slug a given title will produce
9/// (and therefore why the strict gate refuses titles outside the
10/// pipeline's accepted character classes).
11pub const SLUG_DERIVATION_HELP: &str = "\
12Slug derivation:
13  The entity slug derives from the title in five steps:
14    1. NFC-normalize (combining sequences fold to precomposed form);
15    2. Unicode case-fold to lowercase;
16    3. rewrite each whitespace character to '-';
17    4. drop every character that is not Unicode alphanumeric and not '-';
18    5. collapse hyphen runs, trim leading/trailing hyphens.
19
20  The mutation entry refuses titles where step 4 would drop any
21  character, or where the pipeline output is empty (whitespace- or
22  hyphen-only input). Errors carry a `proposed_slug` recovery hint
23  so a sanitised retry is mechanical.
24
25  The title body is stored as-sent (byte-form preserved); slug bytes
26  derive from the NFC-normalised form. An NFD-spelled title therefore
27  produces an NFC-spelled slug — the two byte forms are semantically
28  equivalent and compare equal under NFC normalization.
29
30  Pre-gate entities (created before this stricter rule landed) remain
31  readable. The gate runs at mutation entry only — it does not
32  retroactively reject entities loaded from disk.";
33
34/// `--help` epilog for `memstead create` and `memstead update`. The CLI
35/// stores `--section` / `--append` / `--patch` flag values as bytes
36/// verbatim — backslash escapes (`\n`, `\t`, …) are NOT interpreted.
37/// Agents reading the help learn the multi-line-authoring escape hatch
38/// (`--from <JSON file>`) before they hit the friction.
39pub const SECTION_BYTES_VERBATIM_HELP: &str = "\
40Section / append / patch flag values:
41  `--section KEY=VALUE`, `--append KEY=VALUE`, and `--patch KEY=OLD=>NEW`
42  store the right-hand side as bytes verbatim. The CLI does NOT
43  interpret backslash escapes — `--section purpose=\"line1\\nline2\"`
44  writes the literal two-character sequence `\\n` into the section
45  body, not a newline.
46
47  For multi-line section content, use `--from <FILE>` where FILE is a
48  JSON payload matching the MCP `memstead_create` / `memstead_update` shape.
49  The JSON parser de-escapes `\\n`, `\\t`, etc. before the engine
50  sees the value, so a JSON-quoted `\"line1\\nline2\"` round-trips as
51  two lines on disk.";
52
53/// Combined `--help` epilog for `memstead create`: the section-bytes-
54/// verbatim note followed by the title→slug pipeline description.
55/// `clap`'s `after_long_help` takes a single string, so the two
56/// epilogs are pre-concatenated here.
57pub const CREATE_AFTER_LONG_HELP: &str = concat!(
58    "Section / append / patch flag values:\n",
59    "  `--section KEY=VALUE`, `--append KEY=VALUE`, and `--patch KEY=OLD=>NEW`\n",
60    "  store the right-hand side as bytes verbatim. The CLI does NOT\n",
61    "  interpret backslash escapes — `--section purpose=\"line1\\nline2\"`\n",
62    "  writes the literal two-character sequence `\\n` into the section\n",
63    "  body, not a newline.\n",
64    "\n",
65    "  For multi-line section content, use `--from <FILE>` where FILE is a\n",
66    "  JSON payload matching the MCP `memstead_create` / `memstead_update` shape.\n",
67    "  The JSON parser de-escapes `\\n`, `\\t`, etc. before the engine\n",
68    "  sees the value, so a JSON-quoted `\"line1\\nline2\"` round-trips as\n",
69    "  two lines on disk.\n",
70    "\n",
71    "Slug derivation:\n",
72    "  The entity slug derives from the title in five steps:\n",
73    "    1. NFC-normalize (combining sequences fold to precomposed form);\n",
74    "    2. Unicode case-fold to lowercase;\n",
75    "    3. rewrite each whitespace character to '-';\n",
76    "    4. drop every character that is not Unicode alphanumeric and not '-';\n",
77    "    5. collapse hyphen runs, trim leading/trailing hyphens.\n",
78    "\n",
79    "  The mutation entry refuses titles where step 4 would drop any\n",
80    "  character, or where the pipeline output is empty (whitespace- or\n",
81    "  hyphen-only input). Errors carry a `proposed_slug` recovery hint\n",
82    "  so a sanitised retry is mechanical.\n",
83    "\n",
84    "  The title body is stored as-sent (byte-form preserved); slug bytes\n",
85    "  derive from the NFC-normalised form. An NFD-spelled title therefore\n",
86    "  produces an NFC-spelled slug — the two byte forms are semantically\n",
87    "  equivalent and compare equal under NFC normalization.\n",
88    "\n",
89    "  Pre-gate entities (created before this stricter rule landed) remain\n",
90    "  readable. The gate runs at mutation entry only — it does not\n",
91    "  retroactively reject entities loaded from disk.",
92);
93
94/// `--help` epilog for `memstead search` and `memstead list`. Names the five
95/// frozen named-flag shortcuts and points at `--filter KEY=VALUE` as
96/// the path to any other schema-declared `filterable: equality` field.
97pub const FILTER_HELP: &str = "\
98Filter surface:
99  Named-flag shortcuts (frozen — no new ones are added; use --filter
100  for any other schema-declared filterable field):
101    --type <T>          Filter by entity_type (engine first-class axis).
102    --level <L>         Filter by level (e.g. M0, M1).
103    --status <S>        Filter by status (e.g. active, closed).
104    --edge-type <E>     Filter by edge type (engine first-class axis).
105
106  Generic equality filter:
107    --filter KEY=VALUE  Filter by any schema-declared `filterable: equality`
108                        field. Repeatable. Examples:
109                          --filter tags=auth
110                          --filter scope=subsystem
111                          --filter confidence=high
112                          --filter tags=auth --filter level=M0
113  Unknown keys are silently dropped by the engine and surface as a
114  warning. Named-flag shortcuts and `--filter` populate the same
115  underlying filter map; if both set the same key, `--filter` wins
116  (declared last in the iteration order).";
117
118/// Parse a `KEY=VALUE` argument supplied via `--filter`. Returns a
119/// typed `CliError` on malformed input so the failure rides the
120/// `INVALID_INPUT` envelope rather than crashing the process.
121pub fn parse_filter_arg(raw: &str) -> Result<(String, String), crate::CliError> {
122    let (key, value) = raw.split_once('=').ok_or_else(|| {
123        crate::CliError::new(
124            crate::output::ExitKind::Validation,
125            "INVALID_INPUT",
126            format!("--filter expects KEY=VALUE, got `{raw}`"),
127        )
128    })?;
129    if key.is_empty() {
130        return Err(crate::CliError::new(
131            crate::output::ExitKind::Validation,
132            "INVALID_INPUT",
133            format!("--filter key must be non-empty: `{raw}`"),
134        ));
135    }
136    Ok((key.to_string(), value.to_string()))
137}
138
139/// Render the per-entity-type guidance block surfaced on
140/// `memstead_create`'s text mirror. Emits one "Type-level guidance"
141/// section per `entity_type` key in the map. Returns an empty string
142/// when the map is empty so callers can concatenate unconditionally.
143/// The structured channel ships the same data top-level on
144/// `type_guidance`.
145pub fn render_type_guidance_block(
146    type_guidance: &std::collections::BTreeMap<String, Vec<String>>,
147) -> String {
148    if type_guidance.is_empty() {
149        return String::new();
150    }
151    let mut out = String::new();
152    for (entity_type, rules) in type_guidance {
153        if rules.is_empty() {
154            continue;
155        }
156        out.push_str(&format!("\n>\n> Type-level guidance for `{entity_type}`:",));
157        for rule in rules {
158            out.push_str(&format!("\n> - {rule}"));
159        }
160    }
161    out
162}
163
164/// Merge a `mem_changed` notice array into a `--json` CLI response
165/// body. No-op when no reload happened during the operation or the
166/// body is not a JSON object. Mirrors the MCP server's
167/// `attach_mem_changed` so the two surfaces emit the same key.
168/// (Always compiled; on lean the engine never stashes a notice, so
169/// `notices` is empty and this no-ops.)
170pub fn merge_mem_changed_json(
171    body: &mut serde_json::Value,
172    notices: &[memstead_base::ops::MemChangedNotice],
173) {
174    if notices.is_empty() {
175        return;
176    }
177    if let Some(obj) = body.as_object_mut() {
178        obj.insert(
179            "mem_changed".to_string(),
180            serde_json::to_value(notices).unwrap_or(serde_json::Value::Null),
181        );
182    }
183}
184
185/// Render a human-readable `mem_changed` block for markdown CLI
186/// output. Empty when no reload happened. Names `memstead changes-since`
187/// (never `memstead diff`) for the follow-up, matching the cross-surface
188/// recovery contract. (Always compiled; on lean `notices` is always
189/// empty, so this returns the empty string.)
190pub fn render_mem_changed_block(notices: &[memstead_base::ops::MemChangedNotice]) -> String {
191    use memstead_base::ops::NoticeChanges;
192    if notices.is_empty() {
193        return String::new();
194    }
195    let mut out = String::from("\n\n## Mem changed under you");
196    for n in notices {
197        out.push_str(&format!(
198            "\n\n- `{}` advanced `{}` → `{}`",
199            n.mem, n.from_head, n.to_head
200        ));
201        match &n.changes {
202            NoticeChanges::Detailed { entries } => {
203                for e in entries {
204                    out.push_str(&format!("\n  - {} `{}`", e.action(), e.primary_id()));
205                }
206            }
207            NoticeChanges::Ids { entries } => {
208                out.push_str(&format!(
209                    "\n  - {} changed ids — `memstead changes-since --since {}` for the full delta",
210                    entries.len(),
211                    n.from_head
212                ));
213            }
214            NoticeChanges::Counts { self_inform, .. } => {
215                out.push_str(&format!("\n  - mass change — {self_inform}"));
216            }
217        }
218    }
219    out
220}
221
222pub mod admin;
223pub mod delete;
224pub mod domain;
225pub mod entity;
226pub mod export;
227pub mod health;
228pub mod init;
229pub mod link;
230pub mod list;
231pub mod login;
232pub mod logout;
233pub mod overview;
234pub mod projection;
235pub mod publish;
236pub mod quickstart;
237pub mod relate;
238pub mod relations;
239pub mod reload;
240pub mod rename;
241pub mod schema;
242pub mod search;
243pub mod status;
244pub mod type_cmd;
245pub mod unpublish;
246pub mod update;
247
248// Multi-mem / mem-repo subcommands — compiled into the full
249// `memstead` binary (default features); absent from the lean
250// `--no-default-features` build, which has no git-branch backend.
251#[cfg(feature = "mem-repo")]
252pub mod batch_update;
253#[cfg(feature = "mem-repo")]
254pub mod branch_reset;
255#[cfg(feature = "mem-repo")]
256pub mod install;
257#[cfg(feature = "mem-repo")]
258pub mod mem;
259#[cfg(feature = "mem-repo")]
260pub mod mem_repo;
261#[cfg(feature = "mem-repo")]
262pub mod recover;
263#[cfg(feature = "mem-repo")]
264pub mod transport;
265#[cfg(feature = "mem-repo")]
266pub mod workspace;