use anyhow::{bail, Context, Result};
use std::path::{Path, PathBuf};
pub fn agent_logs_dir(project_root: &Path) -> PathBuf {
project_root
.join(".straymark")
.join("07-ai-audit")
.join("agent-logs")
}
pub fn find_ailog_file(agent_logs_dir: &Path, ailog_id: &str) -> Option<PathBuf> {
let prefix: String = ailog_id
.split('-')
.take(5) .collect::<Vec<_>>()
.join("-");
walk_for_prefix(agent_logs_dir, &prefix)
}
fn walk_for_prefix(dir: &Path, prefix: &str) -> Option<PathBuf> {
let entries = std::fs::read_dir(dir).ok()?;
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
if let Some(found) = walk_for_prefix(&path, prefix) {
return Some(found);
}
continue;
}
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
if name.starts_with(prefix) && name.ends_with(".md") {
return Some(path);
}
}
}
None
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BatchEntry {
pub n: u32,
pub heading_line: String,
pub body_start: usize,
pub body_end: usize,
pub body: String,
pub is_pending: bool,
}
pub fn parse_batch_ledger(content: &str) -> Option<Vec<BatchEntry>> {
let header_idx = find_section_offset(content, "## Batch Ledger")?;
let after_header = &content[header_idx..];
let ledger_end_rel = after_header
.match_indices('\n')
.filter_map(|(i, _)| {
if i == 0 {
return None;
}
let line_start = i + 1;
let rest = &after_header[line_start..];
if rest.starts_with("## ") {
Some(line_start)
} else {
None
}
})
.next()
.unwrap_or(after_header.len());
let ledger_section = &after_header[..ledger_end_rel];
let mut entries: Vec<BatchEntry> = Vec::new();
let mut last_heading: Option<(u32, String, usize)> = None;
for (line_start_rel, line) in line_offsets(ledger_section) {
let abs_offset = header_idx + line_start_rel;
if let Some((n, _)) = parse_batch_heading(line) {
if let Some((prev_n, prev_heading, prev_body_start)) = last_heading.take() {
let body_end_abs = abs_offset;
push_entry(&mut entries, content, prev_n, prev_heading, prev_body_start, body_end_abs);
}
let body_start_abs = abs_offset + line.len() + 1; last_heading = Some((n, line.to_string(), body_start_abs));
}
}
if let Some((prev_n, prev_heading, prev_body_start)) = last_heading.take() {
let body_end_abs = header_idx + ledger_end_rel;
push_entry(&mut entries, content, prev_n, prev_heading, prev_body_start, body_end_abs);
}
Some(entries)
}
fn push_entry(
entries: &mut Vec<BatchEntry>,
content: &str,
n: u32,
heading_line: String,
body_start: usize,
body_end: usize,
) {
let raw = &content[body_start..body_end];
let trimmed = raw.trim_matches(|c: char| c == '\n' || c == '\r').trim();
let is_pending = trimmed.is_empty() || trimmed == "(pending)";
entries.push(BatchEntry {
n,
heading_line,
body_start,
body_end,
body: trimmed.to_string(),
is_pending,
});
}
pub fn pending_batches(content: &str) -> Vec<u32> {
match parse_batch_ledger(content) {
Some(entries) => entries.iter().filter(|e| e.is_pending).map(|e| e.n).collect(),
None => Vec::new(),
}
}
pub fn write_batch_section(
ailog_path: &Path,
batch_number: u32,
new_body: &str,
) -> Result<BatchEntry> {
let content = std::fs::read_to_string(ailog_path)
.with_context(|| format!("Failed to read AILOG at {}", ailog_path.display()))?;
let entries = parse_batch_ledger(&content).ok_or_else(|| {
anyhow::anyhow!(
"AILOG at {} has no `## Batch Ledger` section.\n hint: add the section (see TEMPLATE-AILOG.md) before running batch-complete.",
ailog_path.display()
)
})?;
let target = entries
.iter()
.find(|e| e.n == batch_number)
.ok_or_else(|| {
anyhow::anyhow!(
"No `### Batch {}` heading in AILOG {}.\n hint: add the heading to `## Batch Ledger` (the body should start as `(pending)`).",
batch_number,
ailog_path.display()
)
})?
.clone();
let mut formatted = String::with_capacity(new_body.len() + 4);
formatted.push('\n');
formatted.push_str(new_body.trim());
formatted.push_str("\n\n");
let mut updated = String::with_capacity(content.len() + formatted.len());
updated.push_str(&content[..target.body_start]);
updated.push_str(&formatted);
updated.push_str(&content[target.body_end..]);
std::fs::write(ailog_path, updated)
.with_context(|| format!("Failed to write AILOG at {}", ailog_path.display()))?;
Ok(target)
}
pub fn ensure_pending(entry: &BatchEntry) -> Result<()> {
if !entry.is_pending {
bail!(
"Batch {} is already completed (body: `{}`). Refusing to overwrite — edit the AILOG manually if a correction is needed.",
entry.n,
truncate(&entry.body, 80)
);
}
Ok(())
}
fn truncate(s: &str, max: usize) -> String {
if s.chars().count() <= max {
s.to_string()
} else {
let mut out: String = s.chars().take(max).collect();
out.push('…');
out
}
}
fn find_section_offset(content: &str, heading: &str) -> Option<usize> {
let mut offset = 0;
for line in content.split_inclusive('\n') {
let trimmed_newline = line.trim_end_matches('\n').trim_end_matches('\r');
if trimmed_newline == heading {
return Some(offset);
}
offset += line.len();
}
None
}
fn line_offsets(s: &str) -> impl Iterator<Item = (usize, &str)> {
let mut offset = 0;
s.split_inclusive('\n').map(move |line| {
let start = offset;
offset += line.len();
(start, line.trim_end_matches('\n').trim_end_matches('\r'))
})
}
fn parse_batch_heading(line: &str) -> Option<(u32, &str)> {
let rest = line.strip_prefix("### Batch ")?;
let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
if digits.is_empty() {
return None;
}
let n: u32 = digits.parse().ok()?;
Some((n, line))
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE: &str = r#"# AILOG: Test
## Actions Performed
1. Stuff.
## Batch Ledger
> Use this section ...
### Batch 1 — Setup
Done on 2026-05-10. Files touched: a.rs, b.rs. Tests passing.
### Batch 2 — Impl
(pending)
### Batch 3 — Tests
(pending)
## Modified Files
| File | Lines |
"#;
#[test]
fn parse_finds_three_batches() {
let entries = parse_batch_ledger(SAMPLE).expect("ledger present");
assert_eq!(entries.len(), 3);
assert_eq!(entries[0].n, 1);
assert_eq!(entries[1].n, 2);
assert_eq!(entries[2].n, 3);
}
#[test]
fn parse_marks_pending_correctly() {
let entries = parse_batch_ledger(SAMPLE).unwrap();
assert!(!entries[0].is_pending, "Batch 1 is completed");
assert!(entries[1].is_pending, "Batch 2 is pending");
assert!(entries[2].is_pending, "Batch 3 is pending");
}
#[test]
fn pending_batches_returns_pending_only() {
assert_eq!(pending_batches(SAMPLE), vec![2, 3]);
}
#[test]
fn parse_returns_none_when_ledger_absent() {
let content = "# AILOG\n\n## Actions Performed\n\n1. Stuff.\n";
assert!(parse_batch_ledger(content).is_none());
assert!(pending_batches(content).is_empty());
}
#[test]
fn write_batch_section_replaces_pending_body() {
let tmp = tempfile::TempDir::new().unwrap();
let path = tmp.path().join("AILOG-2026-05-10-001-test.md");
std::fs::write(&path, SAMPLE).unwrap();
let prev = write_batch_section(&path, 2, "Implemented X and Y. Files: handler.go. Tests passing.").unwrap();
assert!(prev.is_pending);
let updated = std::fs::read_to_string(&path).unwrap();
let entries = parse_batch_ledger(&updated).unwrap();
assert!(!entries[1].is_pending, "Batch 2 should now be completed");
assert!(entries[1].body.contains("Implemented X"));
assert!(entries[2].is_pending, "Batch 3 must remain pending");
assert!(entries[0].body.contains("Done on 2026-05-10"));
}
#[test]
fn write_batch_section_errors_when_batch_absent() {
let tmp = tempfile::TempDir::new().unwrap();
let path = tmp.path().join("AILOG-2026-05-10-001-test.md");
std::fs::write(&path, SAMPLE).unwrap();
let err = write_batch_section(&path, 99, "x").unwrap_err();
assert!(err.to_string().contains("Batch 99"));
}
#[test]
fn write_batch_section_errors_when_ledger_absent() {
let tmp = tempfile::TempDir::new().unwrap();
let path = tmp.path().join("AILOG-2026-05-10-001-test.md");
std::fs::write(&path, "# AILOG\n\nNo ledger.\n").unwrap();
let err = write_batch_section(&path, 1, "x").unwrap_err();
assert!(err.to_string().contains("Batch Ledger"));
}
#[test]
fn ensure_pending_rejects_completed_batch() {
let entry = BatchEntry {
n: 5,
heading_line: "### Batch 5".to_string(),
body_start: 0,
body_end: 0,
body: "Already done.".to_string(),
is_pending: false,
};
assert!(ensure_pending(&entry).is_err());
}
#[test]
fn find_ailog_file_matches_letter_suffix_id() {
let tmp = tempfile::TempDir::new().unwrap();
let agent_logs = tmp.path().join("agent-logs");
std::fs::create_dir_all(&agent_logs).unwrap();
let path = agent_logs.join("AILOG-2026-05-02-028b-collision.md");
std::fs::write(&path, "stub\n").unwrap();
let found = find_ailog_file(&agent_logs, "AILOG-2026-05-02-028b").unwrap();
assert_eq!(found, path);
}
}