//! Public ticket adapters (Tauri commands + MCP provider). //! //! Public wire names use `ticket`; the application/domain model remains `Issue`. #![allow(missing_docs)] use std::str::FromStr; use std::sync::Arc; use async_trait::async_trait; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use tauri::State; use uuid::Uuid; use application::{ AppError, AssignIssueAgentInput, CreateIssueInput, LinkIssuesInput, ListIssuesInput, OpenProjectInput, ReadIssueCarnetInput, ReadIssueInput, UnlinkIssuesInput, UpdateIssueCarnetInput, UpdateIssueInput, }; use domain::{ AgentId, AgentIssueRole, Issue, IssueActor, IssueCarnet, IssueIndexEntry, IssueLink, IssueLinkKind, IssueListFilter, IssuePriority, IssueRef, IssueStatus, IssueVersion, Project, ProjectId, }; use infrastructure::{TicketToolError, TicketToolProvider}; use crate::dto::ErrorDto; use crate::state::AppState; /// Public full ticket DTO. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TicketDto { pub id: String, pub r#ref: String, pub number: u64, pub title: String, pub description: String, pub status: String, pub priority: String, #[serde(skip_serializing_if = "Option::is_none")] pub carnet: Option, pub links: Vec, pub assigned_agent_ids: Vec, pub created_by: TicketActorDto, pub updated_by: TicketActorDto, pub created_at: u64, pub updated_at: u64, pub version: u64, } /// Public ticket summary DTO. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TicketSummaryDto { pub r#ref: String, pub path: String, pub title: String, pub status: String, pub priority: String, pub assigned_agent_ids: Vec, pub updated_at: u64, } /// Public list DTO. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TicketListDto { pub items: Vec, #[serde(skip_serializing_if = "Option::is_none")] pub next_cursor: Option, } /// Public link DTO. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TicketLinkDto { pub target_ref: String, pub kind: String, } /// Public actor DTO. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase", tag = "kind")] pub enum TicketActorDto { User, Agent { agent_id: String }, System, } /// Carnet read DTO. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TicketCarnetDto { pub r#ref: String, pub carnet: String, pub version: u64, } /// Create request. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TicketCreateRequestDto { #[serde(default)] pub project_id: String, pub title: String, pub description: Option, pub priority: Option, pub status: Option, pub assigned_agent_ids: Option>, pub links: Option>, } /// Read request. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TicketReadRequestDto { #[serde(default)] pub project_id: String, pub r#ref: String, pub include_carnet: Option, } /// List request. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TicketListRequestDto { #[serde(default)] pub project_id: String, pub status: Option, pub priority: Option, pub assigned_agent_id: Option, pub text: Option, pub limit: Option, pub cursor: Option, } /// Update request. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TicketUpdateRequestDto { #[serde(default)] pub project_id: String, pub r#ref: String, pub title: Option, pub description: Option, pub status: Option, pub priority: Option, pub assigned_agent_ids: Option>, pub expected_version: u64, } /// Carnet update request. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TicketUpdateCarnetRequestDto { #[serde(default)] pub project_id: String, pub r#ref: String, pub carnet: String, pub expected_version: u64, } /// Link mutation request. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TicketLinkRequestDto { pub target_ref: String, pub kind: String, } /// Link command request. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TicketLinkCommandRequestDto { #[serde(default)] pub project_id: String, pub r#ref: String, pub target_ref: String, pub kind: String, pub expected_version: u64, } /// Unlink command request. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TicketUnlinkCommandRequestDto { #[serde(default)] pub project_id: String, pub r#ref: String, pub target_ref: String, pub kind: Option, pub expected_version: u64, } /// Assign command request. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TicketAssignRequestDto { #[serde(default)] pub project_id: String, pub r#ref: String, pub agent_id: String, pub assigned: bool, pub expected_version: u64, } #[derive(Clone)] pub struct AppTicketToolProvider { pub create: Arc, pub read: Arc, pub list: Arc, pub update: Arc, pub read_carnet: Arc, pub update_carnet: Arc, pub link: Arc, pub unlink: Arc, } #[async_trait] impl TicketToolProvider for AppTicketToolProvider { async fn handle_ticket_tool( &self, project: &Project, requester: &str, name: &str, arguments: Value, ) -> Result { let actor = actor_from_requester(requester); let result = match name { "idea_ticket_create" => { let req = mcp_create_request(project, arguments)?; let issue = self .create .execute(create_input(project.clone(), req, actor).map_err(dto_tool_error)?) .await .map_err(ticket_error)? .issue; json!(TicketDto::from_issue(issue, None)) } "idea_ticket_read" => { let issue_ref = parse_json_ref(&arguments, "ref")?; let include_carnet = arguments .get("includeCarnet") .and_then(Value::as_bool) .unwrap_or(false); let issue = self .read .execute(ReadIssueInput { project: project.clone(), issue_ref, }) .await .map_err(ticket_error)? .issue; let carnet = if include_carnet { Some(read_carnet_body(&*self.read_carnet, project, issue_ref).await?) } else { None }; json!(TicketDto::from_issue(issue, carnet)) } "idea_ticket_list" => { let req = mcp_list_request(project, arguments)?; let rows = self .list .execute(ListIssuesInput { project: project.clone(), filter: req.filter, }) .await .map_err(ticket_error)? .issues; json!(paginate(rows, req.limit, req.cursor)) } "idea_ticket_update" => { let req = mcp_update_request(project, arguments)?; let issue = self .update .execute(update_input(project.clone(), req, actor).map_err(dto_tool_error)?) .await .map_err(ticket_error)? .issue; json!(TicketDto::from_issue(issue, None)) } "idea_ticket_update_status" => { let issue = self .update .execute(UpdateIssueInput { project: project.clone(), issue_ref: parse_json_ref(&arguments, "ref")?, expected_version: parse_json_version(&arguments)?, title: None, description: None, status: Some(parse_status(required_str(&arguments, "status")?)?), priority: None, assigned_agent_ids: None, actor, }) .await .map_err(ticket_error)? .issue; json!(TicketDto::from_issue(issue, None)) } "idea_ticket_update_priority" => { let issue = self .update .execute(UpdateIssueInput { project: project.clone(), issue_ref: parse_json_ref(&arguments, "ref")?, expected_version: parse_json_version(&arguments)?, title: None, description: None, status: None, priority: Some(parse_priority(required_str(&arguments, "priority")?)?), assigned_agent_ids: None, actor, }) .await .map_err(ticket_error)? .issue; json!(TicketDto::from_issue(issue, None)) } "idea_ticket_read_carnet" => { let carnet = self .read_carnet .execute(ReadIssueCarnetInput { project: project.clone(), issue_ref: parse_json_ref(&arguments, "ref")?, }) .await .map_err(ticket_error)? .carnet; json!(TicketCarnetDto::from(carnet)) } "idea_ticket_update_carnet" => { let issue_ref = parse_json_ref(&arguments, "ref")?; self.update_carnet .execute(UpdateIssueCarnetInput { project: project.clone(), issue_ref, expected_version: parse_json_version(&arguments)?, carnet: required_str(&arguments, "carnet")?.to_owned(), actor, }) .await .map_err(ticket_error)?; let issue = self .read .execute(ReadIssueInput { project: project.clone(), issue_ref, }) .await .map_err(ticket_error)? .issue; json!(TicketDto::from_issue( issue, Some(read_carnet_body(&*self.read_carnet, project, issue_ref).await?) )) } "idea_ticket_link" => { let issue = self .link .execute(LinkIssuesInput { project: project.clone(), issue_ref: parse_json_ref(&arguments, "ref")?, target: parse_json_ref(&arguments, "targetRef")?, kind: parse_link_kind(required_str(&arguments, "kind")?)?, expected_version: parse_json_version(&arguments)?, actor, }) .await .map_err(ticket_error)? .issue; json!(TicketDto::from_issue(issue, None)) } "idea_ticket_unlink" => { let kind = arguments .get("kind") .and_then(Value::as_str) .map(parse_link_kind) .transpose()?; let issue = self .unlink .execute(UnlinkIssuesInput { project: project.clone(), issue_ref: parse_json_ref(&arguments, "ref")?, target: parse_json_ref(&arguments, "targetRef")?, kind, expected_version: parse_json_version(&arguments)?, actor, }) .await .map_err(ticket_error)? .issue; json!(TicketDto::from_issue(issue, None)) } _ => { return Err(TicketToolError::new( "unknownTool", format!("unknown tool: {name}"), )) } }; Ok(result) } } #[tauri::command] pub async fn ticket_create( request: TicketCreateRequestDto, state: State<'_, AppState>, ) -> Result { let project = resolve_project(&state, &request.project_id).await?; let issue = state .create_issue .execute(create_input(project, request, IssueActor::User)?) .await .map_err(ErrorDto::from)? .issue; Ok(TicketDto::from_issue(issue, None)) } #[tauri::command] pub async fn ticket_read( request: TicketReadRequestDto, state: State<'_, AppState>, ) -> Result { let project = resolve_project(&state, &request.project_id).await?; let issue_ref = parse_ref_dto(&request.r#ref)?; let issue = state .read_issue .execute(ReadIssueInput { project: project.clone(), issue_ref, }) .await .map_err(ErrorDto::from)? .issue; let carnet = if request.include_carnet.unwrap_or(false) { Some( state .read_issue_carnet .execute(ReadIssueCarnetInput { project, issue_ref }) .await .map_err(ErrorDto::from)? .carnet .carnet .as_str() .to_owned(), ) } else { None }; Ok(TicketDto::from_issue(issue, carnet)) } #[tauri::command] pub async fn ticket_list( request: TicketListRequestDto, state: State<'_, AppState>, ) -> Result { let project = resolve_project(&state, &request.project_id).await?; let page = TicketListPageInput::from_request(request)?; let rows = state .list_issues .execute(ListIssuesInput { project, filter: page.filter, }) .await .map_err(ErrorDto::from)? .issues; Ok(paginate(rows, page.limit, page.cursor)) } #[tauri::command] pub async fn ticket_update( request: TicketUpdateRequestDto, state: State<'_, AppState>, ) -> Result { let project = resolve_project(&state, &request.project_id).await?; let issue = state .update_issue .execute(update_input(project, request, IssueActor::User)?) .await .map_err(ErrorDto::from)? .issue; Ok(TicketDto::from_issue(issue, None)) } #[tauri::command] pub async fn ticket_read_carnet( request: TicketReadRequestDto, state: State<'_, AppState>, ) -> Result { let project = resolve_project(&state, &request.project_id).await?; state .read_issue_carnet .execute(ReadIssueCarnetInput { project, issue_ref: parse_ref_dto(&request.r#ref)?, }) .await .map(|out| TicketCarnetDto::from(out.carnet)) .map_err(ErrorDto::from) } #[tauri::command] pub async fn ticket_update_carnet( request: TicketUpdateCarnetRequestDto, state: State<'_, AppState>, ) -> Result { let project = resolve_project(&state, &request.project_id).await?; let issue_ref = parse_ref_dto(&request.r#ref)?; state .update_issue_carnet .execute(UpdateIssueCarnetInput { project: project.clone(), issue_ref, expected_version: version_dto(request.expected_version)?, carnet: request.carnet, actor: IssueActor::User, }) .await .map_err(ErrorDto::from)?; let issue = state .read_issue .execute(ReadIssueInput { project, issue_ref }) .await .map_err(ErrorDto::from)? .issue; Ok(TicketDto::from_issue(issue, None)) } #[tauri::command] pub async fn ticket_link( request: TicketLinkCommandRequestDto, state: State<'_, AppState>, ) -> Result { let project = resolve_project(&state, &request.project_id).await?; let issue = state .link_issues .execute(LinkIssuesInput { project, issue_ref: parse_ref_dto(&request.r#ref)?, target: parse_ref_dto(&request.target_ref)?, kind: parse_link_kind_dto(&request.kind)?, expected_version: version_dto(request.expected_version)?, actor: IssueActor::User, }) .await .map_err(ErrorDto::from)? .issue; Ok(TicketDto::from_issue(issue, None)) } #[tauri::command] pub async fn ticket_unlink( request: TicketUnlinkCommandRequestDto, state: State<'_, AppState>, ) -> Result { let project = resolve_project(&state, &request.project_id).await?; let issue = state .unlink_issues .execute(UnlinkIssuesInput { project, issue_ref: parse_ref_dto(&request.r#ref)?, target: parse_ref_dto(&request.target_ref)?, kind: request .kind .as_deref() .map(parse_link_kind_dto) .transpose()?, expected_version: version_dto(request.expected_version)?, actor: IssueActor::User, }) .await .map_err(ErrorDto::from)? .issue; Ok(TicketDto::from_issue(issue, None)) } #[tauri::command] pub async fn ticket_assign( request: TicketAssignRequestDto, state: State<'_, AppState>, ) -> Result { let project = resolve_project(&state, &request.project_id).await?; let agent_id = parse_agent_id_dto(&request.agent_id)?; let issue = state .assign_issue_agent .execute(AssignIssueAgentInput { project, issue_ref: parse_ref_dto(&request.r#ref)?, agent_id, assigned: request.assigned, expected_version: version_dto(request.expected_version)?, actor: IssueActor::User, }) .await .map_err(ErrorDto::from)? .issue; Ok(TicketDto::from_issue(issue, None)) } impl TicketDto { fn from_issue(issue: Issue, carnet: Option) -> Self { Self { id: issue.id.to_string(), r#ref: issue.reference().to_string(), number: issue.number.get(), title: issue.title, description: issue.description.as_str().to_owned(), status: status_wire(issue.status).to_owned(), priority: priority_wire(issue.priority).to_owned(), carnet, links: issue.links.into_iter().map(TicketLinkDto::from).collect(), assigned_agent_ids: issue .agent_refs .iter() .filter(|r| r.role == AgentIssueRole::Assigned) .map(|r| r.agent_id.to_string()) .collect(), created_by: TicketActorDto::from(issue.created_by), updated_by: TicketActorDto::from(issue.updated_by), created_at: issue.created_at, updated_at: issue.updated_at, version: issue.version.get(), } } } impl From for TicketSummaryDto { fn from(row: IssueIndexEntry) -> Self { Self { r#ref: row.issue_ref.to_string(), path: row.path, title: row.title, status: status_wire(row.status).to_owned(), priority: priority_wire(row.priority).to_owned(), assigned_agent_ids: row .assigned_agent_ids .into_iter() .map(|id| id.to_string()) .collect(), updated_at: row.updated_at, } } } impl From for TicketLinkDto { fn from(link: IssueLink) -> Self { Self { target_ref: link.target.to_string(), kind: link_kind_wire(link.kind).to_owned(), } } } impl From for TicketActorDto { fn from(actor: IssueActor) -> Self { match actor { IssueActor::User => Self::User, IssueActor::Agent { agent_id } => Self::Agent { agent_id: agent_id.to_string(), }, IssueActor::System => Self::System, } } } impl From for TicketCarnetDto { fn from(carnet: IssueCarnet) -> Self { Self { r#ref: carnet.issue_ref.to_string(), carnet: carnet.carnet.as_str().to_owned(), version: carnet.version.get(), } } } struct TicketListPageInput { filter: IssueListFilter, limit: usize, cursor: Option, } impl TicketListPageInput { fn from_request(request: TicketListRequestDto) -> Result { Ok(Self { filter: IssueListFilter { status: request .status .as_deref() .map(parse_status_dto) .transpose()?, priority: request .priority .as_deref() .map(parse_priority_dto) .transpose()?, assigned_agent_id: request .assigned_agent_id .as_deref() .map(parse_agent_id_dto) .transpose()?, text: request.text, }, limit: request.limit.unwrap_or(100).clamp(1, 500), cursor: request.cursor, }) } } async fn resolve_project(state: &AppState, project_id: &str) -> Result { let id = ProjectId::from_uuid( Uuid::parse_str(project_id) .map_err(|e| ErrorDto::invalid(format!("invalid project id: {e}")))?, ); state .open_project .execute(OpenProjectInput { project_id: id }) .await .map(|out| out.project) .map_err(ErrorDto::from) } fn create_input( project: Project, request: TicketCreateRequestDto, actor: IssueActor, ) -> Result { Ok(CreateIssueInput { project, title: request.title, description: request.description.unwrap_or_default(), priority: request .priority .as_deref() .map(parse_priority_dto) .transpose()? .unwrap_or(IssuePriority::Medium), status: request .status .as_deref() .map(parse_status_dto) .transpose()? .unwrap_or(IssueStatus::Open), links: request .links .unwrap_or_default() .into_iter() .map(parse_link_request) .collect::, _>>()?, assigned_agent_ids: request .assigned_agent_ids .unwrap_or_default() .iter() .map(|id| parse_agent_id_dto(id)) .collect::, _>>()?, actor, }) } fn update_input( project: Project, request: TicketUpdateRequestDto, actor: IssueActor, ) -> Result { Ok(UpdateIssueInput { project, issue_ref: parse_ref_dto(&request.r#ref)?, expected_version: version_dto(request.expected_version)?, title: request.title, description: request.description, status: request .status .as_deref() .map(parse_status_dto) .transpose()?, priority: request .priority .as_deref() .map(parse_priority_dto) .transpose()?, assigned_agent_ids: request .assigned_agent_ids .map(|ids| ids.iter().map(|id| parse_agent_id_dto(id)).collect()) .transpose()?, actor, }) } fn parse_link_request(link: TicketLinkRequestDto) -> Result { Ok(IssueLink { target: parse_ref_dto(&link.target_ref)?, kind: parse_link_kind_dto(&link.kind)?, }) } fn paginate(rows: Vec, limit: usize, cursor: Option) -> TicketListDto { let start = cursor .as_deref() .and_then(|raw| raw.parse::().ok()) .unwrap_or(0); let total = rows.len(); let end = total.min(start.saturating_add(limit)); TicketListDto { items: rows .into_iter() .skip(start) .take(limit) .map(TicketSummaryDto::from) .collect(), next_cursor: (end < total).then(|| end.to_string()), } } async fn read_carnet_body( read_carnet: &application::ReadIssueCarnet, project: &Project, issue_ref: IssueRef, ) -> Result { read_carnet .execute(ReadIssueCarnetInput { project: project.clone(), issue_ref, }) .await .map(|out| out.carnet.carnet.as_str().to_owned()) .map_err(ticket_error) } fn actor_from_requester(requester: &str) -> IssueActor { Uuid::parse_str(requester) .ok() .map(|uuid| IssueActor::Agent { agent_id: AgentId::from_uuid(uuid), }) .unwrap_or(IssueActor::System) } fn parse_ref_dto(raw: &str) -> Result { IssueRef::from_str(raw).map_err(|e| ErrorDto::invalid(e.to_string())) } fn parse_agent_id_dto(raw: &str) -> Result { Uuid::parse_str(raw) .map(AgentId::from_uuid) .map_err(|e| ErrorDto::invalid(format!("invalid agent id: {e}"))) } fn version_dto(raw: u64) -> Result { IssueVersion::new(raw).map_err(|e| ErrorDto::invalid(e.to_string())) } fn parse_status_dto(raw: &str) -> Result { parse_status(raw).map_err(|e| ErrorDto::invalid(e.message)) } fn parse_priority_dto(raw: &str) -> Result { parse_priority(raw).map_err(|e| ErrorDto::invalid(e.message)) } fn parse_link_kind_dto(raw: &str) -> Result { parse_link_kind(raw).map_err(|e| ErrorDto::invalid(e.message)) } fn parse_status(raw: &str) -> Result { match raw { "open" => Ok(IssueStatus::Open), "inProgress" => Ok(IssueStatus::InProgress), "QA" => Ok(IssueStatus::Qa), "closed" => Ok(IssueStatus::Closed), _ => Err(TicketToolError::new( "invalid", format!("invalid ticket status: {raw}"), )), } } fn parse_priority(raw: &str) -> Result { match raw { "low" => Ok(IssuePriority::Low), "medium" => Ok(IssuePriority::Medium), "high" => Ok(IssuePriority::High), "critical" => Ok(IssuePriority::Critical), _ => Err(TicketToolError::new( "invalid", format!("invalid ticket priority: {raw}"), )), } } fn parse_link_kind(raw: &str) -> Result { match raw { "relatesTo" => Ok(IssueLinkKind::RelatesTo), "blocks" => Ok(IssueLinkKind::Blocks), "blockedBy" => Ok(IssueLinkKind::BlockedBy), "duplicates" => Ok(IssueLinkKind::Duplicates), "dependsOn" => Ok(IssueLinkKind::DependsOn), _ => Err(TicketToolError::new( "invalid", format!("invalid ticket link kind: {raw}"), )), } } fn status_wire(status: IssueStatus) -> &'static str { match status { IssueStatus::Open => "open", IssueStatus::InProgress => "inProgress", IssueStatus::Qa => "QA", IssueStatus::Closed => "closed", } } fn priority_wire(priority: IssuePriority) -> &'static str { match priority { IssuePriority::Low => "low", IssuePriority::Medium => "medium", IssuePriority::High => "high", IssuePriority::Critical => "critical", } } fn link_kind_wire(kind: IssueLinkKind) -> &'static str { match kind { IssueLinkKind::RelatesTo => "relatesTo", IssueLinkKind::Blocks => "blocks", IssueLinkKind::BlockedBy => "blockedBy", IssueLinkKind::Duplicates => "duplicates", IssueLinkKind::DependsOn => "dependsOn", } } fn ticket_error(err: AppError) -> TicketToolError { let message = err.to_string(); let code = if message.contains("issue version conflict") { "versionConflict" } else { match err.code() { "NOT_FOUND" => "notFound", "INVALID" => "invalid", "STORE" => "store", "FILESYSTEM" => "filesystem", _ => "internal", } }; TicketToolError::new(code, message) } fn dto_tool_error(err: ErrorDto) -> TicketToolError { let code = match err.code.as_str() { "NOT_FOUND" => "notFound", "INVALID" => "invalid", "STORE" => "store", "FILESYSTEM" => "filesystem", _ => "internal", }; TicketToolError::new(code, err.message) } fn mcp_create_request( project: &Project, arguments: Value, ) -> Result { let mut req: TicketCreateRequestDto = serde_json::from_value(arguments) .map_err(|e| TicketToolError::new("invalid", e.to_string()))?; req.project_id = project.id.to_string(); Ok(req) } fn mcp_update_request( project: &Project, arguments: Value, ) -> Result { let mut req: TicketUpdateRequestDto = serde_json::from_value(arguments) .map_err(|e| TicketToolError::new("invalid", e.to_string()))?; req.project_id = project.id.to_string(); Ok(req) } fn mcp_list_request( project: &Project, arguments: Value, ) -> Result { let mut req: TicketListRequestDto = serde_json::from_value(arguments) .map_err(|e| TicketToolError::new("invalid", e.to_string()))?; req.project_id = project.id.to_string(); TicketListPageInput::from_request(req).map_err(|e| TicketToolError::new("invalid", e.message)) } fn parse_json_ref(arguments: &Value, key: &str) -> Result { required_str(arguments, key).and_then(|raw| { IssueRef::from_str(raw).map_err(|e| TicketToolError::new("invalid", e.to_string())) }) } fn parse_json_version(arguments: &Value) -> Result { let version = arguments .get("expectedVersion") .and_then(Value::as_u64) .ok_or_else(|| TicketToolError::new("invalid", "missing expectedVersion"))?; IssueVersion::new(version).map_err(|e| TicketToolError::new("invalid", e.to_string())) } fn required_str<'a>(arguments: &'a Value, key: &str) -> Result<&'a str, TicketToolError> { arguments .get(key) .and_then(Value::as_str) .ok_or_else(|| TicketToolError::new("invalid", format!("missing {key}"))) } impl ErrorDto { fn invalid(message: impl Into) -> Self { Self { code: "INVALID".to_owned(), message: message.into(), } } }