1use 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 #[arg(long)]
17 pub mem: Option<String>,
18
19 #[arg(long)]
27 pub since: String,
28
29 #[arg(long)]
37 pub rename_similarity: Option<f32>,
38
39 #[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 .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 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 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
208fn 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 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, };
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}