use serde::{Deserialize, Deserializer, Serialize};
use super::common::FlagUpdate;
fn deserialize_null_string<'de, D: Deserializer<'de>>(d: D) -> Result<String, D::Error> {
Option::<String>::deserialize(d).map(Option::unwrap_or_default)
}
#[derive(Debug, Serialize, Deserialize)]
#[non_exhaustive]
pub struct Bug {
pub id: u64,
#[serde(default)]
pub summary: String,
#[serde(default)]
pub status: String,
#[serde(default)]
pub resolution: Option<String>,
#[serde(default)]
pub product: Option<String>,
#[serde(default)]
pub component: Option<String>,
#[serde(default)]
pub version: Option<String>,
#[serde(default)]
pub assigned_to: Option<String>,
#[serde(default)]
pub priority: Option<String>,
#[serde(default)]
pub severity: Option<String>,
#[serde(default)]
pub creation_time: Option<String>,
#[serde(default)]
pub last_change_time: Option<String>,
#[serde(default)]
pub creator: Option<String>,
#[serde(default)]
pub url: Option<String>,
#[serde(default)]
pub whiteboard: Option<String>,
#[serde(default)]
pub keywords: Vec<String>,
#[serde(default)]
pub blocks: Vec<u64>,
#[serde(default)]
pub depends_on: Vec<u64>,
#[serde(default)]
pub cc: Vec<String>,
#[serde(default)]
pub op_sys: Option<String>,
#[serde(default)]
pub rep_platform: Option<String>,
}
#[derive(Clone, Debug, Default)]
#[non_exhaustive]
pub struct SearchParams {
pub product: Vec<String>,
pub component: Vec<String>,
pub status: Vec<String>,
pub assigned_to: Vec<String>,
pub creator: Vec<String>,
pub priority: Vec<String>,
pub severity: Vec<String>,
pub cc: Option<String>,
pub alias: Option<String>,
pub id: Vec<u64>,
pub limit: Option<u32>,
pub summary: Option<String>,
pub quicksearch: Option<String>,
pub include_fields: Option<String>,
pub exclude_fields: Option<String>,
}
impl SearchParams {
pub fn has_filters(&self) -> bool {
!self.product.is_empty()
|| !self.component.is_empty()
|| !self.status.is_empty()
|| !self.assigned_to.is_empty()
|| !self.creator.is_empty()
|| !self.priority.is_empty()
|| !self.severity.is_empty()
|| self.cc.is_some()
|| self.alias.is_some()
|| !self.id.is_empty()
|| self.summary.is_some()
|| self.quicksearch.is_some()
}
}
pub fn partition_filters(values: &[String]) -> (Vec<&str>, Vec<&str>) {
let mut positive = Vec::new();
let mut negated = Vec::new();
for v in values {
if let Some(stripped) = v.strip_prefix('!') {
negated.push(stripped);
} else {
positive.push(v.as_str());
}
}
(positive, negated)
}
pub const BOOLEAN_CHART_FIELD_NAMES: &[(&str, &str)] = &[
("product", "product"),
("component", "component"),
("status", "bug_status"),
("assigned_to", "assigned_to"),
("creator", "reporter"),
("priority", "priority"),
("severity", "bug_severity"),
];
#[derive(Debug, Serialize)]
#[non_exhaustive]
pub struct CreateBugParams {
pub product: String,
pub component: String,
pub summary: String,
pub version: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub priority: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub severity: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub assigned_to: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub op_sys: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rep_platform: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub blocks: Vec<u64>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub depends_on: Vec<u64>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub cc: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub keywords: Vec<String>,
}
#[derive(Debug, Default, Serialize)]
#[non_exhaustive]
pub struct IdListUpdate {
#[serde(skip_serializing_if = "Vec::is_empty")]
pub add: Vec<u64>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub remove: Vec<u64>,
}
impl IdListUpdate {
pub fn is_empty(&self) -> bool {
self.add.is_empty() && self.remove.is_empty()
}
}
#[derive(Debug, Default, Serialize)]
#[non_exhaustive]
pub struct UpdateBugParams {
#[serde(skip_serializing_if = "Option::is_none")]
pub status: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub resolution: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub assigned_to: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub priority: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub severity: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub summary: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub whiteboard: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub flags: Vec<FlagUpdate>,
#[serde(skip_serializing_if = "IdListUpdate::is_empty")]
pub blocks: IdListUpdate,
#[serde(skip_serializing_if = "IdListUpdate::is_empty")]
pub depends_on: IdListUpdate,
}
#[derive(Debug, Serialize, Deserialize)]
#[non_exhaustive]
pub struct HistoryEntry {
pub who: String,
pub when: String,
pub changes: Vec<FieldChange>,
}
#[derive(Debug, Serialize, Deserialize)]
#[non_exhaustive]
pub struct FieldChange {
pub field_name: String,
#[serde(default)]
pub removed: String,
#[serde(default)]
pub added: String,
#[serde(default)]
pub attachment_id: Option<u64>,
}
#[derive(Debug, Serialize, Deserialize)]
#[non_exhaustive]
pub struct FieldValue {
#[serde(default, deserialize_with = "deserialize_null_string")]
pub name: String,
#[serde(default)]
pub sort_key: u64,
#[serde(default)]
pub is_active: bool,
#[serde(default)]
pub can_change_to: Option<Vec<StatusTransition>>,
}
#[derive(Debug, Serialize, Deserialize)]
#[non_exhaustive]
pub struct StatusTransition {
pub name: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum QueryKind {
#[default]
List,
Search,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SavedQuery {
#[serde(default)]
pub kind: QueryKind,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub product: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub component: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub status: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub assignee: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub creator: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub priority: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub severity: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub quicksearch: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub limit: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fields: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub exclude_fields: Option<String>,
}
impl SavedQuery {
pub fn to_search_params(&self) -> SearchParams {
SearchParams {
product: self.product.clone(),
component: self.component.clone(),
status: self.status.clone(),
assigned_to: self.assignee.clone(),
creator: self.creator.clone(),
priority: self.priority.clone(),
severity: self.severity.clone(),
quicksearch: self.quicksearch.clone(),
limit: self.limit,
include_fields: self.fields.clone(),
exclude_fields: self.exclude_fields.clone(),
..Default::default()
}
}
pub fn has_filters(&self) -> bool {
!self.product.is_empty()
|| !self.component.is_empty()
|| !self.status.is_empty()
|| !self.assignee.is_empty()
|| !self.creator.is_empty()
|| !self.priority.is_empty()
|| !self.severity.is_empty()
|| self.quicksearch.is_some()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct BugTemplate {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub product: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub component: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub priority: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub severity: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub assignee: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub op_sys: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rep_platform: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
#[cfg(test)]
#[expect(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn bug_deserializes_minimal() {
let json = r#"{"id": 42}"#;
let bug: Bug = serde_json::from_str(json).unwrap();
assert_eq!(bug.id, 42);
assert!(bug.summary.is_empty());
assert!(bug.keywords.is_empty());
}
#[test]
fn bug_deserializes_full() {
let json = r#"{"id": 1, "summary": "test bug", "status": "NEW", "product": "Core", "component": "General", "priority": "P1", "keywords": ["regression"]}"#;
let bug: Bug = serde_json::from_str(json).unwrap();
assert_eq!(bug.summary, "test bug");
assert_eq!(bug.status, "NEW");
assert_eq!(bug.product.as_deref(), Some("Core"));
assert_eq!(bug.keywords, vec!["regression"]);
}
#[test]
fn partition_filters_positive_only() {
let vals: Vec<String> = vec!["NEW".into(), "ASSIGNED".into()];
let (pos, neg) = partition_filters(&vals);
assert_eq!(pos, vec!["NEW", "ASSIGNED"]);
assert!(neg.is_empty());
}
#[test]
fn partition_filters_negated_only() {
let vals: Vec<String> = vec!["!CLOSED".into(), "!VERIFIED".into()];
let (pos, neg) = partition_filters(&vals);
assert!(pos.is_empty());
assert_eq!(neg, vec!["CLOSED", "VERIFIED"]);
}
#[test]
fn partition_filters_mixed() {
let vals: Vec<String> = vec!["NEW".into(), "!CLOSED".into(), "OPEN".into()];
let (pos, neg) = partition_filters(&vals);
assert_eq!(pos, vec!["NEW", "OPEN"]);
assert_eq!(neg, vec!["CLOSED"]);
}
#[test]
fn partition_filters_empty() {
let vals: Vec<String> = vec![];
let (pos, neg) = partition_filters(&vals);
assert!(pos.is_empty());
assert!(neg.is_empty());
}
#[test]
fn field_value_null_name_becomes_empty() {
let json = r#"{"name": null, "sort_key": 0, "is_active": true}"#;
let fv: FieldValue = serde_json::from_str(json).unwrap();
assert!(fv.name.is_empty());
}
#[test]
fn field_value_with_name() {
let json = r#"{"name": "RESOLVED", "sort_key": 5, "is_active": true}"#;
let fv: FieldValue = serde_json::from_str(json).unwrap();
assert_eq!(fv.name, "RESOLVED");
assert_eq!(fv.sort_key, 5);
assert!(fv.is_active);
}
#[test]
fn saved_query_list_roundtrips_json() {
let query = SavedQuery {
kind: QueryKind::List,
product: vec!["Firefox".into()],
component: vec![],
status: vec!["NEW".into(), "ASSIGNED".into()],
assignee: vec![],
creator: vec![],
priority: vec!["P1".into()],
severity: vec![],
quicksearch: None,
limit: Some(25),
fields: None,
exclude_fields: None,
};
let json = serde_json::to_string(&query).unwrap();
let roundtripped: SavedQuery = serde_json::from_str(&json).unwrap();
assert_eq!(roundtripped.kind, QueryKind::List);
assert_eq!(roundtripped.product, vec!["Firefox"]);
assert_eq!(roundtripped.status, vec!["NEW", "ASSIGNED"]);
assert_eq!(roundtripped.limit, Some(25));
}
#[test]
fn saved_query_search_roundtrips_json() {
let query = SavedQuery {
kind: QueryKind::Search,
quicksearch: Some("crash in tab".into()),
limit: Some(10),
..SavedQuery::default()
};
let json = serde_json::to_string(&query).unwrap();
let roundtripped: SavedQuery = serde_json::from_str(&json).unwrap();
assert_eq!(roundtripped.kind, QueryKind::Search);
assert_eq!(roundtripped.quicksearch.as_deref(), Some("crash in tab"));
}
#[test]
fn saved_query_to_search_params_list() {
let query = SavedQuery {
kind: QueryKind::List,
product: vec!["Core".into()],
status: vec!["NEW".into()],
limit: Some(20),
fields: Some("id,summary".into()),
..SavedQuery::default()
};
let params = query.to_search_params();
assert_eq!(params.product, vec!["Core"]);
assert_eq!(params.status, vec!["NEW"]);
assert_eq!(params.limit, Some(20));
assert_eq!(params.include_fields.as_deref(), Some("id,summary"));
assert!(params.quicksearch.is_none());
}
#[test]
fn saved_query_to_search_params_search() {
let query = SavedQuery {
kind: QueryKind::Search,
quicksearch: Some("memory leak".into()),
limit: Some(30),
..SavedQuery::default()
};
let params = query.to_search_params();
assert_eq!(params.quicksearch.as_deref(), Some("memory leak"));
assert_eq!(params.limit, Some(30));
assert!(params.product.is_empty());
}
#[test]
fn saved_query_has_filters_true() {
let query = SavedQuery {
kind: QueryKind::List,
product: vec!["Firefox".into()],
..SavedQuery::default()
};
assert!(query.has_filters());
}
#[test]
fn saved_query_has_filters_false_empty() {
let query = SavedQuery::default();
assert!(!query.has_filters());
}
#[test]
fn saved_query_has_filters_search_only() {
let query = SavedQuery {
kind: QueryKind::Search,
quicksearch: Some("crash".into()),
..SavedQuery::default()
};
assert!(query.has_filters());
}
}