1pub mod model;
2
3use std::collections::{HashMap, VecDeque};
4use std::path::{Path, PathBuf};
5use std::sync::Mutex;
6use std::time::{Duration, SystemTime, UNIX_EPOCH};
7
8use tokio::process::Command;
9use uuid::Uuid;
10
11use crate::config::TaskMode;
12use crate::just::model::{
13 JustDump, JustRecipe, Recipe, RecipeSource, TaskError, TaskExecution, TaskExecutionSummary,
14};
15
16#[derive(Debug, thiserror::Error)]
21pub enum JustError {
22 #[error("just command not found: {0}")]
23 NotFound(String),
24 #[error("just command failed (exit {code}): {stderr}")]
25 CommandFailed { code: i32, stderr: String },
26 #[error("failed to parse just dump json: {0}")]
27 ParseError(#[from] serde_json::Error),
28 #[error("I/O error while reading justfile: {0}")]
29 Io(#[from] std::io::Error),
30}
31
32pub async fn list_recipes(
46 justfile_path: &Path,
47 mode: &TaskMode,
48 workdir: Option<&Path>,
49) -> Result<Vec<Recipe>, JustError> {
50 list_recipes_with_source(justfile_path, mode, workdir, RecipeSource::Project).await
51}
52
53pub async fn list_recipes_merged(
63 project_path: &Path,
64 global_path: Option<&Path>,
65 mode: &TaskMode,
66 project_workdir: Option<&Path>,
67) -> Result<Vec<Recipe>, JustError> {
68 let project_recipes = if tokio::fs::metadata(project_path).await.is_ok() {
72 list_recipes_with_source(project_path, mode, project_workdir, RecipeSource::Project).await?
73 } else {
74 Vec::new()
75 };
76
77 let global_path = match global_path {
78 Some(p) => p,
79 None => return Ok(project_recipes),
80 };
81
82 let global_recipes =
85 list_recipes_with_source(global_path, mode, project_workdir, RecipeSource::Global).await?;
86
87 let project_names: std::collections::HashSet<&str> =
89 project_recipes.iter().map(|r| r.name.as_str()).collect();
90
91 let global_only: Vec<Recipe> = global_recipes
93 .into_iter()
94 .filter(|r| !project_names.contains(r.name.as_str()))
95 .collect();
96
97 let mut merged = project_recipes;
99 merged.extend(global_only);
100 Ok(merged)
101}
102
103async fn list_recipes_with_source(
105 justfile_path: &Path,
106 mode: &TaskMode,
107 workdir: Option<&Path>,
108 source: RecipeSource,
109) -> Result<Vec<Recipe>, JustError> {
110 let dump = dump_json(justfile_path, workdir).await?;
111
112 let mut recipes: Vec<Recipe> = dump
113 .recipes
114 .into_values()
115 .filter(|r| !r.private)
116 .map(|raw| {
117 let allow_agent = is_allow_agent(&raw);
118 Recipe::from_just_recipe_with_source(raw, allow_agent, source)
119 })
120 .collect();
121
122 recipes.sort_by(|a, b| a.name.cmp(&b.name));
123
124 match mode {
125 TaskMode::AgentOnly => Ok(recipes.into_iter().filter(|r| r.allow_agent).collect()),
126 TaskMode::All => Ok(recipes),
127 }
128}
129
130async fn dump_json(justfile_path: &Path, workdir: Option<&Path>) -> Result<JustDump, JustError> {
136 let mut cmd = Command::new("just");
137 cmd.arg("--justfile")
138 .arg(justfile_path)
139 .arg("--dump")
140 .arg("--dump-format")
141 .arg("json")
142 .arg("--unstable");
143 if let Some(dir) = workdir {
144 cmd.current_dir(dir);
145 }
146 let output = cmd
147 .output()
148 .await
149 .map_err(|e| JustError::NotFound(e.to_string()))?;
150
151 if !output.status.success() {
152 let code = output.status.code().unwrap_or(-1);
153 let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
154 return Err(JustError::CommandFailed { code, stderr });
155 }
156
157 let json_str = String::from_utf8_lossy(&output.stdout);
158 let dump: JustDump = serde_json::from_str(&json_str)?;
159 Ok(dump)
160}
161
162fn has_allow_agent_group_attribute(recipe: &JustRecipe) -> bool {
164 recipe
165 .attributes
166 .iter()
167 .any(|a| a.group() == Some("allow-agent"))
168}
169
170fn has_allow_agent_doc(recipe: &JustRecipe) -> bool {
180 recipe
181 .doc
182 .as_deref()
183 .is_some_and(|d| d.split_whitespace().any(|t| t == "[allow-agent]"))
184}
185
186fn is_allow_agent(recipe: &JustRecipe) -> bool {
188 has_allow_agent_group_attribute(recipe) || has_allow_agent_doc(recipe)
189}
190
191pub fn resolve_justfile_path(override_path: Option<&str>, workdir: Option<&Path>) -> PathBuf {
193 match override_path {
194 Some(p) => PathBuf::from(p),
195 None => match workdir {
196 Some(dir) => dir.join("justfile"),
197 None => PathBuf::from("justfile"),
198 },
199 }
200}
201
202const MAX_OUTPUT_BYTES: usize = 100 * 1024; const HEAD_BYTES: usize = 50 * 1024; const TAIL_BYTES: usize = 50 * 1024; pub fn truncate_output(output: &str) -> (String, bool) {
218 if output.len() <= MAX_OUTPUT_BYTES {
219 return (output.to_string(), false);
220 }
221
222 let head_end = safe_byte_boundary(output, HEAD_BYTES);
224 let tail_start_raw = output.len().saturating_sub(TAIL_BYTES);
226 let tail_start = safe_tail_start(output, tail_start_raw);
227
228 let head = &output[..head_end];
229 let tail = &output[tail_start..];
230 let truncated_bytes = output.len() - head_end - (output.len() - tail_start);
231
232 (
233 format!("{head}\n...[truncated {truncated_bytes} bytes]...\n{tail}"),
234 true,
235 )
236}
237
238fn safe_byte_boundary(s: &str, limit: usize) -> usize {
240 if limit >= s.len() {
241 return s.len();
242 }
243 let mut idx = limit;
245 while idx > 0 && !s.is_char_boundary(idx) {
246 idx -= 1;
247 }
248 idx
249}
250
251fn safe_tail_start(s: &str, hint: usize) -> usize {
253 if hint >= s.len() {
254 return s.len();
255 }
256 let mut idx = hint;
257 while idx < s.len() && !s.is_char_boundary(idx) {
258 idx += 1;
259 }
260 idx
261}
262
263pub fn validate_arg_value(value: &str) -> Result<(), TaskError> {
278 const REJECTED_CONTROL_CHARS: &[&str] = &["\n", "\r"];
279 for pattern in REJECTED_CONTROL_CHARS {
280 if value.contains(pattern) {
281 return Err(TaskError::DangerousArgument(value.to_string()));
282 }
283 }
284 Ok(())
285}
286
287const CONTENT_ENV_PREFIX: &str = "TASK_MCP_CONTENT_";
289
290pub fn validate_content_key(key: &str) -> Result<(), TaskError> {
296 let mut chars = key.chars();
297 match chars.next() {
298 None => return Err(TaskError::InvalidContentKey(key.to_string())),
299 Some(c) if !c.is_ascii_alphabetic() => {
300 return Err(TaskError::InvalidContentKey(key.to_string()));
301 }
302 _ => {}
303 }
304 for c in chars {
305 if !c.is_ascii_alphanumeric() && c != '_' {
306 return Err(TaskError::InvalidContentKey(key.to_string()));
307 }
308 }
309 Ok(())
310}
311
312pub async fn execute_recipe(
326 recipe_name: &str,
327 args: &HashMap<String, String>,
328 content: &HashMap<String, String>,
329 justfile_path: &Path,
330 timeout: Duration,
331 mode: &TaskMode,
332 workdir: Option<&Path>,
333) -> Result<TaskExecution, TaskError> {
334 let recipes = list_recipes(justfile_path, mode, workdir).await?;
336 let recipe = recipes
337 .iter()
338 .find(|r| r.name == recipe_name)
339 .ok_or_else(|| TaskError::RecipeNotFound(recipe_name.to_string()))?;
340
341 for value in args.values() {
343 validate_arg_value(value)?;
344 }
345
346 for key in content.keys() {
348 validate_content_key(key)?;
349 }
350
351 execute_with_justfile(recipe, args, content, justfile_path, workdir, timeout).await
352}
353
354#[allow(clippy::too_many_arguments)]
360pub async fn execute_recipe_merged(
361 recipe_name: &str,
362 args: &HashMap<String, String>,
363 content: &HashMap<String, String>,
364 project_justfile_path: &Path,
365 global_justfile_path: Option<&Path>,
366 timeout: Duration,
367 mode: &TaskMode,
368 project_workdir: Option<&Path>,
369) -> Result<TaskExecution, TaskError> {
370 let recipes = list_recipes_merged(
372 project_justfile_path,
373 global_justfile_path,
374 mode,
375 project_workdir,
376 )
377 .await?;
378
379 let recipe = recipes
380 .iter()
381 .find(|r| r.name == recipe_name)
382 .ok_or_else(|| TaskError::RecipeNotFound(recipe_name.to_string()))?;
383
384 for value in args.values() {
386 validate_arg_value(value)?;
387 }
388
389 for key in content.keys() {
391 validate_content_key(key)?;
392 }
393
394 let effective_justfile = match recipe.source {
396 RecipeSource::Global => global_justfile_path
397 .ok_or_else(|| TaskError::RecipeNotFound(recipe_name.to_string()))?,
398 RecipeSource::Project => project_justfile_path,
399 };
400
401 execute_with_justfile(
402 recipe,
403 args,
404 content,
405 effective_justfile,
406 project_workdir,
407 timeout,
408 )
409 .await
410}
411
412async fn execute_with_justfile(
418 recipe: &Recipe,
419 args: &HashMap<String, String>,
420 content: &HashMap<String, String>,
421 effective_justfile: &Path,
422 project_workdir: Option<&Path>,
423 timeout: Duration,
424) -> Result<TaskExecution, TaskError> {
425 let positional: Vec<&str> = recipe
427 .parameters
428 .iter()
429 .filter_map(|p| args.get(&p.name).map(|v| v.as_str()))
430 .collect();
431
432 let started_at = SystemTime::now()
433 .duration_since(UNIX_EPOCH)
434 .unwrap_or_default()
435 .as_secs();
436 let start_instant = std::time::Instant::now();
437
438 let mut cmd = Command::new("just");
439 cmd.arg("--justfile").arg(effective_justfile);
440 if let Some(dir) = project_workdir {
441 cmd.arg("--working-directory").arg(dir);
442 cmd.current_dir(dir);
443 }
444 cmd.arg(&recipe.name);
445 for arg in &positional {
446 cmd.arg(arg);
447 }
448
449 for (key, value) in content {
455 validate_content_key(key)?;
456 let env_name = format!("{}{}", CONTENT_ENV_PREFIX, key.to_uppercase());
457 cmd.env(&env_name, value);
458 }
459
460 let run_result = tokio::time::timeout(timeout, cmd.output()).await;
461 let duration_ms = start_instant.elapsed().as_millis() as u64;
462
463 let output = match run_result {
464 Err(_) => return Err(TaskError::Timeout),
465 Ok(Err(io_err)) => return Err(TaskError::Io(io_err)),
466 Ok(Ok(out)) => out,
467 };
468
469 let exit_code = output.status.code();
470 let raw_stdout = String::from_utf8_lossy(&output.stdout).into_owned();
471 let raw_stderr = String::from_utf8_lossy(&output.stderr).into_owned();
472 let (stdout, stdout_truncated) = truncate_output(&raw_stdout);
473 let (stderr, stderr_truncated) = truncate_output(&raw_stderr);
474 let truncated = stdout_truncated || stderr_truncated;
475
476 Ok(TaskExecution {
477 id: Uuid::new_v4().to_string(),
478 task_name: recipe.name.clone(),
479 args: args.clone(),
480 content: content.clone(),
481 exit_code,
482 stdout,
483 stderr,
484 started_at,
485 duration_ms,
486 truncated,
487 })
488}
489
490pub struct TaskLogStore {
499 logs: Mutex<VecDeque<TaskExecution>>,
500 max_entries: usize,
501}
502
503impl TaskLogStore {
504 pub fn new(max_entries: usize) -> Self {
505 Self {
506 logs: Mutex::new(VecDeque::new()),
507 max_entries,
508 }
509 }
510
511 pub fn push(&self, execution: TaskExecution) {
513 let mut guard = self.logs.lock().expect("log store lock poisoned");
514 if guard.len() >= self.max_entries {
515 guard.pop_front();
516 }
517 guard.push_back(execution);
518 }
519
520 pub fn get(&self, id: &str) -> Option<TaskExecution> {
522 let guard = self.logs.lock().expect("log store lock poisoned");
523 guard.iter().find(|e| e.id == id).cloned()
524 }
525
526 pub fn recent(&self, n: usize) -> Vec<TaskExecutionSummary> {
528 let guard = self.logs.lock().expect("log store lock poisoned");
529 guard
530 .iter()
531 .rev()
532 .take(n)
533 .map(TaskExecutionSummary::from_execution)
534 .collect()
535 }
536}
537
538#[cfg(test)]
539mod tests {
540 use super::*;
541 use crate::just::model::RecipeAttribute;
542
543 fn make_recipe(name: &str, attributes: Vec<RecipeAttribute>) -> JustRecipe {
544 make_recipe_with_doc(name, attributes, None)
545 }
546
547 fn make_recipe_with_doc(
548 name: &str,
549 attributes: Vec<RecipeAttribute>,
550 doc: Option<&str>,
551 ) -> JustRecipe {
552 crate::just::model::JustRecipe {
553 name: name.to_string(),
554 namepath: name.to_string(),
555 doc: doc.map(str::to_string),
556 attributes,
557 parameters: vec![],
558 private: false,
559 quiet: false,
560 }
561 }
562
563 #[test]
564 fn has_allow_agent_group_attribute_true() {
565 let recipe = make_recipe(
566 "build",
567 vec![RecipeAttribute::Object(
568 [("group".to_string(), Some("allow-agent".to_string()))]
569 .into_iter()
570 .collect(),
571 )],
572 );
573 assert!(has_allow_agent_group_attribute(&recipe));
574 }
575
576 #[test]
577 fn has_allow_agent_group_attribute_false_no_attrs() {
578 let recipe = make_recipe("deploy", vec![]);
579 assert!(!has_allow_agent_group_attribute(&recipe));
580 }
581
582 #[test]
583 fn has_allow_agent_group_attribute_false_other_group() {
584 let recipe = make_recipe(
585 "build",
586 vec![RecipeAttribute::Object(
587 [("group".to_string(), Some("ci".to_string()))]
588 .into_iter()
589 .collect(),
590 )],
591 );
592 assert!(!has_allow_agent_group_attribute(&recipe));
593 }
594
595 #[test]
596 fn has_allow_agent_group_attribute_false_legacy_agent_literal() {
597 let recipe = make_recipe(
600 "build",
601 vec![RecipeAttribute::Object(
602 [("group".to_string(), Some("agent".to_string()))]
603 .into_iter()
604 .collect(),
605 )],
606 );
607 assert!(!has_allow_agent_group_attribute(&recipe));
608 }
609
610 #[test]
611 fn has_allow_agent_doc_true() {
612 let recipe = make_recipe_with_doc("build", vec![], Some("[allow-agent]"));
613 assert!(has_allow_agent_doc(&recipe));
614 }
615
616 #[test]
617 fn has_allow_agent_doc_false_no_doc() {
618 let recipe = make_recipe("build", vec![]);
619 assert!(!has_allow_agent_doc(&recipe));
620 }
621
622 #[test]
623 fn has_allow_agent_doc_false_other_doc() {
624 let recipe = make_recipe_with_doc("build", vec![], Some("Build the project"));
625 assert!(!has_allow_agent_doc(&recipe));
626 }
627
628 #[test]
629 fn has_allow_agent_doc_false_substring_in_prose() {
630 let recipe = make_recipe_with_doc(
632 "build",
633 vec![],
634 Some("do not add-[allow-agent]-here casually"),
635 );
636 assert!(!has_allow_agent_doc(&recipe));
637 }
638
639 #[test]
640 fn has_allow_agent_doc_true_with_surrounding_whitespace() {
641 let recipe = make_recipe_with_doc("build", vec![], Some(" [allow-agent] "));
642 assert!(has_allow_agent_doc(&recipe));
643 }
644
645 #[test]
646 fn is_allow_agent_pattern_a() {
647 let recipe = make_recipe(
648 "build",
649 vec![RecipeAttribute::Object(
650 [("group".to_string(), Some("allow-agent".to_string()))]
651 .into_iter()
652 .collect(),
653 )],
654 );
655 assert!(is_allow_agent(&recipe));
656 }
657
658 #[test]
659 fn is_allow_agent_pattern_b() {
660 let recipe = make_recipe_with_doc("build", vec![], Some("[allow-agent]"));
661 assert!(is_allow_agent(&recipe));
662 }
663
664 #[test]
665 fn is_allow_agent_pattern_a_plus_other_groups() {
666 let recipe = make_recipe(
670 "build",
671 vec![
672 RecipeAttribute::Object(
673 [("group".to_string(), Some("allow-agent".to_string()))]
674 .into_iter()
675 .collect(),
676 ),
677 RecipeAttribute::Object(
678 [("group".to_string(), Some("profile".to_string()))]
679 .into_iter()
680 .collect(),
681 ),
682 ],
683 );
684 assert!(is_allow_agent(&recipe));
685 }
686
687 #[test]
688 fn is_allow_agent_neither() {
689 let recipe = make_recipe("deploy", vec![]);
690 assert!(!is_allow_agent(&recipe));
691 }
692
693 #[test]
694 fn is_allow_agent_non_agent_group_only() {
695 let recipe = make_recipe_with_doc(
700 "foo",
701 vec![RecipeAttribute::Object(
702 [("group".to_string(), Some("profile".to_string()))]
703 .into_iter()
704 .collect(),
705 )],
706 Some("[allow-agent]"),
707 );
708 assert!(is_allow_agent(&recipe));
709 }
710
711 #[test]
712 fn resolve_justfile_path_override() {
713 let p = resolve_justfile_path(Some("/custom/justfile"), None);
714 assert_eq!(p, PathBuf::from("/custom/justfile"));
715 }
716
717 #[test]
718 fn resolve_justfile_path_default() {
719 let p = resolve_justfile_path(None, None);
720 assert_eq!(p, PathBuf::from("justfile"));
721 }
722
723 #[test]
724 fn resolve_justfile_path_with_workdir() {
725 let workdir = Path::new("/some/project");
726 let p = resolve_justfile_path(None, Some(workdir));
727 assert_eq!(p, PathBuf::from("/some/project/justfile"));
728 }
729
730 #[test]
731 fn resolve_justfile_path_override_ignores_workdir() {
732 let workdir = Path::new("/some/project");
734 let p = resolve_justfile_path(Some("/custom/justfile"), Some(workdir));
735 assert_eq!(p, PathBuf::from("/custom/justfile"));
736 }
737
738 #[test]
743 fn truncate_output_short_input_unchanged() {
744 let input = "hello";
745 let (result, truncated) = truncate_output(input);
746 assert!(!truncated);
747 assert_eq!(result, input);
748 }
749
750 #[test]
751 fn truncate_output_long_input_truncated() {
752 let input = "x".repeat(200 * 1024);
754 let (result, truncated) = truncate_output(&input);
755 assert!(truncated);
756 assert!(result.contains("...[truncated"));
757 assert!(result.len() < input.len());
759 }
760
761 #[test]
762 fn truncate_output_utf8_boundary() {
763 let char_3bytes = '日';
766 let count = (MAX_OUTPUT_BYTES / 3) + 10;
768 let input: String = std::iter::repeat_n(char_3bytes, count).collect();
769 let (result, truncated) = truncate_output(&input);
770 assert!(std::str::from_utf8(result.as_bytes()).is_ok());
772 if truncated {
773 assert!(result.contains("...[truncated"));
774 }
775 }
776
777 #[test]
782 fn validate_arg_value_safe_values() {
783 assert!(validate_arg_value("hello world").is_ok());
784 assert!(validate_arg_value("value_123-abc").is_ok());
785 assert!(validate_arg_value("path/to/file.txt").is_ok());
786 }
787
788 #[test]
789 fn validate_arg_value_semicolon_allowed() {
790 assert!(validate_arg_value("foo; rm -rf /").is_ok());
791 }
792
793 #[test]
794 fn validate_arg_value_pipe_allowed() {
795 assert!(validate_arg_value("foo | cat /etc/passwd").is_ok());
796 }
797
798 #[test]
799 fn validate_arg_value_and_and_allowed() {
800 assert!(validate_arg_value("foo && evil").is_ok());
801 }
802
803 #[test]
804 fn validate_arg_value_backtick_allowed() {
805 assert!(validate_arg_value("foo`id`").is_ok());
806 }
807
808 #[test]
809 fn validate_arg_value_dollar_paren_allowed() {
810 assert!(validate_arg_value("$(id)").is_ok());
811 }
812
813 #[test]
814 fn validate_arg_value_newline_rejected() {
815 assert!(validate_arg_value("foo\nbar").is_err());
816 }
817
818 #[test]
819 fn validate_arg_value_carriage_return_rejected() {
820 assert!(validate_arg_value("foo\rbar").is_err());
821 }
822
823 #[test]
824 fn validate_arg_value_shell_metacharacters_allowed() {
825 assert!(validate_arg_value("https://example.com?a=1&b=2").is_ok());
828 assert!(validate_arg_value("value with ${VAR} reference").is_ok());
829 assert!(validate_arg_value(".items[] | select(.name)").is_ok());
830 assert!(validate_arg_value("echo hello; echo world").is_ok());
831 assert!(validate_arg_value("foo || bar").is_ok());
832 assert!(validate_arg_value("result=$(cmd)").is_ok());
833 assert!(validate_arg_value("hello `world`").is_ok());
834 }
835
836 #[test]
841 fn validate_content_key_single_letter() {
842 assert!(validate_content_key("a").is_ok());
843 assert!(validate_content_key("Z").is_ok());
844 }
845
846 #[test]
847 fn validate_content_key_alphanumeric_and_underscore() {
848 assert!(validate_content_key("body").is_ok());
849 assert!(validate_content_key("my_content").is_ok());
850 assert!(validate_content_key("Content123").is_ok());
851 assert!(validate_content_key("A_B_C").is_ok());
852 assert!(validate_content_key("myKey_2").is_ok());
853 }
854
855 #[test]
856 fn validate_content_key_empty_rejected() {
857 assert!(validate_content_key("").is_err());
858 }
859
860 #[test]
861 fn validate_content_key_digit_start_rejected() {
862 assert!(validate_content_key("1key").is_err());
863 assert!(validate_content_key("0abc").is_err());
864 }
865
866 #[test]
867 fn validate_content_key_hyphen_rejected() {
868 assert!(validate_content_key("my-key").is_err());
869 }
870
871 #[test]
872 fn validate_content_key_space_rejected() {
873 assert!(validate_content_key("my key").is_err());
874 assert!(validate_content_key(" key").is_err());
875 }
876
877 #[test]
878 fn validate_content_key_newline_rejected() {
879 assert!(validate_content_key("key\nvalue").is_err());
880 assert!(validate_content_key("\nkey").is_err());
881 }
882
883 #[test]
884 fn validate_content_key_underscore_start_rejected() {
885 assert!(validate_content_key("_key").is_err());
887 }
888
889 #[test]
890 fn validate_content_key_dot_rejected() {
891 assert!(validate_content_key("my.key").is_err());
892 }
893
894 fn make_execution(id: &str, task_name: &str) -> TaskExecution {
899 TaskExecution {
900 id: id.to_string(),
901 task_name: task_name.to_string(),
902 args: HashMap::new(),
903 content: HashMap::new(),
904 exit_code: Some(0),
905 stdout: "".to_string(),
906 stderr: "".to_string(),
907 started_at: 0,
908 duration_ms: 0,
909 truncated: false,
910 }
911 }
912
913 #[test]
914 fn task_log_store_push_and_get() {
915 let store = TaskLogStore::new(10);
916 let exec = make_execution("id-1", "build");
917 store.push(exec);
918 let retrieved = store.get("id-1").expect("should find id-1");
919 assert_eq!(retrieved.task_name, "build");
920 }
921
922 #[test]
923 fn task_log_store_get_missing() {
924 let store = TaskLogStore::new(10);
925 assert!(store.get("nonexistent").is_none());
926 }
927
928 #[test]
929 fn task_log_store_evicts_oldest_when_full() {
930 let store = TaskLogStore::new(3);
931 store.push(make_execution("id-1", "a"));
932 store.push(make_execution("id-2", "b"));
933 store.push(make_execution("id-3", "c"));
934 store.push(make_execution("id-4", "d")); assert!(store.get("id-1").is_none(), "id-1 should be evicted");
936 assert!(store.get("id-4").is_some(), "id-4 should exist");
937 }
938
939 #[test]
940 fn task_log_store_recent_newest_first() {
941 let store = TaskLogStore::new(10);
942 store.push(make_execution("id-1", "a"));
943 store.push(make_execution("id-2", "b"));
944 store.push(make_execution("id-3", "c"));
945 let recent = store.recent(2);
946 assert_eq!(recent.len(), 2);
947 assert_eq!(recent[0].id, "id-3", "newest should be first");
948 assert_eq!(recent[1].id, "id-2");
949 }
950
951 #[test]
952 fn task_log_store_recent_n_larger_than_store() {
953 let store = TaskLogStore::new(10);
954 store.push(make_execution("id-1", "a"));
955 let recent = store.recent(5);
956 assert_eq!(recent.len(), 1);
957 }
958}