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)]
24 pub since: String,
25
26 #[arg(long)]
34 pub rename_similarity: Option<f32>,
35
36 #[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 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 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
201fn 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 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, };
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}