1use std::path::Path;
29
30use crate::ops::changes::EMPTY_TREE_SHA;
31use crate::vcs::VcsError;
32
33pub use memstead_base::ops::agent_notes::{AgentNotesReport, CommitNote};
37
38pub fn read_memstead_ref(git_dir: &Path) -> Result<Option<String>, VcsError> {
43 let repo = gix::open(git_dir)?;
44 Ok(repo
45 .rev_parse_single("refs/heads/__MEMSTEAD")
46 .ok()
47 .map(|id| id.to_hex().to_string()))
48}
49
50#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct ParsedCommit {
64 pub subject: String,
65 pub tool_verb: Option<String>,
66 pub entity_id: Option<String>,
67 pub note: Option<String>,
68 pub actor: Option<String>,
69 pub tool: Option<String>,
70 pub client: Option<String>,
71 pub logical_operation_id: Option<String>,
74 pub entity_ids: Vec<String>,
79}
80
81pub fn parse_commit_message(body: &str) -> ParsedCommit {
94 let trimmed = body.trim_matches('\n');
95 let mut lines = trimmed.split('\n');
96 let subject = lines.next().unwrap_or("").trim_end().to_string();
97
98 let (tool_verb, entity_id) = parse_subject(&subject);
103
104 let body_lines: Vec<&str> = lines.collect();
107 let first_trailer_idx = body_lines.iter().position(|l| is_trailer_line(l));
108
109 let note_slice = match first_trailer_idx {
110 Some(idx) => &body_lines[..idx],
111 None => &body_lines[..],
112 };
113 let note = collect_note(note_slice);
114
115 let mut tool: Option<String> = None;
116 let mut actor: Option<String> = None;
117 let mut client: Option<String> = None;
118 let mut logical_operation_id: Option<String> = None;
119 let mut entity_ids: Vec<String> = Vec::new();
120 if let Some(start) = first_trailer_idx {
121 for line in &body_lines[start..] {
122 if let Some((key, value)) = split_trailer(line) {
123 match key {
124 "Tool" if tool.is_none() => tool = Some(value.to_string()),
125 "Actor" if actor.is_none() => actor = Some(value.to_string()),
126 "Client" if client.is_none() => client = Some(value.to_string()),
127 "Logical-Op" if logical_operation_id.is_none() => {
128 logical_operation_id = Some(value.to_string());
129 }
130 "Entities" if entity_ids.is_empty() => {
131 entity_ids = value
132 .split(',')
133 .map(str::trim)
134 .filter(|s| !s.is_empty())
135 .map(str::to_string)
136 .collect();
137 }
138 _ => {}
139 }
140 }
141 }
142 }
143
144 ParsedCommit {
145 subject,
146 tool_verb,
147 entity_id,
148 note,
149 actor,
150 tool,
151 client,
152 logical_operation_id,
153 entity_ids,
154 }
155}
156
157fn parse_subject(subject: &str) -> (Option<String>, Option<String>) {
158 let rest = match subject.strip_prefix("memstead:") {
160 Some(r) => r.trim_start(),
161 None => return (None, None),
162 };
163 let mut parts = rest.splitn(2, char::is_whitespace);
164 let verb = match parts.next() {
165 Some(v) if !v.is_empty() => v.to_string(),
166 _ => return (None, None),
167 };
168 let remainder = parts.next().unwrap_or("").trim().to_string();
169 let entity_id = if remainder.is_empty() {
170 None
171 } else {
172 Some(remainder)
173 };
174 (Some(verb), entity_id)
175}
176
177fn is_trailer_line(line: &str) -> bool {
178 split_trailer(line).is_some()
179}
180
181fn split_trailer(line: &str) -> Option<(&str, &str)> {
185 let colon = line.find(':')?;
186 let (key, rest) = line.split_at(colon);
187 if key.is_empty() {
188 return None;
189 }
190 let mut chars = key.chars();
191 let first = chars.next()?;
192 if !first.is_ascii_uppercase() {
193 return None;
194 }
195 if !chars.all(|c| c.is_ascii_alphabetic() || c == '-') {
196 return None;
197 }
198 let value_with_colon = &rest[1..];
199 let value = value_with_colon.strip_prefix(' ')?.trim_end();
200 if value.is_empty() {
201 return None;
202 }
203 Some((key, value))
204}
205
206fn collect_note(slice: &[&str]) -> Option<String> {
207 let mut start = 0;
210 while start < slice.len() && slice[start].trim().is_empty() {
211 start += 1;
212 }
213 let mut end = slice.len();
214 while end > start && slice[end - 1].trim().is_empty() {
215 end -= 1;
216 }
217 if start == end {
218 return None;
219 }
220 let joined = slice[start..end].join("\n");
221 let trimmed = joined.trim();
222 if trimmed.is_empty() {
223 None
224 } else {
225 Some(trimmed.to_string())
226 }
227}
228
229pub fn agent_notes_since(
242 mem_name: &str,
243 git_dir: &Path,
244 since: &str,
245 head_ref: Option<&str>,
246) -> Result<AgentNotesReport, VcsError> {
247 let repo = gix::open(git_dir)?;
248
249 let head_lookup: Result<gix::Commit<'_>, ()> = match head_ref {
250 Some(ref_name) => repo
251 .rev_parse_single(ref_name)
252 .ok()
253 .and_then(|id| id.object().ok())
254 .and_then(|obj| obj.try_into_commit().ok())
255 .ok_or(()),
256 None => repo.head_commit().map_err(|_| ()),
257 };
258 let head_commit = match head_lookup {
259 Ok(c) => c,
260 Err(()) => {
261 let memstead_ref = read_memstead_ref(git_dir)?;
262 return Ok(AgentNotesReport {
263 mem: mem_name.to_string(),
264 since: since.to_string(),
265 head: EMPTY_TREE_SHA.to_string(),
266 notes: Vec::new(),
267 memstead_ref,
268 });
269 }
270 };
271 let head_sha = head_commit.id.to_hex().to_string();
272
273 let hidden: Vec<gix::ObjectId> = if since == EMPTY_TREE_SHA {
278 Vec::new()
279 } else {
280 let id = repo
281 .rev_parse_single(since)
282 .map_err(|e| VcsError::ObjectNotFound(format!("{since}: {e}")))?;
283 let object = id
286 .object()
287 .map_err(|e| VcsError::ObjectNotFound(format!("{since}: {e}")))?;
288 object
289 .try_into_commit()
290 .map_err(|_| VcsError::ObjectNotFound(format!("{since} is not a commit")))?;
291 vec![id.detach()]
292 };
293
294 let walk = repo
295 .rev_walk([head_commit.id])
296 .with_hidden(hidden)
297 .all()
298 .map_err(|e| VcsError::Git(format!("rev-walk: {e}")))?;
299
300 let mut notes: Vec<CommitNote> = Vec::new();
301 for info in walk {
302 let info = info.map_err(|e| VcsError::Git(format!("rev-walk-step: {e}")))?;
303 let commit = info
304 .object()
305 .map_err(|e| VcsError::Git(format!("commit-load: {e}")))?;
306 let sha = commit.id.to_hex().to_string();
307 let timestamp = commit.time().map(|t| t.seconds).unwrap_or(0);
308
309 let body_string = match commit.message_raw() {
313 Ok(bstr) => std::str::from_utf8(bstr.as_ref())
314 .map(|s| s.to_string())
315 .unwrap_or_default(),
316 Err(_) => String::new(),
317 };
318
319 let parsed = parse_commit_message(&body_string);
320 notes.push(CommitNote {
321 mem: mem_name.to_string(),
322 sha,
323 subject: parsed.subject,
324 tool_verb: parsed.tool_verb,
325 entity_id: parsed.entity_id,
326 note: parsed.note,
327 actor: parsed.actor,
328 tool: parsed.tool,
329 client: parsed.client,
330 logical_operation_id: parsed.logical_operation_id,
331 entity_ids: parsed.entity_ids,
332 timestamp,
333 });
334 }
335
336 let memstead_ref = read_memstead_ref(git_dir)?;
337
338 Ok(AgentNotesReport {
339 mem: mem_name.to_string(),
340 since: since.to_string(),
341 head: head_sha,
342 notes,
343 memstead_ref,
344 })
345}
346
347#[cfg(test)]
348mod tests {
349 use super::*;
350
351 fn ctx() -> crate::vcs::CommitContext<'static> {
352 crate::vcs::CommitContext {
353 actor: crate::vcs::Actor::Agent,
354 client: Some(crate::vcs::ClientId {
355 name: "claude-code".into(),
356 version: "2.1.0".into(),
357 }),
358 tool: Some("memstead_create"),
359 note: Some("Demoting drift hook to engine surface.".into()),
360 logical_operation_id: None,
361 entity_ids: None,
362 }
363 }
364
365 #[test]
366 fn parser_round_trips_logical_operation_id_trailer() {
367 let ctx = crate::vcs::CommitContext {
374 actor: crate::vcs::Actor::Agent,
375 client: Some(crate::vcs::ClientId {
376 name: "claude-code".into(),
377 version: "2.1.0".into(),
378 }),
379 tool: Some("rename_entity"),
380 note: None,
381 logical_operation_id: Some("logop-abc123def456"),
382 entity_ids: None,
383 };
384 let raw = crate::vcs::format_commit_message("memstead: rename a → b", &ctx);
385 assert!(
386 raw.contains("Logical-Op: logop-abc123def456"),
387 "format_commit_message must emit the Logical-Op trailer; got:\n{raw}"
388 );
389 let parsed = parse_commit_message(&raw);
390 assert_eq!(
391 parsed.logical_operation_id.as_deref(),
392 Some("logop-abc123def456"),
393 "parser must reconstruct the Logical-Op trailer; got: {:?}",
394 parsed.logical_operation_id
395 );
396 }
397
398 #[test]
399 fn parser_round_trips_entities_trailer() {
400 let ctx = crate::vcs::CommitContext {
404 actor: crate::vcs::Actor::Cli,
405 client: None,
406 tool: Some("batch_update"),
407 note: None,
408 logical_operation_id: None,
409 entity_ids: Some(vec![
410 "specs--alpha".to_string(),
411 "specs--beta".to_string(),
412 "memos--gamma".to_string(),
413 ]),
414 };
415 let raw = crate::vcs::format_commit_message("memstead: batch-update (3 entities)", &ctx);
416 assert!(
417 raw.contains("Entities: specs--alpha, specs--beta, memos--gamma"),
418 "format_commit_message must emit the Entities trailer; got:\n{raw}"
419 );
420 let parsed = parse_commit_message(&raw);
421 assert_eq!(parsed.entity_id.as_deref(), Some("(3 entities)"));
423 assert_eq!(
425 parsed.entity_ids,
426 vec!["specs--alpha", "specs--beta", "memos--gamma"],
427 "parser must reconstruct the Entities trailer; got: {:?}",
428 parsed.entity_ids
429 );
430 }
431
432 #[test]
433 fn parser_leaves_entity_ids_empty_without_trailer() {
434 let raw = crate::vcs::format_commit_message("memstead: update specs--solo", &ctx());
437 let parsed = parse_commit_message(&raw);
438 assert!(
439 parsed.entity_ids.is_empty(),
440 "no trailer → empty: {:?}",
441 parsed.entity_ids
442 );
443 assert_eq!(parsed.entity_id.as_deref(), Some("specs--solo"));
444 }
445
446 #[test]
447 fn parser_round_trips_format_commit_message_with_note() {
448 let ctx = ctx();
449 let raw = crate::vcs::format_commit_message("memstead: create specs--demo", &ctx);
450 let parsed = parse_commit_message(&raw);
451 assert_eq!(parsed.subject, "memstead: create specs--demo");
452 assert_eq!(parsed.tool_verb.as_deref(), Some("create"));
453 assert_eq!(parsed.entity_id.as_deref(), Some("specs--demo"));
454 assert_eq!(parsed.tool.as_deref(), Some("memstead_create"));
455 assert_eq!(parsed.actor.as_deref(), Some("agent"));
456 assert_eq!(parsed.client.as_deref(), Some("claude-code@2.1.0"));
457 assert_eq!(
458 parsed.note.as_deref(),
459 Some("Demoting drift hook to engine surface.")
460 );
461 }
462
463 #[test]
464 fn parser_round_trips_format_commit_message_without_note() {
465 let ctx = crate::vcs::CommitContext {
466 actor: crate::vcs::Actor::External,
467 client: None,
468 tool: None,
469 note: None,
470 logical_operation_id: None,
471 entity_ids: None,
472 };
473 let raw = crate::vcs::format_commit_message("memstead: rename a → b", &ctx);
474 let parsed = parse_commit_message(&raw);
475 assert_eq!(parsed.subject, "memstead: rename a → b");
476 assert_eq!(parsed.tool_verb.as_deref(), Some("rename"));
477 assert_eq!(parsed.entity_id.as_deref(), Some("a → b"));
478 assert_eq!(parsed.actor.as_deref(), Some("external"));
479 assert!(parsed.note.is_none());
480 assert!(parsed.tool.is_none());
481 assert!(parsed.client.is_none());
482 }
483
484 #[test]
485 fn parser_recognises_anchor_verb() {
486 let raw = crate::vcs::format_commit_message("memstead: anchor specs--alpha", &ctx());
492 let parsed = parse_commit_message(&raw);
493 assert_eq!(parsed.subject, "memstead: anchor specs--alpha");
494 assert_eq!(parsed.tool_verb.as_deref(), Some("anchor"));
495 assert_eq!(parsed.entity_id.as_deref(), Some("specs--alpha"));
496 }
497
498 #[test]
499 fn parser_handles_unrecognized_subject() {
500 let parsed = parse_commit_message("hand-typed external drift\n");
501 assert_eq!(parsed.subject, "hand-typed external drift");
502 assert!(parsed.tool_verb.is_none());
503 assert!(parsed.entity_id.is_none());
504 assert!(parsed.actor.is_none());
505 }
506
507 #[test]
508 fn parser_recognises_multiline_note() {
509 let body = "\
510memstead: update specs--alpha
511
512First line of the note.
513Second line of the note.
514
515Tool: memstead_update
516Actor: agent
517Client: claude-code@2.1.0
518";
519 let parsed = parse_commit_message(body);
520 assert_eq!(
521 parsed.note.as_deref(),
522 Some("First line of the note.\nSecond line of the note.")
523 );
524 assert_eq!(parsed.actor.as_deref(), Some("agent"));
525 }
526
527 #[test]
528 fn parser_tolerates_subject_only_body() {
529 let parsed = parse_commit_message("memstead: update specs--alpha\n");
530 assert_eq!(parsed.subject, "memstead: update specs--alpha");
531 assert_eq!(parsed.tool_verb.as_deref(), Some("update"));
532 assert_eq!(parsed.entity_id.as_deref(), Some("specs--alpha"));
533 assert!(parsed.note.is_none());
534 assert!(parsed.actor.is_none());
535 }
536
537 #[test]
538 fn parser_treats_lowercase_keys_as_body() {
539 let body = "\
543memstead: update specs--alpha
544
545note line one
546tool: this is prose, not a trailer
547
548Actor: agent
549";
550 let parsed = parse_commit_message(body);
551 assert_eq!(
552 parsed.note.as_deref(),
553 Some("note line one\ntool: this is prose, not a trailer")
554 );
555 assert_eq!(parsed.actor.as_deref(), Some("agent"));
556 assert!(parsed.tool.is_none());
557 }
558
559 #[test]
560 fn parser_skips_trailer_without_value() {
561 assert!(!is_trailer_line("Foo:"));
564 assert!(!is_trailer_line("Foo: "));
565 assert!(is_trailer_line("Foo: bar"));
566 }
567
568 }