use super::config::{PodConfig, resolve_pod_path};
use super::error::{PodError, Result};
use std::collections::BTreeSet;
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct Target {
pub session: String,
pub window: String,
pub pane: String,
}
impl Target {
pub fn display(&self) -> String {
format!("{}:{}.{}", self.session, self.window, self.pane)
}
}
pub fn parse_heartbeat_targets(path: &Path) -> Result<Vec<Target>> {
let raw = fs::read_to_string(path).map_err(|e| {
PodError::InvalidConfig(format!(
"cannot read heartbeat script {}: {e}",
path.display()
))
})?;
let mut set: BTreeSet<Target> = BTreeSet::new();
for line in raw.lines() {
let trimmed = line.trim_start();
if !trimmed.starts_with("[\"") {
continue;
}
let after_open = match trimmed.strip_prefix("[\"") {
Some(s) => s,
None => continue,
};
let close = match after_open.find("\"]") {
Some(i) => i,
None => continue,
};
let key = &after_open[..close];
let rest = &after_open[close + 2..];
if !rest.trim_start().starts_with('=') {
continue;
}
if let Some((session, win_pane)) = key.split_once(':')
&& let Some((window, pane)) = win_pane.split_once('.')
{
set.insert(Target {
session: session.to_string(),
window: window.to_string(),
pane: pane.to_string(),
});
}
}
Ok(set.into_iter().collect())
}
#[derive(Debug, Clone)]
pub struct Gap {
pub target: Target,
pub reason: String,
}
fn check_pod_against_targets(pod_path: &Path, targets: &[Target]) -> Result<Vec<Gap>> {
let pod = PodConfig::load(pod_path)?;
let matching: Vec<&Target> = targets.iter().filter(|t| t.session == pod.name).collect();
let mut gaps = Vec::new();
for t in matching {
let covered = if t
.window
.chars()
.next()
.map(|c| c.is_ascii_digit())
.unwrap_or(false)
{
let idx: usize = t.window.parse().unwrap_or(0);
idx <= pod.windows.len() && idx > 0
} else {
pod.windows.iter().any(|w| w.name == t.window)
};
if !covered {
gaps.push(Gap {
target: t.clone(),
reason: format!(
"pod '{}' ({}) has no matching window for '{}'",
pod.name,
pod_path.display(),
t.window
),
});
}
}
Ok(gaps)
}
pub fn detect_heartbeat_script() -> Option<PathBuf> {
let home = std::env::var_os("HOME").map(PathBuf::from)?;
let hostname = hostname_lower();
let nxtg = home.join("ASIF/scripts/cos-heartbeat-nxtg.sh");
let clx9 = home.join("ASIF/scripts/cos-heartbeat-clx9.sh");
let generic = home.join("ASIF/scripts/cos-heartbeat.sh");
if hostname.contains("nxtg") && nxtg.exists() {
return Some(nxtg);
}
if hostname.contains("clx9") && clx9.exists() {
return Some(clx9);
}
if generic.exists() {
return Some(generic);
}
if nxtg.exists() {
return Some(nxtg);
}
if clx9.exists() {
return Some(clx9);
}
None
}
fn hostname_lower() -> String {
std::process::Command::new("hostname")
.output()
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_ascii_lowercase())
.unwrap_or_default()
}
pub struct Report {
pub heartbeat_path: PathBuf,
pub targets_found: usize,
pub pods_checked: Vec<PathBuf>,
pub gaps: Vec<Gap>,
pub empty_parse: bool,
}
impl Report {
pub fn exit_code(&self) -> i32 {
if self.empty_parse || !self.gaps.is_empty() {
2
} else {
0
}
}
}
pub fn run(pod_scope: Option<&str>, against: Option<&Path>) -> Result<Report> {
let heartbeat_path = match against {
Some(p) => p.to_path_buf(),
None => detect_heartbeat_script().ok_or_else(|| {
PodError::InvalidConfig(
"no heartbeat script provided and none auto-detected (looked for \
~/ASIF/scripts/cos-heartbeat{,-nxtg,-clx9}.sh)"
.into(),
)
})?,
};
let targets = parse_heartbeat_targets(&heartbeat_path)?;
let empty_parse = targets.is_empty();
let sessions: BTreeSet<String> = match pod_scope {
Some(p) => [p.to_string()].into_iter().collect(),
None => targets.iter().map(|t| t.session.clone()).collect(),
};
let mut pods_checked = Vec::new();
let mut gaps = Vec::new();
for session in &sessions {
match resolve_pod_path(session) {
Ok(path) => {
pods_checked.push(path.clone());
let pod_gaps = check_pod_against_targets(&path, &targets)?;
gaps.extend(pod_gaps);
}
Err(_) => {
for t in targets.iter().filter(|t| &t.session == session) {
gaps.push(Gap {
target: t.clone(),
reason: format!("no pod YAML found for session '{session}'"),
});
}
}
}
}
Ok(Report {
heartbeat_path,
targets_found: targets.len(),
pods_checked,
gaps,
empty_parse,
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
struct Scratch(PathBuf);
impl Scratch {
fn new(contents: &str) -> Self {
let dir = std::env::temp_dir().join(format!(
"forge-pod-preflight-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("heartbeat.sh");
let mut f = std::fs::File::create(&path).unwrap();
f.write_all(contents.as_bytes()).unwrap();
Self(path)
}
fn path(&self) -> &Path {
&self.0
}
}
impl Drop for Scratch {
fn drop(&mut self) {
if let Some(dir) = self.0.parent() {
let _ = std::fs::remove_dir_all(dir);
}
}
}
#[test]
fn parses_nxtg_bash_assoc_form() {
let s = Scratch::new(
r#"
declare -A TEAMS_FALLBACK=(
["WORKSTREAMS:Tier-1.1"]="$HOME/projects/nxtg-content-engine"
["WORKSTREAMS:Faultline-Web.1"]="$HOME/projects/faultline-web"
["WORKSTREAMS:dx3.1"]="$HOME/projects/dx3"
)
"#,
);
let targets = parse_heartbeat_targets(s.path()).unwrap();
assert_eq!(targets.len(), 3);
assert!(
targets.iter().any(|t| t.session == "WORKSTREAMS"
&& t.window == "Faultline-Web"
&& t.pane == "1")
);
}
#[test]
fn empty_heartbeat_parses_to_zero_targets() {
let s = Scratch::new("# just a comment\n");
assert!(parse_heartbeat_targets(s.path()).unwrap().is_empty());
}
#[test]
fn lines_without_an_assignment_are_ignored() {
let s = Scratch::new(
r#"
# a comment ["FAKE:win.1"] with no = sign
["REAL:w.1"]="/tmp"
echo ["NOT:assignment.1"]
"#,
);
let targets = parse_heartbeat_targets(s.path()).unwrap();
assert_eq!(targets.len(), 1);
assert_eq!(targets[0].session, "REAL");
}
#[test]
fn empty_target_set_is_a_hard_failure_not_a_pass() {
let s = Scratch::new("# no targets here\n");
let report = run(None, Some(s.path())).unwrap();
assert!(report.empty_parse, "zero targets must set empty_parse");
assert_eq!(report.targets_found, 0);
assert_eq!(
report.exit_code(),
2,
"empty parse must be a failing exit, never 0"
);
}
#[test]
fn uncovered_target_exits_two_and_covered_exits_zero() {
let s = Scratch::new(" [\"NOPOD:win.1\"]=\"/tmp\"\n");
let report = run(None, Some(s.path())).unwrap();
assert_eq!(report.targets_found, 1);
assert!(!report.gaps.is_empty(), "no pod YAML exists for NOPOD");
assert_eq!(report.exit_code(), 2);
}
#[test]
fn missing_heartbeat_file_is_an_operational_error() {
let err = parse_heartbeat_targets(Path::new("/nonexistent/heartbeat.sh")).unwrap_err();
assert!(
err.to_string().contains("cannot read heartbeat script"),
"{err}"
);
}
#[test]
fn target_display_round_trips_the_key_form() {
let t = Target {
session: "WORKSTREAMS".into(),
window: "Tier-1".into(),
pane: "1".into(),
};
assert_eq!(t.display(), "WORKSTREAMS:Tier-1.1");
}
}