use crate::item_type::ItemType;
use crate::schema::items_col;
use arrow::array::{Array, RecordBatch, StringArray};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Severity {
Error,
Warning,
}
#[derive(Debug, Clone)]
pub struct Violation {
pub field: String,
pub message: String,
pub severity: Severity,
}
#[derive(Debug)]
pub struct ValidationReport {
pub item_id: String,
pub item_type: String,
pub violations: Vec<Violation>,
}
impl ValidationReport {
pub fn is_conformant(&self) -> bool {
!self
.violations
.iter()
.any(|v| v.severity == Severity::Error)
}
}
pub fn validate_item(batch: &RecordBatch) -> ValidationReport {
let item_id = get_string(batch, items_col::ID).unwrap_or_default();
let item_type_str = get_string(batch, items_col::ITEM_TYPE).unwrap_or_default();
let mut violations = Vec::new();
let item_type = ItemType::from_str_loose(&item_type_str);
let body = get_nullable_string(batch, items_col::BODY);
let body_empty = body.as_deref().map(|s| s.trim().is_empty()).unwrap_or(true);
let body_severity = match item_type {
Some(ItemType::Hazard) | Some(ItemType::Signal) => Some(Severity::Warning),
Some(_) => Some(Severity::Error),
None => Some(Severity::Error),
};
if body_empty && let Some(sev) = body_severity {
violations.push(Violation {
field: "body".to_string(),
message: "body is empty — use: nk update <ID> --body-file /tmp/body.md".to_string(),
severity: sev,
});
}
let priority = get_nullable_string(batch, items_col::PRIORITY);
let priority_required = matches!(
item_type,
Some(ItemType::Expedition)
| Some(ItemType::Voyage)
| Some(ItemType::Chore)
| Some(ItemType::Feature)
);
if priority_required && priority.is_none() {
violations.push(Violation {
field: "priority".to_string(),
message: "priority required: low|medium|high|critical".to_string(),
severity: Severity::Error,
});
}
let assignee = get_nullable_string(batch, items_col::ASSIGNEE);
let assignee_required = matches!(
item_type,
Some(ItemType::Expedition) | Some(ItemType::Voyage)
);
if assignee_required && assignee.is_none() {
violations.push(Violation {
field: "assignee".to_string(),
message: "assignee required: M5|DGX|Mini|unassigned".to_string(),
severity: Severity::Warning,
});
}
let status = get_string(batch, items_col::STATUS).unwrap_or_default();
if status == "in_progress"
&& let Some(ref a) = assignee
&& a == "unassigned"
{
violations.push(Violation {
field: "assignee".to_string(),
message: "item is in_progress but assignee is 'unassigned'".to_string(),
severity: Severity::Warning,
});
}
ValidationReport {
item_id,
item_type: item_type_str,
violations,
}
}
pub fn suggest_fixes(report: &ValidationReport) -> Vec<String> {
report
.violations
.iter()
.map(|v| match v.field.as_str() {
"assignee" => format!("nk update {} --assign M5", report.item_id),
"body" => format!("nk update {} --body-file /tmp/body.md", report.item_id),
"priority" => format!("nk update {} --priority medium", report.item_id),
_ => format!("nk update {} # fix {}", report.item_id, v.field),
})
.collect()
}
pub fn validate_all(batches: &[RecordBatch]) -> Vec<ValidationReport> {
batches.iter().map(validate_item).collect()
}
pub fn format_report(report: &ValidationReport, show_fixes: bool) -> String {
let mut lines = Vec::new();
if report.violations.is_empty() {
lines.push(format!("{} {} — OK", report.item_id, report.item_type));
} else {
let error_count = report
.violations
.iter()
.filter(|v| v.severity == Severity::Error)
.count();
let warn_count = report
.violations
.iter()
.filter(|v| v.severity == Severity::Warning)
.count();
let summary = match (error_count, warn_count) {
(0, w) => format!("WARNINGS ({})", w),
(e, 0) => format!("VIOLATIONS ({})", e),
(e, w) => format!("VIOLATIONS ({} errors, {} warnings)", e, w),
};
lines.push(format!(
"{} {} — {}",
report.item_id, report.item_type, summary
));
for v in &report.violations {
let label = match v.severity {
Severity::Error => " ERROR ",
Severity::Warning => " WARNING",
};
lines.push(format!("{} {}: {}", label, v.field, v.message));
}
if show_fixes {
let fixes = suggest_fixes(report);
if !fixes.is_empty() {
lines.push(String::new());
lines.push("Suggested fixes:".to_string());
for fix in &fixes {
lines.push(format!(" {fix}"));
}
}
}
}
lines.join("\n")
}
pub fn format_board_summary(reports: &[ValidationReport]) -> String {
let conformant: Vec<&ValidationReport> = reports.iter().filter(|r| r.is_conformant()).collect();
let violating: Vec<&ValidationReport> = reports.iter().filter(|r| !r.is_conformant()).collect();
let mut lines = Vec::new();
lines.push(format!(
"Validation summary: {} conformant, {} with violations",
conformant.len(),
violating.len()
));
if violating.is_empty() {
lines.push("All items conform to their SHACL shapes.".to_string());
} else {
lines.push(String::new());
lines.push("Items with violations:".to_string());
for r in &violating {
let error_count = r
.violations
.iter()
.filter(|v| v.severity == Severity::Error)
.count();
let warn_count = r
.violations
.iter()
.filter(|v| v.severity == Severity::Warning)
.count();
lines.push(format!(
" {} ({}) — {} errors, {} warnings",
r.item_id, r.item_type, error_count, warn_count
));
}
}
lines.join("\n")
}
fn get_string(batch: &RecordBatch, col_idx: usize) -> Option<String> {
batch
.column(col_idx)
.as_any()
.downcast_ref::<StringArray>()
.and_then(|arr| {
if arr.is_empty() {
None
} else {
Some(arr.value(0).to_string())
}
})
}
fn get_nullable_string(batch: &RecordBatch, col_idx: usize) -> Option<String> {
batch
.column(col_idx)
.as_any()
.downcast_ref::<StringArray>()
.and_then(|arr| {
if arr.is_empty() || arr.is_null(0) {
None
} else {
let s = arr.value(0);
if s.is_empty() {
None
} else {
Some(s.to_string())
}
}
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::crud::{CreateItemInput, KanbanStore};
fn make_item(
item_type: ItemType,
priority: Option<&str>,
assignee: Option<&str>,
body: Option<&str>,
status: &str,
) -> RecordBatch {
let mut store = KanbanStore::new();
let id = store
.create_item(&CreateItemInput {
title: "Test Item".to_string(),
item_type,
priority: priority.map(|s| s.to_string()),
assignee: assignee.map(|s| s.to_string()),
tags: vec![],
related: vec![],
depends_on: vec![],
body: body.map(|s| s.to_string()),
})
.expect("create_item");
if status != "backlog" {
store
.update_status(&id, status, None, true, None)
.expect("update_status");
}
store.get_item(&id).expect("get_item")
}
#[test]
fn test_expedition_without_body_produces_error() {
let batch = make_item(
ItemType::Expedition,
Some("high"),
Some("M5"),
None,
"backlog",
);
let report = validate_item(&batch);
assert!(!report.is_conformant());
assert!(report.violations.iter().any(|v| v.field == "body"));
assert!(
report
.violations
.iter()
.any(|v| v.field == "body" && v.severity == Severity::Error)
);
}
#[test]
fn test_expedition_with_body_no_body_violation() {
let batch = make_item(
ItemType::Expedition,
Some("high"),
Some("M5"),
Some("## Phase 1\nDo the thing."),
"backlog",
);
let report = validate_item(&batch);
assert!(
!report.violations.iter().any(|v| v.field == "body"),
"should have no body violation when body is set"
);
}
#[test]
fn test_chore_without_body_produces_error() {
let batch = make_item(ItemType::Chore, Some("low"), None, None, "backlog");
let report = validate_item(&batch);
assert!(
report
.violations
.iter()
.any(|v| v.field == "body" && v.severity == Severity::Error)
);
}
#[test]
fn test_hazard_without_body_produces_warning_not_error() {
let batch = make_item(ItemType::Hazard, None, None, None, "backlog");
let report = validate_item(&batch);
let body_violations: Vec<_> = report
.violations
.iter()
.filter(|v| v.field == "body")
.collect();
assert!(
!body_violations.is_empty(),
"hazard without body should have a body violation"
);
assert!(
body_violations
.iter()
.all(|v| v.severity == Severity::Warning)
);
}
#[test]
fn test_signal_without_body_produces_warning_not_error() {
let batch = make_item(ItemType::Signal, None, None, None, "backlog");
let report = validate_item(&batch);
let body_violations: Vec<_> = report
.violations
.iter()
.filter(|v| v.field == "body")
.collect();
assert!(
body_violations
.iter()
.all(|v| v.severity == Severity::Warning)
);
}
#[test]
fn test_expedition_without_priority_produces_error() {
let batch = make_item(
ItemType::Expedition,
None,
Some("M5"),
Some("body"),
"backlog",
);
let report = validate_item(&batch);
assert!(
report
.violations
.iter()
.any(|v| v.field == "priority" && v.severity == Severity::Error)
);
}
#[test]
fn test_voyage_without_priority_produces_error() {
let batch = make_item(ItemType::Voyage, None, None, Some("body"), "backlog");
let report = validate_item(&batch);
assert!(
report
.violations
.iter()
.any(|v| v.field == "priority" && v.severity == Severity::Error)
);
}
#[test]
fn test_hypothesis_does_not_require_priority() {
let batch = make_item(ItemType::Hypothesis, None, None, Some("body"), "backlog");
let report = validate_item(&batch);
assert!(
!report.violations.iter().any(|v| v.field == "priority"),
"hypothesis should not require priority"
);
}
#[test]
fn test_expedition_without_assignee_produces_warning() {
let batch = make_item(
ItemType::Expedition,
Some("high"),
None,
Some("body"),
"backlog",
);
let report = validate_item(&batch);
assert!(
report
.violations
.iter()
.any(|v| v.field == "assignee" && v.severity == Severity::Warning)
);
}
#[test]
fn test_chore_without_assignee_no_violation() {
let batch = make_item(ItemType::Chore, Some("low"), None, Some("body"), "backlog");
let report = validate_item(&batch);
assert!(
!report.violations.iter().any(|v| v.field == "assignee"),
"chore should not require assignee"
);
}
#[test]
fn test_in_progress_with_unassigned_produces_warning() {
let batch = make_item(
ItemType::Expedition,
Some("high"),
Some("unassigned"),
Some("body"),
"in_progress",
);
let report = validate_item(&batch);
assert!(
report
.violations
.iter()
.any(|v| v.field == "assignee" && v.severity == Severity::Warning),
"in_progress item with 'unassigned' should produce assignee warning"
);
}
#[test]
fn test_fully_conformant_expedition() {
let batch = make_item(
ItemType::Expedition,
Some("high"),
Some("M5"),
Some("## Phase 1\nDo the thing."),
"backlog",
);
let report = validate_item(&batch);
assert!(
report.is_conformant(),
"fully-set expedition should be conformant (no errors)"
);
assert!(
!report
.violations
.iter()
.any(|v| v.severity == Severity::Error)
);
}
#[test]
fn test_is_conformant_false_when_errors_exist() {
let batch = make_item(ItemType::Expedition, None, None, None, "backlog");
let report = validate_item(&batch);
assert!(!report.is_conformant());
}
#[test]
fn test_suggest_fixes_returns_nk_update_commands() {
let batch = make_item(ItemType::Expedition, None, None, None, "backlog");
let report = validate_item(&batch);
let fixes = suggest_fixes(&report);
assert!(!fixes.is_empty());
assert!(
fixes.iter().all(|f| f.starts_with("nk update ")),
"all fix suggestions should start with 'nk update'"
);
}
#[test]
fn test_suggest_fixes_assignee_command() {
let batch = make_item(
ItemType::Expedition,
Some("high"),
None,
Some("body"),
"backlog",
);
let report = validate_item(&batch);
let fixes = suggest_fixes(&report);
assert!(
fixes.iter().any(|f| f.contains("--assign")),
"should suggest --assign fix"
);
}
#[test]
fn test_suggest_fixes_body_command() {
let batch = make_item(
ItemType::Expedition,
Some("high"),
Some("M5"),
None,
"backlog",
);
let report = validate_item(&batch);
let fixes = suggest_fixes(&report);
assert!(
fixes.iter().any(|f| f.contains("--body-file")),
"should suggest --body-file fix"
);
}
#[test]
fn test_suggest_fixes_priority_command() {
let batch = make_item(
ItemType::Expedition,
None,
Some("M5"),
Some("body"),
"backlog",
);
let report = validate_item(&batch);
let fixes = suggest_fixes(&report);
assert!(
fixes.iter().any(|f| f.contains("--priority")),
"should suggest --priority fix"
);
}
#[test]
fn test_validate_all_returns_one_report_per_item() {
let mut store = KanbanStore::new();
store
.create_item(&CreateItemInput {
title: "A".to_string(),
item_type: ItemType::Expedition,
priority: None,
assignee: None,
tags: vec![],
related: vec![],
depends_on: vec![],
body: None,
})
.unwrap();
store
.create_item(&CreateItemInput {
title: "B".to_string(),
item_type: ItemType::Chore,
priority: Some("low".to_string()),
assignee: None,
tags: vec![],
related: vec![],
depends_on: vec![],
body: Some("body".to_string()),
})
.unwrap();
let batches = store.query_items(None, None, None, None);
let reports = validate_all(&batches);
assert_eq!(reports.len(), 2);
}
#[test]
fn test_validate_all_conformant_item_passes() {
let mut store = KanbanStore::new();
store
.create_item(&CreateItemInput {
title: "Good".to_string(),
item_type: ItemType::Expedition,
priority: Some("high".to_string()),
assignee: Some("M5".to_string()),
tags: vec![],
related: vec![],
depends_on: vec![],
body: Some("## Phase 1\nDo the thing.".to_string()),
})
.unwrap();
let batches = store.query_items(None, None, None, None);
let reports = validate_all(&batches);
assert_eq!(reports.len(), 1);
assert!(reports[0].is_conformant());
}
#[test]
fn test_format_report_conformant_shows_ok() {
let batch = make_item(
ItemType::Expedition,
Some("high"),
Some("M5"),
Some("body"),
"backlog",
);
let report = validate_item(&batch);
let out = format_report(&report, false);
assert!(out.contains("OK"), "conformant item should show OK");
}
#[test]
fn test_format_report_violations_shows_error_label() {
let batch = make_item(ItemType::Expedition, None, None, None, "backlog");
let report = validate_item(&batch);
let out = format_report(&report, false);
assert!(out.contains("ERROR"), "report should show ERROR label");
assert!(
out.contains("VIOLATIONS"),
"report header should say VIOLATIONS"
);
}
#[test]
fn test_format_report_with_fixes_shows_suggestions() {
let batch = make_item(ItemType::Expedition, None, None, None, "backlog");
let report = validate_item(&batch);
let out = format_report(&report, true);
assert!(out.contains("Suggested fixes:"));
assert!(out.contains("nk update "));
}
#[test]
fn test_format_board_summary_all_ok() {
let mut store = KanbanStore::new();
store
.create_item(&CreateItemInput {
title: "Good".to_string(),
item_type: ItemType::Expedition,
priority: Some("high".to_string()),
assignee: Some("M5".to_string()),
tags: vec![],
related: vec![],
depends_on: vec![],
body: Some("body".to_string()),
})
.unwrap();
let batches = store.query_items(None, None, None, None);
let reports = validate_all(&batches);
let summary = format_board_summary(&reports);
assert!(summary.contains("1 conformant"));
assert!(summary.contains("0 with violations"));
assert!(summary.contains("All items conform"));
}
#[test]
fn test_format_board_summary_with_violations() {
let mut store = KanbanStore::new();
store
.create_item(&CreateItemInput {
title: "Bad".to_string(),
item_type: ItemType::Expedition,
priority: None,
assignee: None,
tags: vec![],
related: vec![],
depends_on: vec![],
body: None,
})
.unwrap();
store
.create_item(&CreateItemInput {
title: "Good".to_string(),
item_type: ItemType::Chore,
priority: Some("low".to_string()),
assignee: None,
tags: vec![],
related: vec![],
depends_on: vec![],
body: Some("body".to_string()),
})
.unwrap();
let batches = store.query_items(None, None, None, None);
let reports = validate_all(&batches);
let summary = format_board_summary(&reports);
assert!(summary.contains("1 conformant"));
assert!(summary.contains("1 with violations"));
}
}