use crate::output::CliError;
use bones_core::model::item::{Kind, Size, State};
pub const MAX_TITLE_LEN: usize = 200;
pub const MAX_LABEL_LEN: usize = 50;
pub const MAX_AGENT_LEN: usize = 64;
#[derive(Debug, Clone)]
pub struct ValidationError {
pub field: &'static str,
pub value: String,
pub reason: String,
pub suggestion: String,
pub code: &'static str,
}
impl ValidationError {
pub fn new(
field: &'static str,
value: impl Into<String>,
reason: impl Into<String>,
suggestion: impl Into<String>,
code: &'static str,
) -> Self {
Self {
field,
value: value.into(),
reason: reason.into(),
suggestion: suggestion.into(),
code,
}
}
pub fn to_cli_error(&self) -> CliError {
CliError::with_details(
format!("invalid {} '{}': {}", self.field, self.value, self.reason),
self.suggestion.clone(),
self.code,
)
}
}
pub fn validate_title(s: &str) -> Result<(), ValidationError> {
if s.trim() != s {
return Err(ValidationError::new(
"title",
s,
"must not start or end with whitespace",
"trim leading/trailing whitespace from --title",
"invalid_title",
));
}
if s.is_empty() {
return Err(ValidationError::new(
"title",
s,
"must not be empty",
"provide a non-empty --title",
"invalid_title",
));
}
if s.chars().count() > MAX_TITLE_LEN {
return Err(ValidationError::new(
"title",
s,
format!("must be <= {MAX_TITLE_LEN} characters"),
"shorten the title",
"invalid_title",
));
}
if s.chars().any(char::is_control) {
return Err(ValidationError::new(
"title",
s,
"must not contain control characters",
"remove control characters from the title",
"invalid_title",
));
}
Ok(())
}
pub fn validate_item_id(s: &str) -> Result<(), ValidationError> {
let value = s.trim();
if value.is_empty() {
return Err(ValidationError::new(
"item_id",
s,
"must not be empty",
"use an ID like bn-abc123 or a partial like abc123",
"invalid_item_id",
));
}
if let Some((prefix, rest)) = value.split_once('-') {
let valid_prefix = !prefix.is_empty() && prefix.chars().all(|c| c.is_ascii_lowercase());
if valid_prefix && is_valid_item_id_segments(rest) {
return Ok(());
}
} else if value.chars().all(|c| c.is_ascii_alphanumeric()) {
return Ok(());
}
Err(ValidationError::new(
"item_id",
s,
"must match bn-[a-z0-9]+(.[0-9]+)* or be an alphanumeric partial ID",
"use IDs like bn-abc123, bn-abc123.1, or partial abc123",
"invalid_item_id",
))
}
fn is_valid_item_id_segments(rest: &str) -> bool {
let mut parts = rest.split('.');
let Some(head) = parts.next() else {
return false;
};
if head.is_empty()
|| !head
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
{
return false;
}
for seg in parts {
if seg.is_empty() || !seg.chars().all(|c| c.is_ascii_digit()) {
return false;
}
}
true
}
pub fn validate_label(s: &str) -> Result<(), ValidationError> {
if s.is_empty() {
return Err(ValidationError::new(
"label",
s,
"must not be empty",
"provide a non-empty label",
"invalid_label",
));
}
if s.chars().count() > MAX_LABEL_LEN {
return Err(ValidationError::new(
"label",
s,
format!("must be <= {MAX_LABEL_LEN} characters"),
"shorten the label",
"invalid_label",
));
}
let mut chars = s.chars();
let first = chars.next().unwrap();
if !first.is_ascii_alphanumeric() {
return Err(ValidationError::new(
"label",
s,
"must start with an ASCII letter or number",
"start the label with [a-zA-Z0-9]",
"invalid_label",
));
}
if !chars.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') {
return Err(ValidationError::new(
"label",
s,
"may only contain ASCII letters, numbers, '-' or '_'",
"remove spaces or punctuation from the label",
"invalid_label",
));
}
Ok(())
}
pub fn validate_agent(s: &str) -> Result<(), ValidationError> {
if s.is_empty() {
return Err(ValidationError::new(
"agent",
s,
"must not be empty",
"set --agent or BONES_AGENT/AGENT",
"invalid_agent",
));
}
if s.chars().count() > MAX_AGENT_LEN {
return Err(ValidationError::new(
"agent",
s,
format!("must be <= {MAX_AGENT_LEN} characters"),
"use a shorter agent identifier",
"invalid_agent",
));
}
if s.chars().any(char::is_whitespace) {
return Err(ValidationError::new(
"agent",
s,
"must not contain whitespace",
"remove spaces and tabs from the agent identifier",
"invalid_agent",
));
}
Ok(())
}
pub fn validate_size(s: &str) -> Result<Size, ValidationError> {
s.parse().map_err(|_| {
ValidationError::new(
"size",
s,
"expected one of xs, s, m, l, xl",
"use --size s, --size m, etc.",
"invalid_size",
)
})
}
pub fn validate_state(s: &str) -> Result<State, ValidationError> {
s.parse().map_err(|_| {
ValidationError::new(
"state",
s,
"expected one of open, doing, done, archived",
"use --state open|doing|done|archived",
"invalid_state",
)
})
}
pub fn validate_kind(s: &str) -> Result<Kind, ValidationError> {
s.parse().map_err(|_| {
ValidationError::new(
"kind",
s,
"expected one of task, goal, bug",
"use --kind task|goal|bug",
"invalid_kind",
)
})
}
pub fn detect_secret_kind(s: &str) -> Option<&'static str> {
if s.contains("-----BEGIN ") && s.contains(" PRIVATE KEY-----") {
return Some("private_key_block");
}
if has_prefixed_run(s, "ghp_", 30) || has_prefixed_run(s, "github_pat_", 20) {
return Some("github_token");
}
if has_aws_access_key_pattern(s) {
return Some("aws_access_key_id");
}
None
}
pub fn validate_no_secrets(field: &'static str, s: &str) -> Result<(), ValidationError> {
if let Some(kind) = detect_secret_kind(s) {
return Err(ValidationError::new(
field,
"<redacted>",
format!("contains a high-confidence secret pattern ({kind})"),
"remove credentials from text or pass --allow-secret to override intentionally",
"secret_detected",
));
}
Ok(())
}
fn has_prefixed_run(s: &str, prefix: &str, min_len: usize) -> bool {
let mut start = 0;
while let Some(idx) = s[start..].find(prefix) {
let abs = start + idx + prefix.len();
let run = s[abs..]
.chars()
.take_while(|c| c.is_ascii_alphanumeric() || *c == '_')
.count();
if run >= min_len {
return true;
}
start = abs;
}
false
}
fn has_aws_access_key_pattern(s: &str) -> bool {
let bytes = s.as_bytes();
for i in 0..bytes.len().saturating_sub(19) {
if &bytes[i..i + 4] != b"AKIA" {
continue;
}
let tail = &bytes[i + 4..i + 20];
if tail
.iter()
.all(|b| b.is_ascii_uppercase() || b.is_ascii_digit())
{
return true;
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn valid_ids() {
assert!(validate_item_id("bn-abc123").is_ok());
assert!(validate_item_id("bn-abc123.1").is_ok());
assert!(validate_item_id("abc123").is_ok());
assert!(validate_item_id("bd-abc123").is_ok());
assert!(validate_item_id("bd-abc123.1").is_ok());
}
#[test]
fn invalid_ids() {
assert!(validate_item_id("bn-ABC").is_err());
assert!(validate_item_id("bn-abc.").is_err());
assert!(validate_item_id("bn-abc.x").is_err());
assert!(validate_item_id("123-abc").is_err());
}
#[test]
fn label_rules() {
assert!(validate_label("backend_api").is_ok());
assert!(validate_label("-bad").is_err());
assert!(validate_label("bad label").is_err());
}
#[test]
fn detects_private_key_like_material() {
let s = "-----BEGIN OPENSSH PRIVATE KEY-----\nAAA";
assert_eq!(detect_secret_kind(s), Some("private_key_block"));
assert!(validate_no_secrets("title", s).is_err());
}
#[test]
fn detects_github_and_aws_tokens() {
let gh = "token=ghp_abcdefghijklmnopqrstuvwxyz012345";
let aws = "AKIA1234567890ABCD12";
assert_eq!(detect_secret_kind(gh), Some("github_token"));
assert_eq!(detect_secret_kind(aws), Some("aws_access_key_id"));
}
#[test]
fn ignores_regular_text() {
let s = "normal project update text with no credentials";
assert_eq!(detect_secret_kind(s), None);
assert!(validate_no_secrets("description", s).is_ok());
}
}