jirust_cli/runners/jira_cmd_runners/
issue_cmd_runner.rs

1use async_trait::async_trait;
2use jira_v3_openapi::additional_apis::issue_attachments_api::add_attachment;
3use jira_v3_openapi::apis::issue_search_api::search_and_reconsile_issues_using_jql_post;
4use jira_v3_openapi::apis::issues_api::*;
5use jira_v3_openapi::models::user::AccountType;
6use jira_v3_openapi::models::{
7    Attachment, CreatedIssue, IssueBean, IssueTransition, SearchAndReconcileRequestBean,
8    Transitions, User,
9};
10use jira_v3_openapi::{apis::configuration::Configuration, models::IssueUpdateDetails};
11use serde_json::Value;
12use std::collections::HashMap;
13use std::io::Error;
14use std::path::Path;
15
16#[cfg(test)]
17use mockall::automock;
18
19#[cfg(feature = "attachment_scan")]
20use crate::config::config_file::YaraSection;
21#[cfg(feature = "attachment_scan")]
22use crate::utils::cached_scanner::CachedYaraScanner;
23
24use crate::args::commands::TransitionArgs;
25use crate::{
26    args::commands::IssueArgs,
27    config::config_file::{AuthData, ConfigFile},
28};
29
30/// Issue command runner
31/// This struct is responsible for running the issue command
32/// It uses the Jira API to perform the operations
33///
34/// # Fields
35///
36/// * `cfg` - Configuration object
37pub struct IssueCmdRunner {
38    /// Configuration object
39    cfg: Configuration,
40    #[cfg(feature = "attachment_scan")]
41    yara_config: YaraSection,
42}
43
44/// Implementation of IssueCmdRunner
45///
46/// # Methods
47///
48/// * `new` - Creates a new instance of IssueCmdRunner
49/// * `assign_jira_issue` - Assigns a Jira issue to a user
50/// * `create_jira_issue` - Creates a Jira issue
51/// * `delete_jira_issue` - Deletes a Jira issue
52/// * `get_jira_issue` - Gets a Jira issue
53/// * `transition_jira_issue` - Transitions a Jira issue
54/// * `update_jira_issue` - Updates a Jira issue
55/// * `get_issue_available_transitions` - Gets available transitions for a Jira issue
56impl IssueCmdRunner {
57    /// Creates a new instance of IssueCmdRunner
58    ///
59    /// # Arguments
60    ///
61    /// * `cfg_file` - Configuration file
62    ///
63    /// # Returns
64    ///
65    /// * `IssueCmdRunner` - Instance of IssueCmdRunner
66    ///
67    /// # Examples
68    ///
69    /// ```
70    /// use jirust_cli::config::config_file::{ConfigFile, YaraSection};
71    /// use jirust_cli::runners::jira_cmd_runners::issue_cmd_runner::IssueCmdRunner;
72    /// use toml::Table;
73    ///
74    /// let cfg_file = ConfigFile::new("dXNlcm5hbWU6YXBpX2tleQ==".to_string(), "jira_url".to_string(), "standard_resolution".to_string(), "standard_resolution_comment".to_string(), Table::new(), YaraSection::default());
75    ///
76    /// let issue_cmd_runner = IssueCmdRunner::new(&cfg_file);
77    /// ```
78    pub fn new(cfg_file: &ConfigFile) -> IssueCmdRunner {
79        let mut config = Configuration::new();
80        let auth_data = AuthData::from_base64(cfg_file.get_auth_key());
81        config.base_path = cfg_file.get_jira_url().to_string();
82        config.basic_auth = Some((auth_data.0, Some(auth_data.1)));
83        IssueCmdRunner {
84            cfg: config,
85            #[cfg(feature = "attachment_scan")]
86            yara_config: cfg_file.get_yara_section().clone(),
87        }
88    }
89
90    /// Assigns a Jira issue to a user
91    ///
92    /// # Arguments
93    ///
94    /// * `params` - Issue command parameters
95    ///
96    /// # Returns
97    ///
98    /// * `Value` - JSON value
99    ///
100    /// # Examples
101    ///
102    /// ```no_run
103    /// use jirust_cli::runners::jira_cmd_runners::issue_cmd_runner::{IssueCmdRunner, IssueCmdParams};
104    /// use jirust_cli::config::config_file::{ConfigFile, YaraSection};
105    /// use toml::Table;
106    ///
107    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
108    /// # tokio_test::block_on(async {
109    /// let cfg_file = ConfigFile::new("dXNlcm5hbWU6YXBpX2tleQ==".to_string(), "jira_url".to_string(), "standard_resolution".to_string(), "standard_resolution_comment".to_string(), Table::new(), YaraSection::default());
110    /// let issue_cmd_runner = IssueCmdRunner::new(&cfg_file);
111    /// let mut params = IssueCmdParams::new();
112    /// params.assignee = Some("assignee".to_string());
113    ///
114    /// let result = issue_cmd_runner.assign_jira_issue(params).await?;
115    /// # Ok(())
116    /// # })
117    /// # }
118    /// ```
119    pub async fn assign_jira_issue(
120        &self,
121        params: IssueCmdParams,
122    ) -> Result<Value, Box<dyn std::error::Error>> {
123        let user_data = User {
124            account_id: Some(params.assignee.expect("Assignee is required")),
125            account_type: Some(AccountType::Atlassian),
126            ..Default::default()
127        };
128
129        let i_key = if let Some(key) = &params.issue_key {
130            key.as_str()
131        } else {
132            return Err(Box::new(Error::other(
133                "Error assigning issue: Empty issue key".to_string(),
134            )));
135        };
136
137        Ok(assign_issue(&self.cfg, i_key, user_data).await?)
138    }
139
140    /// Attaches a file to a Jira issue
141    ///
142    /// # Arguments
143    ///
144    /// * `params` - Issue command parameters
145    ///
146    /// # Returns
147    ///
148    /// * `Attachment` - Attachment object
149    ///
150    /// # Examples
151    ///
152    /// ```no_run
153    /// use jirust_cli::runners::jira_cmd_runners::issue_cmd_runner::{IssueCmdRunner, IssueCmdParams};
154    /// use jirust_cli::config::config_file::{ConfigFile, YaraSection};
155    /// use toml::Table;
156    ///
157    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
158    /// # tokio_test::block_on(async {
159    /// let cfg_file = ConfigFile::new("dXNlcm5hbWU6YXBpX2tleQ==".to_string(), "jira_url".to_string(), "standard_resolution".to_string(), "standard_resolution_comment".to_string(), Table::new(), YaraSection::default());
160    /// let issue_cmd_runner = IssueCmdRunner::new(&cfg_file);
161    /// let mut params = IssueCmdParams::new();
162    /// params.issue_key = Some("issue_key".to_string());
163    /// params.attachment_file_path = Some("path/to/file".to_string());
164    ///
165    /// let result = issue_cmd_runner.attach_file_to_jira_issue(params).await?;
166    /// # Ok(())
167    /// # })
168    /// # }
169    /// ```
170    pub async fn attach_file_to_jira_issue(
171        &self,
172        params: IssueCmdParams,
173    ) -> Result<Vec<Attachment>, Box<dyn std::error::Error>> {
174        let i_key = if let Some(key) = &params.issue_key {
175            key.as_str()
176        } else {
177            return Err(Box::new(Error::other(
178                "Error attaching file to issue: Empty issue key".to_string(),
179            )));
180        };
181
182        if let Some(path) = &params.attachment_file_path {
183            let attachment_bytes = std::fs::read(path)?;
184            let file_name = Path::new(path)
185                .file_name()
186                .and_then(|name| name.to_str())
187                .unwrap_or("attachment")
188                .to_string();
189
190            #[cfg(feature = "attachment_scan")]
191            let _scan_result = self.scan_bytes(&attachment_bytes).await?;
192
193            return Ok(
194                add_attachment(&self.cfg, i_key, attachment_bytes, file_name.as_str()).await?,
195            );
196        } else {
197            Err(Box::new(Error::other(
198                "Error attaching file to issue: Empty attachment file path".to_string(),
199            )))
200        }
201    }
202
203    #[cfg(feature = "attachment_scan")]
204    async fn scan_bytes(&self, bytes: &[u8]) -> Result<Vec<String>, Box<dyn std::error::Error>> {
205        let scanner = CachedYaraScanner::from_config(&self.yara_config).await?;
206        let scan_result = scanner.scan_buffer(bytes).unwrap_or_else(|e| {
207            eprintln!("⚠️ YARA scan failed: {}", e);
208            eprintln!("   Proceeding with attachment upload anyway...");
209            vec![]
210        });
211        if !scan_result.is_empty() {
212            println!(
213                "⚠️ Attachment file triggered the following YARA scanner rules: {:?}",
214                scan_result
215            );
216        } else {
217            println!("✅ No issue found by YARA scanner in the attachment file");
218        }
219
220        Ok(scan_result)
221    }
222
223    #[cfg(all(test, feature = "attachment_scan"))]
224    async fn scan_bytes_with_base_dir(&self, bytes: &[u8], base_dir: std::path::PathBuf) -> Result<Vec<String>, Box<dyn std::error::Error>> {
225        let scanner = CachedYaraScanner::from_config_with_base_dir(&self.yara_config, base_dir).await?;
226        let scan_result = scanner.scan_buffer(bytes).unwrap_or_else(|e| {
227            eprintln!("⚠️ YARA scan failed: {}", e);
228            eprintln!("   Proceeding with attachment upload anyway...");
229            vec![]
230        });
231        if !scan_result.is_empty() {
232            println!(
233                "⚠️ Attachment file triggered the following YARA scanner rules: {:?}",
234                scan_result
235            );
236        } else {
237            println!("✅ No issue found by YARA scanner in the attachment file");
238        }
239
240        Ok(scan_result)
241    }
242
243    /// Creates a Jira issue
244    ///
245    /// # Arguments
246    ///
247    /// * `params` - Issue command parameters
248    ///
249    /// # Returns
250    ///
251    /// * `CreatedIssue` - Created issue
252    ///
253    /// # Examples
254    ///
255    /// ```no_run
256    /// use jirust_cli::runners::jira_cmd_runners::issue_cmd_runner::{IssueCmdRunner, IssueCmdParams};
257    /// use jirust_cli::config::config_file::{ConfigFile, YaraSection};
258    /// use toml::Table;
259    ///
260    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
261    /// # tokio_test::block_on(async {
262    /// let cfg_file = ConfigFile::new("dXNlcm5hbWU6YXBpX2tleQ==".to_string(), "jira_url".to_string(), "standard_resolution".to_string(), "standard_resolution_comment".to_string(), Table::new(), YaraSection::default());
263    /// let issue_cmd_runner = IssueCmdRunner::new(&cfg_file);
264    /// let params = IssueCmdParams::new();
265    ///
266    /// let result = issue_cmd_runner.create_jira_issue(params).await?;
267    /// # Ok(())
268    /// # })
269    /// # }
270    /// ```
271    pub async fn create_jira_issue(
272        &self,
273        params: IssueCmdParams,
274    ) -> Result<CreatedIssue, Box<dyn std::error::Error>> {
275        let mut issue_fields = params.issue_fields.unwrap_or_default();
276        issue_fields.insert(
277            "project".to_string(),
278            serde_json::json!({"key": params.project_key.expect("Project Key is required to create an issue!")}),
279        );
280        let issue_data = IssueUpdateDetails {
281            fields: Some(issue_fields),
282            history_metadata: None,
283            properties: None,
284            transition: None,
285            update: None,
286        };
287        Ok(create_issue(&self.cfg, issue_data, None).await?)
288    }
289
290    /// Deletes a Jira issue
291    ///
292    /// # Arguments
293    ///
294    /// * `params` - Issue command parameters
295    ///
296    /// # Returns
297    ///
298    /// * `()` - Empty tuple
299    ///
300    /// # Examples
301    ///
302    /// ```no_run
303    /// use jirust_cli::runners::jira_cmd_runners::issue_cmd_runner::{IssueCmdRunner, IssueCmdParams};
304    /// use jirust_cli::config::config_file::{ConfigFile, YaraSection};
305    /// use toml::Table;
306    ///
307    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
308    /// # tokio_test::block_on(async {
309    /// let cfg_file = ConfigFile::new("dXNlcm5hbWU6YXBpX2tleQ==".to_string(), "jira_url".to_string(), "standard_resolution".to_string(), "standard_resolution_comment".to_string(), Table::new(), YaraSection::default());
310    /// let issue_cmd_runner = IssueCmdRunner::new(&cfg_file);
311    /// let mut params = IssueCmdParams::new();
312    /// params.issue_key = Some("issue_key".to_string());
313    ///
314    /// let result = issue_cmd_runner.delete_jira_issue(params).await?;
315    /// # Ok(())
316    /// # })
317    /// # }
318    /// ```
319    pub async fn delete_jira_issue(
320        &self,
321        params: IssueCmdParams,
322    ) -> Result<(), Box<dyn std::error::Error>> {
323        let i_key = if let Some(key) = &params.issue_key {
324            key.as_str()
325        } else {
326            return Err(Box::new(Error::other(
327                "Error deleting issue: Empty issue key".to_string(),
328            )));
329        };
330
331        Ok(delete_issue(&self.cfg, i_key, Some("true")).await?)
332    }
333
334    /// Gets a Jira issue
335    ///
336    /// # Arguments
337    ///
338    /// * `params` - Issue command parameters
339    ///
340    /// # Returns
341    ///
342    /// * `IssueBean` - Jira issue
343    ///
344    /// # Examples
345    ///
346    /// ```no_run
347    /// use jirust_cli::runners::jira_cmd_runners::issue_cmd_runner::{IssueCmdRunner, IssueCmdParams};
348    /// use jirust_cli::config::config_file::{ConfigFile, YaraSection};
349    /// use toml::Table;
350    ///
351    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
352    /// # tokio_test::block_on(async {
353    /// let cfg_file = ConfigFile::new("dXNlcm5hbWU6YXBpX2tleQ==".to_string(), "jira_url".to_string(), "standard_resolution".to_string(), "standard_resolution_comment".to_string(), Table::new(), YaraSection::default());
354    /// let issue_cmd_runner = IssueCmdRunner::new(&cfg_file);
355    /// let mut params = IssueCmdParams::new();
356    /// params.issue_key = Some("issue_key".to_string());
357    ///
358    /// let result = issue_cmd_runner.get_jira_issue(params).await?;
359    /// # Ok(())
360    /// # })
361    /// # }
362    /// ```
363    pub async fn get_jira_issue(
364        &self,
365        params: IssueCmdParams,
366    ) -> Result<IssueBean, Box<dyn std::error::Error>> {
367        let i_key = if let Some(key) = &params.issue_key {
368            key.as_str()
369        } else {
370            return Err(Box::new(Error::other(
371                "Error retrieving issue: Empty issue key".to_string(),
372            )));
373        };
374        Ok(get_issue(&self.cfg, i_key, None, None, None, None, None, None).await?)
375    }
376
377    /// This method searches for Jira issues using the provided JQL query parameters.
378    ///
379    /// # Arguments
380    ///
381    /// * `params` - Issue command parameters
382    ///
383    /// # Returns
384    ///
385    /// * `Vec<IssueBean>` - A vector of Jira issue beans
386    ///
387    /// # Examples
388    ///
389    /// ```no_run
390    /// use jirust_cli::runners::jira_cmd_runners::issue_cmd_runner::{IssueCmdRunner, IssueCmdParams};
391    /// use jirust_cli::config::config_file::{ConfigFile, YaraSection};
392    /// use toml::Table;
393    ///
394    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
395    /// # tokio_test::block_on(async {
396    /// let cfg_file = ConfigFile::new("dXNlcm5hbWU6YXBpX2tleQ==".to_string(), "jira_url".to_string(), "standard_resolution".to_string(), "standard_resolution_comment".to_string(), Table::new(), YaraSection::default());
397    /// let issue_cmd_runner = IssueCmdRunner::new(&cfg_file);
398    /// let mut params = IssueCmdParams::new();
399    /// params.query = Some("field=value".to_string());
400    ///
401    /// let result = issue_cmd_runner.search_jira_issues(params).await?;
402    /// # Ok(())
403    /// # })
404    /// # }
405    /// ```
406    pub async fn search_jira_issues(
407        &self,
408        params: IssueCmdParams,
409    ) -> Result<Vec<IssueBean>, Box<dyn std::error::Error>> {
410        let search_params: SearchAndReconcileRequestBean = SearchAndReconcileRequestBean {
411            fields: Some(vec!["*navigable".to_string(), "-comment".to_string()]),
412            jql: params.query,
413            ..Default::default()
414        };
415        match search_and_reconsile_issues_using_jql_post(&self.cfg, search_params).await {
416            Ok(result) => {
417                if let Some(issues) = result.issues {
418                    Ok(issues)
419                } else {
420                    Ok(vec![])
421                }
422            }
423            Err(e) => Err(Box::new(e)),
424        }
425    }
426
427    /// Transitions a Jira issue
428    ///
429    /// # Arguments
430    ///
431    /// * `params` - Issue command parameters
432    ///
433    /// # Returns
434    ///
435    /// * `Value` - Jira issue transition
436    ///
437    /// # Examples
438    ///
439    /// ```no_run
440    /// use jirust_cli::runners::jira_cmd_runners::issue_cmd_runner::{IssueCmdRunner, IssueCmdParams};
441    /// use jirust_cli::config::config_file::{ConfigFile, YaraSection};
442    /// use toml::Table;
443    ///
444    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
445    /// # tokio_test::block_on(async {
446    /// let cfg_file = ConfigFile::new("dXNlcm5hbWU6YXBpX2tleQ==".to_string(), "jira_url".to_string(), "standard_resolution".to_string(), "standard_resolution_comment".to_string(), Table::new(), YaraSection::default());
447    /// let issue_cmd_runner = IssueCmdRunner::new(&cfg_file);
448    ///
449    /// let mut params = IssueCmdParams::new();
450    /// params.transition = Some("transition_id".to_string());
451    ///
452    /// let result = issue_cmd_runner.transition_jira_issue(params).await?;
453    /// # Ok(())
454    /// # })
455    /// # }
456    /// ```
457    pub async fn transition_jira_issue(
458        &self,
459        params: IssueCmdParams,
460    ) -> Result<Value, Box<dyn std::error::Error>> {
461        let i_key = if let Some(key) = &params.issue_key {
462            key.as_str()
463        } else {
464            return Err(Box::new(Error::other(
465                "Error with issue transition: Empty issue key".to_string(),
466            )));
467        };
468
469        let trans = if let Some(transition) = &params.transition {
470            transition.as_str()
471        } else {
472            return Err(Box::new(Error::other(
473                "Error with issue transition: Empty transition".to_string(),
474            )));
475        };
476
477        let transition = IssueTransition {
478            id: Some(trans.to_string()),
479            ..Default::default()
480        };
481        let issue_data = IssueUpdateDetails {
482            fields: params.issue_fields,
483            history_metadata: None,
484            properties: None,
485            transition: Some(transition),
486            update: None,
487        };
488        Ok(do_transition(&self.cfg, i_key, issue_data).await?)
489    }
490
491    /// Updates a Jira issue
492    ///
493    /// # Arguments
494    ///
495    /// * `params` - Issue command parameters
496    ///
497    /// # Returns
498    ///
499    /// * `Value` - Jira issue update
500    ///
501    /// # Examples
502    ///
503    /// ```no_run
504    /// use jirust_cli::runners::jira_cmd_runners::issue_cmd_runner::{IssueCmdRunner, IssueCmdParams};
505    /// use jirust_cli::config::config_file::{ConfigFile, YaraSection};
506    /// use toml::Table;
507    ///
508    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
509    /// # tokio_test::block_on(async {
510    /// let cfg_file = ConfigFile::new("dXNlcm5hbWU6YXBpX2tleQ==".to_string(), "jira_url".to_string(), "standard_resolution".to_string(), "standard_resolution_comment".to_string(), Table::new(), YaraSection::default());
511    /// let issue_cmd_runner = IssueCmdRunner::new(&cfg_file);
512    /// let params = IssueCmdParams::new();
513    ///
514    /// let result = issue_cmd_runner.update_jira_issue(params).await?;
515    /// # Ok(())
516    /// # })
517    /// # }
518    /// ```
519    pub async fn update_jira_issue(
520        &self,
521        params: IssueCmdParams,
522    ) -> Result<Value, Box<dyn std::error::Error>> {
523        let i_key = if let Some(key) = &params.issue_key {
524            key.as_str()
525        } else {
526            return Err(Box::new(Error::other(
527                "Error updating issue: Empty issue key".to_string(),
528            )));
529        };
530
531        let issue_data = IssueUpdateDetails {
532            fields: params.issue_fields,
533            history_metadata: None,
534            properties: None,
535            transition: None,
536            update: None,
537        };
538        Ok(edit_issue(
539            &self.cfg,
540            i_key,
541            issue_data,
542            None,
543            None,
544            None,
545            Some(true),
546            None,
547        )
548        .await?)
549    }
550
551    /// Gets available transitions for a Jira issue
552    ///
553    /// # Arguments
554    ///
555    /// * `params` - Issue command parameters
556    ///
557    /// # Returns
558    ///
559    /// * `Transitions` - Jira issue transitions
560    ///
561    /// # Examples
562    ///
563    /// ```no_run
564    /// use jirust_cli::runners::jira_cmd_runners::issue_cmd_runner::{IssueCmdRunner, IssueTransitionCmdParams};
565    /// use jirust_cli::config::config_file::{ConfigFile, YaraSection};
566    /// use toml::Table;
567    ///
568    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
569    /// # tokio_test::block_on(async {
570    /// let cfg_file = ConfigFile::new("dXNlcm5hbWU6YXBpX2tleQ==".to_string(), "jira_url".to_string(), "standard_resolution".to_string(), "standard_resolution_comment".to_string(), Table::new(), YaraSection::default());
571    /// let issue_cmd_runner = IssueCmdRunner::new(&cfg_file);
572    /// let mut params = IssueTransitionCmdParams::new();
573    /// params.issue_key = "issue_key".to_string();
574    ///
575    /// let result = issue_cmd_runner.get_issue_available_transitions(params).await?;
576    /// # Ok(())
577    /// # })
578    /// # }
579    /// ```
580    pub async fn get_issue_available_transitions(
581        &self,
582        params: IssueTransitionCmdParams,
583    ) -> Result<Transitions, Box<dyn std::error::Error>> {
584        Ok(get_transitions(
585            &self.cfg,
586            &params.issue_key,
587            None,
588            None,
589            None,
590            Some(false),
591            None,
592        )
593        .await?)
594    }
595}
596
597/// Issue command parameters
598///
599/// # Fields
600///
601/// * `project_key` - Jira project key
602/// * `issue_key` - Jira issue key
603/// * `issue_fields` - Jira issue fields
604/// * `transition` - Jira issue transition
605/// * `assignee` - Jira issue assignee
606/// * `query` - Jira issue query
607pub struct IssueCmdParams {
608    /// Jira project key
609    pub project_key: Option<String>,
610    /// Jira issue key
611    pub issue_key: Option<String>,
612    /// Jira issue fields
613    pub issue_fields: Option<HashMap<String, Value>>,
614    /// Jira issue transition
615    pub transition: Option<String>,
616    /// Jira issue assignee
617    pub assignee: Option<String>,
618    /// Jira issue query
619    pub query: Option<String>,
620    /// Jira issue file path for attachment
621    pub attachment_file_path: Option<String>,
622}
623
624/// Implementation of IssueCmdParams struct
625///
626/// # Methods
627///
628/// * `new` - Creates a new IssueCmdParams instance
629impl IssueCmdParams {
630    /// Creates a new IssueCmdParams instance
631    ///
632    /// # Returns
633    ///
634    /// * `IssueCmdParams` - Issue command parameters
635    ///
636    /// # Examples
637    ///
638    /// ```
639    /// use jirust_cli::runners::jira_cmd_runners::issue_cmd_runner::IssueCmdParams;
640    ///
641    /// let params = IssueCmdParams::new();
642    /// ```
643    pub fn new() -> IssueCmdParams {
644        IssueCmdParams {
645            project_key: Some("".to_string()),
646            issue_key: None,
647            issue_fields: None,
648            transition: None,
649            assignee: None,
650            query: None,
651            attachment_file_path: None,
652        }
653    }
654}
655
656/// Implementation of From trait for IssueCmdParams struct
657/// to convert IssueArgs struct to IssueCmdParams struct
658impl From<&IssueArgs> for IssueCmdParams {
659    /// Converts IssueArgs struct to IssueCmdParams struct
660    /// to create a new IssueCmdParams instance
661    ///
662    /// # Arguments
663    ///
664    /// * `value` - IssueArgs struct
665    ///
666    /// # Returns
667    ///
668    /// * `IssueCmdParams` - Issue command parameters
669    ///
670    /// # Examples
671    ///
672    /// ```
673    /// use jirust_cli::runners::jira_cmd_runners::issue_cmd_runner::IssueCmdParams;
674    /// use jirust_cli::args::commands::{IssueArgs, PaginationArgs, OutputArgs, IssueActionValues};
675    /// use std::collections::HashMap;
676    /// use serde_json::Value;
677    ///
678    /// let issue_args = IssueArgs {
679    ///    issue_act: IssueActionValues::Get,
680    ///    project_key: Some("project_key".to_string()),
681    ///    issue_key: Some("issue_key".to_string()),
682    ///    issue_fields: Some(vec![("key".to_string(), r#"{ "key": "value" }"#.to_string())]),
683    ///    transition_to: Some("transition_to".to_string()),
684    ///    assignee: Some("assignee".to_string()),
685    ///    query: None,
686    ///    attachment_file_path: None,
687    ///    pagination: PaginationArgs { page_size: Some(20), page_offset: None },
688    ///    output: OutputArgs { output_format: None, output_type: None },
689    /// };
690    ///
691    /// let params = IssueCmdParams::from(&issue_args);
692    ///
693    /// assert_eq!(params.project_key, Some("project_key".to_string()));
694    /// assert_eq!(params.issue_key.unwrap(), "issue_key".to_string());
695    /// assert_eq!(params.transition.unwrap(), "transition_to".to_string());
696    /// assert_eq!(params.assignee.unwrap(), "assignee".to_string());
697    /// ```
698    fn from(value: &IssueArgs) -> Self {
699        IssueCmdParams {
700            project_key: value.project_key.clone(),
701            issue_key: value.issue_key.clone(),
702            issue_fields: Some(
703                value
704                    .issue_fields
705                    .clone()
706                    .unwrap_or_default()
707                    .iter()
708                    .map(|elem| {
709                        (
710                            elem.0.clone(),
711                            serde_json::from_str(elem.1.clone().as_str()).unwrap_or(Value::Null),
712                        )
713                    })
714                    .collect::<HashMap<_, _>>(),
715            ),
716            transition: value.transition_to.clone(),
717            assignee: value.assignee.clone(),
718            query: value.query.clone(),
719            attachment_file_path: value.attachment_file_path.clone(),
720        }
721    }
722}
723
724/// Issue transition command parameters
725///
726/// # Fields
727///
728/// * `issue_key` - Jira issue key
729pub struct IssueTransitionCmdParams {
730    /// Jira issue key
731    pub issue_key: String,
732}
733
734/// Implementation of IssueTransitionCmdParams struct
735///
736/// # Methods
737///
738/// * `new` - Creates a new IssueTransitionCmdParams instance
739impl IssueTransitionCmdParams {
740    /// Creates a new IssueTransitionCmdParams instance
741    ///
742    /// # Returns
743    ///
744    /// * `IssueTransitionCmdParams` - Issue transition command parameters
745    ///
746    /// # Examples
747    ///
748    /// ```
749    /// use jirust_cli::runners::jira_cmd_runners::issue_cmd_runner::IssueTransitionCmdParams;
750    ///
751    /// let params = IssueTransitionCmdParams::new();
752    /// ```
753    pub fn new() -> IssueTransitionCmdParams {
754        IssueTransitionCmdParams {
755            issue_key: "".to_string(),
756        }
757    }
758}
759
760/// Implementation of From trait for IssueTransitionCmdParams struct
761/// to convert TransitionArgs struct to IssueTransitionCmdParams struct
762impl From<&TransitionArgs> for IssueTransitionCmdParams {
763    /// Converts TransitionArgs struct to IssueTransitionCmdParams struct
764    /// to create a new IssueTransitionCmdParams instance
765    ///
766    /// # Arguments
767    ///
768    /// * `value` - TransitionArgs struct
769    ///
770    /// # Returns
771    ///
772    /// * `IssueTransitionCmdParams` - Issue transition command parameters
773    ///
774    /// # Examples
775    ///
776    /// ```
777    /// use jirust_cli::runners::jira_cmd_runners::issue_cmd_runner::IssueTransitionCmdParams;
778    /// use jirust_cli::args::commands::{TransitionArgs, TransitionActionValues, OutputArgs};
779    ///
780    /// let transition_args = TransitionArgs {
781    ///    transition_act: TransitionActionValues::List,
782    ///    issue_key: "issue_key".to_string(),
783    ///    output: OutputArgs { output_format: None, output_type: None },
784    /// };
785    ///
786    /// let params = IssueTransitionCmdParams::from(&transition_args);
787    ///
788    /// assert_eq!(params.issue_key, "issue_key".to_string());
789    /// ```
790    fn from(value: &TransitionArgs) -> Self {
791        IssueTransitionCmdParams {
792            issue_key: value.issue_key.clone(),
793        }
794    }
795}
796
797/// Default implementation for IssueCmdParams struct
798impl Default for IssueTransitionCmdParams {
799    /// Creates a default IssueTransitionCmdParams instance
800    ///
801    /// # Returns
802    ///
803    /// A IssueTransitionCmdParams instance with default values
804    ///
805    /// # Examples
806    ///
807    /// ```
808    /// use jirust_cli::runners::jira_cmd_runners::issue_cmd_runner::IssueTransitionCmdParams;
809    ///
810    /// let params = IssueTransitionCmdParams::default();
811    ///
812    /// assert_eq!(params.issue_key, "".to_string());
813    /// ```
814    fn default() -> Self {
815        IssueTransitionCmdParams::new()
816    }
817}
818
819/// Default implementation for IssueCmdParams struct
820impl Default for IssueCmdParams {
821    /// Creates a default IssueCmdParams instance
822    ///
823    /// # Returns
824    ///
825    /// A IssueCmdParams instance with default values
826    ///
827    /// # Examples
828    ///
829    /// ```
830    /// use jirust_cli::runners::jira_cmd_runners::issue_cmd_runner::IssueCmdParams;
831    ///
832    /// let params = IssueCmdParams::default();
833    ///
834    /// assert_eq!(params.project_key, Some("".to_string()));
835    /// assert_eq!(params.issue_key, None);
836    /// assert_eq!(params.issue_fields, None);
837    /// assert_eq!(params.transition, None);
838    /// assert_eq!(params.assignee, None);
839    /// ```
840    fn default() -> Self {
841        IssueCmdParams::new()
842    }
843}
844
845/// API contract for running Jira issue operations.
846#[cfg_attr(test, automock)]
847#[async_trait(?Send)]
848pub trait IssueCmdRunnerApi: Send + Sync {
849    /// Assigns a Jira issue to a user or placeholder account.
850    async fn assign_jira_issue(
851        &self,
852        params: IssueCmdParams,
853    ) -> Result<Value, Box<dyn std::error::Error>>;
854
855    /// Attaches a file to a Jira issue.
856    async fn attach_file_to_jira_issue(
857        &self,
858        params: IssueCmdParams,
859    ) -> Result<Vec<Attachment>, Box<dyn std::error::Error>>;
860
861    /// Creates a Jira issue using the provided parameters.
862    async fn create_jira_issue(
863        &self,
864        params: IssueCmdParams,
865    ) -> Result<CreatedIssue, Box<dyn std::error::Error>>;
866
867    /// Deletes a Jira issue by key.
868    async fn delete_jira_issue(
869        &self,
870        params: IssueCmdParams,
871    ) -> Result<(), Box<dyn std::error::Error>>;
872
873    /// Retrieves a Jira issue by key.
874    async fn get_jira_issue(
875        &self,
876        params: IssueCmdParams,
877    ) -> Result<IssueBean, Box<dyn std::error::Error>>;
878
879    /// Executes a search query and returns the matching issues.
880    async fn search_jira_issues(
881        &self,
882        params: IssueCmdParams,
883    ) -> Result<Vec<IssueBean>, Box<dyn std::error::Error>>;
884
885    /// Transitions an issue to a new status.
886    async fn transition_jira_issue(
887        &self,
888        params: IssueCmdParams,
889    ) -> Result<Value, Box<dyn std::error::Error>>;
890
891    /// Updates an issue fields payload.
892    async fn update_jira_issue(
893        &self,
894        params: IssueCmdParams,
895    ) -> Result<Value, Box<dyn std::error::Error>>;
896
897    /// Fetches the available transitions for an issue.
898    async fn get_issue_available_transitions(
899        &self,
900        params: IssueTransitionCmdParams,
901    ) -> Result<Transitions, Box<dyn std::error::Error>>;
902}
903
904#[async_trait(?Send)]
905impl IssueCmdRunnerApi for IssueCmdRunner {
906    async fn assign_jira_issue(
907        &self,
908        params: IssueCmdParams,
909    ) -> Result<Value, Box<dyn std::error::Error>> {
910        IssueCmdRunner::assign_jira_issue(self, params).await
911    }
912
913    async fn attach_file_to_jira_issue(
914        &self,
915        params: IssueCmdParams,
916    ) -> Result<Vec<Attachment>, Box<dyn std::error::Error>> {
917        IssueCmdRunner::attach_file_to_jira_issue(self, params).await
918    }
919
920    async fn create_jira_issue(
921        &self,
922        params: IssueCmdParams,
923    ) -> Result<CreatedIssue, Box<dyn std::error::Error>> {
924        IssueCmdRunner::create_jira_issue(self, params).await
925    }
926
927    async fn delete_jira_issue(
928        &self,
929        params: IssueCmdParams,
930    ) -> Result<(), Box<dyn std::error::Error>> {
931        IssueCmdRunner::delete_jira_issue(self, params).await
932    }
933
934    async fn get_jira_issue(
935        &self,
936        params: IssueCmdParams,
937    ) -> Result<IssueBean, Box<dyn std::error::Error>> {
938        IssueCmdRunner::get_jira_issue(self, params).await
939    }
940
941    async fn search_jira_issues(
942        &self,
943        params: IssueCmdParams,
944    ) -> Result<Vec<IssueBean>, Box<dyn std::error::Error>> {
945        IssueCmdRunner::search_jira_issues(self, params).await
946    }
947
948    async fn transition_jira_issue(
949        &self,
950        params: IssueCmdParams,
951    ) -> Result<Value, Box<dyn std::error::Error>> {
952        IssueCmdRunner::transition_jira_issue(self, params).await
953    }
954
955    async fn update_jira_issue(
956        &self,
957        params: IssueCmdParams,
958    ) -> Result<Value, Box<dyn std::error::Error>> {
959        IssueCmdRunner::update_jira_issue(self, params).await
960    }
961
962    async fn get_issue_available_transitions(
963        &self,
964        params: IssueTransitionCmdParams,
965    ) -> Result<Transitions, Box<dyn std::error::Error>> {
966        IssueCmdRunner::get_issue_available_transitions(self, params).await
967    }
968}
969
970#[cfg(test)]
971mod tests {
972    use super::*;
973    use crate::config::config_file::{ConfigFile, YaraSection};
974    use std::sync::Mutex;
975    use std::{env, fs};
976    use tempfile::tempdir;
977    use toml::Table;
978
979    static ENV_MUTEX: Mutex<()> = Mutex::new(());
980
981    fn basic_config() -> ConfigFile {
982        ConfigFile::new(
983            "dGVzdDp0b2tlbg==".to_string(),
984            "https://example.atlassian.net".to_string(),
985            "Done".to_string(),
986            "Completed".to_string(),
987            Table::new(),
988            YaraSection::default(),
989        )
990    }
991
992    #[tokio::test]
993    async fn attach_without_issue_key_returns_error() {
994        let runner = IssueCmdRunner::new(&basic_config());
995        let mut params = IssueCmdParams::new();
996        params.attachment_file_path = Some("/tmp/file.txt".to_string());
997
998        let result = runner.attach_file_to_jira_issue(params).await;
999        assert!(result.is_err());
1000        assert!(result.unwrap_err().to_string().contains("Empty issue key"));
1001    }
1002
1003    #[tokio::test]
1004    async fn attach_without_file_path_returns_error() {
1005        let runner = IssueCmdRunner::new(&basic_config());
1006        let mut params = IssueCmdParams::new();
1007        params.issue_key = Some("TEST-123".to_string());
1008
1009        let result = runner.attach_file_to_jira_issue(params).await;
1010        assert!(result.is_err());
1011        assert!(
1012            result
1013                .unwrap_err()
1014                .to_string()
1015                .contains("Empty attachment file path")
1016        );
1017    }
1018
1019    #[cfg(feature = "attachment_scan")]
1020    #[tokio::test]
1021    async fn scan_bytes_uses_local_rules() {
1022        let _guard = ENV_MUTEX.lock().unwrap();
1023        let temp_home = tempdir().expect("temp HOME");
1024        let base_dir = temp_home.path().join(".jirust-cli");
1025        let rules_dir = base_dir.join("rules");
1026        fs::create_dir_all(&rules_dir).expect("create rules dir");
1027
1028        fs::write(rules_dir.join(".version"), "v1").expect("write version marker");
1029        fs::write(
1030            rules_dir.join("test_rule.yar"),
1031            r#"
1032rule TestRule {
1033  strings:
1034    $a = "hitme"
1035  condition:
1036    $a
1037}
1038"#,
1039        )
1040        .expect("write yara rule");
1041
1042        let config = ConfigFile::new(
1043            "dGVzdDp0b2tlbg==".to_string(),
1044            "https://example.atlassian.net".to_string(),
1045            "Done".to_string(),
1046            "Completed".to_string(),
1047            Table::new(),
1048            YaraSection::new(
1049                "local_rules.zip".to_string(),
1050                "rules".to_string(),
1051                "yara_rules.cache".to_string(),
1052                "yara_rules.cache.version".to_string(),
1053            ),
1054        );
1055
1056        let runner = IssueCmdRunner::new(&config);
1057        let matches = runner
1058            .scan_bytes_with_base_dir(b"hitme", base_dir.clone())
1059            .await
1060            .expect("scan should succeed");
1061
1062        assert!(matches.contains(&"TestRule".to_string()));
1063        assert!(base_dir.join("yara_rules.cache").exists());
1064        assert!(base_dir.join("yara_rules.cache.version").exists());
1065    }
1066}