Skip to main content

memstead_cli/commands/
mod.rs

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