use super::{
acquire_routed_workspace_write_lock, auto_import_storage_ctx_if_stale,
cli_for_routed_workspace, resolve_issue_id,
};
use crate::cli::LintArgs;
use crate::config;
use crate::error::{BeadsError, Result};
use crate::format::sanitize_terminal_inline;
use crate::model::{Issue, IssueType, Status};
use crate::output::OutputContext;
use crate::storage::{ListFilters, SqliteStorage};
use crate::util::id::{IdResolver, ResolverConfig};
use rich_rust::prelude::*;
use serde::Serialize;
use std::collections::BTreeMap;
use std::path::Path;
#[derive(Debug, Serialize)]
struct LintResult {
id: String,
title: String,
#[serde(rename = "type")]
issue_type: String,
#[serde(skip_serializing_if = "Vec::is_empty")]
missing: Vec<String>,
warnings: usize,
#[serde(skip_serializing_if = "Vec::is_empty")]
suggestions: Vec<LintSuggestion>,
}
#[derive(Debug, Serialize)]
struct LintSuggestion {
section: String,
hint: String,
}
#[derive(Debug, Serialize)]
struct LintOutput {
total: usize,
issues: usize,
results: Vec<LintResult>,
}
#[derive(Debug)]
struct LintSummary {
checked: usize,
warnings: usize,
results: Vec<LintResult>,
}
impl LintSummary {
const fn exit_code(&self, structured: bool) -> i32 {
if structured || self.warnings == 0 {
0
} else {
1
}
}
}
#[derive(Debug, Clone, Copy)]
struct RequiredSection {
heading: &'static str,
hint: &'static str,
}
const BUG_SECTIONS: [RequiredSection; 2] = [
RequiredSection {
heading: "## Steps to Reproduce",
hint: "Describe how to reproduce the bug",
},
RequiredSection {
heading: "## Acceptance Criteria",
hint: "Define criteria to verify the fix",
},
];
const TASK_SECTIONS: [RequiredSection; 1] = [RequiredSection {
heading: "## Acceptance Criteria",
hint: "Define criteria to verify completion",
}];
const EPIC_SECTIONS: [RequiredSection; 1] = [RequiredSection {
heading: "## Success Criteria",
hint: "Define high-level success criteria",
}];
pub fn execute(
args: &LintArgs,
_json: bool,
cli: &config::CliOverrides,
ctx: &OutputContext,
) -> Result<()> {
let beads_dir = config::discover_beads_dir_with_cli(cli)?;
let issues = if args.ids.is_empty() {
let storage_ctx = config::open_storage_with_cli(&beads_dir, cli)?;
lint_issues_with_storage(args, &storage_ctx.storage)?
} else {
resolve_issues(&beads_dir, args, cli)?
};
render_lint_output(lint_issues(&issues), ctx);
Ok(())
}
pub fn execute_with_storage_ctx(
args: &LintArgs,
ctx: &OutputContext,
storage_ctx: &config::OpenStorageResult,
) -> Result<bool> {
if !args.ids.is_empty() {
return Ok(false);
}
let issues = lint_issues_with_storage(args, &storage_ctx.storage)?;
render_lint_output(lint_issues(&issues), ctx);
Ok(true)
}
fn lint_issues_with_storage(args: &LintArgs, storage: &SqliteStorage) -> Result<Vec<Issue>> {
let filters = build_filters(args)?;
storage.list_lint_issues_for_command_output(&filters)
}
fn render_lint_output(summary: LintSummary, ctx: &OutputContext) {
if ctx.is_toon() {
let output = LintOutput {
total: summary.warnings,
issues: summary.results.len(),
results: summary.results,
};
ctx.toon(&output);
return;
}
if ctx.is_json() {
let output = LintOutput {
total: summary.warnings,
issues: summary.results.len(),
results: summary.results,
};
ctx.json_pretty(&output);
return;
}
if ctx.is_quiet() {
if summary.results.is_empty() {
return;
}
crate::shutdown::exit_process(summary.exit_code(false));
}
if ctx.is_rich() {
render_lint_rich(&summary, ctx);
} else {
if summary.results.is_empty() {
println!(
"✓ No template warnings found ({} issues checked)",
summary.checked
);
return;
}
println!(
"Template warnings ({} issues, {} warnings):\n",
summary.results.len(),
summary.warnings
);
for result in &summary.results {
println!(
"{} [{}]: {}",
result.id,
result.issue_type,
sanitize_terminal_inline(&result.title)
);
for suggestion in &result.suggestions {
println!(" âš Missing: {} - {}", suggestion.section, suggestion.hint);
}
println!();
}
}
crate::shutdown::exit_process(summary.exit_code(false));
}
fn render_lint_rich(summary: &LintSummary, ctx: &OutputContext) {
let theme = ctx.theme();
let mut content = Text::new("");
content.append_styled("Template Lint\n", theme.emphasis.clone());
content.append("\n");
content.append_styled("Checked: ", theme.dimmed.clone());
content.append_styled(&summary.checked.to_string(), theme.emphasis.clone());
content.append_styled(" Warnings: ", theme.dimmed.clone());
if summary.warnings == 0 {
content.append_styled("0", theme.success.clone());
} else {
content.append_styled(&summary.warnings.to_string(), theme.warning.clone());
}
content.append("\n\n");
if summary.results.is_empty() {
content.append_styled(
&format!(
"✓ No template warnings found ({} issues checked)",
summary.checked
),
theme.success.clone(),
);
} else {
let mut by_type: BTreeMap<&str, Vec<&LintResult>> = BTreeMap::new();
for result in &summary.results {
by_type
.entry(result.issue_type.as_str())
.or_default()
.push(result);
}
for (issue_type, results) in by_type {
content.append_styled(
&format!(
"{} ({})\n",
sanitize_terminal_inline(issue_type),
results.len()
),
theme.section.clone(),
);
for result in results {
content.append_styled("- ", theme.warning.clone());
content.append_styled(&result.id, theme.issue_id.clone());
content.append(" ");
content.append_styled(
&format!("[{}] ", sanitize_terminal_inline(&result.issue_type)),
issue_type_style(theme, &result.issue_type),
);
content.append_styled(
sanitize_terminal_inline(&result.title).as_ref(),
theme.issue_title.clone(),
);
content.append("\n");
for suggestion in &result.suggestions {
content.append_styled(" missing: ", theme.dimmed.clone());
content.append_styled(&suggestion.section, theme.warning.clone());
content.append_styled(" - ", theme.dimmed.clone());
content.append_styled(&suggestion.hint, theme.dimmed.clone());
content.append("\n");
}
}
content.append("\n");
}
content.append_styled(
"Tip: Add the missing sections to issue descriptions to clear warnings.\n",
theme.dimmed.clone(),
);
}
let panel = Panel::from_rich_text(&content, ctx.width())
.title(Text::styled("Lint Results", theme.panel_title.clone()))
.box_style(theme.box_style)
.border_style(theme.panel_border.clone());
ctx.render(&panel);
}
fn issue_type_style(theme: &crate::output::Theme, issue_type: &str) -> Style {
match issue_type {
"task" => theme.type_task.clone(),
"bug" => theme.type_bug.clone(),
"feature" => theme.type_feature.clone(),
"epic" => theme.type_epic.clone(),
"chore" => theme.type_chore.clone(),
"docs" => theme.type_docs.clone(),
"question" => theme.type_question.clone(),
_ => theme.dimmed.clone(),
}
}
fn build_filters(args: &LintArgs) -> Result<ListFilters> {
let mut filters = ListFilters {
include_templates: false,
..ListFilters::default()
};
if let Some(ref type_str) = args.type_ {
let issue_type: IssueType = type_str.parse()?;
if !issue_type.is_standard() {
return Err(BeadsError::InvalidType {
issue_type: type_str.clone(),
});
}
filters.types = Some(vec![issue_type]);
}
let status_filter = args.status.as_deref().unwrap_or("open").trim();
if !status_filter.is_empty() && !status_filter.eq_ignore_ascii_case("all") {
let status: Status = status_filter.parse()?;
if status.is_terminal() {
filters.include_closed = true;
}
if status == Status::Deferred {
filters.include_deferred = true;
}
filters.statuses = Some(vec![status]);
} else if status_filter.eq_ignore_ascii_case("all") {
filters.include_closed = true;
filters.include_deferred = true;
}
Ok(filters)
}
fn resolve_issues(
beads_dir: &Path,
args: &LintArgs,
cli: &config::CliOverrides,
) -> Result<Vec<Issue>> {
let routed_batches = config::routing::group_issue_inputs_by_route(&args.ids, beads_dir)?;
let mut issues_by_input = std::collections::HashMap::new();
for batch in routed_batches {
let mut batch_cli = routed_cli_for_batch(cli, batch.is_external);
let routed_write_lock = acquire_routed_workspace_write_lock(
&batch.beads_dir,
batch.is_external,
batch_cli.lock_timeout,
)?;
routed_write_lock.mark_cli_write_lock_held(&mut batch_cli);
let mut storage_ctx = config::open_storage_with_cli(&batch.beads_dir, &batch_cli)?;
auto_import_storage_ctx_if_stale(&mut storage_ctx, &batch_cli)?;
let config_layer = storage_ctx.load_config(&batch_cli)?;
let id_config = config::id_config_from_layer(&config_layer);
let resolver = IdResolver::new(ResolverConfig::with_prefix(id_config.prefix));
let mut resolved_ids = Vec::with_capacity(batch.issue_inputs.len());
for id_input in &batch.issue_inputs {
resolved_ids.push(resolve_issue_id(&storage_ctx.storage, &resolver, id_input)?);
}
let issues = fetch_issues_in_resolved_order(&storage_ctx.storage, &resolved_ids)?;
for (input, issue) in batch.issue_inputs.into_iter().zip(issues) {
issues_by_input.insert(input, issue);
}
}
args.ids
.iter()
.map(|input| {
issues_by_input
.get(input)
.cloned()
.ok_or_else(|| BeadsError::IssueNotFound { id: input.clone() })
})
.collect()
}
fn fetch_issues_in_resolved_order(
storage: &SqliteStorage,
resolved_ids: &[String],
) -> Result<Vec<Issue>> {
let issues_by_id = storage
.get_issues_by_ids(resolved_ids)?
.into_iter()
.map(|issue| (issue.id.clone(), issue))
.collect::<std::collections::HashMap<_, _>>();
resolved_ids
.iter()
.map(|id| {
issues_by_id
.get(id)
.cloned()
.ok_or_else(|| BeadsError::IssueNotFound { id: id.clone() })
})
.collect()
}
fn routed_cli_for_batch(cli: &config::CliOverrides, is_external: bool) -> config::CliOverrides {
cli_for_routed_workspace(cli, is_external)
}
fn lint_issues(issues: &[Issue]) -> LintSummary {
let mut warnings = 0;
let mut results = Vec::new();
for issue in issues {
if let Some(result) = lint_issue(issue) {
warnings += result.warnings;
results.push(result);
}
}
LintSummary {
checked: issues.len(),
warnings,
results,
}
}
fn lint_issue(issue: &Issue) -> Option<LintResult> {
let required = required_sections(&issue.issue_type);
if required.is_empty() {
return None;
}
let description = issue.description.as_deref().unwrap_or("");
let missing = missing_sections(description, required);
if missing.is_empty() {
return None;
}
let missing_headings = missing
.iter()
.map(|section| section.heading.to_string())
.collect();
let suggestions = missing
.iter()
.map(|section| LintSuggestion {
section: section.heading.to_string(),
hint: section.hint.to_string(),
})
.collect();
Some(LintResult {
id: issue.id.clone(),
title: issue.title.clone(),
issue_type: issue.issue_type.as_str().to_string(),
warnings: missing.len(),
missing: missing_headings,
suggestions,
})
}
const fn required_sections(issue_type: &IssueType) -> &'static [RequiredSection] {
match issue_type {
IssueType::Bug => &BUG_SECTIONS,
IssueType::Task | IssueType::Feature => &TASK_SECTIONS,
IssueType::Epic => &EPIC_SECTIONS,
_ => &[],
}
}
fn missing_sections(description: &str, required: &[RequiredSection]) -> Vec<RequiredSection> {
let desc_lower = description.to_lowercase();
let mut missing = Vec::new();
for section in required {
let heading_text = strip_heading_prefix(section.heading);
let heading_lower = heading_text.to_lowercase();
if !desc_lower.contains(&heading_lower) {
missing.push(*section);
}
}
missing
}
fn strip_heading_prefix(heading: &str) -> &str {
let trimmed = heading.trim();
trimmed
.strip_prefix("## ")
.or_else(|| trimmed.strip_prefix("# "))
.unwrap_or(trimmed)
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{TimeZone, Utc};
use tempfile::TempDir;
fn make_issue(issue_type: IssueType, description: Option<&str>) -> Issue {
Issue {
id: "bd-123".to_string(),
content_hash: None,
title: "Sample".to_string(),
description: description.map(str::to_string),
design: None,
acceptance_criteria: None,
notes: None,
status: Status::Open,
priority: crate::model::Priority::MEDIUM,
issue_type,
assignee: None,
owner: None,
estimated_minutes: None,
created_at: Utc::now(),
created_by: None,
updated_at: Utc::now(),
closed_at: None,
close_reason: None,
closed_by_session: None,
due_at: None,
defer_until: None,
external_ref: None,
source_system: None,
source_repo: None,
source_repo_path: None,
agent_context: None,
deleted_at: None,
deleted_by: None,
delete_reason: None,
original_type: None,
compaction_level: None,
compacted_at: None,
compacted_at_commit: None,
original_size: None,
sender: None,
ephemeral: false,
pinned: false,
is_template: false,
labels: vec![],
dependencies: vec![],
comments: vec![],
}
}
#[test]
fn test_missing_sections_for_bug() {
let issue = make_issue(IssueType::Bug, Some("Bug report"));
let result = lint_issue(&issue).expect("lint result");
assert_eq!(result.warnings, 2);
assert!(
result
.missing
.contains(&"## Steps to Reproduce".to_string())
);
assert!(
result
.missing
.contains(&"## Acceptance Criteria".to_string())
);
assert!(result.suggestions.iter().any(|suggestion| {
suggestion.section == "## Steps to Reproduce"
&& suggestion.hint == "Describe how to reproduce the bug"
}));
assert!(result.suggestions.iter().any(|suggestion| {
suggestion.section == "## Acceptance Criteria"
&& suggestion.hint == "Define criteria to verify the fix"
}));
}
#[test]
fn test_required_sections_present_case_insensitive() {
let description = "## steps to reproduce\n- foo\n# acceptance criteria\n- bar";
let issue = make_issue(IssueType::Bug, Some(description));
assert!(lint_issue(&issue).is_none());
}
#[test]
fn lint_issues_with_storage_matches_full_hydration_results() {
let mut storage = SqliteStorage::open_memory().unwrap();
let now = Utc.with_ymd_and_hms(2026, 5, 3, 12, 0, 0).unwrap();
let mut missing = make_issue(IssueType::Task, Some("Needs a real section"));
missing.id = "bd-lint-missing".to_string();
missing.title = "Lint missing section".to_string();
missing.created_at = now;
missing.updated_at = now;
missing.design = Some("unused design".repeat(512));
missing.acceptance_criteria = Some("unused criteria".repeat(512));
missing.notes = Some("unused notes".repeat(512));
missing.owner = Some("owner".to_string());
missing.sender = Some("cli".to_string());
let mut complete = make_issue(
IssueType::Task,
Some("## Acceptance Criteria\n- Already present"),
);
complete.id = "bd-lint-complete".to_string();
complete.title = "Lint complete section".to_string();
complete.created_at = now;
complete.updated_at = now;
storage.create_issue(&missing, "tester").unwrap();
storage.create_issue(&complete, "tester").unwrap();
let args = LintArgs::default();
let filters = build_filters(&args).unwrap();
let full_summary = lint_issues(&storage.list_issues(&filters).unwrap());
let projected_raw = lint_issues_with_storage(&args, &storage).unwrap();
let projected_issue = projected_raw
.iter()
.find(|issue| issue.id == "bd-lint-missing")
.unwrap();
assert!(projected_issue.design.is_none());
assert!(projected_issue.acceptance_criteria.is_none());
assert!(projected_issue.notes.is_none());
assert!(projected_issue.owner.is_none());
assert!(projected_issue.sender.is_none());
let projected_summary = lint_issues(&projected_raw);
assert_eq!(projected_summary.checked, full_summary.checked);
assert_eq!(projected_summary.warnings, full_summary.warnings);
assert_eq!(
serde_json::to_value(projected_summary.results).unwrap(),
serde_json::to_value(full_summary.results).unwrap()
);
}
#[test]
fn fetch_issues_in_resolved_order_preserves_duplicate_ids() {
let temp = TempDir::new().expect("tempdir");
let db_path = temp.path().join("beads.db");
let mut storage = SqliteStorage::open(&db_path).expect("storage");
storage
.create_issue(&make_issue(IssueType::Bug, Some("Bug report")), "tester")
.expect("create issue");
let duplicate_ids = vec!["bd-123".to_string(), "bd-123".to_string()];
let issues =
fetch_issues_in_resolved_order(&storage, &duplicate_ids).expect("duplicate lookup");
assert_eq!(issues.len(), 2);
assert_eq!(issues[0].id, "bd-123");
assert_eq!(issues[1].id, "bd-123");
}
#[test]
fn test_exit_code_behavior() {
let issue = make_issue(IssueType::Task, Some("No criteria"));
let summary = lint_issues(&[issue]);
assert_eq!(summary.exit_code(true), 0);
assert_eq!(summary.exit_code(false), 1);
}
}