Skip to main content

memstead_cli/commands/
changes.rs

1//! `memstead changes` — diff a mem's HEAD against a caller-provided SHA.
2//!
3//! Mirrors the MCP `memstead_changes_since` tool so the same commit-SHA
4//! response field can drive both interactive (CLI) and agent (MCP)
5//! polling flows.
6
7use clap::Parser;
8
9use crate::CliError;
10use crate::output::{ExitKind, print_json, print_markdown};
11use crate::setup::{CliContext, CliEngine};
12
13#[derive(Parser, Debug)]
14pub struct Args {
15    /// Writable mem name. Defaults to the first loaded mem.
16    #[arg(long)]
17    pub mem: Option<String>,
18
19    /// Change cursor to diff against. Backend-specific, and never a
20    /// mutation's `write_id`: on a git-branch mem a commit SHA (the
21    /// `head` a prior call returned, or the git canonical empty-tree
22    /// hash `4b825dc642cb6eb9a060e54bf8d69288fbee4904` for a
23    /// fresh-client first sync); on a folder mem an RFC3339 timestamp
24    /// (the `ts` of the last entry you received, or empty for a first
25    /// sync).
26    #[arg(long)]
27    pub since: String,
28
29    /// Rename detection threshold in [0.1, 1.0]; mirrors the MCP
30    /// `rename_similarity` parameter. Default 0.6. Engine-authored
31    /// renames pair via commit-note provenance and bypass this
32    /// threshold; the value drives the rename-similarity fallback for
33    /// non-engine renames (external `git mv`, pre-provenance
34    /// migrations). Lower widens the recall window at the cost of
35    /// false-positive pairing on that path.
36    #[arg(long)]
37    pub rename_similarity: Option<f32>,
38
39    /// Fold per-commit agent-notes (subject, note, actor, tool, client)
40    /// and the workspace-level schema/registry ref tip (unified schemas +
41    /// per-mem configs) into the response. Default off — entity-
42    /// delta only. Commit-mirroring clients turn this on so
43    /// they get notes + the registry-ref sha in one round-trip without
44    /// re-walking the gitdir.
45    #[arg(long)]
46    pub include_notes: bool,
47}
48
49pub fn run(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
50    match ctx.cli_engine()? {
51        #[cfg(feature = "mem-repo")]
52        CliEngine::MemRepo(engine) => run_mem_repo(ctx, engine, args),
53        CliEngine::Filesystem(engine) => run_filesystem(ctx, engine, args),
54    }
55}
56
57#[cfg(feature = "mem-repo")]
58fn run_mem_repo(ctx: &CliContext, engine: memstead_base::Engine, args: Args) -> anyhow::Result<()> {
59    let mem = match args.mem {
60        Some(v) => v,
61        None => engine
62            // Every mount, not only those with a readable config: a mem
63            // whose config is unreadable is still the workspace's only
64            // writable mem, and picking "none" there is a worse answer than
65            // picking it (04/05, criterion 8).
66            .mounts_with_optional_config()
67            .find(|(name, _)| engine.mem_router().is_writable(name))
68            .map(|(name, _)| name.to_string())
69            .ok_or_else(|| {
70                CliError::new(
71                    ExitKind::Generic,
72                    "NO_WRITABLE_MEM",
73                    "no writable mem loaded — pass --mem <name>",
74                )
75            })?,
76    };
77
78    let mut report = engine
79        .changes_since(&mem, &args.since, args.rename_similarity)
80        .map_err(CliError::from_engine_op)?;
81
82    // The engine unconditionally populates `notes` and `memstead_ref`
83    // on every git-branch backend call (the engine layer has the
84    // gitdir access, the renderer layer knows the caller's intent).
85    // The CLI is the renderer that filters:
86    // strip both fields when the flag is absent so a caller switching
87    // between `--include-notes` and the default sees the documented
88    // two-shape response.
89    if !args.include_notes {
90        report.notes = None;
91        report.memstead_ref = None;
92    }
93
94    if ctx.json {
95        print_json(&report)?;
96        return Ok(());
97    }
98
99    let mut lines: Vec<String> = Vec::new();
100    lines.push(format!(
101        "# Changes in `{}` since `{}`",
102        report.mem, report.since
103    ));
104    lines.push(String::new());
105    lines.push(format!("- HEAD: `{}`", report.head));
106    lines.push(format!("- Changes: {}", report.changes.len()));
107    lines.push(String::new());
108
109    if report.changes.is_empty() {
110        lines.push("_no changes_".to_string());
111    } else {
112        for change in &report.changes {
113            use memstead_git_branch::ChangeEnvelope::*;
114            let type_suffix =
115                |t: &Option<String>| t.as_ref().map(|s| format!(" [{s}]")).unwrap_or_default();
116            let title_suffix =
117                |t: &Option<String>| t.as_ref().map(|s| format!(" — {s}")).unwrap_or_default();
118            let line = match change {
119                Added {
120                    id,
121                    title,
122                    entity_type,
123                } => format!(
124                    "- **added** `{}`{}{}",
125                    id,
126                    type_suffix(entity_type),
127                    title_suffix(title)
128                ),
129                Updated {
130                    id,
131                    title,
132                    entity_type,
133                } => format!(
134                    "- **updated** `{}`{}{}",
135                    id,
136                    type_suffix(entity_type),
137                    title_suffix(title)
138                ),
139                Removed {
140                    id,
141                    title,
142                    entity_type,
143                } => format!(
144                    "- **removed** `{}`{}{}",
145                    id,
146                    type_suffix(entity_type),
147                    title_suffix(title)
148                ),
149                Renamed {
150                    from_id,
151                    to_id,
152                    title,
153                    entity_type,
154                } => format!(
155                    "- **renamed** `{from_id}` → `{to_id}`{}{}",
156                    type_suffix(entity_type),
157                    title_suffix(title)
158                ),
159            };
160            lines.push(line);
161        }
162    }
163
164    if let Some(notes) = report.notes.as_ref() {
165        lines.push(String::new());
166        lines.push(format!("## Agent notes ({})", notes.len()));
167        if notes.is_empty() {
168            lines.push("_no commits in range_".to_string());
169        } else {
170            for n in notes {
171                let actor = n.actor.as_deref().unwrap_or("unknown");
172                let subject = if n.subject.is_empty() {
173                    "(no subject)"
174                } else {
175                    n.subject.as_str()
176                };
177                lines.push(format!(
178                    "- `{}` [{}] {}",
179                    &n.sha[..n.sha.len().min(12)],
180                    actor,
181                    subject
182                ));
183                // Multi-entity commits (batch-update) collapse their
184                // subject to `(N entities)`; name the entities so the note
185                // is self-describing here, not only in the JSON envelope.
186                if !n.entity_ids.is_empty() {
187                    lines.push(format!("    entities: {}", n.entity_ids.join(", ")));
188                }
189                if let Some(note) = n.note.as_deref() {
190                    for body_line in note.lines() {
191                        lines.push(format!("    {body_line}"));
192                    }
193                }
194            }
195        }
196    }
197
198    if let Some(sha) = report.memstead_ref.as_deref() {
199        lines.push(String::new());
200        lines.push("## Registry ref".to_string());
201        lines.push(format!("- `__MEMSTEAD`: `{sha}`"));
202    }
203
204    print_markdown(&lines.join("\n"));
205    Ok(())
206}
207
208/// Filesystem-mem `memstead changes` reads `.memstead/changes.jsonl` and
209/// returns entries with `ts > since`. The cursor is a timestamp
210/// string (RFC 3339), not a commit-SHA — same divergence as the
211/// MCP `memstead_changes_since` tool's filesystem path. `--rename-similarity`
212/// and `--include-notes` are accepted for shape parity but ignored:
213/// filesystem-mem has no rename detection (mutations are explicit
214/// in the changelog) and no agent-notes layer (notes ride on each
215/// changelog entry).
216fn run_filesystem(
217    ctx: &CliContext,
218    engine: memstead_base::Engine,
219    args: Args,
220) -> anyhow::Result<()> {
221    let workspace_mem = engine
222        .mem_names()
223        .into_iter()
224        .next()
225        .map(String::from)
226        .unwrap_or_default();
227    if let Some(name) = args.mem.as_deref()
228        && name != workspace_mem
229    {
230        return Err(CliError::new(
231                ExitKind::NotFound,
232                "UNKNOWN_MEM",
233                format!(
234                    "filesystem-mem is single-mem: workspace mem is `{workspace_mem}`, --mem `{name}` does not match"
235                ),
236            )
237            .into());
238    }
239
240    // Unified engine doesn't expose workspace_root (mounts can be
241    // heterogeneous); discover from cwd.
242    let workspace_root =
243        crate::setup::find_filesystem_workspace_root(&std::env::current_dir().map_err(|e| {
244            CliError::new(
245                ExitKind::Generic,
246                crate::INTERNAL_CODE,
247                format!("current_dir: {e}"),
248            )
249        })?)
250        .ok_or_else(|| {
251            CliError::new(
252                ExitKind::NotFound,
253                "WORKSPACE_NOT_INITIALISED",
254                "no filesystem-mem workspace found from cwd",
255            )
256        })?;
257    let log_path = workspace_root
258        .join(memstead_base::MEM_META_DIR)
259        .join("changes.jsonl");
260    let raw = match std::fs::read_to_string(&log_path) {
261        Ok(s) => s,
262        Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
263        Err(e) => {
264            return Err(CliError::new(
265                ExitKind::Generic,
266                crate::INTERNAL_CODE,
267                format!("read {}: {e}", log_path.display()),
268            )
269            .into());
270        }
271    };
272
273    let since = args.since.trim();
274    let mut entries: Vec<serde_json::Value> = Vec::new();
275    for line in raw.lines() {
276        let trimmed = line.trim();
277        if trimmed.is_empty() {
278            continue;
279        }
280        let value: serde_json::Value = match serde_json::from_str(trimmed) {
281            Ok(v) => v,
282            Err(_) => continue, // skip malformed lines silently
283        };
284        let ts_match = value
285            .get("ts")
286            .and_then(|v| v.as_str())
287            .unwrap_or("")
288            .to_string();
289        if !since.is_empty() && ts_match.as_str() <= since {
290            continue;
291        }
292        entries.push(value);
293    }
294
295    if ctx.json {
296        print_json(&serde_json::json!({
297            "mem": workspace_mem,
298            "since": since,
299            "entries": entries,
300        }))?;
301        return Ok(());
302    }
303
304    let mut lines: Vec<String> = Vec::new();
305    lines.push(format!(
306        "# Changes in `{}` since `{}`",
307        workspace_mem, since
308    ));
309    lines.push(String::new());
310    lines.push(format!("- Entries: {}", entries.len()));
311    lines.push(String::new());
312    if entries.is_empty() {
313        lines.push("_no changes_".to_string());
314    } else {
315        for entry in &entries {
316            let kind = entry.get("kind").and_then(|v| v.as_str()).unwrap_or("?");
317            let id = entry
318                .get("entity")
319                .and_then(|v| v.as_str())
320                .unwrap_or("(no entity)");
321            let ts = entry.get("ts").and_then(|v| v.as_str()).unwrap_or("?");
322            let note = entry
323                .get("note")
324                .and_then(|v| v.as_str())
325                .map(|s| format!(" — {s}"))
326                .unwrap_or_default();
327            lines.push(format!("- `{ts}` **{kind}** `{id}`{note}"));
328        }
329    }
330    print_markdown(&lines.join("\n"));
331    Ok(())
332}