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