use super::helpers::*;
use crate::core::types;
use std::path::Path;
pub(crate) fn cmd_status_resource_type_distribution(file: &Path, json: bool) -> Result<(), String> {
let content = std::fs::read_to_string(file).map_err(|e| format!("Read error: {e}"))?;
let config: types::ForjarConfig =
serde_yaml_ng::from_str(&content).map_err(|e| format!("Parse error: {e}"))?;
let dist = count_resource_types(&config);
if json {
let items: Vec<String> = dist
.iter()
.map(|(t, c)| format!("{{\"type\":\"{t}\",\"count\":{c}}}"))
.collect();
println!("{{\"resource_types\":[{}]}}", items.join(","));
} else if dist.is_empty() {
println!("No resources.");
} else {
let total: usize = dist.iter().map(|(_, c)| c).sum();
println!("Resource type distribution ({total} total):");
for (t, c) in &dist {
println!(" {t} — {c}");
}
}
Ok(())
}
fn count_resource_types(config: &types::ForjarConfig) -> Vec<(String, usize)> {
let mut counts: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
for resource in config.resources.values() {
*counts
.entry(resource.resource_type.to_string())
.or_default() += 1;
}
let mut result: Vec<(String, usize)> = counts.into_iter().collect();
result.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
result
}
pub(crate) fn cmd_status_resource_apply_age(
state_dir: &Path,
machine: Option<&str>,
json: bool,
) -> Result<(), String> {
let machines = discover_machines(state_dir);
let targets: Vec<&String> = match machine {
Some(m) => machines.iter().filter(|x| x.as_str() == m).collect(),
None => machines.iter().collect(),
};
let entries = collect_apply_ages(state_dir, &targets);
if json {
let items: Vec<String> = entries
.iter()
.map(|(m, r, age)| {
format!("{{\"machine\":\"{m}\",\"resource\":\"{r}\",\"age\":\"{age}\"}}")
})
.collect();
println!("{{\"resource_apply_ages\":[{}]}}", items.join(","));
} else if entries.is_empty() {
println!("No resource apply data found.");
} else {
println!("Resource apply ages:");
for (m, r, age) in &entries {
println!(" {m} / {r} — {age}");
}
}
Ok(())
}
fn collect_apply_ages(sd: &Path, targets: &[&String]) -> Vec<(String, String, String)> {
let now = std::time::SystemTime::now();
let mut entries = Vec::new();
for m in targets {
let lock_path = sd.join(m).join("state.lock.yaml");
if let Ok(content) = std::fs::read_to_string(&lock_path) {
if let Ok(lock) = serde_yaml_ng::from_str::<types::StateLock>(&content) {
for (name, rl) in &lock.resources {
let age = format_age_from_timestamp(rl.applied_at.as_deref(), &now);
entries.push((m.to_string(), name.clone(), age));
}
}
}
}
entries
}
fn format_age_from_timestamp(ts: Option<&str>, now: &std::time::SystemTime) -> String {
let Some(ts) = ts else {
return "unknown".to_string();
};
let Some(epoch_secs) = parse_rfc3339_to_epoch(ts) else {
return "unknown".to_string();
};
let applied = std::time::UNIX_EPOCH + std::time::Duration::from_secs(epoch_secs);
match now.duration_since(applied) {
Ok(d) => format_duration_human(d.as_secs()),
Err(_) => "in future".to_string(),
}
}
fn format_duration_human(secs: u64) -> String {
if secs < 60 {
format!("{secs}s ago")
} else if secs < 3600 {
format!("{}m ago", secs / 60)
} else if secs < 86400 {
format!("{}h ago", secs / 3600)
} else {
format!("{}d ago", secs / 86400)
}
}
fn parse_rfc3339_to_epoch(ts: &str) -> Option<u64> {
let date_part = ts.get(..10)?;
let time_part = ts.get(11..19)?;
let parts: Vec<&str> = date_part.split('-').collect();
if parts.len() != 3 {
return None;
}
let year: u64 = parts[0].parse().ok()?;
let month: u64 = parts[1].parse().ok()?;
let day: u64 = parts[2].parse().ok()?;
let tparts: Vec<&str> = time_part.split(':').collect();
if tparts.len() != 3 {
return None;
}
let hour: u64 = tparts[0].parse().ok()?;
let min: u64 = tparts[1].parse().ok()?;
let sec: u64 = tparts[2].parse().ok()?;
let days = days_from_epoch(year, month, day)?;
Some(days * 86400 + hour * 3600 + min * 60 + sec)
}
fn days_from_epoch(year: u64, month: u64, day: u64) -> Option<u64> {
if year < 1970 {
return None;
}
let mut days: u64 = 0;
for y in 1970..year {
days += if is_leap(y) { 366 } else { 365 };
}
let month_days = [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
for m in 1..month {
days += month_days[m as usize];
if m == 2 && is_leap(year) {
days += 1;
}
}
days += day - 1;
Some(days)
}
fn is_leap(y: u64) -> bool {
y.is_multiple_of(4) && (!y.is_multiple_of(100) || y.is_multiple_of(400))
}
pub(crate) fn cmd_status_machine_uptime(
state_dir: &Path,
machine: Option<&str>,
json: bool,
) -> Result<(), String> {
let machines = discover_machines(state_dir);
let targets: Vec<&String> = match machine {
Some(m) => machines.iter().filter(|x| x.as_str() == m).collect(),
None => machines.iter().collect(),
};
let now = std::time::SystemTime::now();
let data: Vec<_> = targets
.iter()
.map(|m| {
let events_path = state_dir.join(m.as_str()).join("events.jsonl");
let age = first_event_age(&events_path, &now);
(m.to_string(), age)
})
.collect();
if json {
let items: Vec<String> = data
.iter()
.map(|(m, a)| format!("{{\"machine\":\"{m}\",\"uptime\":\"{a}\"}}"))
.collect();
println!("{{\"machine_uptime\":[{}]}}", items.join(","));
} else if data.is_empty() {
println!("No machines found.");
} else {
println!("Machine uptime (since first apply):");
for (m, a) in &data {
println!(" {m} — {a}");
}
}
Ok(())
}
fn first_event_age(path: &Path, now: &std::time::SystemTime) -> String {
let content = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(_) => return "no events".to_string(),
};
let first_line = match content.lines().next() {
Some(l) => l,
None => return "no events".to_string(),
};
if let Some(start) = first_line.find("\"timestamp\":\"") {
let rest = &first_line[start + 13..];
if let Some(end) = rest.find('"') {
let ts = &rest[..end];
return format_age_from_timestamp(Some(ts), now);
}
}
"unknown".to_string()
}
pub(crate) fn cmd_status_resource_churn(
state_dir: &Path,
machine: Option<&str>,
json: bool,
) -> Result<(), String> {
let machines = discover_machines(state_dir);
let targets: Vec<&String> = match machine {
Some(m) => machines.iter().filter(|x| x.as_str() == m).collect(),
None => machines.iter().collect(),
};
let data = collect_resource_churn(state_dir, &targets);
if json {
let items: Vec<String> = data
.iter()
.map(|(m, r, c)| {
format!("{{\"machine\":\"{m}\",\"resource\":\"{r}\",\"apply_count\":{c}}}")
})
.collect();
println!("{{\"resource_churn\":[{}]}}", items.join(","));
} else if data.is_empty() {
println!("No resource churn data found.");
} else {
println!("Resource churn (apply counts):");
for (m, r, c) in &data {
println!(" {m} / {r} — {c} applies");
}
}
Ok(())
}
fn collect_resource_churn(sd: &Path, targets: &[&String]) -> Vec<(String, String, usize)> {
let mut counts: std::collections::HashMap<(String, String), usize> =
std::collections::HashMap::new();
for m in targets {
let events_path = sd.join(m.as_str()).join("events.jsonl");
if let Ok(content) = std::fs::read_to_string(&events_path) {
for line in content.lines() {
if line.contains("resource_applied") {
if let Some(rname) = extract_resource_name(line) {
*counts.entry((m.to_string(), rname)).or_default() += 1;
}
}
}
}
}
let mut result: Vec<(String, String, usize)> =
counts.into_iter().map(|((m, r), c)| (m, r, c)).collect();
result.sort_by(|a, b| b.2.cmp(&a.2).then(a.0.cmp(&b.0)).then(a.1.cmp(&b.1)));
result
}
fn extract_resource_name(line: &str) -> Option<String> {
if let Some(start) = line.find("\"resource\":\"") {
let rest = &line[start + 12..];
if let Some(end) = rest.find('"') {
return Some(rest[..end].to_string());
}
}
None
}