use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TrackerField { pub field_id: i64, pub label: String, pub field_type: String, pub values: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct FieldValue { pub id: i64, pub label: String, } pub struct TuleapClient { http: reqwest::Client, base_url: String, username: String, password: String, } impl TuleapClient { pub fn new(http: &reqwest::Client, base_url: &str, username: &str, password: &str) -> Self { Self { http: http.clone(), base_url: base_url.trim_end_matches('/').to_string(), username: username.to_string(), password: password.to_string(), } } #[allow(dead_code)] pub async fn test_connection(&self) -> Result<(), String> { let url = format!("{}/api/projects?limit=1", self.base_url); let resp = self .http .get(&url) .basic_auth(&self.username, Some(&self.password)) .send() .await .map_err(|e| format!("request failed: {}", e))?; if resp.status().is_success() { Ok(()) } else { Err(format!("connection test failed: HTTP {}", resp.status())) } } pub async fn get_tracker_fields(&self, tracker_id: i32) -> Result, String> { let url = format!("{}/api/trackers/{}", self.base_url, tracker_id); let resp = self .http .get(&url) .basic_auth(&self.username, Some(&self.password)) .send() .await .map_err(|e| format!("request failed: {}", e))?; if !resp.status().is_success() { return Err(format!("get tracker fields failed: HTTP {}", resp.status())); } let body: serde_json::Value = resp .json() .await .map_err(|e| format!("failed to parse response: {}", e))?; Ok(parse_tracker_fields(&body)) } pub async fn get_artifacts(&self, tracker_id: i32) -> Result, String> { let mut all_artifacts: Vec = Vec::new(); let mut offset = 0usize; let limit = 50usize; loop { let url = format!( "{}/api/trackers/{}/artifacts?values=all&limit={}&offset={}", self.base_url, tracker_id, limit, offset ); let resp = self .http .get(&url) .basic_auth(&self.username, Some(&self.password)) .send() .await .map_err(|e| format!("request failed: {}", e))?; if !resp.status().is_success() { return Err(format!("get artifacts failed: HTTP {}", resp.status())); } // Read total size from header before consuming body let total: usize = resp .headers() .get("x-pagination-size") .and_then(|v| v.to_str().ok()) .and_then(|s| s.parse().ok()) .unwrap_or(0); let body: serde_json::Value = resp .json() .await .map_err(|e| format!("failed to parse response: {}", e))?; let artifacts = body .as_array() .cloned() .unwrap_or_default(); let fetched = artifacts.len(); all_artifacts.extend(artifacts); offset += fetched; if fetched == 0 || offset >= total { break; } } Ok(all_artifacts) } } // --------------------------------------------------------------------------- // Pure parsing functions (unit-testable without HTTP) // --------------------------------------------------------------------------- pub fn parse_tracker_fields(tracker_json: &serde_json::Value) -> Vec { let fields = match tracker_json.get("fields").and_then(|f| f.as_array()) { Some(f) => f, None => return vec![], }; fields .iter() .filter_map(|field| { let field_id = field.get("field_id")?.as_i64()?; let label = field.get("label")?.as_str()?.to_string(); let field_type = field.get("type")?.as_str()?.to_string(); let values = match field_type.as_str() { "sb" | "msb" | "rb" | "cb" => extract_field_values(field), _ => vec![], }; Some(TrackerField { field_id, label, field_type, values, }) }) .collect() } fn extract_field_values(field: &serde_json::Value) -> Vec { // Try "values" first (sb, rb), then "bind_value_objects" (msb) let candidates = field .get("values") .and_then(|v| v.as_array()) .filter(|arr| !arr.is_empty()) .or_else(|| { field .get("bind_value_objects") .and_then(|v| v.as_array()) }); let arr = match candidates { Some(a) => a, None => return vec![], }; arr.iter() .filter_map(|v| { let id = v.get("id")?.as_i64()?; let label = v .get("label") .and_then(|l| l.as_str()) .unwrap_or("") .to_string(); // Filter out "None" labels if label == "None" { return None; } Some(FieldValue { id, label }) }) .collect() } pub fn extract_artifact_field_values( artifact: &serde_json::Value, field_label: &str, ) -> Vec { let values_arr = match artifact.get("values").and_then(|v| v.as_array()) { Some(a) => a, None => return vec![], }; // Find the field entry matching the label let field_entry = values_arr .iter() .find(|entry| entry.get("label").and_then(|l| l.as_str()) == Some(field_label)); let entry = match field_entry { Some(e) => e, None => return vec![], }; let field_type = entry .get("type") .and_then(|t| t.as_str()) .unwrap_or(""); match field_type { "sb" | "rb" => { // values[*].label entry .get("values") .and_then(|v| v.as_array()) .map(|arr| { arr.iter() .filter_map(|v| v.get("label").and_then(|l| l.as_str()).map(str::to_string)) .collect() }) .unwrap_or_default() } "msb" | "cb" => { // bind_value_objects[*].display_name, fallback to label entry .get("bind_value_objects") .and_then(|v| v.as_array()) .map(|arr| { arr.iter() .filter_map(|v| { v.get("display_name") .and_then(|d| d.as_str()) .or_else(|| v.get("label").and_then(|l| l.as_str())) .map(str::to_string) }) .collect() }) .unwrap_or_default() } "string" | "text" | "int" | "float" => { // scalar "value" field entry .get("value") .map(|v| match v { serde_json::Value::String(s) => vec![s.clone()], serde_json::Value::Number(n) => vec![n.to_string()], serde_json::Value::Bool(b) => vec![b.to_string()], serde_json::Value::Null => vec![], other => vec![other.to_string()], }) .unwrap_or_default() } _ => vec![], } } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; use serde_json::json; #[test] fn test_parse_tracker_fields_extracts_sb() { let tracker = json!({ "fields": [ { "field_id": 1, "label": "Status", "type": "sb", "values": [ { "id": 0, "label": "None" }, { "id": 1, "label": "Nouveau" }, { "id": 2, "label": "En cours" } ] } ] }); let fields = parse_tracker_fields(&tracker); assert_eq!(fields.len(), 1); let f = &fields[0]; assert_eq!(f.label, "Status"); assert_eq!(f.field_type, "sb"); // "None" is filtered out assert_eq!(f.values.len(), 2); assert_eq!(f.values[0].label, "Nouveau"); assert_eq!(f.values[1].label, "En cours"); } #[test] fn test_parse_tracker_fields_extracts_msb() { let tracker = json!({ "fields": [ { "field_id": 2, "label": "Assigned to", "type": "msb", "bind_value_objects": [ { "id": 10, "label": "Alice" }, { "id": 11, "label": "Bob" } ] } ] }); let fields = parse_tracker_fields(&tracker); assert_eq!(fields.len(), 1); let f = &fields[0]; assert_eq!(f.label, "Assigned to"); assert_eq!(f.values.len(), 2); assert_eq!(f.values[0].label, "Alice"); assert_eq!(f.values[1].label, "Bob"); } #[test] fn test_parse_tracker_fields_skips_text_fields() { let tracker = json!({ "fields": [ { "field_id": 3, "label": "Summary", "type": "text" } ] }); let fields = parse_tracker_fields(&tracker); assert_eq!(fields.len(), 1); let f = &fields[0]; assert_eq!(f.label, "Summary"); assert_eq!(f.field_type, "text"); assert!(f.values.is_empty()); } #[test] fn test_extract_artifact_field_values_sb() { let artifact = json!({ "values": [ { "field_id": 1, "label": "Status", "type": "sb", "values": [ { "id": 1, "label": "Nouveau" } ] } ] }); let result = extract_artifact_field_values(&artifact, "Status"); assert_eq!(result, vec!["Nouveau"]); } #[test] fn test_extract_artifact_field_values_msb() { let artifact = json!({ "values": [ { "field_id": 2, "label": "Assigned to", "type": "msb", "bind_value_objects": [ { "id": 10, "display_name": "Team Maintenance" } ] } ] }); let result = extract_artifact_field_values(&artifact, "Assigned to"); assert_eq!(result, vec!["Team Maintenance"]); } #[test] fn test_extract_artifact_field_values_missing_field() { let artifact = json!({ "values": [] }); let result = extract_artifact_field_values(&artifact, "Status"); assert!(result.is_empty()); } #[test] fn test_extract_artifact_field_values_string_field() { let artifact = json!({ "values": [ { "field_id": 5, "label": "Summary", "type": "string", "value": "Login broken" } ] }); let result = extract_artifact_field_values(&artifact, "Summary"); assert_eq!(result, vec!["Login broken"]); } }