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 role: Option<String>,
78 pub entity_ids: Vec<String>,
83}
84
85pub fn parse_commit_message(body: &str) -> ParsedCommit {
98 let trimmed = body.trim_matches('\n');
99 let mut lines = trimmed.split('\n');
100 let subject = lines.next().unwrap_or("").trim_end().to_string();
101
102 let (tool_verb, entity_id) = parse_subject(&subject);
107
108 let body_lines: Vec<&str> = lines.collect();
111 let first_trailer_idx = body_lines.iter().position(|l| is_trailer_line(l));
112
113 let note_slice = match first_trailer_idx {
114 Some(idx) => &body_lines[..idx],
115 None => &body_lines[..],
116 };
117 let note = collect_note(note_slice);
118
119 let mut tool: Option<String> = None;
120 let mut actor: Option<String> = None;
121 let mut client: Option<String> = None;
122 let mut logical_operation_id: Option<String> = None;
123 let mut role: Option<String> = None;
124 let mut entity_ids: Vec<String> = Vec::new();
125 if let Some(start) = first_trailer_idx {
126 for line in &body_lines[start..] {
127 if let Some((key, value)) = split_trailer(line) {
128 match key {
129 "Tool" if tool.is_none() => tool = Some(value.to_string()),
130 "Actor" if actor.is_none() => actor = Some(value.to_string()),
131 "Client" if client.is_none() => client = Some(value.to_string()),
132 "Logical-Op" if logical_operation_id.is_none() => {
133 logical_operation_id = Some(value.to_string());
134 }
135 "Role" if role.is_none() => role = Some(value.to_string()),
136 "Entities" if entity_ids.is_empty() => {
137 entity_ids = value
138 .split(',')
139 .map(str::trim)
140 .filter(|s| !s.is_empty())
141 .map(str::to_string)
142 .collect();
143 }
144 _ => {}
145 }
146 }
147 }
148 }
149
150 ParsedCommit {
151 subject,
152 tool_verb,
153 entity_id,
154 note,
155 actor,
156 tool,
157 client,
158 logical_operation_id,
159 role,
160 entity_ids,
161 }
162}
163
164fn parse_subject(subject: &str) -> (Option<String>, Option<String>) {
165 let rest = match subject.strip_prefix("memstead:") {
167 Some(r) => r.trim_start(),
168 None => return (None, None),
169 };
170 let mut parts = rest.splitn(2, char::is_whitespace);
171 let verb = match parts.next() {
172 Some(v) if !v.is_empty() => v.to_string(),
173 _ => return (None, None),
174 };
175 let remainder = parts.next().unwrap_or("").trim().to_string();
176 let entity_id = if remainder.is_empty() {
177 None
178 } else {
179 Some(remainder)
180 };
181 (Some(verb), entity_id)
182}
183
184fn is_trailer_line(line: &str) -> bool {
185 split_trailer(line).is_some()
186}
187
188fn split_trailer(line: &str) -> Option<(&str, &str)> {
192 let colon = line.find(':')?;
193 let (key, rest) = line.split_at(colon);
194 if key.is_empty() {
195 return None;
196 }
197 let mut chars = key.chars();
198 let first = chars.next()?;
199 if !first.is_ascii_uppercase() {
200 return None;
201 }
202 if !chars.all(|c| c.is_ascii_alphabetic() || c == '-') {
203 return None;
204 }
205 let value_with_colon = &rest[1..];
206 let value = value_with_colon.strip_prefix(' ')?.trim_end();
207 if value.is_empty() {
208 return None;
209 }
210 Some((key, value))
211}
212
213fn collect_note(slice: &[&str]) -> Option<String> {
214 let mut start = 0;
217 while start < slice.len() && slice[start].trim().is_empty() {
218 start += 1;
219 }
220 let mut end = slice.len();
221 while end > start && slice[end - 1].trim().is_empty() {
222 end -= 1;
223 }
224 if start == end {
225 return None;
226 }
227 let joined = slice[start..end].join("\n");
228 let trimmed = joined.trim();
229 if trimmed.is_empty() {
230 None
231 } else {
232 Some(trimmed.to_string())
233 }
234}
235
236pub fn agent_notes_since(
249 mem_name: &str,
250 git_dir: &Path,
251 since: &str,
252 head_ref: Option<&str>,
253) -> Result<AgentNotesReport, VcsError> {
254 let repo = gix::open(git_dir)?;
255
256 let head_lookup: Result<gix::Commit<'_>, ()> = match head_ref {
257 Some(ref_name) => repo
258 .rev_parse_single(ref_name)
259 .ok()
260 .and_then(|id| id.object().ok())
261 .and_then(|obj| obj.try_into_commit().ok())
262 .ok_or(()),
263 None => repo.head_commit().map_err(|_| ()),
264 };
265 let head_commit = match head_lookup {
266 Ok(c) => c,
267 Err(()) => {
268 let memstead_ref = read_memstead_ref(git_dir)?;
269 return Ok(AgentNotesReport {
270 mem: mem_name.to_string(),
271 since: since.to_string(),
272 head: EMPTY_TREE_SHA.to_string(),
273 notes: Vec::new(),
274 memstead_ref,
275 });
276 }
277 };
278 let head_sha = head_commit.id.to_hex().to_string();
279
280 let hidden: Vec<gix::ObjectId> = if since == EMPTY_TREE_SHA {
285 Vec::new()
286 } else {
287 let id = repo
288 .rev_parse_single(since)
289 .map_err(|e| VcsError::ObjectNotFound(format!("{since}: {e}")))?;
290 let object = id
293 .object()
294 .map_err(|e| VcsError::ObjectNotFound(format!("{since}: {e}")))?;
295 object
296 .try_into_commit()
297 .map_err(|_| VcsError::ObjectNotFound(format!("{since} is not a commit")))?;
298 vec![id.detach()]
299 };
300
301 let walk = repo
302 .rev_walk([head_commit.id])
303 .with_hidden(hidden)
304 .all()
305 .map_err(|e| VcsError::Git(format!("rev-walk: {e}")))?;
306
307 let mut notes: Vec<CommitNote> = Vec::new();
308 for info in walk {
309 let info = info.map_err(|e| VcsError::Git(format!("rev-walk-step: {e}")))?;
310 let commit = info
311 .object()
312 .map_err(|e| VcsError::Git(format!("commit-load: {e}")))?;
313 let sha = commit.id.to_hex().to_string();
314 let timestamp = commit.time().map(|t| t.seconds).unwrap_or(0);
315
316 let body_string = match commit.message_raw() {
320 Ok(bstr) => std::str::from_utf8(bstr.as_ref())
321 .map(|s| s.to_string())
322 .unwrap_or_default(),
323 Err(_) => String::new(),
324 };
325
326 let parsed = parse_commit_message(&body_string);
327 notes.push(CommitNote {
328 mem: mem_name.to_string(),
329 sha,
330 subject: parsed.subject,
331 tool_verb: parsed.tool_verb,
332 entity_id: parsed.entity_id,
333 note: parsed.note,
334 actor: parsed.actor,
335 tool: parsed.tool,
336 client: parsed.client,
337 logical_operation_id: parsed.logical_operation_id,
338 role: parsed.role,
339 entity_ids: parsed.entity_ids,
340 timestamp,
341 });
342 }
343
344 let memstead_ref = read_memstead_ref(git_dir)?;
345
346 Ok(AgentNotesReport {
347 mem: mem_name.to_string(),
348 since: since.to_string(),
349 head: head_sha,
350 notes,
351 memstead_ref,
352 })
353}
354
355#[cfg(test)]
356mod tests {
357 use super::*;
358
359 fn ctx() -> crate::vcs::CommitContext<'static> {
360 crate::vcs::CommitContext {
361 actor: crate::vcs::Actor::Agent,
362 client: Some(crate::vcs::ClientId {
363 name: "claude-code".into(),
364 version: "2.1.0".into(),
365 }),
366 tool: Some("memstead_create"),
367 note: Some("Demoting drift hook to engine surface.".into()),
368 role: Default::default(),
369 logical_operation_id: None,
370 entity_ids: None,
371 }
372 }
373
374 #[test]
375 fn parser_round_trips_logical_operation_id_trailer() {
376 let ctx = crate::vcs::CommitContext {
383 actor: crate::vcs::Actor::Agent,
384 client: Some(crate::vcs::ClientId {
385 name: "claude-code".into(),
386 version: "2.1.0".into(),
387 }),
388 tool: Some("rename_entity"),
389 note: None,
390 role: Default::default(),
391 logical_operation_id: Some("logop-abc123def456"),
392 entity_ids: None,
393 };
394 let raw = crate::vcs::format_commit_message("memstead: rename a → b", &ctx);
395 assert!(
396 raw.contains("Logical-Op: logop-abc123def456"),
397 "format_commit_message must emit the Logical-Op trailer; got:\n{raw}"
398 );
399 let parsed = parse_commit_message(&raw);
400 assert_eq!(
401 parsed.logical_operation_id.as_deref(),
402 Some("logop-abc123def456"),
403 "parser must reconstruct the Logical-Op trailer; got: {:?}",
404 parsed.logical_operation_id
405 );
406 }
407
408 #[test]
409 fn parser_round_trips_entities_trailer() {
410 let ctx = crate::vcs::CommitContext {
414 actor: crate::vcs::Actor::Cli,
415 client: None,
416 tool: Some("batch_update"),
417 note: None,
418 role: Default::default(),
419 logical_operation_id: None,
420 entity_ids: Some(vec![
421 "specs--alpha".to_string(),
422 "specs--beta".to_string(),
423 "memos--gamma".to_string(),
424 ]),
425 };
426 let raw = crate::vcs::format_commit_message("memstead: batch-update (3 entities)", &ctx);
427 assert!(
428 raw.contains("Entities: specs--alpha, specs--beta, memos--gamma"),
429 "format_commit_message must emit the Entities trailer; got:\n{raw}"
430 );
431 let parsed = parse_commit_message(&raw);
432 assert_eq!(parsed.entity_id.as_deref(), Some("(3 entities)"));
434 assert_eq!(
436 parsed.entity_ids,
437 vec!["specs--alpha", "specs--beta", "memos--gamma"],
438 "parser must reconstruct the Entities trailer; got: {:?}",
439 parsed.entity_ids
440 );
441 }
442
443 #[test]
444 fn parser_leaves_entity_ids_empty_without_trailer() {
445 let raw = crate::vcs::format_commit_message("memstead: update specs--solo", &ctx());
448 let parsed = parse_commit_message(&raw);
449 assert!(
450 parsed.entity_ids.is_empty(),
451 "no trailer → empty: {:?}",
452 parsed.entity_ids
453 );
454 assert_eq!(parsed.entity_id.as_deref(), Some("specs--solo"));
455 }
456
457 #[test]
458 fn parser_round_trips_format_commit_message_with_note() {
459 let ctx = ctx();
460 let raw = crate::vcs::format_commit_message("memstead: create specs--demo", &ctx);
461 let parsed = parse_commit_message(&raw);
462 assert_eq!(parsed.subject, "memstead: create specs--demo");
463 assert_eq!(parsed.tool_verb.as_deref(), Some("create"));
464 assert_eq!(parsed.entity_id.as_deref(), Some("specs--demo"));
465 assert_eq!(parsed.tool.as_deref(), Some("memstead_create"));
466 assert_eq!(parsed.actor.as_deref(), Some("agent"));
467 assert_eq!(parsed.client.as_deref(), Some("claude-code@2.1.0"));
468 assert_eq!(
469 parsed.note.as_deref(),
470 Some("Demoting drift hook to engine surface.")
471 );
472 }
473
474 #[test]
475 fn parser_round_trips_format_commit_message_without_note() {
476 let ctx = crate::vcs::CommitContext {
477 actor: crate::vcs::Actor::External,
478 client: None,
479 tool: None,
480 note: None,
481 role: Default::default(),
482 logical_operation_id: None,
483 entity_ids: None,
484 };
485 let raw = crate::vcs::format_commit_message("memstead: rename a → b", &ctx);
486 let parsed = parse_commit_message(&raw);
487 assert_eq!(parsed.subject, "memstead: rename a → b");
488 assert_eq!(parsed.tool_verb.as_deref(), Some("rename"));
489 assert_eq!(parsed.entity_id.as_deref(), Some("a → b"));
490 assert_eq!(parsed.actor.as_deref(), Some("external"));
491 assert!(parsed.note.is_none());
492 assert!(parsed.tool.is_none());
493 assert!(parsed.client.is_none());
494 }
495
496 #[test]
497 fn parser_round_trips_app_actor_with_client_identity() {
498 let ctx = crate::vcs::CommitContext {
504 actor: crate::vcs::Actor::App,
505 client: Some(crate::vcs::ClientId {
506 name: "memstead-node-app".into(),
507 version: "0.1.0".into(),
508 }),
509 tool: None,
510 note: Some("edited via the workspace room".into()),
511 role: Default::default(),
512 logical_operation_id: None,
513 entity_ids: None,
514 };
515 let raw = crate::vcs::format_commit_message("memstead: update specs--alpha", &ctx);
516 assert!(raw.contains("Actor: app"), "missing app trailer:\n{raw}");
517 let parsed = parse_commit_message(&raw);
518 assert_eq!(parsed.actor.as_deref(), Some("app"));
519 assert_eq!(parsed.client.as_deref(), Some("memstead-node-app@0.1.0"));
520 assert_eq!(
521 parsed.note.as_deref(),
522 Some("edited via the workspace room")
523 );
524
525 let author = crate::vcs::author_identity(&ctx).expect("app + client derives an author");
526 assert_eq!(author.0, "memstead-node-app");
527 assert_eq!(author.1, "memstead-node-app@memstead.io");
528 }
529
530 #[test]
531 fn parser_recognises_anchor_verb() {
532 let raw = crate::vcs::format_commit_message("memstead: anchor specs--alpha", &ctx());
538 let parsed = parse_commit_message(&raw);
539 assert_eq!(parsed.subject, "memstead: anchor specs--alpha");
540 assert_eq!(parsed.tool_verb.as_deref(), Some("anchor"));
541 assert_eq!(parsed.entity_id.as_deref(), Some("specs--alpha"));
542 }
543
544 #[test]
545 fn parser_handles_unrecognized_subject() {
546 let parsed = parse_commit_message("hand-typed external drift\n");
547 assert_eq!(parsed.subject, "hand-typed external drift");
548 assert!(parsed.tool_verb.is_none());
549 assert!(parsed.entity_id.is_none());
550 assert!(parsed.actor.is_none());
551 }
552
553 #[test]
554 fn parser_recognises_multiline_note() {
555 let body = "\
556memstead: update specs--alpha
557
558First line of the note.
559Second line of the note.
560
561Tool: memstead_update
562Actor: agent
563Client: claude-code@2.1.0
564";
565 let parsed = parse_commit_message(body);
566 assert_eq!(
567 parsed.note.as_deref(),
568 Some("First line of the note.\nSecond line of the note.")
569 );
570 assert_eq!(parsed.actor.as_deref(), Some("agent"));
571 }
572
573 #[test]
574 fn parser_tolerates_subject_only_body() {
575 let parsed = parse_commit_message("memstead: update specs--alpha\n");
576 assert_eq!(parsed.subject, "memstead: update specs--alpha");
577 assert_eq!(parsed.tool_verb.as_deref(), Some("update"));
578 assert_eq!(parsed.entity_id.as_deref(), Some("specs--alpha"));
579 assert!(parsed.note.is_none());
580 assert!(parsed.actor.is_none());
581 }
582
583 #[test]
584 fn parser_treats_lowercase_keys_as_body() {
585 let body = "\
589memstead: update specs--alpha
590
591note line one
592tool: this is prose, not a trailer
593
594Actor: agent
595";
596 let parsed = parse_commit_message(body);
597 assert_eq!(
598 parsed.note.as_deref(),
599 Some("note line one\ntool: this is prose, not a trailer")
600 );
601 assert_eq!(parsed.actor.as_deref(), Some("agent"));
602 assert!(parsed.tool.is_none());
603 }
604
605 #[test]
606 fn parser_skips_trailer_without_value() {
607 assert!(!is_trailer_line("Foo:"));
610 assert!(!is_trailer_line("Foo: "));
611 assert!(is_trailer_line("Foo: bar"));
612 }
613
614 }