use anyhow::{Context, Result};
use serde::Serialize;
use std::path::Path;
use std::process::Command;
use crate::{diff, frontmatter, git, recover, sessions, snapshot};
#[derive(Serialize)]
pub struct RelatedDocChange {
pub path: String,
pub summary: String,
pub exists: bool,
}
#[derive(Serialize)]
pub struct PreflightOutput {
pub layout_issues: Vec<String>,
pub recovered: bool,
pub committed: bool,
pub claims: Vec<String>,
pub diff: Option<String>,
pub no_changes: bool,
pub document: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub related_doc_changes: Vec<RelatedDocChange>,
}
const IDLE_SHELLS: &[&str] = &["zsh", "bash", "sh", "fish"];
pub fn check_layout() -> Vec<String> {
if !sessions::in_tmux() {
return vec![];
}
let mut issues = Vec::new();
let session_name = match Command::new("tmux")
.args(["display-message", "-p", "#{session_name}"])
.output()
{
Ok(out) if out.status.success() => {
String::from_utf8_lossy(&out.stdout).trim().to_string()
}
_ => return issues, };
if session_name.is_empty() {
return issues;
}
let window_output = match Command::new("tmux")
.args([
"list-windows",
"-t",
&format!("{}:", session_name),
"-F",
"#{window_index}\t#{window_name}\t#{window_panes}",
])
.output()
{
Ok(out) if out.status.success() => {
String::from_utf8_lossy(&out.stdout).to_string()
}
_ => return issues,
};
struct WinInfo {
index: u32,
name: String,
}
let windows: Vec<WinInfo> = window_output
.lines()
.filter_map(|line| {
let mut parts = line.splitn(3, '\t');
let index: u32 = parts.next()?.parse().ok()?;
let name = parts.next()?.to_string();
let _pane_count: usize = parts.next()?.parse().ok()?;
Some(WinInfo { index, name })
})
.collect();
if !windows.iter().any(|w| w.index == 0) {
issues.push(format!(
"window index 0 missing in session '{}' (base-index compliance)",
session_name,
));
}
for win in &windows {
if win.name != "stash" && !win.name.starts_with("stash-") {
continue;
}
let pane_output = match Command::new("tmux")
.args([
"list-panes",
"-t",
&format!("{}:{}", session_name, win.index),
"-F",
"#{pane_id}\t#{pane_current_command}",
])
.output()
{
Ok(out) if out.status.success() => {
String::from_utf8_lossy(&out.stdout).to_string()
}
_ => continue,
};
for line in pane_output.lines() {
let mut parts = line.splitn(2, '\t');
let pane_id = match parts.next() {
Some(id) => id,
None => continue,
};
let cmd = match parts.next() {
Some(c) => c,
None => continue,
};
if !IDLE_SHELLS.contains(&cmd) {
issues.push(format!(
"stash window '{}' has non-idle pane {} running '{}'",
win.name, pane_id, cmd,
));
}
}
}
issues
}
pub fn run(file: &Path) -> Result<()> {
if !file.exists() {
anyhow::bail!("file not found: {}", file.display());
}
eprintln!("[preflight] step 0: layout check");
let layout_issues = check_layout();
for issue in &layout_issues {
eprintln!("[preflight] layout issue: {}", issue);
}
eprintln!("[preflight] step 1: recover");
let recovered = recover::run(file).unwrap_or_else(|e| {
eprintln!("[preflight] recover warning: {}", e);
false
});
eprintln!("[preflight] step 2: commit");
let committed = match git::commit(file) {
Ok(()) => true,
Err(e) => {
eprintln!("[preflight] commit warning: {}", e);
false
}
};
eprintln!("[preflight] step 3: claims");
let claims = read_and_truncate_claims(file);
{
let debounce = std::time::Duration::from_millis(500);
let max_wait = std::time::Duration::from_secs(3);
let poll = std::time::Duration::from_millis(100);
let start = std::time::Instant::now();
let file_str = file.to_string_lossy();
loop {
let idle_for = std::fs::metadata(file)
.and_then(|m| m.modified())
.ok()
.and_then(|t| t.elapsed().ok())
.unwrap_or(debounce);
let typing_active = agent_doc::debounce::is_typing_via_file(&file_str, 1500);
if idle_for >= debounce && !typing_active {
break;
}
if start.elapsed() >= max_wait {
if typing_active {
eprintln!("[preflight] typing indicator active but timeout after {:.1}s — proceeding", start.elapsed().as_secs_f64());
} else {
eprintln!("[preflight] mtime debounce timeout after {:.1}s — proceeding", start.elapsed().as_secs_f64());
}
break;
}
std::thread::sleep(poll);
}
}
eprintln!("[preflight] step 3c: related docs");
let related_doc_changes = check_related_docs(file);
for change in &related_doc_changes {
eprintln!("[preflight] related doc change: {} — {}", change.path, change.summary);
}
eprintln!("[preflight] step 4: diff");
let diff_result = diff::compute(file)?;
let no_changes = diff_result.is_none();
eprintln!("[preflight] step 5: read document");
let document = std::fs::read_to_string(file)
.with_context(|| format!("failed to read document {}", file.display()))?;
let output = PreflightOutput {
layout_issues,
recovered,
committed,
claims,
diff: diff_result,
no_changes,
document,
related_doc_changes,
};
let json = serde_json::to_string_pretty(&output)
.context("failed to serialize preflight output")?;
println!("{}", json);
Ok(())
}
fn read_and_truncate_claims(file: &Path) -> Vec<String> {
let canonical = match file.canonicalize() {
Ok(p) => p,
Err(_) => return vec![],
};
let root = match snapshot::find_project_root(&canonical) {
Some(r) => r,
None => return vec![],
};
let log_path = root.join(".agent-doc/claims.log");
let contents = match std::fs::read_to_string(&log_path) {
Ok(s) => s,
Err(_) => return vec![],
};
if contents.is_empty() {
return vec![];
}
let claims: Vec<String> = contents
.lines()
.filter(|l| !l.trim().is_empty())
.map(|l| l.to_string())
.collect();
if let Err(e) = std::fs::write(&log_path, "") {
eprintln!("[preflight] failed to truncate claims log: {}", e);
}
claims
}
fn check_related_docs(file: &Path) -> Vec<RelatedDocChange> {
let content = match std::fs::read_to_string(file) {
Ok(c) => c,
Err(_) => return vec![],
};
let fm = match frontmatter::parse(&content) {
Ok((fm, _)) => fm,
Err(_) => return vec![],
};
if fm.related_docs.is_empty() {
return vec![];
}
let our_snapshot_mtime = snapshot::path_for(file)
.ok()
.and_then(|p| std::fs::metadata(&p).ok())
.and_then(|m| m.modified().ok());
let doc_dir = match file.parent() {
Some(d) => d,
None => return vec![],
};
let mut changes = Vec::new();
for rel_path in &fm.related_docs {
let resolved = doc_dir.join(rel_path);
if !resolved.exists() {
changes.push(RelatedDocChange {
path: rel_path.clone(),
summary: "file not found".to_string(),
exists: false,
});
continue;
}
let related_mtime = match git::last_commit_mtime(&resolved) {
Ok(Some(t)) => t,
_ => continue, };
let is_newer = match our_snapshot_mtime {
Some(snap_time) => related_mtime > snap_time,
None => true, };
if !is_newer {
continue;
}
let summary = recent_commit_summary(&resolved, our_snapshot_mtime);
changes.push(RelatedDocChange {
path: rel_path.clone(),
summary,
exists: true,
});
}
changes
}
fn recent_commit_summary(file: &Path, since: Option<std::time::SystemTime>) -> String {
let since_arg = since.and_then(|t| {
t.duration_since(std::time::UNIX_EPOCH)
.ok()
.map(|d| format!("--since={}", d.as_secs()))
});
let (git_root, resolved) = match git::resolve_to_git_root(file) {
Ok(pair) => pair,
Err(_) => return "changed (git unavailable)".to_string(),
};
let rel_path = resolved
.strip_prefix(&git_root)
.unwrap_or(&resolved);
let mut args = vec!["log", "--oneline", "-5"];
let since_str;
if let Some(ref s) = since_arg {
since_str = s.clone();
args.push(&since_str);
}
args.push("--");
let rel_str = rel_path.to_string_lossy().to_string();
args.push(&rel_str);
let output = std::process::Command::new("git")
.current_dir(&git_root)
.args(&args)
.output();
match output {
Ok(out) if out.status.success() => {
let text = String::from_utf8_lossy(&out.stdout).to_string();
let lines: Vec<&str> = text.lines().take(5).collect();
if lines.is_empty() {
"changed".to_string()
} else {
lines.join("; ")
}
}
_ => "changed (git log failed)".to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::process::Command;
use tempfile::TempDir;
fn setup_project() -> TempDir {
let dir = TempDir::new().unwrap();
std::fs::create_dir_all(dir.path().join(".agent-doc/snapshots")).unwrap();
std::fs::create_dir_all(dir.path().join(".agent-doc/pending")).unwrap();
std::fs::create_dir_all(dir.path().join(".agent-doc/locks")).unwrap();
Command::new("git")
.current_dir(dir.path())
.args(["init"])
.output()
.ok();
Command::new("git")
.current_dir(dir.path())
.args(["config", "user.email", "test@test.com"])
.output()
.ok();
Command::new("git")
.current_dir(dir.path())
.args(["config", "user.name", "Test"])
.output()
.ok();
dir
}
#[test]
fn preflight_produces_valid_json() {
let dir = setup_project();
let doc = dir.path().join("session.md");
std::fs::write(
&doc,
"---\nsession: test\n---\n\n## User\n\nHello\n",
)
.unwrap();
snapshot::save(&doc, &std::fs::read_to_string(&doc).unwrap()).unwrap();
run(&doc).unwrap();
}
#[test]
fn preflight_file_not_found() {
let err = run(Path::new("/nonexistent/missing.md")).unwrap_err();
assert!(err.to_string().contains("file not found"));
}
#[test]
fn preflight_detects_diff() {
let dir = setup_project();
let doc = dir.path().join("session.md");
let original = "---\nsession: test\n---\n\n## User\n\nHello\n";
std::fs::write(&doc, original).unwrap();
snapshot::save(&doc, original).unwrap();
std::fs::write(
&doc,
"---\nsession: test\n---\n\n## User\n\nHello\n\nNew question here.\n",
)
.unwrap();
let diff_result = diff::compute(&doc).unwrap();
assert!(diff_result.is_some(), "diff should detect new content");
}
#[test]
fn preflight_claims_read_and_truncated() {
let dir = setup_project();
let doc = dir.path().join("session.md");
std::fs::write(&doc, "# Doc\n").unwrap();
snapshot::save(&doc, "# Doc\n").unwrap();
let log_path = dir.path().join(".agent-doc/claims.log");
std::fs::write(&log_path, "claim A\nclaim B\n").unwrap();
let claims = read_and_truncate_claims(&doc);
assert_eq!(claims, vec!["claim A", "claim B"]);
let after = std::fs::read_to_string(&log_path).unwrap();
assert!(after.is_empty(), "claims log should be empty after read");
}
#[test]
fn preflight_no_claims_log_returns_empty() {
let dir = setup_project();
let doc = dir.path().join("session.md");
std::fs::write(&doc, "# Doc\n").unwrap();
let claims = read_and_truncate_claims(&doc);
assert!(claims.is_empty());
}
#[test]
fn preflight_output_serializes_correctly() {
let output = PreflightOutput {
layout_issues: vec![],
recovered: false,
committed: true,
claims: vec!["foo".to_string()],
diff: Some("+new line\n".to_string()),
no_changes: false,
document: "# Doc\n".to_string(),
related_doc_changes: vec![],
};
let json = serde_json::to_string(&output).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["recovered"], false);
assert_eq!(parsed["committed"], true);
assert_eq!(parsed["claims"][0], "foo");
assert_eq!(parsed["no_changes"], false);
assert!(parsed["diff"].as_str().is_some());
assert_eq!(parsed["document"], "# Doc\n");
}
#[test]
fn preflight_output_null_diff_when_no_changes() {
let output = PreflightOutput {
layout_issues: vec![],
recovered: false,
committed: false,
claims: vec![],
diff: None,
no_changes: true,
document: "content".to_string(),
related_doc_changes: vec![],
};
let json = serde_json::to_string(&output).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert!(parsed["diff"].is_null());
assert_eq!(parsed["no_changes"], true);
}
#[test]
fn check_layout_returns_empty_outside_tmux() {
let saved = std::env::var("TMUX").ok();
unsafe { std::env::remove_var("TMUX") };
let issues = check_layout();
if let Some(val) = saved {
unsafe { std::env::set_var("TMUX", val) };
}
assert!(issues.is_empty(), "expected no issues outside tmux, got: {:?}", issues);
}
#[test]
fn preflight_output_includes_layout_issues() {
let output = PreflightOutput {
layout_issues: vec!["window index 0 missing".to_string()],
recovered: false,
committed: false,
claims: vec![],
diff: None,
no_changes: true,
document: "content".to_string(),
related_doc_changes: vec![],
};
let json = serde_json::to_string(&output).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["layout_issues"].as_array().unwrap().len(), 1);
assert_eq!(parsed["layout_issues"][0], "window index 0 missing");
}
}