use anyhow::{anyhow, Result};
use chrono::Local;
use colored::Colorize;
use std::path::{Path, PathBuf};
use std::process::Command;
use crate::ailog;
use crate::followups::{self, ExtractedFu, Registry};
use crate::utils;
pub fn run(path: &str, apply: bool, scan_all: bool, range: Option<&str>) -> Result<()> {
let resolved = utils::resolve_project_root(path)
.ok_or_else(|| anyhow!("StrayMark not installed. Run 'straymark init' first."))?;
let project_root = &resolved.path;
let registry_path = followups::registry_path(project_root);
if !registry_path.exists() {
if apply {
let template = project_root
.join(".straymark")
.join("templates")
.join("follow-ups-backlog.md");
if template.exists() {
std::fs::copy(&template, ®istry_path)?;
utils::info(&format!(
"Created {} from the framework template.",
registry_path
.strip_prefix(project_root)
.unwrap_or(®istry_path)
.display()
));
} else {
return Err(anyhow!(
"No registry at {} and no template at .straymark/templates/follow-ups-backlog.md.\n \
hint: run `straymark update-framework` (the template ships with fw-4.21.0+).",
registry_path.display()
));
}
} else {
println!(
"No follow-ups registry yet. Run `straymark followups drift --scan-all --apply` to create and seed it (see STRAYMARK.md §16)."
);
return Ok(());
}
}
let registry = followups::parse_registry(®istry_path)?;
for w in ®istry.warnings {
utils::warn(w);
}
let drifted = detect_drift_candidates(project_root, ®istry, scan_all, range);
if drifted.is_empty() {
println!(
"{} registry in sync — no AILOGs with unextracted follow-up content.",
"OK".green().bold()
);
if apply {
let counters = followups::compute_counters(®istry);
let fm =
followups::fm_apply_counters_and_v1(®istry.frontmatter_raw, &counters);
if fm != registry.frontmatter_raw {
let was_v0 = registry.is_v0();
std::fs::write(®istry_path, followups::assemble(&fm, ®istry.body))?;
println!(
" Counters recomputed: {} open / {} suspected-closed / {} promoted (total {}).",
counters.open, counters.suspected_closed, counters.promoted, counters.total
);
if was_v0 {
utils::info(
"Registry upgraded to schema v1 (non-destructive — counters are now CLI-owned).",
);
}
}
}
return Ok(());
}
if !apply {
eprintln!(
"{} {} AILOG(s) have follow-up content not yet in the registry:",
"WARNING:".red().bold(),
drifted.len()
);
for (id, path, found) in &drifted {
eprintln!(
" - {} ({} entr{}) — {}",
id,
found.len(),
if found.len() == 1 { "y" } else { "ies" },
path.strip_prefix(project_root).unwrap_or(path).display()
);
}
eprintln!();
eprintln!(" Action: run `straymark followups drift --apply` to extract them.");
std::process::exit(1);
}
let today = Local::now().format("%Y-%m-%d").to_string();
let report = apply_candidates(®istry_path, ®istry, &drifted, &today)?;
utils::success(&format!(
"Extracted {} entr{} from {} AILOG(s) into `## Bucket: ready`.",
report.applied.len(),
if report.applied.len() == 1 { "y" } else { "ies" },
report.ailogs
));
if report.suspected > 0 {
println!(
" {} {} extracted as {} (closure marker in source AILOG) — confirm at the next triage.",
"!".magenta().bold(),
report.suspected,
"suspected-closed".magenta()
);
}
if report.was_v0 {
utils::info("Registry upgraded to schema v1 (non-destructive — counters are now CLI-owned).");
}
println!(
" Counters recomputed: {} open / {} suspected-closed / {} promoted (total {}).",
report.counters.open,
report.counters.suspected_closed,
report.counters.promoted,
report.counters.total
);
println!(" Next: reclassify bucket/trigger/destination at triage (`straymark followups list --bucket ready`).");
Ok(())
}
pub fn detect_drift_candidates(
project_root: &Path,
registry: &Registry,
scan_all: bool,
range: Option<&str>,
) -> Vec<(String, PathBuf, Vec<ExtractedFu>)> {
let agent_logs = ailog::agent_logs_dir(project_root);
let candidates: Vec<PathBuf> = if scan_all {
walk_ailogs(&agent_logs)
} else {
let mut set = ailogs_in_git_range(project_root, &agent_logs, range);
for p in ailogs_in_working_tree(project_root, &agent_logs) {
if !set.contains(&p) {
set.push(p);
}
}
set
};
let seen_hashes = followups::registry_extracted_hashes(registry);
let mut drifted: Vec<(String, PathBuf, Vec<ExtractedFu>)> = Vec::new();
for path in candidates {
let Some(id) = followups::ailog_id_from_path(&path) else {
continue;
};
let Ok(content) = std::fs::read_to_string(&path) else {
continue;
};
let new: Vec<ExtractedFu> = followups::extract_followups_from_ailog(&content)
.into_iter()
.filter(|fu| {
!seen_hashes.contains(&followups::fu_content_hash(
&id,
&fu.origin_section,
&fu.description,
))
})
.collect();
if !new.is_empty() {
drifted.push((id, path, new));
}
}
drifted
}
pub struct AppliedFu {
pub fu_id: String,
pub description: String,
pub suspected_closed: bool,
}
pub struct ApplyReport {
pub applied: Vec<AppliedFu>,
pub ailogs: usize,
pub suspected: usize,
pub counters: followups::Counters,
pub was_v0: bool,
}
pub fn apply_candidates(
registry_path: &Path,
registry: &Registry,
drifted: &[(String, PathBuf, Vec<ExtractedFu>)],
today: &str,
) -> Result<ApplyReport> {
let mut body = registry.body.clone();
let mut next_n = followups::next_fu_number(registry);
let mut applied: Vec<AppliedFu> = Vec::new();
let mut suspected = 0usize;
for (id, _path, found) in drifted {
for fu in found {
let current = followups::parse_registry_str(
registry_path,
&followups::assemble(®istry.frontmatter_raw, &body),
)?;
let block = followups::render_new_entry(next_n, fu, id, today);
body = followups::insert_into_bucket(¤t, "ready", &block);
if fu.suspected_closed {
suspected += 1;
}
applied.push(AppliedFu {
fu_id: format!("FU-{:03}", next_n),
description: fu.description.clone(),
suspected_closed: fu.suspected_closed,
});
next_n += 1;
}
}
let already: std::collections::HashSet<&str> = registry
.frontmatter
.fully_extracted_ailogs
.iter()
.map(|s| s.as_str())
.collect();
let mut new_ids: Vec<String> = Vec::new();
for (id, _, _) in drifted {
if !already.contains(id.as_str()) && !new_ids.contains(id) {
new_ids.push(id.clone());
}
}
let mut fm = followups::fm_append_list_items(
®istry.frontmatter_raw,
"fully_extracted_ailogs",
&new_ids,
);
fm = followups::fm_set_scalar(&fm, "last_scan", today);
let final_registry =
followups::parse_registry_str(registry_path, &followups::assemble(&fm, &body))?;
let counters = followups::compute_counters(&final_registry);
fm = followups::fm_apply_counters_and_v1(&fm, &counters);
let was_v0 = registry.is_v0();
std::fs::write(registry_path, followups::assemble(&fm, &body))?;
Ok(ApplyReport {
ailogs: drifted.len(),
suspected,
counters,
was_v0,
applied,
})
}
fn walk_ailogs(dir: &Path) -> Vec<PathBuf> {
let mut out = Vec::new();
let Ok(entries) = std::fs::read_dir(dir) else {
return out;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
out.extend(walk_ailogs(&path));
} else if path
.file_name()
.and_then(|n| n.to_str())
.map(|n| n.starts_with("AILOG-") && n.ends_with(".md"))
.unwrap_or(false)
{
out.push(path);
}
}
out.sort();
out
}
fn ailogs_in_git_range(
project_root: &Path,
agent_logs: &Path,
range: Option<&str>,
) -> Vec<PathBuf> {
let range = match range {
Some(r) => r.to_string(),
None => {
if git_ref_exists(project_root, "origin/main") {
"origin/main..HEAD".to_string()
} else if git_ref_exists(project_root, "origin/master") {
"origin/master..HEAD".to_string()
} else {
utils::warn("No origin/main or origin/master — falling back to HEAD~1..HEAD.");
"HEAD~1..HEAD".to_string()
}
}
};
let output = Command::new("git")
.args(["diff", "--name-only", &range])
.current_dir(project_root)
.output();
let Ok(output) = output else {
utils::warn("git not available — nothing to scan (use --scan-all for a filesystem sweep).");
return Vec::new();
};
if !output.status.success() {
utils::warn(&format!(
"`git diff --name-only {}` failed — nothing to scan (use --scan-all for a filesystem sweep).",
range
));
return Vec::new();
}
let agent_logs_rel = agent_logs
.strip_prefix(project_root)
.unwrap_or(agent_logs)
.to_path_buf();
String::from_utf8_lossy(&output.stdout)
.lines()
.map(PathBuf::from)
.filter(|p| {
p.starts_with(&agent_logs_rel)
&& p.file_name()
.and_then(|n| n.to_str())
.map(|n| n.starts_with("AILOG-") && n.ends_with(".md"))
.unwrap_or(false)
})
.map(|p| project_root.join(p))
.filter(|p| p.exists())
.collect()
}
fn ailogs_in_working_tree(project_root: &Path, agent_logs: &Path) -> Vec<PathBuf> {
let output = Command::new("git")
.args(["status", "--porcelain"])
.current_dir(project_root)
.output();
let Ok(output) = output else {
return Vec::new();
};
if !output.status.success() {
return Vec::new();
}
let agent_logs_rel = agent_logs
.strip_prefix(project_root)
.unwrap_or(agent_logs)
.to_path_buf();
parse_porcelain_ailogs(&String::from_utf8_lossy(&output.stdout), &agent_logs_rel)
.into_iter()
.map(|p| project_root.join(p))
.filter(|p| p.exists())
.collect()
}
fn parse_porcelain_ailogs(stdout: &str, agent_logs_rel: &Path) -> Vec<PathBuf> {
let mut out = Vec::new();
for line in stdout.lines() {
if line.len() < 4 {
continue;
}
let rest = &line[3..];
let path_str = rest.rsplit(" -> ").next().unwrap_or(rest).trim();
let path_str = path_str.trim_matches('"');
let p = PathBuf::from(path_str);
if p.starts_with(agent_logs_rel)
&& p.file_name()
.and_then(|n| n.to_str())
.map(|n| n.starts_with("AILOG-") && n.ends_with(".md"))
.unwrap_or(false)
{
out.push(p);
}
}
out
}
fn git_ref_exists(project_root: &Path, r: &str) -> bool {
Command::new("git")
.args(["rev-parse", "--verify", "--quiet", r])
.current_dir(project_root)
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn tier3_detect_apply_dedup_roundtrip() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
let agent_logs = root.join(".straymark/07-ai-audit/agent-logs");
std::fs::create_dir_all(&agent_logs).unwrap();
std::fs::write(
agent_logs.join("AILOG-2026-06-11-001-x.md"),
"# AILOG\n\n## Follow-ups\n\n- do the thing\n",
)
.unwrap();
let registry_path = crate::followups::registry_path(root);
std::fs::create_dir_all(registry_path.parent().unwrap()).unwrap();
std::fs::write(
®istry_path,
"---\nschema_version: v1\ntotal_open: 0\nbuckets:\n - ready\nfully_extracted_ailogs: []\n---\n\n## Bucket: ready\n",
)
.unwrap();
let registry = crate::followups::parse_registry(®istry_path).unwrap();
let drifted = detect_drift_candidates(root, ®istry, true, None);
assert_eq!(drifted.len(), 1);
assert_eq!(drifted[0].0, "AILOG-2026-06-11-001");
assert_eq!(drifted[0].2.len(), 1);
assert_eq!(drifted[0].2[0].description, "do the thing");
let report = apply_candidates(®istry_path, ®istry, &drifted, "2026-06-11").unwrap();
assert_eq!(report.applied.len(), 1);
assert_eq!(report.applied[0].fu_id, "FU-001");
assert_eq!(report.applied[0].description, "do the thing");
assert!(!report.applied[0].suspected_closed);
let written = std::fs::read_to_string(®istry_path).unwrap();
assert!(written.contains("### FU-001 — do the thing"));
assert!(written.contains("- **Source-hash**:"));
assert!(written.contains("AILOG-2026-06-11-001"));
let registry2 = crate::followups::parse_registry(®istry_path).unwrap();
let drifted2 = detect_drift_candidates(root, ®istry2, true, None);
assert!(
drifted2.is_empty(),
"already-extracted follow-up must not re-drift"
);
}
#[test]
fn parse_porcelain_picks_ailogs_across_status_codes() {
let rel = Path::new(".straymark/07-ai-audit/agent-logs");
let stdout = [
"?? .straymark/07-ai-audit/agent-logs/AILOG-2026-06-09-001-untracked.md",
" M .straymark/07-ai-audit/agent-logs/AILOG-2026-06-09-002-modified.md",
"A .straymark/07-ai-audit/agent-logs/AILOG-2026-06-09-003-staged.md",
"R .straymark/07-ai-audit/agent-logs/AILOG-old.md -> .straymark/07-ai-audit/agent-logs/AILOG-2026-06-09-004-renamed.md",
" M .straymark/07-ai-audit/agent-logs/notes.md",
"?? src/main.rs",
" M .straymark/07-ai-audit/decisions/AIDEC-2026-06-09-001.md",
]
.join("\n");
let got = parse_porcelain_ailogs(&stdout, rel);
let names: Vec<String> = got
.iter()
.map(|p| p.file_name().unwrap().to_string_lossy().into_owned())
.collect();
assert_eq!(
names,
vec![
"AILOG-2026-06-09-001-untracked.md",
"AILOG-2026-06-09-002-modified.md",
"AILOG-2026-06-09-003-staged.md",
"AILOG-2026-06-09-004-renamed.md", ]
);
}
#[test]
fn parse_porcelain_tolerates_quoted_paths_and_short_lines() {
let rel = Path::new(".straymark/07-ai-audit/agent-logs");
let stdout = [
"?? \".straymark/07-ai-audit/agent-logs/AILOG-2026-06-09-005-spaced name.md\"",
"",
"XX",
]
.join("\n");
let got = parse_porcelain_ailogs(&stdout, rel);
assert_eq!(got.len(), 1);
assert_eq!(
got[0].file_name().unwrap().to_string_lossy(),
"AILOG-2026-06-09-005-spaced name.md"
);
}
}