diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index 274f0ed..3d4d3e4 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -1 +1,2 @@ pub mod crypto; +pub mod tuleap_client; diff --git a/src-tauri/src/services/tuleap_client.rs b/src-tauri/src/services/tuleap_client.rs new file mode 100644 index 0000000..b8d3f39 --- /dev/null +++ b/src-tauri/src/services/tuleap_client.rs @@ -0,0 +1,410 @@ +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(), + } + } + + 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"]); + } +}