use crate::error::{BeadsError, ValidationError};
use crate::model::{Comment, Dependency, Issue, Priority, Status};
use crate::util::id::MAX_ID_LENGTH;
use std::path::Path;
pub struct IssueValidator;
impl IssueValidator {
pub fn validate(issue: &Issue) -> Result<(), Vec<ValidationError>> {
let mut errors = Vec::new();
if issue.id.trim().is_empty() {
errors.push(ValidationError::new("id", "cannot be empty"));
}
if issue.id.len() > MAX_ID_LENGTH {
errors.push(ValidationError::new(
"id",
format!("exceeds {MAX_ID_LENGTH} characters"),
));
}
if !issue.id.is_empty() && !is_valid_id_format(&issue.id) {
errors.push(ValidationError::new(
"id",
"invalid format (expected prefix-hash)",
));
}
if issue.title.trim().is_empty() {
errors.push(ValidationError::new("title", "cannot be empty"));
}
if issue.title.len() > 500 {
errors.push(ValidationError::new("title", "exceeds 500 characters"));
}
if let Some(description) = issue.description.as_ref()
&& description.len() > 102_400
{
errors.push(ValidationError::new("description", "exceeds 100KB"));
}
if issue.priority.0 < Priority::CRITICAL.0 || issue.priority.0 > Priority::BACKLOG.0 {
errors.push(ValidationError::new("priority", "must be 0-4"));
}
if issue.updated_at < issue.created_at {
errors.push(ValidationError::new(
"updated_at",
"cannot be before created_at",
));
}
if let Some(minutes) = issue.estimated_minutes {
if minutes < 0 {
errors.push(ValidationError::new(
"estimated_minutes",
"cannot be negative",
));
} else if minutes > 525_960 {
errors.push(ValidationError::new(
"estimated_minutes",
"exceeds maximum (525960 minutes / ~1 year)",
));
}
}
if issue.status == Status::Closed && issue.closed_at.is_none() {
errors.push(ValidationError::new(
"closed_at",
"closed issues must set closed_at",
));
}
if !matches!(issue.status, Status::Closed | Status::Tombstone) && issue.closed_at.is_some()
{
errors.push(ValidationError::new(
"closed_at",
"only closed or tombstone issues may set closed_at",
));
}
if let Some(closed_at) = issue.closed_at
&& closed_at < issue.created_at
{
errors.push(ValidationError::new(
"closed_at",
"cannot be before created_at",
));
}
if let Some(external_ref) = issue.external_ref.as_ref() {
if external_ref.len() > 200 {
errors.push(ValidationError::new(
"external_ref",
"exceeds 200 characters",
));
}
if external_ref.chars().any(char::is_whitespace) {
errors.push(ValidationError::new(
"external_ref",
"cannot contain whitespace",
));
}
}
if errors.is_empty() {
Ok(())
} else {
Err(errors)
}
}
}
pub trait DependencyStore {
fn issue_exists(&self, id: &str) -> Result<bool, BeadsError>;
fn dependency_exists(&self, issue_id: &str, depends_on_id: &str) -> Result<bool, BeadsError>;
fn would_create_cycle(&self, issue_id: &str, depends_on_id: &str) -> Result<bool, BeadsError>;
}
pub struct DependencyValidator;
impl DependencyValidator {
pub fn validate(dep: &Dependency, store: &impl DependencyStore) -> Result<(), BeadsError> {
let mut errors = Vec::new();
if dep.issue_id == dep.depends_on_id {
errors.push(ValidationError::new(
"depends_on_id",
"issue cannot depend on itself",
));
}
if !store.issue_exists(&dep.issue_id)? {
errors.push(ValidationError::new("issue_id", "issue not found"));
}
if !store.issue_exists(&dep.depends_on_id)? {
errors.push(ValidationError::new(
"depends_on_id",
"dependency target not found",
));
}
if dep.dep_type.is_blocking()
&& store.would_create_cycle(&dep.issue_id, &dep.depends_on_id)?
{
errors.push(ValidationError::new(
"depends_on_id",
"would create dependency cycle",
));
}
if store.dependency_exists(&dep.issue_id, &dep.depends_on_id)? {
errors.push(ValidationError::new(
"depends_on_id",
"dependency already exists",
));
}
if errors.is_empty() {
Ok(())
} else {
Err(BeadsError::from_validation_errors(errors))
}
}
}
pub struct LabelValidator;
impl LabelValidator {
pub fn validate(label: &str) -> Result<(), ValidationError> {
if label.is_empty() {
return Err(ValidationError::new("label", "cannot be empty"));
}
if label.len() > 50 {
return Err(ValidationError::new("label", "exceeds 50 characters"));
}
if !label
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == ':')
{
return Err(ValidationError::new(
"label",
"invalid characters (only alphanumeric, hyphen, underscore, colon allowed)",
));
}
Ok(())
}
}
pub struct CommentValidator;
impl CommentValidator {
pub fn validate(comment: &Comment) -> Result<(), Vec<ValidationError>> {
let mut errors = Vec::new();
if comment.id <= 0 {
errors.push(ValidationError::new("id", "must be positive"));
}
if comment.issue_id.trim().is_empty() {
errors.push(ValidationError::new("issue_id", "cannot be empty"));
}
if comment.body.trim().is_empty() {
errors.push(ValidationError::new("content", "cannot be empty"));
}
if comment.body.len() > 51_200 {
errors.push(ValidationError::new("content", "exceeds 50KB"));
}
if comment.author.trim().is_empty() {
errors.push(ValidationError::new("author", "cannot be empty"));
}
if comment.author.len() > 200 {
errors.push(ValidationError::new("author", "exceeds 200 characters"));
}
if errors.is_empty() {
Ok(())
} else {
Err(errors)
}
}
}
#[must_use]
pub fn is_valid_id_format(id: &str) -> bool {
crate::util::id::is_valid_id_format(id)
}
pub struct SyncSafetyValidator;
impl SyncSafetyValidator {
pub fn validate_no_git_path(path: &Path) -> Result<(), ValidationError> {
for component in path.components() {
if let std::path::Component::Normal(name) = component
&& name == ".git"
{
return Err(ValidationError::new(
"path",
"sync operations cannot access .git directory (safety invariant)",
));
}
}
let path_str = path.to_string_lossy();
if path_str.contains("/.git/")
|| path_str.contains("\\.git\\")
|| path_str.ends_with("/.git")
|| path_str.ends_with("\\.git")
{
return Err(ValidationError::new(
"path",
"sync operations cannot access .git directory (safety invariant)",
));
}
Ok(())
}
pub fn validate_path_containment(
path: &Path,
beads_dir: &Path,
allow_external: bool,
) -> Result<(), ValidationError> {
Self::validate_no_git_path(path)?;
if allow_external {
return Ok(());
}
let canonical_path = dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
let canonical_beads =
dunce::canonicalize(beads_dir).unwrap_or_else(|_| beads_dir.to_path_buf());
if !canonical_path.starts_with(&canonical_beads) {
return Err(ValidationError::new(
"path",
format!(
"path '{}' is outside allowed directory '{}' \
(use --allow-external-jsonl to override)",
path.display(),
beads_dir.display()
),
));
}
Ok(())
}
#[inline]
pub const fn assert_no_git_in_sync() {
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{DependencyType, IssueType, Status};
use chrono::{TimeZone, Utc};
fn base_issue() -> Issue {
Issue {
id: "bd-abc123".to_string(),
content_hash: None,
title: "Test issue".to_string(),
description: None,
design: None,
acceptance_criteria: None,
notes: None,
status: Status::Open,
priority: Priority::MEDIUM,
issue_type: IssueType::Task,
assignee: None,
owner: None,
estimated_minutes: None,
created_at: Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap(),
created_by: None,
updated_at: Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap(),
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,
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::new(),
dependencies: Vec::new(),
comments: Vec::new(),
}
}
#[test]
fn issue_validation_rejects_empty_title() {
let mut issue = base_issue();
issue.title = " ".to_string();
let errors = IssueValidator::validate(&issue).unwrap_err();
assert!(errors.iter().any(|err| err.field == "title"));
}
#[test]
fn issue_validation_rejects_invalid_id() {
let mut issue = base_issue();
issue.id = "invalid".to_string();
let errors = IssueValidator::validate(&issue).unwrap_err();
assert!(errors.iter().any(|err| err.field == "id"));
}
#[test]
fn issue_validation_rejects_priority_out_of_range() {
let mut issue = base_issue();
issue.priority = Priority(9);
let errors = IssueValidator::validate(&issue).unwrap_err();
assert!(errors.iter().any(|err| err.field == "priority"));
}
#[test]
fn issue_validation_rejects_large_description() {
let mut issue = base_issue();
issue.description = Some("x".repeat(102_401));
let errors = IssueValidator::validate(&issue).unwrap_err();
assert!(errors.iter().any(|err| err.field == "description"));
}
#[test]
fn issue_validation_rejects_closed_without_closed_at() {
let mut issue = base_issue();
issue.status = Status::Closed;
let errors = IssueValidator::validate(&issue).unwrap_err();
assert!(errors.iter().any(|err| err.field == "closed_at"));
}
#[test]
fn issue_validation_rejects_non_terminal_closed_at() {
let mut issue = base_issue();
issue.closed_at = Some(issue.updated_at);
let errors = IssueValidator::validate(&issue).unwrap_err();
assert!(errors.iter().any(|err| err.field == "closed_at"));
}
#[test]
fn issue_validation_allows_tombstone_without_closed_at() {
let mut issue = base_issue();
issue.status = Status::Tombstone;
assert!(IssueValidator::validate(&issue).is_ok());
}
#[test]
fn label_validation_rejects_invalid_characters() {
let err = LabelValidator::validate("bad label").unwrap_err();
assert_eq!(err.field, "label");
}
#[test]
fn label_validation_rejects_empty() {
let err = LabelValidator::validate("").unwrap_err();
assert_eq!(err.field, "label");
}
#[test]
fn label_validation_allows_namespaced_labels() {
assert!(LabelValidator::validate("team:backend").is_ok());
}
#[test]
fn comment_validation_rejects_empty_body() {
let comment = Comment {
id: 1,
issue_id: "bd-abc123".to_string(),
author: "tester".to_string(),
body: " ".to_string(),
created_at: Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap(),
};
let errors = CommentValidator::validate(&comment).unwrap_err();
assert!(errors.iter().any(|err| err.field == "content"));
}
#[allow(clippy::struct_excessive_bools)]
struct FakeStore {
issue_exists: bool,
depends_on_exists: bool,
dependency_exists: bool,
would_cycle: bool,
}
impl DependencyStore for FakeStore {
fn issue_exists(&self, id: &str) -> Result<bool, BeadsError> {
Ok(match id {
"issue" => self.issue_exists,
_ => self.depends_on_exists,
})
}
fn dependency_exists(
&self,
_issue_id: &str,
_depends_on_id: &str,
) -> Result<bool, BeadsError> {
Ok(self.dependency_exists)
}
fn would_create_cycle(
&self,
_issue_id: &str,
_depends_on_id: &str,
) -> Result<bool, BeadsError> {
Ok(self.would_cycle)
}
}
fn base_dependency() -> Dependency {
Dependency {
issue_id: "issue".to_string(),
depends_on_id: "dep".to_string(),
dep_type: DependencyType::Blocks,
created_at: Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap(),
created_by: None,
metadata: None,
thread_id: None,
}
}
#[test]
fn dependency_validation_rejects_self_dependency() {
let mut dep = base_dependency();
dep.depends_on_id = "issue".to_string();
let store = FakeStore {
issue_exists: true,
depends_on_exists: true,
dependency_exists: false,
would_cycle: false,
};
let err = DependencyValidator::validate(&dep, &store).unwrap_err();
match err {
BeadsError::Validation { field, .. } => assert_eq!(field, "depends_on_id"),
_ => unreachable!("expected validation error"),
}
}
#[test]
fn dependency_validation_rejects_missing_issue() {
let dep = base_dependency();
let store = FakeStore {
issue_exists: false,
depends_on_exists: false,
dependency_exists: false,
would_cycle: false,
};
let err = DependencyValidator::validate(&dep, &store).unwrap_err();
match err {
BeadsError::ValidationErrors { errors } => {
assert!(errors.iter().any(|e| e.field == "issue_id"));
assert!(errors.iter().any(|e| e.field == "depends_on_id"));
}
_ => unreachable!("expected validation errors"),
}
}
#[test]
fn dependency_validation_rejects_cycle() {
let dep = base_dependency();
let store = FakeStore {
issue_exists: true,
depends_on_exists: true,
dependency_exists: false,
would_cycle: true,
};
let err = DependencyValidator::validate(&dep, &store).unwrap_err();
match err {
BeadsError::Validation { field, .. } => assert_eq!(field, "depends_on_id"),
_ => unreachable!("expected validation error"),
}
}
#[test]
fn dependency_validation_allows_non_blocking_cycle() {
let mut dep = base_dependency();
dep.dep_type = DependencyType::Related;
let store = FakeStore {
issue_exists: true,
depends_on_exists: true,
dependency_exists: false,
would_cycle: true,
};
assert!(DependencyValidator::validate(&dep, &store).is_ok());
}
#[test]
fn dependency_validation_rejects_duplicate() {
let dep = base_dependency();
let store = FakeStore {
issue_exists: true,
depends_on_exists: true,
dependency_exists: true,
would_cycle: false,
};
let err = DependencyValidator::validate(&dep, &store).unwrap_err();
match err {
BeadsError::Validation { field, .. } => assert_eq!(field, "depends_on_id"),
_ => unreachable!("expected validation error"),
}
}
#[test]
fn issue_validation_collects_multiple_errors() {
let mut issue = base_issue();
issue.id = String::new();
issue.title = String::new();
issue.priority = Priority(9);
issue.updated_at = Utc.with_ymd_and_hms(2025, 12, 31, 0, 0, 0).unwrap();
let errors = IssueValidator::validate(&issue).unwrap_err();
let fields: Vec<_> = errors.iter().map(|err| err.field.as_str()).collect();
assert!(fields.contains(&"id"));
assert!(fields.contains(&"title"));
assert!(fields.contains(&"priority"));
assert!(fields.contains(&"updated_at"));
}
#[test]
fn issue_validation_rejects_external_ref_whitespace() {
let mut issue = base_issue();
issue.external_ref = Some("gh 12".to_string());
let errors = IssueValidator::validate(&issue).unwrap_err();
assert!(errors.iter().any(|err| err.field == "external_ref"));
}
#[test]
fn id_format_validation_accepts_classic_ids() {
assert!(is_valid_id_format("bd-abc123"));
assert!(is_valid_id_format("beads9-0a9"));
}
#[test]
fn id_format_validation_rejects_invalid_ids() {
assert!(!is_valid_id_format("BD-abc123"));
assert!(!is_valid_id_format("bd-ABC"));
assert!(is_valid_id_format("bd-1"));
assert!(is_valid_id_format("bd-abc123456"));
assert!(!is_valid_id_format("bd_abc"));
assert!(!is_valid_id_format("bd-abc.def"));
assert!(!is_valid_id_format("bd-abc.1a"));
assert!(is_valid_id_format("bd-abc12345678901234567890123456"));
assert!(!is_valid_id_format(
"bd-abc123456789012345678901234567890123456789"
));
}
#[test]
fn id_format_validation_accepts_long_hash() {
assert!(is_valid_id_format("bd-abc123456789"));
}
#[test]
fn sync_safety_rejects_git_path_component() {
use std::path::PathBuf;
let git_path = PathBuf::from("/project/.git/config");
let result = SyncSafetyValidator::validate_no_git_path(&git_path);
assert!(result.is_err());
assert!(result.unwrap_err().message.contains(".git"));
let git_path2 = PathBuf::from("/project/.git/objects/pack");
assert!(SyncSafetyValidator::validate_no_git_path(&git_path2).is_err());
}
#[test]
fn sync_safety_allows_beads_path() {
use std::path::PathBuf;
let beads_path = PathBuf::from("/project/.beads/issues.jsonl");
let result = SyncSafetyValidator::validate_no_git_path(&beads_path);
assert!(result.is_ok());
}
#[test]
fn sync_safety_allows_gitignore_file() {
use std::path::PathBuf;
let gitignore_path = PathBuf::from("/project/.gitignore");
let result = SyncSafetyValidator::validate_no_git_path(&gitignore_path);
assert!(result.is_ok());
}
#[test]
fn sync_safety_rejects_git_in_string() {
use std::path::PathBuf;
let git_path = PathBuf::from("/project/.git");
assert!(SyncSafetyValidator::validate_no_git_path(&git_path).is_err());
let git_path2 = PathBuf::from("/repo/.git/hooks/pre-commit");
assert!(SyncSafetyValidator::validate_no_git_path(&git_path2).is_err());
}
#[test]
fn sync_safety_containment_rejects_escape() {
use tempfile::TempDir;
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
std::fs::create_dir_all(&beads_dir).unwrap();
let outside_path = temp.path().join("src/main.rs");
let result =
SyncSafetyValidator::validate_path_containment(&outside_path, &beads_dir, false);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.message
.contains("outside allowed directory")
);
}
#[test]
fn sync_safety_containment_allows_beads_subpath() {
use tempfile::TempDir;
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
std::fs::create_dir_all(&beads_dir).unwrap();
let jsonl_path = beads_dir.join("issues.jsonl");
std::fs::write(&jsonl_path, "").unwrap();
let result = SyncSafetyValidator::validate_path_containment(&jsonl_path, &beads_dir, false);
assert!(result.is_ok());
}
#[test]
fn sync_safety_containment_allows_external_with_flag() {
use tempfile::TempDir;
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
let outside_path = temp.path().join("external.jsonl");
let result =
SyncSafetyValidator::validate_path_containment(&outside_path, &beads_dir, true);
assert!(result.is_ok());
}
#[test]
fn sync_safety_containment_rejects_git_even_with_external_flag() {
use std::path::PathBuf;
let beads_dir = PathBuf::from("/project/.beads");
let git_path = PathBuf::from("/project/.git/config");
let result = SyncSafetyValidator::validate_path_containment(&git_path, &beads_dir, true);
assert!(result.is_err());
assert!(result.unwrap_err().message.contains(".git"));
}
#[test]
fn sync_safety_no_git_commands_in_sync_module() {
use std::process::Command;
let output = Command::new("grep")
.args(["-r", "Command::new.*git", "src/sync/"])
.output();
match output {
Ok(result) => {
let stdout = String::from_utf8_lossy(&result.stdout);
assert!(
result.status.code() == Some(1) || stdout.is_empty(),
"SAFETY VIOLATION: Found git commands in sync module:\n{stdout}"
);
}
Err(_) => {
eprintln!("Warning: grep not available, skipping static analysis test");
}
}
}
#[test]
fn sync_safety_no_git_library_dependencies() {
let cargo_toml = std::fs::read_to_string("Cargo.toml").unwrap_or_default();
let deps_section = cargo_toml
.lines()
.skip_while(|line| !line.starts_with("[dependencies]"))
.skip(1) .take_while(|line| !line.starts_with('[')) .collect::<Vec<_>>()
.join("\n");
let git_crates = ["git2 ", "gitoxide ", "gix ", "libgit2 "];
for crate_name in git_crates {
let crate_name = crate_name.trim();
assert!(
!deps_section.contains(crate_name),
"SAFETY VIOLATION: Found git library dependency '{crate_name}' in runtime [dependencies]"
);
}
}
}