Skip to main content

memstead_cli/commands/
mod.rs

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