1use rustc_hash::FxHashMap;
26use serde::{Deserialize, Deserializer, Serialize};
27
28use crate::ast::{AgentParams, InvokeParams};
29use crate::error::NikaError;
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
33#[serde(rename_all = "lowercase")]
34pub enum ResponseFormat {
35 #[default]
37 Text,
38 Json,
40 Markdown,
42}
43
44#[derive(Debug, Clone, Default)]
57pub struct InferParams {
58 pub prompt: String,
59 pub provider: Option<String>,
61 pub model: Option<String>,
63 pub temperature: Option<f64>,
65 pub max_tokens: Option<u32>,
67 pub system: Option<String>,
69 pub response_format: Option<ResponseFormat>,
71 pub extended_thinking: Option<bool>,
73 pub thinking_budget: Option<u64>,
75 pub content: Option<Vec<crate::ast::content::ContentPart>>,
77 pub guardrails: Vec<crate::ast::guardrails::GuardrailConfig>,
79}
80
81impl<'de> Deserialize<'de> for InferParams {
82 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
83 where
84 D: Deserializer<'de>,
85 {
86 #[derive(Deserialize)]
87 #[serde(untagged)]
88 enum InferParamsHelper {
89 Short(String),
90 Full {
91 #[serde(default)]
92 prompt: String,
93 #[serde(default)]
94 provider: Option<String>,
95 #[serde(default)]
96 model: Option<String>,
97 #[serde(default)]
98 temperature: Option<f64>,
99 #[serde(default)]
100 max_tokens: Option<u32>,
101 #[serde(default)]
102 system: Option<String>,
103 #[serde(default)]
104 response_format: Option<ResponseFormat>,
105 #[serde(default)]
106 extended_thinking: Option<bool>,
107 #[serde(default)]
108 thinking_budget: Option<u64>,
109 #[serde(default)]
110 content: Option<Vec<crate::ast::content::ContentPart>>,
111 #[serde(default)]
112 guardrails: Vec<crate::ast::guardrails::GuardrailConfig>,
113 },
114 }
115
116 match InferParamsHelper::deserialize(deserializer)? {
117 InferParamsHelper::Short(prompt) => Ok(InferParams {
118 prompt,
119 provider: None,
120 model: None,
121 temperature: None,
122 max_tokens: None,
123 system: None,
124 response_format: None,
125 extended_thinking: None,
126 thinking_budget: None,
127 content: None,
128 guardrails: Vec::new(),
129 }),
130 InferParamsHelper::Full {
131 prompt,
132 provider,
133 model,
134 temperature,
135 max_tokens,
136 system,
137 response_format,
138 extended_thinking,
139 thinking_budget,
140 content,
141 guardrails,
142 } => Ok(InferParams {
143 prompt,
144 provider,
145 model,
146 temperature,
147 max_tokens,
148 system,
149 response_format,
150 extended_thinking,
151 thinking_budget,
152 content,
153 guardrails,
154 }),
155 }
156 }
157}
158
159impl InferParams {
160 pub fn validate(&self) -> Result<(), NikaError> {
171 let has_content = self.content.as_ref().is_some_and(|c| !c.is_empty());
173 if self.prompt.trim().is_empty() && !has_content {
174 return Err(NikaError::ValidationError {
175 reason: "Infer requires 'prompt' or 'content' (neither provided)".into(),
176 });
177 }
178
179 if let Some(temp) = self.temperature {
180 if !(0.0..=2.0).contains(&temp) {
181 return Err(NikaError::ValidationError {
182 reason: format!("temperature must be between 0.0 and 2.0, got {}", temp),
183 });
184 }
185 }
186
187 if self.extended_thinking == Some(true) {
189 if let Some(ref provider) = self.provider {
190 if provider != "claude" {
191 return Err(NikaError::ValidationError {
192 reason: format!(
193 "extended_thinking only supported for claude provider, got '{}'",
194 provider
195 ),
196 });
197 }
198 }
199 }
201
202 if let Some(budget) = self.thinking_budget {
204 if !(1024..=65536).contains(&budget) {
205 return Err(NikaError::ValidationError {
206 reason: format!(
207 "thinking_budget must be between 1024 and 65536, got {}",
208 budget
209 ),
210 });
211 }
212 }
213
214 Ok(())
215 }
216
217 pub fn effective_thinking_budget(&self) -> u64 {
222 self.thinking_budget.unwrap_or(4096)
223 }
224}
225
226#[derive(Debug, Clone)]
235pub struct ExecParams {
236 pub command: String,
237 pub shell: Option<bool>,
239 pub timeout: Option<u64>,
241 pub cwd: Option<String>,
243 pub env: Option<FxHashMap<String, String>>,
245}
246
247impl<'de> Deserialize<'de> for ExecParams {
248 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
249 where
250 D: Deserializer<'de>,
251 {
252 #[derive(Deserialize)]
253 #[serde(untagged)]
254 enum ExecParamsHelper {
255 Short(String),
256 Full {
257 command: String,
258 #[serde(default)]
259 shell: Option<bool>,
260 #[serde(default)]
261 timeout: Option<u64>,
262 #[serde(default)]
263 cwd: Option<String>,
264 #[serde(default)]
265 env: Option<FxHashMap<String, String>>,
266 },
267 }
268
269 match ExecParamsHelper::deserialize(deserializer)? {
270 ExecParamsHelper::Short(command) => Ok(ExecParams {
271 command,
272 shell: None,
273 timeout: None,
274 cwd: None,
275 env: None,
276 }),
277 ExecParamsHelper::Full {
278 command,
279 shell,
280 timeout,
281 cwd,
282 env,
283 } => Ok(ExecParams {
284 command,
285 shell,
286 timeout,
287 cwd,
288 env,
289 }),
290 }
291 }
292}
293
294impl ExecParams {
295 pub fn validate(&self) -> Result<(), NikaError> {
303 if self.command.trim().is_empty() {
304 return Err(NikaError::ValidationError {
305 reason: "Exec command cannot be empty".into(),
306 });
307 }
308 if self.timeout == Some(0) {
309 return Err(NikaError::ValidationError {
310 reason: "Exec timeout must be greater than 0".into(),
311 });
312 }
313 Ok(())
314 }
315}
316
317#[derive(Debug, Clone, Deserialize, Default, PartialEq)]
319pub struct RetryConfig {
320 #[serde(default = "default_max_attempts")]
322 pub max_attempts: u32,
323
324 #[serde(default = "default_backoff_ms")]
326 pub backoff_ms: u64,
327
328 #[serde(default = "default_multiplier")]
330 pub multiplier: f64,
331}
332
333fn default_max_attempts() -> u32 {
334 3
335}
336
337fn default_backoff_ms() -> u64 {
338 1000
339}
340
341fn default_multiplier() -> f64 {
342 2.0
343}
344
345#[derive(Debug, Clone, Deserialize)]
355pub struct FetchParams {
356 pub url: String,
357 #[serde(default = "default_method")]
358 pub method: String,
359 #[serde(default)]
360 pub headers: FxHashMap<String, String>,
361 pub body: Option<String>,
362 #[serde(default)]
366 pub json: Option<serde_json::Value>,
367 pub timeout: Option<u64>,
369 #[serde(default)]
371 pub retry: Option<RetryConfig>,
372 #[serde(default)]
375 pub follow_redirects: Option<bool>,
376 #[serde(default)]
378 pub response: Option<String>,
379 #[serde(default)]
381 pub extract: Option<String>,
382 #[serde(default)]
384 pub selector: Option<String>,
385}
386
387impl FetchParams {
388 pub fn validate(&self) -> Result<(), NikaError> {
397 if self.url.trim().is_empty() {
398 return Err(NikaError::ValidationError {
399 reason: "Fetch URL cannot be empty".into(),
400 });
401 }
402 let valid_methods = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"];
403 let method_upper = self.method.to_uppercase();
404 if !valid_methods.contains(&method_upper.as_str()) {
405 return Err(NikaError::ValidationError {
406 reason: format!(
407 "Invalid HTTP method '{}', expected one of: {}",
408 self.method,
409 valid_methods.join(", ")
410 ),
411 });
412 }
413 if self.timeout == Some(0) {
414 return Err(NikaError::ValidationError {
415 reason: "Fetch timeout must be greater than 0".into(),
416 });
417 }
418 if let Some(ref r) = self.response {
419 if r != "full" && r != "binary" {
420 return Err(NikaError::ValidationError {
421 reason: format!("Invalid response mode '{}', expected 'full' or 'binary'", r),
422 });
423 }
424 }
425 if let Some(ref extract) = self.extract {
426 let valid = [
427 "markdown", "article", "text", "selector", "metadata", "links", "jsonpath", "feed",
428 "llm_txt",
429 ];
430 if !valid.contains(&extract.as_str()) {
431 return Err(NikaError::ValidationError {
432 reason: format!(
433 "fetch extract must be one of: {}, got '{}'",
434 valid.join(", "),
435 extract
436 ),
437 });
438 }
439 }
440 if self.selector.is_some() && self.extract.is_none() {
441 return Err(NikaError::ValidationError {
442 reason: "fetch 'selector' requires 'extract' to be set".to_string(),
443 });
444 }
445 if self.response.is_some() && self.extract.is_some() {
446 return Err(NikaError::ValidationError {
447 reason: format!(
448 "fetch cannot combine 'response: {}' with 'extract: {}' — response modes bypass extraction",
449 self.response.as_deref().unwrap_or(""),
450 self.extract.as_deref().unwrap_or("")
451 ),
452 });
453 }
454 Ok(())
455 }
456}
457
458fn default_method() -> String {
459 "GET".to_string()
460}
461
462#[derive(Debug, Clone, Deserialize)]
471#[serde(untagged)]
472#[allow(clippy::large_enum_variant)] pub enum TaskAction {
474 Infer { infer: InferParams },
475 Exec { exec: ExecParams },
476 Fetch { fetch: FetchParams },
477 Invoke { invoke: InvokeParams },
478 Agent { agent: AgentParams },
479}
480
481impl TaskAction {
482 pub fn verb_name(&self) -> &'static str {
484 match self {
485 TaskAction::Infer { .. } => "infer",
486 TaskAction::Exec { .. } => "exec",
487 TaskAction::Fetch { .. } => "fetch",
488 TaskAction::Invoke { .. } => "invoke",
489 TaskAction::Agent { .. } => "agent",
490 }
491 }
492}
493
494#[cfg(test)]
495mod tests {
496 use super::*;
497 use crate::serde_yaml;
498 use serde_json::json;
499
500 #[test]
505 fn test_infer_params_shorthand_deserialize() {
506 let yaml = r#"
507infer: "Generate a headline for QR Code AI"
508"#;
509 let action: TaskAction = serde_yaml::from_str(yaml).unwrap();
510 match action {
511 TaskAction::Infer { infer } => {
512 assert_eq!(infer.prompt, "Generate a headline for QR Code AI");
513 assert!(infer.provider.is_none());
514 assert!(infer.model.is_none());
515 }
516 _ => panic!("Expected TaskAction::Infer"),
517 }
518 }
519
520 #[test]
521 fn test_infer_params_full_form_deserialize() {
522 let yaml = r#"
523infer:
524 prompt: "Generate a headline"
525 provider: claude
526 model: claude-sonnet-4-6
527"#;
528 let action: TaskAction = serde_yaml::from_str(yaml).unwrap();
529 match action {
530 TaskAction::Infer { infer } => {
531 assert_eq!(infer.prompt, "Generate a headline");
532 assert_eq!(infer.provider, Some("claude".to_string()));
533 assert_eq!(infer.model, Some("claude-sonnet-4-6".to_string()));
534 }
535 _ => panic!("Expected TaskAction::Infer"),
536 }
537 }
538
539 #[test]
540 fn test_infer_params_full_form_only_prompt() {
541 let yaml = r#"
542infer:
543 prompt: "Generate a headline"
544"#;
545 let action: TaskAction = serde_yaml::from_str(yaml).unwrap();
546 match action {
547 TaskAction::Infer { infer } => {
548 assert_eq!(infer.prompt, "Generate a headline");
549 assert!(infer.provider.is_none());
550 assert!(infer.model.is_none());
551 }
552 _ => panic!("Expected TaskAction::Infer"),
553 }
554 }
555
556 #[test]
557 fn test_infer_params_multiline_prompt_shorthand() {
558 let yaml = r#"
559infer: |
560 Generate a comprehensive headline for QR Code AI.
561 Include value proposition and key benefit.
562 Keep under 100 characters.
563"#;
564 let action: TaskAction = serde_yaml::from_str(yaml).unwrap();
565 match action {
566 TaskAction::Infer { infer } => {
567 assert!(infer.prompt.contains("Generate a comprehensive headline"));
568 assert!(infer.prompt.contains("value proposition"));
569 }
570 _ => panic!("Expected TaskAction::Infer"),
571 }
572 }
573
574 #[test]
575 fn test_infer_params_with_provider_only() {
576 let yaml = r#"
577infer:
578 prompt: "Test"
579 provider: openai
580"#;
581 let action: TaskAction = serde_yaml::from_str(yaml).unwrap();
582 match action {
583 TaskAction::Infer { infer } => {
584 assert_eq!(infer.provider, Some("openai".to_string()));
585 assert!(infer.model.is_none());
586 }
587 _ => panic!("Expected TaskAction::Infer"),
588 }
589 }
590
591 #[test]
592 fn test_infer_params_with_model_only() {
593 let yaml = r#"
594infer:
595 prompt: "Test"
596 model: gpt-4
597"#;
598 let action: TaskAction = serde_yaml::from_str(yaml).unwrap();
599 match action {
600 TaskAction::Infer { infer } => {
601 assert!(infer.provider.is_none());
602 assert_eq!(infer.model, Some("gpt-4".to_string()));
603 }
604 _ => panic!("Expected TaskAction::Infer"),
605 }
606 }
607
608 #[test]
613 fn test_infer_params_validate_ok() {
614 let params = InferParams {
615 prompt: "Generate something".to_string(),
616 temperature: Some(0.7),
617 ..Default::default()
618 };
619 assert!(params.validate().is_ok());
620 }
621
622 #[test]
623 fn test_infer_params_validate_empty_prompt() {
624 let params = InferParams {
625 prompt: "".to_string(),
626 ..Default::default()
627 };
628 let result = params.validate();
629 assert!(result.is_err());
630 assert!(result.unwrap_err().to_string().contains("neither provided"));
631 }
632
633 #[test]
634 fn test_infer_params_validate_whitespace_only_prompt() {
635 let params = InferParams {
636 prompt: " \n\t ".to_string(),
637 ..Default::default()
638 };
639 let result = params.validate();
640 assert!(result.is_err());
641 assert!(result.unwrap_err().to_string().contains("neither provided"));
642 }
643
644 #[test]
645 fn test_infer_params_validate_content_without_prompt_ok() {
646 use crate::ast::content::{ContentPart, ImageDetail};
647 let params = InferParams {
648 prompt: "".to_string(),
649 content: Some(vec![ContentPart::Image {
650 source: "blake3:abc".to_string(),
651 detail: ImageDetail::Auto,
652 }]),
653 ..Default::default()
654 };
655 assert!(params.validate().is_ok());
656 }
657
658 #[test]
659 fn test_infer_params_validate_content_and_prompt_ok() {
660 use crate::ast::content::ContentPart;
661 let params = InferParams {
662 prompt: "Describe this image".to_string(),
663 content: Some(vec![ContentPart::Text {
664 text: "hello".to_string(),
665 }]),
666 ..Default::default()
667 };
668 assert!(params.validate().is_ok());
669 }
670
671 #[test]
672 fn test_infer_params_validate_empty_content_vec_rejected() {
673 let params = InferParams {
674 prompt: "".to_string(),
675 content: Some(vec![]),
676 ..Default::default()
677 };
678 let result = params.validate();
679 assert!(result.is_err());
680 assert!(result.unwrap_err().to_string().contains("neither provided"));
681 }
682
683 #[test]
684 fn test_infer_params_validate_temperature_too_low() {
685 let params = InferParams {
686 prompt: "Test".to_string(),
687 temperature: Some(-0.1),
688 ..Default::default()
689 };
690 let result = params.validate();
691 assert!(result.is_err());
692 assert!(result.unwrap_err().to_string().contains("temperature"));
693 }
694
695 #[test]
696 fn test_infer_params_validate_temperature_too_high() {
697 let params = InferParams {
698 prompt: "Test".to_string(),
699 temperature: Some(2.5),
700 ..Default::default()
701 };
702 let result = params.validate();
703 assert!(result.is_err());
704 assert!(result.unwrap_err().to_string().contains("temperature"));
705 }
706
707 #[test]
708 fn test_infer_params_validate_temperature_boundary_valid() {
709 let params_min = InferParams {
711 prompt: "Test".to_string(),
712 temperature: Some(0.0),
713 ..Default::default()
714 };
715 assert!(params_min.validate().is_ok());
716
717 let params_max = InferParams {
718 prompt: "Test".to_string(),
719 temperature: Some(2.0),
720 ..Default::default()
721 };
722 assert!(params_max.validate().is_ok());
723 }
724
725 #[test]
730 fn test_infer_params_with_temperature() {
731 let yaml = r#"
732infer:
733 prompt: "Be creative"
734 temperature: 0.9
735"#;
736 let action: TaskAction = serde_yaml::from_str(yaml).unwrap();
737 match action {
738 TaskAction::Infer { infer } => {
739 assert_eq!(infer.prompt, "Be creative");
740 assert_eq!(infer.temperature, Some(0.9));
741 assert!(infer.max_tokens.is_none());
742 assert!(infer.system.is_none());
743 }
744 _ => panic!("Expected TaskAction::Infer"),
745 }
746 }
747
748 #[test]
749 fn test_infer_params_with_max_tokens() {
750 let yaml = r#"
751infer:
752 prompt: "Short answer"
753 max_tokens: 100
754"#;
755 let action: TaskAction = serde_yaml::from_str(yaml).unwrap();
756 match action {
757 TaskAction::Infer { infer } => {
758 assert_eq!(infer.prompt, "Short answer");
759 assert_eq!(infer.max_tokens, Some(100));
760 assert!(infer.temperature.is_none());
761 }
762 _ => panic!("Expected TaskAction::Infer"),
763 }
764 }
765
766 #[test]
767 fn test_infer_params_with_system_prompt() {
768 let yaml = r#"
769infer:
770 prompt: "Explain quantum computing"
771 system: "You are a physics professor explaining to undergraduates."
772"#;
773 let action: TaskAction = serde_yaml::from_str(yaml).unwrap();
774 match action {
775 TaskAction::Infer { infer } => {
776 assert_eq!(infer.prompt, "Explain quantum computing");
777 assert_eq!(
778 infer.system,
779 Some("You are a physics professor explaining to undergraduates.".to_string())
780 );
781 }
782 _ => panic!("Expected TaskAction::Infer"),
783 }
784 }
785
786 #[test]
787 fn test_infer_params_full_llm_control() {
788 let yaml = r#"
789infer:
790 prompt: "Write a haiku"
791 provider: openai
792 model: gpt-4o
793 temperature: 0.7
794 max_tokens: 50
795 system: "You are a Japanese poetry master."
796"#;
797 let action: TaskAction = serde_yaml::from_str(yaml).unwrap();
798 match action {
799 TaskAction::Infer { infer } => {
800 assert_eq!(infer.prompt, "Write a haiku");
801 assert_eq!(infer.provider, Some("openai".to_string()));
802 assert_eq!(infer.model, Some("gpt-4o".to_string()));
803 assert_eq!(infer.temperature, Some(0.7));
804 assert_eq!(infer.max_tokens, Some(50));
805 assert_eq!(
806 infer.system,
807 Some("You are a Japanese poetry master.".to_string())
808 );
809 }
810 _ => panic!("Expected TaskAction::Infer"),
811 }
812 }
813
814 #[test]
815 fn test_infer_params_shorthand_defaults_llm_options() {
816 let yaml = r#"
817infer: "Simple prompt"
818"#;
819 let action: TaskAction = serde_yaml::from_str(yaml).unwrap();
820 match action {
821 TaskAction::Infer { infer } => {
822 assert_eq!(infer.prompt, "Simple prompt");
823 assert!(infer.temperature.is_none());
824 assert!(infer.max_tokens.is_none());
825 assert!(infer.system.is_none());
826 }
827 _ => panic!("Expected TaskAction::Infer"),
828 }
829 }
830
831 #[test]
832 fn test_infer_params_temperature_zero() {
833 let yaml = r#"
834infer:
835 prompt: "Deterministic output"
836 temperature: 0.0
837"#;
838 let action: TaskAction = serde_yaml::from_str(yaml).unwrap();
839 match action {
840 TaskAction::Infer { infer } => {
841 assert_eq!(infer.temperature, Some(0.0));
842 }
843 _ => panic!("Expected TaskAction::Infer"),
844 }
845 }
846
847 #[test]
852 fn test_exec_params_shorthand_deserialize() {
853 let yaml = r#"
854exec: "npm run build"
855"#;
856 let action: TaskAction = serde_yaml::from_str(yaml).unwrap();
857 match action {
858 TaskAction::Exec { exec } => {
859 assert_eq!(exec.command, "npm run build");
860 }
861 _ => panic!("Expected TaskAction::Exec"),
862 }
863 }
864
865 #[test]
866 fn test_exec_params_full_form_deserialize() {
867 let yaml = r#"
868exec:
869 command: "npm run build"
870"#;
871 let action: TaskAction = serde_yaml::from_str(yaml).unwrap();
872 match action {
873 TaskAction::Exec { exec } => {
874 assert_eq!(exec.command, "npm run build");
875 }
876 _ => panic!("Expected TaskAction::Exec"),
877 }
878 }
879
880 #[test]
881 fn test_exec_params_complex_command() {
882 let yaml = r#"
883exec: "cargo test --lib -- --test-threads=1 --nocapture"
884"#;
885 let action: TaskAction = serde_yaml::from_str(yaml).unwrap();
886 match action {
887 TaskAction::Exec { exec } => {
888 assert!(exec.command.contains("cargo test"));
889 assert!(exec.command.contains("--test-threads=1"));
890 }
891 _ => panic!("Expected TaskAction::Exec"),
892 }
893 }
894
895 #[test]
896 fn test_exec_params_with_pipes_and_redirects() {
897 let yaml = r#"
898exec: "cat file.txt | grep pattern > output.txt"
899"#;
900 let action: TaskAction = serde_yaml::from_str(yaml).unwrap();
901 match action {
902 TaskAction::Exec { exec } => {
903 assert!(exec.command.contains("grep pattern"));
904 }
905 _ => panic!("Expected TaskAction::Exec"),
906 }
907 }
908
909 #[test]
914 fn test_exec_params_shell_field_default_none() {
915 let yaml = r#"
916exec:
917 command: "echo hello"
918"#;
919 let action: TaskAction = serde_yaml::from_str(yaml).unwrap();
920 match action {
921 TaskAction::Exec { exec } => {
922 assert_eq!(exec.command, "echo hello");
923 assert_eq!(exec.shell, None); }
925 _ => panic!("Expected TaskAction::Exec"),
926 }
927 }
928
929 #[test]
930 fn test_exec_params_shell_true_explicit() {
931 let yaml = r#"
932exec:
933 command: "echo $HOME && ls | grep foo"
934 shell: true
935"#;
936 let action: TaskAction = serde_yaml::from_str(yaml).unwrap();
937 match action {
938 TaskAction::Exec { exec } => {
939 assert!(exec.command.contains("$HOME"));
940 assert_eq!(exec.shell, Some(true));
941 }
942 _ => panic!("Expected TaskAction::Exec"),
943 }
944 }
945
946 #[test]
947 fn test_exec_params_shell_false_explicit() {
948 let yaml = r#"
949exec:
950 command: "echo hello"
951 shell: false
952"#;
953 let action: TaskAction = serde_yaml::from_str(yaml).unwrap();
954 match action {
955 TaskAction::Exec { exec } => {
956 assert_eq!(exec.shell, Some(false));
957 }
958 _ => panic!("Expected TaskAction::Exec"),
959 }
960 }
961
962 #[test]
967 fn test_fetch_params_minimal() {
968 let yaml = r#"
969fetch:
970 url: "https://api.example.com/data"
971"#;
972 let action: TaskAction = serde_yaml::from_str(yaml).unwrap();
973 match action {
974 TaskAction::Fetch { fetch } => {
975 assert_eq!(fetch.url, "https://api.example.com/data");
976 assert_eq!(fetch.method, "GET");
977 assert!(fetch.headers.is_empty());
978 assert!(fetch.body.is_none());
979 }
980 _ => panic!("Expected TaskAction::Fetch"),
981 }
982 }
983
984 #[test]
985 fn test_fetch_params_with_method() {
986 let yaml = r#"
987fetch:
988 url: "https://api.example.com/data"
989 method: "POST"
990"#;
991 let action: TaskAction = serde_yaml::from_str(yaml).unwrap();
992 match action {
993 TaskAction::Fetch { fetch } => {
994 assert_eq!(fetch.method, "POST");
995 }
996 _ => panic!("Expected TaskAction::Fetch"),
997 }
998 }
999
1000 #[test]
1001 fn test_fetch_params_default_method_get() {
1002 let yaml = r#"
1003fetch:
1004 url: "https://api.example.com/data"
1005"#;
1006 let action: TaskAction = serde_yaml::from_str(yaml).unwrap();
1007 match action {
1008 TaskAction::Fetch { fetch } => {
1009 assert_eq!(fetch.method, "GET");
1010 }
1011 _ => panic!("Expected TaskAction::Fetch"),
1012 }
1013 }
1014
1015 #[test]
1016 fn test_fetch_params_with_headers() {
1017 let yaml = r#"
1018fetch:
1019 url: "https://api.example.com/data"
1020 method: "GET"
1021 headers:
1022 Authorization: "Bearer token123"
1023 Content-Type: "application/json"
1024"#;
1025 let action: TaskAction = serde_yaml::from_str(yaml).unwrap();
1026 match action {
1027 TaskAction::Fetch { fetch } => {
1028 assert_eq!(fetch.headers.len(), 2);
1029 assert_eq!(
1030 fetch.headers.get("Authorization"),
1031 Some(&"Bearer token123".to_string())
1032 );
1033 assert_eq!(
1034 fetch.headers.get("Content-Type"),
1035 Some(&"application/json".to_string())
1036 );
1037 }
1038 _ => panic!("Expected TaskAction::Fetch"),
1039 }
1040 }
1041
1042 #[test]
1043 fn test_fetch_params_with_body() {
1044 let yaml = r#"
1045fetch:
1046 url: "https://api.example.com/data"
1047 method: "POST"
1048 body: '{"key": "value"}'
1049"#;
1050 let action: TaskAction = serde_yaml::from_str(yaml).unwrap();
1051 match action {
1052 TaskAction::Fetch { fetch } => {
1053 assert_eq!(fetch.body, Some(r#"{"key": "value"}"#.to_string()));
1054 }
1055 _ => panic!("Expected TaskAction::Fetch"),
1056 }
1057 }
1058
1059 #[test]
1060 fn test_fetch_params_complete() {
1061 let yaml = r#"
1062fetch:
1063 url: "https://api.example.com/users"
1064 method: "POST"
1065 headers:
1066 Authorization: "Bearer token"
1067 Content-Type: "application/json"
1068 body: '{"name": "Alice"}'
1069"#;
1070 let action: TaskAction = serde_yaml::from_str(yaml).unwrap();
1071 match action {
1072 TaskAction::Fetch { fetch } => {
1073 assert_eq!(fetch.url, "https://api.example.com/users");
1074 assert_eq!(fetch.method, "POST");
1075 assert_eq!(fetch.headers.len(), 2);
1076 assert_eq!(fetch.body, Some(r#"{"name": "Alice"}"#.to_string()));
1077 }
1078 _ => panic!("Expected TaskAction::Fetch"),
1079 }
1080 }
1081
1082 #[test]
1083 fn test_fetch_params_with_json() {
1084 let yaml = r#"
1085fetch:
1086 url: "https://api.example.com/users"
1087 method: "POST"
1088 json:
1089 name: "Alice"
1090 age: 30
1091 active: true
1092"#;
1093 let action: TaskAction = serde_yaml::from_str(yaml).unwrap();
1094 match action {
1095 TaskAction::Fetch { fetch } => {
1096 assert_eq!(fetch.url, "https://api.example.com/users");
1097 assert_eq!(fetch.method, "POST");
1098 assert!(fetch.json.is_some());
1099 let json = fetch.json.unwrap();
1100 assert_eq!(json["name"], "Alice");
1101 assert_eq!(json["age"], 30);
1102 assert_eq!(json["active"], true);
1103 assert!(fetch.body.is_none()); }
1105 _ => panic!("Expected TaskAction::Fetch"),
1106 }
1107 }
1108
1109 #[test]
1110 fn test_fetch_params_json_with_nested_objects() {
1111 let yaml = r#"
1112fetch:
1113 url: "https://api.example.com/data"
1114 method: "POST"
1115 json:
1116 user:
1117 name: "Bob"
1118 email: "bob@example.com"
1119 tags:
1120 - "admin"
1121 - "active"
1122"#;
1123 let action: TaskAction = serde_yaml::from_str(yaml).unwrap();
1124 match action {
1125 TaskAction::Fetch { fetch } => {
1126 let json = fetch.json.unwrap();
1127 assert_eq!(json["user"]["name"], "Bob");
1128 assert_eq!(json["user"]["email"], "bob@example.com");
1129 assert_eq!(json["tags"][0], "admin");
1130 assert_eq!(json["tags"][1], "active");
1131 }
1132 _ => panic!("Expected TaskAction::Fetch"),
1133 }
1134 }
1135
1136 #[test]
1137 fn test_fetch_params_follow_redirects_true() {
1138 let yaml = r#"
1139fetch:
1140 url: "https://example.com/redirect"
1141 follow_redirects: true
1142"#;
1143 let action: TaskAction = serde_yaml::from_str(yaml).unwrap();
1144 match action {
1145 TaskAction::Fetch { fetch } => {
1146 assert_eq!(fetch.url, "https://example.com/redirect");
1147 assert_eq!(fetch.follow_redirects, Some(true));
1148 }
1149 _ => panic!("Expected TaskAction::Fetch"),
1150 }
1151 }
1152
1153 #[test]
1154 fn test_fetch_params_follow_redirects_false() {
1155 let yaml = r#"
1156fetch:
1157 url: "https://example.com/redirect"
1158 follow_redirects: false
1159"#;
1160 let action: TaskAction = serde_yaml::from_str(yaml).unwrap();
1161 match action {
1162 TaskAction::Fetch { fetch } => {
1163 assert_eq!(fetch.url, "https://example.com/redirect");
1164 assert_eq!(fetch.follow_redirects, Some(false));
1165 }
1166 _ => panic!("Expected TaskAction::Fetch"),
1167 }
1168 }
1169
1170 #[test]
1171 fn test_fetch_params_follow_redirects_default_none() {
1172 let yaml = r#"
1173fetch:
1174 url: "https://example.com/api"
1175"#;
1176 let action: TaskAction = serde_yaml::from_str(yaml).unwrap();
1177 match action {
1178 TaskAction::Fetch { fetch } => {
1179 assert_eq!(fetch.url, "https://example.com/api");
1180 assert!(fetch.follow_redirects.is_none()); }
1182 _ => panic!("Expected TaskAction::Fetch"),
1183 }
1184 }
1185
1186 #[test]
1191 fn test_invoke_params_tool_call() {
1192 let yaml = r#"
1193invoke:
1194 mcp: novanet
1195 tool: novanet_context
1196 params:
1197 entity: qr-code
1198 locale: fr-FR
1199"#;
1200 let action: TaskAction = serde_yaml::from_str(yaml).unwrap();
1201 match action {
1202 TaskAction::Invoke { invoke } => {
1203 assert_eq!(invoke.mcp, Some("novanet".to_string()));
1204 assert_eq!(invoke.tool, Some("novanet_context".to_string()));
1205 assert_eq!(
1206 invoke.params,
1207 Some(json!({"entity": "qr-code", "locale": "fr-FR"}))
1208 );
1209 assert!(invoke.resource.is_none());
1210 }
1211 _ => panic!("Expected TaskAction::Invoke"),
1212 }
1213 }
1214
1215 #[test]
1216 fn test_invoke_params_resource_read() {
1217 let yaml = r#"
1218invoke:
1219 mcp: novanet
1220 resource: entity://qr-code/fr-FR
1221"#;
1222 let action: TaskAction = serde_yaml::from_str(yaml).unwrap();
1223 match action {
1224 TaskAction::Invoke { invoke } => {
1225 assert_eq!(invoke.mcp, Some("novanet".to_string()));
1226 assert!(invoke.tool.is_none());
1227 assert_eq!(invoke.resource, Some("entity://qr-code/fr-FR".to_string()));
1228 assert!(invoke.params.is_none());
1229 }
1230 _ => panic!("Expected TaskAction::Invoke"),
1231 }
1232 }
1233
1234 #[test]
1235 fn test_invoke_params_tool_without_params() {
1236 let yaml = r#"
1237invoke:
1238 mcp: test_server
1239 tool: simple_tool
1240"#;
1241 let action: TaskAction = serde_yaml::from_str(yaml).unwrap();
1242 match action {
1243 TaskAction::Invoke { invoke } => {
1244 assert_eq!(invoke.mcp, Some("test_server".to_string()));
1245 assert_eq!(invoke.tool, Some("simple_tool".to_string()));
1246 assert!(invoke.params.is_none());
1247 }
1248 _ => panic!("Expected TaskAction::Invoke"),
1249 }
1250 }
1251
1252 #[test]
1257 fn test_agent_params_minimal() {
1258 let yaml = r#"
1259agent:
1260 prompt: "Generate content for homepage"
1261"#;
1262 let action: TaskAction = serde_yaml::from_str(yaml).unwrap();
1263 match action {
1264 TaskAction::Agent { agent } => {
1265 assert_eq!(agent.prompt, "Generate content for homepage");
1266 assert!(agent.system.is_none());
1267 assert!(agent.provider.is_none());
1268 assert!(agent.model.is_none());
1269 assert!(agent.mcp.is_empty());
1270 }
1271 _ => panic!("Expected TaskAction::Agent"),
1272 }
1273 }
1274
1275 #[test]
1276 fn test_agent_params_with_mcp() {
1277 let yaml = r#"
1278agent:
1279 prompt: "Generate with MCP tools"
1280 mcp:
1281 - novanet
1282 - perplexity
1283"#;
1284 let action: TaskAction = serde_yaml::from_str(yaml).unwrap();
1285 match action {
1286 TaskAction::Agent { agent } => {
1287 assert_eq!(agent.mcp.len(), 2);
1288 assert!(agent.mcp.contains(&"novanet".to_string()));
1289 assert!(agent.mcp.contains(&"perplexity".to_string()));
1290 }
1291 _ => panic!("Expected TaskAction::Agent"),
1292 }
1293 }
1294
1295 #[test]
1296 fn test_agent_params_with_max_turns() {
1297 let yaml = r#"
1298agent:
1299 prompt: "Test prompt"
1300 max_turns: 5
1301"#;
1302 let action: TaskAction = serde_yaml::from_str(yaml).unwrap();
1303 match action {
1304 TaskAction::Agent { agent } => {
1305 assert_eq!(agent.max_turns, Some(5));
1306 }
1307 _ => panic!("Expected TaskAction::Agent"),
1308 }
1309 }
1310
1311 #[test]
1312 fn test_agent_params_with_extended_thinking() {
1313 let yaml = r#"
1314agent:
1315 prompt: "Test prompt"
1316 extended_thinking: true
1317 thinking_budget: 8192
1318"#;
1319 let action: TaskAction = serde_yaml::from_str(yaml).unwrap();
1320 match action {
1321 TaskAction::Agent { agent } => {
1322 assert_eq!(agent.extended_thinking, Some(true));
1323 assert_eq!(agent.thinking_budget, Some(8192));
1324 }
1325 _ => panic!("Expected TaskAction::Agent"),
1326 }
1327 }
1328
1329 #[test]
1330 fn test_agent_params_with_provider_and_model() {
1331 let yaml = r#"
1332agent:
1333 prompt: "Test prompt"
1334 provider: claude
1335 model: claude-sonnet-4-6
1336"#;
1337 let action: TaskAction = serde_yaml::from_str(yaml).unwrap();
1338 match action {
1339 TaskAction::Agent { agent } => {
1340 assert_eq!(agent.provider, Some("claude".to_string()));
1341 assert_eq!(agent.model, Some("claude-sonnet-4-6".to_string()));
1342 }
1343 _ => panic!("Expected TaskAction::Agent"),
1344 }
1345 }
1346
1347 #[test]
1348 fn test_agent_params_complete() {
1349 let yaml = r#"
1350agent:
1351 prompt: "Generate landing page content"
1352 system: "You are a web content expert"
1353 provider: claude
1354 model: claude-sonnet-4-6
1355 mcp:
1356 - novanet
1357 max_turns: 10
1358 token_budget: 10000
1359 scope: full
1360 extended_thinking: true
1361 thinking_budget: 4096
1362"#;
1363 let action: TaskAction = serde_yaml::from_str(yaml).unwrap();
1364 match action {
1365 TaskAction::Agent { agent } => {
1366 assert_eq!(agent.prompt, "Generate landing page content");
1367 assert_eq!(
1368 agent.system,
1369 Some("You are a web content expert".to_string())
1370 );
1371 assert_eq!(agent.provider, Some("claude".to_string()));
1372 assert_eq!(agent.model, Some("claude-sonnet-4-6".to_string()));
1373 assert_eq!(agent.mcp.len(), 1);
1374 assert_eq!(agent.max_turns, Some(10));
1375 assert_eq!(agent.token_budget, Some(10000));
1376 assert_eq!(agent.scope, Some("full".to_string()));
1377 assert_eq!(agent.extended_thinking, Some(true));
1378 assert_eq!(agent.thinking_budget, Some(4096));
1379 }
1380 _ => panic!("Expected TaskAction::Agent"),
1381 }
1382 }
1383
1384 #[test]
1389 fn test_verb_name_infer() {
1390 let action = TaskAction::Infer {
1391 infer: InferParams {
1392 prompt: "test".to_string(),
1393 ..Default::default()
1394 },
1395 };
1396 assert_eq!(action.verb_name(), "infer");
1397 }
1398
1399 #[test]
1400 fn test_verb_name_exec() {
1401 let action = TaskAction::Exec {
1402 exec: ExecParams {
1403 command: "echo test".to_string(),
1404 shell: None,
1405 timeout: None,
1406 cwd: None,
1407 env: None,
1408 },
1409 };
1410 assert_eq!(action.verb_name(), "exec");
1411 }
1412
1413 #[test]
1414 fn test_verb_name_fetch() {
1415 let action = TaskAction::Fetch {
1416 fetch: FetchParams {
1417 url: "https://example.com".to_string(),
1418 method: "GET".to_string(),
1419 headers: FxHashMap::default(),
1420 body: None,
1421 json: None,
1422 timeout: None,
1423 retry: None,
1424 follow_redirects: None,
1425 response: None,
1426 extract: None,
1427 selector: None,
1428 },
1429 };
1430 assert_eq!(action.verb_name(), "fetch");
1431 }
1432
1433 #[test]
1434 fn test_verb_name_invoke() {
1435 let action = TaskAction::Invoke {
1436 invoke: InvokeParams {
1437 mcp: Some("test".to_string()),
1438 tool: Some("test_tool".to_string()),
1439 params: None,
1440 resource: None,
1441 timeout: None,
1442 },
1443 };
1444 assert_eq!(action.verb_name(), "invoke");
1445 }
1446
1447 #[test]
1448 fn test_verb_name_agent() {
1449 let action = TaskAction::Agent {
1450 agent: AgentParams {
1451 prompt: "test".to_string(),
1452 ..Default::default()
1453 },
1454 };
1455 assert_eq!(action.verb_name(), "agent");
1456 }
1457
1458 #[test]
1463 fn test_parse_multiple_action_types() {
1464 let infer_yaml = r#"infer: "test""#;
1465 let exec_yaml = r#"exec: "test""#;
1466 let fetch_yaml = r#"fetch: { url: "http://example.com" }"#;
1467
1468 let infer_action: TaskAction = serde_yaml::from_str(infer_yaml).unwrap();
1469 let exec_action: TaskAction = serde_yaml::from_str(exec_yaml).unwrap();
1470 let fetch_action: TaskAction = serde_yaml::from_str(fetch_yaml).unwrap();
1471
1472 assert_eq!(infer_action.verb_name(), "infer");
1473 assert_eq!(exec_action.verb_name(), "exec");
1474 assert_eq!(fetch_action.verb_name(), "fetch");
1475 }
1476
1477 #[test]
1482 fn test_infer_params_empty_prompt() {
1483 let yaml = r#"
1484infer: ""
1485"#;
1486 let action: TaskAction = serde_yaml::from_str(yaml).unwrap();
1487 match action {
1488 TaskAction::Infer { infer } => {
1489 assert_eq!(infer.prompt, "");
1490 }
1491 _ => panic!("Expected TaskAction::Infer"),
1492 }
1493 }
1494
1495 #[test]
1496 fn test_exec_params_empty_command() {
1497 let yaml = r#"
1498exec: ""
1499"#;
1500 let action: TaskAction = serde_yaml::from_str(yaml).unwrap();
1501 match action {
1502 TaskAction::Exec { exec } => {
1503 assert_eq!(exec.command, "");
1504 }
1505 _ => panic!("Expected TaskAction::Exec"),
1506 }
1507 }
1508
1509 #[test]
1510 fn test_fetch_params_empty_headers() {
1511 let yaml = r#"
1512fetch:
1513 url: "https://example.com"
1514 headers: {}
1515"#;
1516 let action: TaskAction = serde_yaml::from_str(yaml).unwrap();
1517 match action {
1518 TaskAction::Fetch { fetch } => {
1519 assert!(fetch.headers.is_empty());
1520 }
1521 _ => panic!("Expected TaskAction::Fetch"),
1522 }
1523 }
1524
1525 #[test]
1526 fn test_agent_params_empty_mcp_list() {
1527 let yaml = r#"
1528agent:
1529 prompt: "test"
1530 mcp: []
1531"#;
1532 let action: TaskAction = serde_yaml::from_str(yaml).unwrap();
1533 match action {
1534 TaskAction::Agent { agent } => {
1535 assert!(agent.mcp.is_empty());
1536 }
1537 _ => panic!("Expected TaskAction::Agent"),
1538 }
1539 }
1540
1541 #[test]
1546 fn test_infer_params_special_characters() {
1547 let yaml = r#"
1548infer: "Generate content with special chars: !@#$%^&*()"
1549"#;
1550 let action: TaskAction = serde_yaml::from_str(yaml).unwrap();
1551 match action {
1552 TaskAction::Infer { infer } => {
1553 assert!(infer.prompt.contains("!@#$%^&*()"));
1554 }
1555 _ => panic!("Expected TaskAction::Infer"),
1556 }
1557 }
1558
1559 #[test]
1560 fn test_infer_params_unicode() {
1561 let yaml = r#"
1562infer: "Generate content en français: résumé, café, naïve"
1563"#;
1564 let action: TaskAction = serde_yaml::from_str(yaml).unwrap();
1565 match action {
1566 TaskAction::Infer { infer } => {
1567 assert!(infer.prompt.contains("français"));
1568 assert!(infer.prompt.contains("résumé"));
1569 }
1570 _ => panic!("Expected TaskAction::Infer"),
1571 }
1572 }
1573
1574 #[test]
1575 fn test_fetch_params_url_with_query_string() {
1576 let yaml = r#"
1577fetch:
1578 url: "https://api.example.com/search?q=rust&limit=10&offset=5"
1579"#;
1580 let action: TaskAction = serde_yaml::from_str(yaml).unwrap();
1581 match action {
1582 TaskAction::Fetch { fetch } => {
1583 assert!(fetch.url.contains("search?q=rust"));
1584 assert!(fetch.url.contains("limit=10"));
1585 }
1586 _ => panic!("Expected TaskAction::Fetch"),
1587 }
1588 }
1589
1590 #[test]
1595 fn test_infer_action_clone() {
1596 let action = TaskAction::Infer {
1597 infer: InferParams {
1598 prompt: "test".to_string(),
1599 provider: Some("claude".to_string()),
1600 model: Some("claude-sonnet-4-6".to_string()),
1601 ..Default::default()
1602 },
1603 };
1604 let cloned = action.clone();
1605 assert_eq!(action.verb_name(), cloned.verb_name());
1606 }
1607
1608 #[test]
1609 fn test_all_action_types_cloneable() {
1610 let infer = TaskAction::Infer {
1611 infer: InferParams {
1612 prompt: "test".to_string(),
1613 ..Default::default()
1614 },
1615 };
1616 let exec = TaskAction::Exec {
1617 exec: ExecParams {
1618 command: "echo".to_string(),
1619 shell: None,
1620 timeout: None,
1621 cwd: None,
1622 env: None,
1623 },
1624 };
1625 let fetch = TaskAction::Fetch {
1626 fetch: FetchParams {
1627 url: "http://example.com".to_string(),
1628 method: "GET".to_string(),
1629 headers: FxHashMap::default(),
1630 body: None,
1631 json: None,
1632 timeout: None,
1633 retry: None,
1634 follow_redirects: None,
1635 response: None,
1636 extract: None,
1637 selector: None,
1638 },
1639 };
1640
1641 let _ = infer.clone();
1642 let _ = exec.clone();
1643 let _ = fetch.clone();
1644 }
1645
1646 #[test]
1651 fn test_fetch_validate_valid_extract_modes() {
1652 let valid_modes = [
1653 "markdown", "article", "text", "selector", "metadata", "links", "jsonpath", "feed",
1654 "llm_txt",
1655 ];
1656 for mode in &valid_modes {
1657 let params = FetchParams {
1658 url: "https://example.com".to_string(),
1659 method: "GET".to_string(),
1660 headers: FxHashMap::default(),
1661 body: None,
1662 json: None,
1663 timeout: None,
1664 retry: None,
1665 follow_redirects: None,
1666 response: None,
1667 extract: Some(mode.to_string()),
1668 selector: None,
1669 };
1670 assert!(
1671 params.validate().is_ok(),
1672 "extract mode '{}' should be valid",
1673 mode
1674 );
1675 }
1676 }
1677
1678 #[test]
1679 fn test_fetch_validate_invalid_extract_mode() {
1680 let params = FetchParams {
1681 url: "https://example.com".to_string(),
1682 method: "GET".to_string(),
1683 headers: FxHashMap::default(),
1684 body: None,
1685 json: None,
1686 timeout: None,
1687 retry: None,
1688 follow_redirects: None,
1689 response: None,
1690 extract: Some("invalid_mode".to_string()),
1691 selector: None,
1692 };
1693 let err = params.validate().unwrap_err();
1694 assert!(err.to_string().contains("extract must be one of"));
1695 assert!(err.to_string().contains("invalid_mode"));
1696 }
1697
1698 #[test]
1699 fn test_fetch_validate_selector_without_extract() {
1700 let params = FetchParams {
1701 url: "https://example.com".to_string(),
1702 method: "GET".to_string(),
1703 headers: FxHashMap::default(),
1704 body: None,
1705 json: None,
1706 timeout: None,
1707 retry: None,
1708 follow_redirects: None,
1709 response: None,
1710 extract: None,
1711 selector: Some("div.content".to_string()),
1712 };
1713 let err = params.validate().unwrap_err();
1714 assert!(err.to_string().contains("selector"));
1715 assert!(err.to_string().contains("requires"));
1716 }
1717
1718 #[test]
1719 fn test_fetch_validate_selector_with_extract() {
1720 let params = FetchParams {
1721 url: "https://example.com".to_string(),
1722 method: "GET".to_string(),
1723 headers: FxHashMap::default(),
1724 body: None,
1725 json: None,
1726 timeout: None,
1727 retry: None,
1728 follow_redirects: None,
1729 response: None,
1730 extract: Some("text".to_string()),
1731 selector: Some("p.intro".to_string()),
1732 };
1733 assert!(params.validate().is_ok());
1734 }
1735
1736 #[test]
1737 fn test_fetch_validate_no_extract_no_selector() {
1738 let params = FetchParams {
1739 url: "https://example.com".to_string(),
1740 method: "GET".to_string(),
1741 headers: FxHashMap::default(),
1742 body: None,
1743 json: None,
1744 timeout: None,
1745 retry: None,
1746 follow_redirects: None,
1747 response: None,
1748 extract: None,
1749 selector: None,
1750 };
1751 assert!(params.validate().is_ok());
1752 }
1753
1754 #[test]
1755 fn test_fetch_params_extract_deserialize() {
1756 let yaml = r#"
1757fetch:
1758 url: "https://example.com"
1759 extract: markdown
1760"#;
1761 let action: TaskAction = serde_yaml::from_str(yaml).unwrap();
1762 match action {
1763 TaskAction::Fetch { fetch } => {
1764 assert_eq!(fetch.url, "https://example.com");
1765 assert_eq!(fetch.extract, Some("markdown".to_string()));
1766 assert!(fetch.selector.is_none());
1767 }
1768 _ => panic!("Expected TaskAction::Fetch"),
1769 }
1770 }
1771
1772 #[test]
1773 fn test_fetch_params_extract_with_selector_deserialize() {
1774 let yaml = r#"
1775fetch:
1776 url: "https://example.com"
1777 extract: selector
1778 selector: "div.content"
1779"#;
1780 let action: TaskAction = serde_yaml::from_str(yaml).unwrap();
1781 match action {
1782 TaskAction::Fetch { fetch } => {
1783 assert_eq!(fetch.extract, Some("selector".to_string()));
1784 assert_eq!(fetch.selector, Some("div.content".to_string()));
1785 }
1786 _ => panic!("Expected TaskAction::Fetch"),
1787 }
1788 }
1789
1790 #[test]
1791 fn test_fetch_params_no_extract_backward_compatible() {
1792 let yaml = r#"
1793fetch:
1794 url: "https://example.com"
1795"#;
1796 let action: TaskAction = serde_yaml::from_str(yaml).unwrap();
1797 match action {
1798 TaskAction::Fetch { fetch } => {
1799 assert!(fetch.extract.is_none());
1800 assert!(fetch.selector.is_none());
1801 }
1802 _ => panic!("Expected TaskAction::Fetch"),
1803 }
1804 }
1805}