Skip to main content

memstead_cli/commands/
mod.rs

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