use crate::listing::{self, Column, ColumnPaint, RenderOpts, TITLE_EVEN, TITLE_ODD, status_hue};
use owo_colors::{AnsiColors::Cyan, DynColors};
use super::view::{ActionabilityBlock, BlockersView, Explanation, NextRow, ReasonKind, SurveyRow};
pub(crate) const PRIORITY_POLICY_VERSION: &str = "priority.v3";
const SURVEY_COLS: [Column<SurveyRow>; 7] = [
Column {
name: "id",
header: "id",
cell: |r| r.id.clone(),
paint: ColumnPaint::Fixed(DynColors::Ansi(Cyan)),
},
Column {
name: "kind",
header: "kind",
cell: |r| r.kind.clone(),
paint: ColumnPaint::None,
},
Column {
name: "status",
header: "status",
cell: |r| r.status.clone(),
paint: ColumnPaint::ByValue(|r| status_hue(&r.status)),
},
Column {
name: "act",
header: "",
cell: |r| r.act.badge().to_string(),
paint: ColumnPaint::ByValue(|r| status_hue(r.act.token())),
},
Column {
name: "score",
header: "score",
cell: |r| format!("{:.1}", r.score),
paint: ColumnPaint::None,
},
Column {
name: "blocker",
header: "blocker",
cell: |r| r.blockers.first().cloned().unwrap_or_default(),
paint: ColumnPaint::None,
},
Column {
name: "title",
header: "title",
cell: |r| r.title.clone(),
paint: ColumnPaint::Alternate([TITLE_EVEN, TITLE_ODD]),
},
];
#[expect(
dead_code,
reason = "declared for IMP-038 validation parity; not used by render_columns (priority has no --columns surface)"
)]
const SURVEY_DEFAULT: &[&str] = &["id", "kind", "status", "act", "score", "blocker", "title"];
const NEXT_COLS: [Column<NextRow>; 6] = [
Column {
name: "id",
header: "id",
cell: |r| r.id.clone(),
paint: ColumnPaint::Fixed(DynColors::Ansi(Cyan)),
},
Column {
name: "kind",
header: "kind",
cell: |r| r.kind.clone(),
paint: ColumnPaint::None,
},
Column {
name: "status",
header: "status",
cell: |r| r.status.clone(),
paint: ColumnPaint::ByValue(|r| status_hue(&r.status)),
},
Column {
name: "score",
header: "score",
cell: |r| format!("{:.1}", r.score),
paint: ColumnPaint::None,
},
Column {
name: "unblocks",
header: "unblocks",
cell: |r| r.blocking.len().to_string(),
paint: ColumnPaint::None,
},
Column {
name: "title",
header: "title",
cell: |r| r.title.clone(),
paint: ColumnPaint::Alternate([TITLE_EVEN, TITLE_ODD]),
},
];
#[expect(
dead_code,
reason = "declared for IMP-038 validation parity; not used by render_columns (priority has no --columns surface)"
)]
const NEXT_DEFAULT: &[&str] = &["id", "kind", "status", "score", "unblocks", "title"];
pub(crate) fn survey_human(rows: &[SurveyRow], opts: RenderOpts) -> String {
if rows.is_empty() {
return "(no eligible work)\n".to_string();
}
let sel: Vec<&Column<SurveyRow>> = SURVEY_COLS.iter().collect();
listing::render_columns(rows, &sel, opts)
}
pub(crate) fn next_human(rows: &[NextRow], opts: RenderOpts) -> String {
if rows.is_empty() {
return "(nothing actionable)\n".to_string();
}
let sel: Vec<&Column<NextRow>> = NEXT_COLS.iter().collect();
listing::render_columns(rows, &sel, opts)
}
pub(crate) fn blockers_human(view: &BlockersView) -> String {
let depth = if view.transitive {
"transitive"
} else {
"direct"
};
let mut parts: Vec<String> = vec![format!("{} — blockers ({depth})\n", view.id)];
if !view.blocked_by.is_empty() {
parts.push("\nblocked by:\n".to_string());
for b in &view.blocked_by {
parts.push(format!(" {b}\n"));
}
}
if !view.blocking.is_empty() {
parts.push("\nblocking:\n".to_string());
for b in &view.blocking {
parts.push(format!(" {b}\n"));
}
}
if view.blocked_by.is_empty() && view.blocking.is_empty() {
parts.push("\n(no blockers, blocks nothing)\n".to_string());
}
parts.concat()
}
fn reason_line(reason: &ReasonKind) -> String {
match reason {
ReasonKind::Eligibility { status, class } => {
let s = status.as_deref().unwrap_or("—");
format!(" eligibility: {s} → {class:?}\n")
}
ReasonKind::BlockedBy { items } => format!(" blocked by: {}\n", items.join(", ")),
ReasonKind::Blocking { items } => format!(" blocking: {}\n", items.join(", ")),
ReasonKind::Score {
base,
value_dim,
risk_dim,
leverage,
optionality,
total,
} => format!(
" score: {total:.1} (base {base:.1} [value {value_dim:.1}, risk {risk_dim:.1}], \
leverage {leverage:.1}, optionality {optionality:.1})\n"
),
ReasonKind::EvictedEdge { from, to, reason } => {
format!(" evicted seq edge: {from} → {to} ({reason:?})\n")
}
ReasonKind::CycleDegraded { nodes } => {
format!(" dep cycle (order degraded): {}\n", nodes.join(", "))
}
}
}
pub(crate) fn explain_human(ex: &Explanation) -> String {
let mut parts: Vec<String> = vec![format!("{} — explain\n", ex.id)];
parts.push(reason_line(&ex.eligibility));
for r in &ex.blocker_chain {
parts.push(reason_line(r));
}
for r in &ex.evictions {
parts.push(reason_line(r));
}
parts.push(reason_line(&ex.score));
parts.concat()
}
pub(crate) fn actionability_block_human(block: &ActionabilityBlock) -> String {
let mut parts: Vec<String> = vec!["\nactionability:\n".to_string()];
parts.push(format!(" eligible: {}\n", block.eligible));
parts.push(format!(" actionable: {}\n", block.actionable));
parts.push(format!(" score: {:.1}\n", block.score));
if !block.blockers.is_empty() {
parts.push(format!(" blocked by: {}\n", block.blockers.join(", ")));
}
if !block.blocking.is_empty() {
parts.push(format!(" blocking: {}\n", block.blocking.join(", ")));
}
parts.concat()
}
fn reason_json(reason: &ReasonKind) -> serde_json::Value {
match reason {
ReasonKind::Eligibility { status, class } => serde_json::json!({
"kind": "eligibility",
"status": status,
"class": format!("{class:?}"),
}),
ReasonKind::BlockedBy { items } => {
serde_json::json!({ "kind": "blocked_by", "items": items })
}
ReasonKind::Blocking { items } => {
serde_json::json!({ "kind": "blocking", "items": items })
}
ReasonKind::Score {
base,
value_dim,
risk_dim,
leverage,
optionality,
total,
} => serde_json::json!({
"kind": "score",
"base": base,
"value_dim": value_dim,
"risk_dim": risk_dim,
"leverage": leverage,
"optionality": optionality,
"total": total,
}),
ReasonKind::EvictedEdge { from, to, reason } => serde_json::json!({
"kind": "evicted_edge",
"from": from,
"to": to,
"reason": format!("{reason:?}"),
}),
ReasonKind::CycleDegraded { nodes } => {
serde_json::json!({ "kind": "cycle_degraded", "nodes": nodes })
}
}
}
fn finish(value: &serde_json::Value) -> anyhow::Result<String> {
serde_json::to_string_pretty(value)
.map_err(|e| anyhow::anyhow!("failed to serialize priority JSON: {e}"))
}
pub(crate) fn survey_json(rows: &[SurveyRow]) -> anyhow::Result<String> {
let rows: Vec<serde_json::Value> = rows
.iter()
.map(|r| {
serde_json::json!({
"id": r.id,
"title": r.title,
"kind": r.kind,
"status": r.status,
"actionability": r.act.token(),
"score": r.score,
"blockers": r.blockers,
"reasons": r.reasons.iter().map(reason_json).collect::<Vec<_>>(),
})
})
.collect();
finish(&serde_json::json!({
"kind": "survey",
"policy_version": PRIORITY_POLICY_VERSION,
"rows": rows,
}))
}
pub(crate) fn next_json(rows: &[NextRow]) -> anyhow::Result<String> {
let rows: Vec<serde_json::Value> = rows
.iter()
.map(|r| {
serde_json::json!({
"id": r.id,
"title": r.title,
"kind": r.kind,
"status": r.status,
"actionability": r.act.token(),
"score": r.score,
"blocking": r.blocking,
"reasons": r.reasons.iter().map(reason_json).collect::<Vec<_>>(),
})
})
.collect();
finish(&serde_json::json!({
"kind": "next",
"policy_version": PRIORITY_POLICY_VERSION,
"rows": rows,
}))
}
pub(crate) fn blockers_json(view: &BlockersView) -> anyhow::Result<String> {
finish(&serde_json::json!({
"kind": "blockers",
"policy_version": PRIORITY_POLICY_VERSION,
"id": view.id,
"transitive": view.transitive,
"blocked_by": view.blocked_by,
"blocking": view.blocking,
}))
}
pub(crate) fn explain_json(ex: &Explanation) -> anyhow::Result<String> {
finish(&serde_json::json!({
"kind": "explain",
"policy_version": PRIORITY_POLICY_VERSION,
"id": ex.id,
"eligibility": reason_json(&ex.eligibility),
"blocker_chain": ex.blocker_chain.iter().map(reason_json).collect::<Vec<_>>(),
"evictions": ex.evictions.iter().map(reason_json).collect::<Vec<_>>(),
"score": reason_json(&ex.score),
}))
}
pub(crate) fn actionability_block_value(block: &ActionabilityBlock) -> serde_json::Value {
serde_json::json!({
"eligible": block.eligible,
"actionable": block.actionable,
"blockers": block.blockers,
"blocking": block.blocking,
"score": block.score,
})
}