use std::collections::BTreeSet;
use std::path::Path;
use std::process::Command;
use anyhow::{Context, Result};
use colored::Colorize;
use straymark_core::architecture::{
parse_model, project, ComponentProjection, ComponentState, GovernanceState, Projection,
};
use straymark_core::charter::{self, Charter, CharterStatus};
use straymark_core::charter_files::parse_files_to_modify;
use straymark_core::document::{detect_doc_type, discover_documents, parse_document, DocType};
use straymark_core::drift::compute_drift;
use super::common;
use crate::ailog;
use crate::utils;
pub fn run(path: &str, out: Option<&str>) -> Result<()> {
let root = common::resolve_root(path);
let (_out_dir, model_path, _drawio) = common::artifact_paths(&root, out);
if !model_path.exists() {
utils::info(&format!(
"No architecture model at {} yet.",
model_path.display()
));
utils::info(&format!(
"Run {} to draft one from your codebase + ADRs.",
"straymark architecture generate".cyan()
));
return Ok(());
}
let model = parse_model(&model_path)
.with_context(|| format!("parsing {}", model_path.display()))?;
let state = build_governance_state(&root);
let projection = project(&model, &state);
render(&root, &model_path, &model, &projection, &state);
Ok(())
}
pub fn build_governance_state(root: &Path) -> GovernanceState {
let (charters, _errors) = charter::discover_and_parse(root);
let active_charter_files = declared_files(
charters
.iter()
.filter(|c| c.frontmatter.status == CharterStatus::InProgress),
);
let mut closed_charter_files: BTreeSet<String> = declared_files(
charters
.iter()
.filter(|c| c.frontmatter.status == CharterStatus::Closed),
)
.into_iter()
.collect();
closed_charter_files.extend(closed_ailog_files(root, &charters));
let closed_charter_files: Vec<String> = closed_charter_files.into_iter().collect();
let modified = git_modified_files(root);
let (_omitted, extra) = compute_drift(&active_charter_files, &modified);
let in_progress_files: Vec<String> = modified
.iter()
.filter(|m| !extra.contains(m))
.cloned()
.collect();
let on_disk_files: Vec<String> = common::collect_source_files(root)
.iter()
.map(|p| p.to_string_lossy().replace('\\', "/"))
.collect();
GovernanceState {
active_charter_files,
in_progress_files,
closed_charter_files,
tde_files: open_tde_files(root),
wiring_gap_files: Vec::new(),
on_disk_files,
}
}
fn declared_files<'a>(charters: impl Iterator<Item = &'a Charter>) -> Vec<String> {
let mut set: BTreeSet<String> = BTreeSet::new();
for c in charters {
for f in parse_files_to_modify(&c.body) {
set.insert(f.path);
}
}
set.into_iter().collect()
}
fn closed_ailog_files(root: &Path, charters: &[Charter]) -> Vec<String> {
let agent_logs_dir = ailog::agent_logs_dir(root);
if !agent_logs_dir.exists() {
return Vec::new();
}
let mut set: BTreeSet<String> = BTreeSet::new();
for c in charters {
if c.frontmatter.status != CharterStatus::Closed {
continue;
}
let ids = match &c.frontmatter.originating_ailogs {
Some(ids) => ids,
None => continue,
};
for id in ids {
if let Some(path) = ailog::find_ailog_file(&agent_logs_dir, id) {
if let Ok(content) = std::fs::read_to_string(&path) {
let body = utils::split_frontmatter(&content)
.map(|(_, b)| b)
.unwrap_or(&content);
for f in straymark_core::ailog::parse_modified_files(body) {
set.insert(f);
}
}
}
}
}
set.into_iter().collect()
}
fn git_modified_files(root: &Path) -> Vec<String> {
let Ok(output) = Command::new("git")
.args(["diff", "--name-only", "HEAD"])
.current_dir(root)
.output()
else {
return Vec::new();
};
if !output.status.success() {
return Vec::new();
}
let mut v: Vec<String> = String::from_utf8_lossy(&output.stdout)
.lines()
.filter(|s| !s.is_empty())
.map(str::to_string)
.collect();
v.sort();
v.dedup();
v
}
fn open_tde_files(root: &Path) -> Vec<String> {
let straymark_dir = root.join(".straymark");
if !straymark_dir.is_dir() {
return Vec::new();
}
let mut set: BTreeSet<String> = BTreeSet::new();
for p in discover_documents(&straymark_dir) {
let is_tde = p
.file_name()
.and_then(|n| n.to_str())
.and_then(detect_doc_type)
== Some(DocType::Tde);
if !is_tde {
continue;
}
if let Ok(doc) = parse_document(&p) {
if !tde_is_open(doc.frontmatter.status.as_deref()) {
continue;
}
if let Some(related) = doc.frontmatter.related {
for r in related {
set.insert(r);
}
}
}
}
set.into_iter().collect()
}
fn tde_is_open(status: Option<&str>) -> bool {
match status {
None => true,
Some(s) => !matches!(
s.trim().to_ascii_lowercase().as_str(),
"resolved" | "closed" | "mitigated" | "done" | "fixed"
),
}
}
fn render(
root: &Path,
model_path: &Path,
model: &straymark_core::architecture::ArchModel,
projection: &Projection,
state: &GovernanceState,
) {
println!();
println!(" {}", "Where are we".bold().cyan());
println!();
println!(
" {} {}",
"Model".dimmed(),
model_path.display().to_string().dimmed()
);
println!();
render_layers(model, projection);
render_summary(root, projection, state);
}
fn render_layers(
model: &straymark_core::architecture::ArchModel,
projection: &Projection,
) {
let any_active = projection
.components
.iter()
.any(|c| c.states.contains(&ComponentState::Active));
for layer in &model.layers {
let comps: Vec<&ComponentProjection> = projection
.components
.iter()
.filter(|c| c.layer == layer.id)
.collect();
if comps.is_empty() {
continue;
}
println!(" {}", layer.label.bold());
for cp in comps {
let label = model
.components
.iter()
.find(|c| c.id == cp.component_id)
.map(|c| c.label.as_str())
.unwrap_or(&cp.component_id);
let here = cp.states.contains(&ComponentState::Active);
let marker = if here { "▸".green().bold() } else { "·".dimmed() };
let name = if here {
label.green().bold()
} else {
label.normal()
};
let badges = state_badges(&cp.states);
let you_are_here = if here {
" ← you are here".green()
} else {
"".normal()
};
println!(" {marker} {name} {badges}{you_are_here}");
}
println!();
}
if !any_active {
utils::info("No active component — no in-progress Charter declares files in any component.");
println!();
}
}
fn state_badges(states: &[ComponentState]) -> String {
if states.is_empty() {
return "—".dimmed().to_string();
}
states
.iter()
.map(|s| {
let t = format!("[{}]", s.as_str());
match s {
ComponentState::Active => t.green().bold().to_string(),
ComponentState::InProgress => t.yellow().bold().to_string(),
ComponentState::Implemented => t.green().to_string(),
ComponentState::HasDebt => t.magenta().to_string(),
ComponentState::WiringGap => t.red().to_string(),
ComponentState::Uncharted => t.dimmed().to_string(),
}
})
.collect::<Vec<_>>()
.join(" ")
}
fn render_summary(root: &Path, projection: &Projection, state: &GovernanceState) {
println!(" {}", "Summary".bold());
let (charters, _e) = charter::discover_and_parse(root);
let active: Vec<&Charter> = charters
.iter()
.filter(|c| c.frontmatter.status == CharterStatus::InProgress)
.collect();
if active.is_empty() {
println!(
" {} no in-progress Charter",
"Active Charter:".dimmed()
);
} else {
for c in &active {
println!(
" {} {}",
"Active Charter:".dimmed(),
charter::display_title(c)
);
}
}
let declared = &state.active_charter_files;
if !declared.is_empty() {
let touched = state.in_progress_files.len();
let total = declared.len();
let pct = (touched * 100) / total.max(1);
println!(
" {} {}/{} declared files touched ({}%)",
"Progress:".dimmed(),
touched,
total,
pct
);
}
let recent = recent_ailogs(root, 3);
if !recent.is_empty() {
println!(" {} {}", "Recent AILOGs:".dimmed(), recent.join(", "));
}
let debt = projection
.components
.iter()
.filter(|c| c.states.contains(&ComponentState::HasDebt))
.count();
let uncharted = projection
.components
.iter()
.filter(|c| c.states.contains(&ComponentState::Uncharted))
.count();
println!(
" {} {} component{} with open debt, {} uncharted",
"Debt:".dimmed(),
debt,
common::plural(debt),
uncharted
);
println!();
}
fn recent_ailogs(root: &Path, n: usize) -> Vec<String> {
let dir = ailog::agent_logs_dir(root);
if !dir.is_dir() {
return Vec::new();
}
let mut names: Vec<String> = match std::fs::read_dir(&dir) {
Ok(rd) => rd
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| {
p.file_name()
.and_then(|s| s.to_str())
.map(|s| s.starts_with("AILOG-") && s.ends_with(".md"))
.unwrap_or(false)
})
.filter_map(|p| {
p.file_stem()
.and_then(|s| s.to_str())
.map(|s| s.to_string())
})
.collect(),
Err(_) => return Vec::new(),
};
names.sort();
names.into_iter().rev().take(n).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tde_open_unless_resolved() {
assert!(tde_is_open(None));
assert!(tde_is_open(Some("open")));
assert!(tde_is_open(Some("accepted")));
assert!(!tde_is_open(Some("resolved")));
assert!(!tde_is_open(Some(" Closed ")));
assert!(!tde_is_open(Some("MITIGATED")));
}
#[test]
fn state_badges_orders_and_marks() {
let badges = state_badges(&[ComponentState::Active, ComponentState::InProgress]);
assert!(badges.contains("[active]"));
assert!(badges.contains("[in-progress]"));
assert_eq!(state_badges(&[]), "—".dimmed().to_string());
}
}