Skip to main content

task_mcp/just/
mod.rs

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 regex::Regex;
9use tokio::process::Command;
10use uuid::Uuid;
11
12use crate::config::TaskMode;
13use crate::just::model::{
14    JustDump, JustRecipe, Recipe, RecipeSource, TaskError, TaskExecution, TaskExecutionSummary,
15};
16
17// =============================================================================
18// Error
19// =============================================================================
20
21#[derive(Debug, thiserror::Error)]
22pub enum JustError {
23    #[error("just command not found: {0}")]
24    NotFound(String),
25    #[error("just command failed (exit {code}): {stderr}")]
26    CommandFailed { code: i32, stderr: String },
27    #[error("failed to parse just dump json: {0}")]
28    ParseError(#[from] serde_json::Error),
29    #[error("I/O error while reading justfile: {0}")]
30    Io(#[from] std::io::Error),
31}
32
33// =============================================================================
34// Public API
35// =============================================================================
36
37/// Discover recipes from the justfile at `justfile_path`.
38///
39/// Filtering behaviour depends on `mode`:
40/// - `TaskMode::AgentOnly`: only recipes marked agent-safe are returned.
41/// - `TaskMode::All`: all non-private recipes are returned.
42///
43/// Agent-safe detection: patten A (group attribute) first, pattern B (comment regex) as fallback.
44pub async fn list_recipes(
45    justfile_path: &Path,
46    mode: &TaskMode,
47    workdir: Option<&Path>,
48) -> Result<Vec<Recipe>, JustError> {
49    list_recipes_with_source(justfile_path, mode, workdir, RecipeSource::Project).await
50}
51
52/// Discover and merge recipes from a project justfile and an optional global justfile.
53///
54/// Merge semantics:
55/// - Both sides are filtered by `mode` independently.
56/// - Project recipes have `source = Project`; global recipes have `source = Global`.
57/// - On name collision, the project recipe wins (global is hidden).
58/// - Result order: project recipes (alphabetical) followed by global-only recipes (alphabetical).
59///
60/// When `global_path` is `None`, this is equivalent to `list_recipes`.
61pub async fn list_recipes_merged(
62    project_path: &Path,
63    global_path: Option<&Path>,
64    mode: &TaskMode,
65    project_workdir: Option<&Path>,
66) -> Result<Vec<Recipe>, JustError> {
67    // Collect project recipes, tagging them as Project source.
68    // If the project justfile does not exist (e.g. new project with global-only setup),
69    // treat it as an empty list rather than propagating a "file not found" error.
70    let project_recipes = if tokio::fs::metadata(project_path).await.is_ok() {
71        list_recipes_with_source(project_path, mode, project_workdir, RecipeSource::Project).await?
72    } else {
73        Vec::new()
74    };
75
76    let global_path = match global_path {
77        Some(p) => p,
78        None => return Ok(project_recipes),
79    };
80
81    // Collect global recipes, tagging them as Global source.
82    // Use project_workdir as workdir so global recipes run in project context.
83    let global_recipes =
84        list_recipes_with_source(global_path, mode, project_workdir, RecipeSource::Global).await?;
85
86    // Build name set from project recipes for override detection.
87    let project_names: std::collections::HashSet<&str> =
88        project_recipes.iter().map(|r| r.name.as_str()).collect();
89
90    // Global-only recipes (not overridden by project).
91    let global_only: Vec<Recipe> = global_recipes
92        .into_iter()
93        .filter(|r| !project_names.contains(r.name.as_str()))
94        .collect();
95
96    // Result: project first, then global-only.
97    let mut merged = project_recipes;
98    merged.extend(global_only);
99    Ok(merged)
100}
101
102/// Internal helper: like `list_recipes` but tags each recipe with `source`.
103async fn list_recipes_with_source(
104    justfile_path: &Path,
105    mode: &TaskMode,
106    workdir: Option<&Path>,
107    source: RecipeSource,
108) -> Result<Vec<Recipe>, JustError> {
109    let dump = dump_json(justfile_path, workdir).await?;
110
111    let justfile_text = tokio::fs::read_to_string(justfile_path).await.ok();
112    let comment_tagged = justfile_text
113        .as_deref()
114        .map(extract_comment_tagged_recipes)
115        .unwrap_or_default();
116
117    let mut recipes: Vec<Recipe> = dump
118        .recipes
119        .into_values()
120        .filter(|r| !r.private)
121        .map(|raw| {
122            let allow_agent = is_allow_agent(&raw, &comment_tagged);
123            Recipe::from_just_recipe_with_source(raw, allow_agent, source)
124        })
125        .collect();
126
127    recipes.sort_by(|a, b| a.name.cmp(&b.name));
128
129    match mode {
130        TaskMode::AgentOnly => Ok(recipes.into_iter().filter(|r| r.allow_agent).collect()),
131        TaskMode::All => Ok(recipes),
132    }
133}
134
135// =============================================================================
136// Internal helpers
137// =============================================================================
138
139/// Run `just --dump --dump-format json --unstable` and return parsed output.
140async fn dump_json(justfile_path: &Path, workdir: Option<&Path>) -> Result<JustDump, JustError> {
141    let mut cmd = Command::new("just");
142    cmd.arg("--justfile")
143        .arg(justfile_path)
144        .arg("--dump")
145        .arg("--dump-format")
146        .arg("json")
147        .arg("--unstable");
148    if let Some(dir) = workdir {
149        cmd.current_dir(dir);
150    }
151    let output = cmd
152        .output()
153        .await
154        .map_err(|e| JustError::NotFound(e.to_string()))?;
155
156    if !output.status.success() {
157        let code = output.status.code().unwrap_or(-1);
158        let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
159        return Err(JustError::CommandFailed { code, stderr });
160    }
161
162    let json_str = String::from_utf8_lossy(&output.stdout);
163    let dump: JustDump = serde_json::from_str(&json_str)?;
164    Ok(dump)
165}
166
167/// Pattern A: check if the recipe has a `[group: 'agent']` attribute.
168fn has_group_agent_attribute(recipe: &JustRecipe) -> bool {
169    recipe.attributes.iter().any(|a| a.group() == Some("agent"))
170}
171
172/// Pattern B: extract recipe names that are preceded by a `# [allow-agent]` comment.
173///
174/// Regex matches lines like (justfile syntax, not Rust):
175/// ```text
176/// # [allow-agent]
177/// recipe_name:
178/// ```
179fn extract_comment_tagged_recipes(justfile_text: &str) -> std::collections::HashSet<String> {
180    // Allow-agent comment followed (with optional blank/doc lines) by a recipe line
181    // Simple approach: line-by-line scan with state
182    let allow_agent_re = Regex::new(r"^\s*#\s*\[allow-agent\]").expect("valid regex");
183    let recipe_name_re = Regex::new(r"^([a-zA-Z0-9_-]+)\s*(?:\S.*)?:").expect("valid regex");
184
185    let mut tagged = std::collections::HashSet::new();
186    let mut saw_allow_agent = false;
187
188    for line in justfile_text.lines() {
189        if allow_agent_re.is_match(line) {
190            saw_allow_agent = true;
191            continue;
192        }
193
194        if saw_allow_agent {
195            // Skip doc comment lines and blank lines between tag and recipe
196            let trimmed = line.trim();
197            if trimmed.is_empty() || trimmed.starts_with('#') {
198                continue;
199            }
200
201            // Try to extract recipe name
202            if let Some(cap) = recipe_name_re.captures(line) {
203                let name = cap[1].to_string();
204                // Exclude attribute lines like [group: 'agent']
205                if !name.starts_with('[') {
206                    tagged.insert(name);
207                }
208            }
209            // Once we see a non-comment, non-blank line, reset state
210            saw_allow_agent = false;
211        }
212    }
213
214    tagged
215}
216
217/// Determine if a recipe is agent-safe using pattern A first, then pattern B.
218fn is_allow_agent(recipe: &JustRecipe, comment_tagged: &std::collections::HashSet<String>) -> bool {
219    has_group_agent_attribute(recipe) || comment_tagged.contains(&recipe.name)
220}
221
222/// Resolve justfile path from an optional override, workdir, or the current directory.
223pub fn resolve_justfile_path(override_path: Option<&str>, workdir: Option<&Path>) -> PathBuf {
224    match override_path {
225        Some(p) => PathBuf::from(p),
226        None => match workdir {
227            Some(dir) => dir.join("justfile"),
228            None => PathBuf::from("justfile"),
229        },
230    }
231}
232
233// =============================================================================
234// Output truncation
235// =============================================================================
236
237const MAX_OUTPUT_BYTES: usize = 100 * 1024; // 100 KB
238const HEAD_BYTES: usize = 50 * 1024; // 50 KB
239const TAIL_BYTES: usize = 50 * 1024; // 50 KB
240
241/// Truncate output to at most `MAX_OUTPUT_BYTES`.
242///
243/// If truncation is necessary the result contains:
244/// `{head}\n...[truncated {n} bytes]...\n{tail}`
245///
246/// UTF-8 multi-byte boundaries are respected — the slice points are adjusted
247/// so that we never split a multi-byte character.
248pub fn truncate_output(output: &str) -> (String, bool) {
249    if output.len() <= MAX_OUTPUT_BYTES {
250        return (output.to_string(), false);
251    }
252
253    // Find safe byte boundary for the head (≤ HEAD_BYTES)
254    let head_end = safe_byte_boundary(output, HEAD_BYTES);
255    // Find safe byte boundary for the tail (last TAIL_BYTES)
256    let tail_start_raw = output.len().saturating_sub(TAIL_BYTES);
257    let tail_start = safe_tail_start(output, tail_start_raw);
258
259    let head = &output[..head_end];
260    let tail = &output[tail_start..];
261    let truncated_bytes = output.len() - head_end - (output.len() - tail_start);
262
263    (
264        format!("{head}\n...[truncated {truncated_bytes} bytes]...\n{tail}"),
265        true,
266    )
267}
268
269/// Find the largest byte index `<= limit` that lies on a UTF-8 character boundary.
270fn safe_byte_boundary(s: &str, limit: usize) -> usize {
271    if limit >= s.len() {
272        return s.len();
273    }
274    // Walk backwards from `limit` until we hit a valid char boundary
275    let mut idx = limit;
276    while idx > 0 && !s.is_char_boundary(idx) {
277        idx -= 1;
278    }
279    idx
280}
281
282/// Find the smallest byte index `>= hint` that lies on a UTF-8 character boundary.
283fn safe_tail_start(s: &str, hint: usize) -> usize {
284    if hint >= s.len() {
285        return s.len();
286    }
287    let mut idx = hint;
288    while idx < s.len() && !s.is_char_boundary(idx) {
289        idx += 1;
290    }
291    idx
292}
293
294// =============================================================================
295// Argument validation
296// =============================================================================
297
298/// Reject argument values that contain shell meta-characters.
299///
300/// `tokio::process::Command` bypasses the shell, but `just` itself invokes a
301/// shell interpreter for recipe bodies.  Validating inputs here prevents
302/// injection attacks in case a recipe passes an argument through to the shell.
303pub fn validate_arg_value(value: &str) -> Result<(), TaskError> {
304    const DANGEROUS: &[&str] = &[";", "|", "&&", "||", "`", "$(", "${", "\n", "\r"];
305    for pattern in DANGEROUS {
306        if value.contains(pattern) {
307            return Err(TaskError::DangerousArgument(value.to_string()));
308        }
309    }
310    Ok(())
311}
312
313// =============================================================================
314// Recipe execution
315// =============================================================================
316
317/// Execute a recipe by name, passing `args` as positional parameters.
318///
319/// Steps:
320/// 1. Confirm the recipe exists in `list_recipes(justfile_path, mode)`.
321/// 2. Validate each argument value for dangerous characters.
322/// 3. Run `just --justfile {path} {recipe_name} {arg_values...}` with a
323///    timeout.
324/// 4. Capture stdout/stderr and apply truncation.
325/// 5. Return a `TaskExecution` record.
326pub async fn execute_recipe(
327    recipe_name: &str,
328    args: &HashMap<String, String>,
329    justfile_path: &Path,
330    timeout: Duration,
331    mode: &TaskMode,
332    workdir: Option<&Path>,
333) -> Result<TaskExecution, TaskError> {
334    // 1. Whitelist check
335    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    // 2. Argument validation
342    for value in args.values() {
343        validate_arg_value(value)?;
344    }
345
346    execute_with_justfile(recipe, args, justfile_path, workdir, timeout).await
347}
348
349/// Execute a recipe resolved from a merged recipe list (project + optional global).
350///
351/// Lookup order: project first, then global. When the recipe is found in the global
352/// justfile, `global_justfile_path` is used as `--justfile` but the cwd remains
353/// `project_workdir` so that recipes that write to `./` target the project directory.
354pub async fn execute_recipe_merged(
355    recipe_name: &str,
356    args: &HashMap<String, String>,
357    project_justfile_path: &Path,
358    global_justfile_path: Option<&Path>,
359    timeout: Duration,
360    mode: &TaskMode,
361    project_workdir: Option<&Path>,
362) -> Result<TaskExecution, TaskError> {
363    // Build merged recipe list to find the target recipe and its source.
364    let recipes = list_recipes_merged(
365        project_justfile_path,
366        global_justfile_path,
367        mode,
368        project_workdir,
369    )
370    .await?;
371
372    let recipe = recipes
373        .iter()
374        .find(|r| r.name == recipe_name)
375        .ok_or_else(|| TaskError::RecipeNotFound(recipe_name.to_string()))?;
376
377    // Validate arguments.
378    for value in args.values() {
379        validate_arg_value(value)?;
380    }
381
382    // Determine which justfile to invoke based on recipe source.
383    let effective_justfile = match recipe.source {
384        RecipeSource::Global => global_justfile_path
385            .ok_or_else(|| TaskError::RecipeNotFound(recipe_name.to_string()))?,
386        RecipeSource::Project => project_justfile_path,
387    };
388
389    execute_with_justfile(recipe, args, effective_justfile, project_workdir, timeout).await
390}
391
392/// Internal helper that constructs and runs a `just` command for the given recipe.
393///
394/// Handles argument ordering, timeout, output capture/truncation, and
395/// `TaskExecution` assembly.  Both `execute_recipe` and `execute_recipe_merged`
396/// delegate here after performing their recipe-lookup and validation steps.
397async fn execute_with_justfile(
398    recipe: &Recipe,
399    args: &HashMap<String, String>,
400    effective_justfile: &Path,
401    project_workdir: Option<&Path>,
402    timeout: Duration,
403) -> Result<TaskExecution, TaskError> {
404    // Build positional argument list in parameter definition order.
405    let positional: Vec<&str> = recipe
406        .parameters
407        .iter()
408        .filter_map(|p| args.get(&p.name).map(|v| v.as_str()))
409        .collect();
410
411    let started_at = SystemTime::now()
412        .duration_since(UNIX_EPOCH)
413        .unwrap_or_default()
414        .as_secs();
415    let start_instant = std::time::Instant::now();
416
417    let mut cmd = Command::new("just");
418    cmd.arg("--justfile").arg(effective_justfile);
419    cmd.arg(&recipe.name);
420    for arg in &positional {
421        cmd.arg(arg);
422    }
423    if let Some(dir) = project_workdir {
424        cmd.current_dir(dir);
425    }
426
427    let run_result = tokio::time::timeout(timeout, cmd.output()).await;
428    let duration_ms = start_instant.elapsed().as_millis() as u64;
429
430    let output = match run_result {
431        Err(_) => return Err(TaskError::Timeout),
432        Ok(Err(io_err)) => return Err(TaskError::Io(io_err)),
433        Ok(Ok(out)) => out,
434    };
435
436    let exit_code = output.status.code();
437    let raw_stdout = String::from_utf8_lossy(&output.stdout).into_owned();
438    let raw_stderr = String::from_utf8_lossy(&output.stderr).into_owned();
439    let (stdout, stdout_truncated) = truncate_output(&raw_stdout);
440    let (stderr, stderr_truncated) = truncate_output(&raw_stderr);
441    let truncated = stdout_truncated || stderr_truncated;
442
443    Ok(TaskExecution {
444        id: Uuid::new_v4().to_string(),
445        task_name: recipe.name.clone(),
446        args: args.clone(),
447        exit_code,
448        stdout,
449        stderr,
450        started_at,
451        duration_ms,
452        truncated,
453    })
454}
455
456// =============================================================================
457// Task log store
458// =============================================================================
459
460/// In-memory ring buffer of recent task executions.
461///
462/// `Arc<TaskLogStore>` is `Clone` because `Arc<T>` implements `Clone` for any
463/// `T: ?Sized`.  `TaskLogStore` itself does not need to implement `Clone`.
464pub struct TaskLogStore {
465    logs: Mutex<VecDeque<TaskExecution>>,
466    max_entries: usize,
467}
468
469impl TaskLogStore {
470    pub fn new(max_entries: usize) -> Self {
471        Self {
472            logs: Mutex::new(VecDeque::new()),
473            max_entries,
474        }
475    }
476
477    /// Append an execution record, evicting the oldest entry when full.
478    pub fn push(&self, execution: TaskExecution) {
479        let mut guard = self.logs.lock().expect("log store lock poisoned");
480        if guard.len() >= self.max_entries {
481            guard.pop_front();
482        }
483        guard.push_back(execution);
484    }
485
486    /// Look up a specific execution by ID.  Returns a clone.
487    pub fn get(&self, id: &str) -> Option<TaskExecution> {
488        let guard = self.logs.lock().expect("log store lock poisoned");
489        guard.iter().find(|e| e.id == id).cloned()
490    }
491
492    /// Return summaries of the most recent `n` executions (newest first).
493    pub fn recent(&self, n: usize) -> Vec<TaskExecutionSummary> {
494        let guard = self.logs.lock().expect("log store lock poisoned");
495        guard
496            .iter()
497            .rev()
498            .take(n)
499            .map(TaskExecutionSummary::from_execution)
500            .collect()
501    }
502}
503
504#[cfg(test)]
505mod tests {
506    use super::*;
507    use crate::just::model::RecipeAttribute;
508
509    fn make_recipe(name: &str, attributes: Vec<RecipeAttribute>) -> JustRecipe {
510        crate::just::model::JustRecipe {
511            name: name.to_string(),
512            namepath: name.to_string(),
513            doc: None,
514            attributes,
515            parameters: vec![],
516            private: false,
517            quiet: false,
518        }
519    }
520
521    #[test]
522    fn has_group_agent_attribute_true() {
523        let recipe = make_recipe(
524            "build",
525            vec![RecipeAttribute::Object(
526                [("group".to_string(), Some("agent".to_string()))]
527                    .into_iter()
528                    .collect(),
529            )],
530        );
531        assert!(has_group_agent_attribute(&recipe));
532    }
533
534    #[test]
535    fn has_group_agent_attribute_false_no_attrs() {
536        let recipe = make_recipe("deploy", vec![]);
537        assert!(!has_group_agent_attribute(&recipe));
538    }
539
540    #[test]
541    fn has_group_agent_attribute_false_other_group() {
542        let recipe = make_recipe(
543            "build",
544            vec![RecipeAttribute::Object(
545                [("group".to_string(), Some("ci".to_string()))]
546                    .into_iter()
547                    .collect(),
548            )],
549        );
550        assert!(!has_group_agent_attribute(&recipe));
551    }
552
553    #[test]
554    fn extract_comment_tagged_recipes_basic() {
555        let text = "# [allow-agent]\nbuild:\n    cargo build\n\ndeploy:\n    ./deploy.sh\n";
556        let tagged = extract_comment_tagged_recipes(text);
557        assert!(tagged.contains("build"), "build should be tagged");
558        assert!(!tagged.contains("deploy"), "deploy should not be tagged");
559    }
560
561    #[test]
562    fn extract_comment_tagged_recipes_with_doc_comment() {
563        let text = "# [allow-agent]\n# Run tests\ntest filter=\"\":\n    cargo test {{filter}}\n\ndeploy:\n    ./deploy.sh\n";
564        let tagged = extract_comment_tagged_recipes(text);
565        assert!(tagged.contains("test"), "test should be tagged");
566        assert!(!tagged.contains("deploy"));
567    }
568
569    #[test]
570    fn extract_comment_tagged_recipes_multiple() {
571        let text = "# [allow-agent]\nbuild:\n    cargo build\n\n# [allow-agent]\ninfo:\n    echo info\n\ndeploy:\n    ./deploy.sh\n";
572        let tagged = extract_comment_tagged_recipes(text);
573        assert!(tagged.contains("build"));
574        assert!(tagged.contains("info"));
575        assert!(!tagged.contains("deploy"));
576    }
577
578    #[test]
579    fn is_allow_agent_pattern_a() {
580        let tagged = std::collections::HashSet::new();
581        // not in comment set
582        let recipe = make_recipe(
583            "build",
584            vec![RecipeAttribute::Object(
585                [("group".to_string(), Some("agent".to_string()))]
586                    .into_iter()
587                    .collect(),
588            )],
589        );
590        assert!(is_allow_agent(&recipe, &tagged));
591    }
592
593    #[test]
594    fn is_allow_agent_pattern_b() {
595        let mut tagged = std::collections::HashSet::new();
596        tagged.insert("build".to_string());
597        let recipe = make_recipe("build", vec![]);
598        assert!(is_allow_agent(&recipe, &tagged));
599    }
600
601    #[test]
602    fn is_allow_agent_neither() {
603        let tagged = std::collections::HashSet::new();
604        let recipe = make_recipe("deploy", vec![]);
605        assert!(!is_allow_agent(&recipe, &tagged));
606    }
607
608    #[test]
609    fn resolve_justfile_path_override() {
610        let p = resolve_justfile_path(Some("/custom/justfile"), None);
611        assert_eq!(p, PathBuf::from("/custom/justfile"));
612    }
613
614    #[test]
615    fn resolve_justfile_path_default() {
616        let p = resolve_justfile_path(None, None);
617        assert_eq!(p, PathBuf::from("justfile"));
618    }
619
620    #[test]
621    fn resolve_justfile_path_with_workdir() {
622        let workdir = Path::new("/some/project");
623        let p = resolve_justfile_path(None, Some(workdir));
624        assert_eq!(p, PathBuf::from("/some/project/justfile"));
625    }
626
627    #[test]
628    fn resolve_justfile_path_override_ignores_workdir() {
629        // override_path takes precedence over workdir
630        let workdir = Path::new("/some/project");
631        let p = resolve_justfile_path(Some("/custom/justfile"), Some(workdir));
632        assert_eq!(p, PathBuf::from("/custom/justfile"));
633    }
634
635    // -------------------------------------------------------------------------
636    // truncate_output tests
637    // -------------------------------------------------------------------------
638
639    #[test]
640    fn truncate_output_short_input_unchanged() {
641        let input = "hello";
642        let (result, truncated) = truncate_output(input);
643        assert!(!truncated);
644        assert_eq!(result, input);
645    }
646
647    #[test]
648    fn truncate_output_long_input_truncated() {
649        // Create a string longer than MAX_OUTPUT_BYTES (100 KB)
650        let input = "x".repeat(200 * 1024);
651        let (result, truncated) = truncate_output(&input);
652        assert!(truncated);
653        assert!(result.contains("...[truncated"));
654        // Result should be smaller than the input
655        assert!(result.len() < input.len());
656    }
657
658    #[test]
659    fn truncate_output_utf8_boundary() {
660        // Build a string that is just over HEAD_BYTES using multi-byte chars
661        // Each '日' is 3 bytes; we need HEAD_BYTES+1 bytes to trigger truncation
662        let char_3bytes = '日';
663        // Fill slightly above MAX_OUTPUT_BYTES boundary
664        let count = (MAX_OUTPUT_BYTES / 3) + 10;
665        let input: String = std::iter::repeat_n(char_3bytes, count).collect();
666        let (result, truncated) = truncate_output(&input);
667        // Verify the result is valid UTF-8 (no panic = success)
668        assert!(std::str::from_utf8(result.as_bytes()).is_ok());
669        if truncated {
670            assert!(result.contains("...[truncated"));
671        }
672    }
673
674    // -------------------------------------------------------------------------
675    // validate_arg_value tests
676    // -------------------------------------------------------------------------
677
678    #[test]
679    fn validate_arg_value_safe_values() {
680        assert!(validate_arg_value("hello world").is_ok());
681        assert!(validate_arg_value("value_123-abc").is_ok());
682        assert!(validate_arg_value("path/to/file.txt").is_ok());
683    }
684
685    #[test]
686    fn validate_arg_value_semicolon_rejected() {
687        assert!(validate_arg_value("foo; rm -rf /").is_err());
688    }
689
690    #[test]
691    fn validate_arg_value_pipe_rejected() {
692        assert!(validate_arg_value("foo | cat /etc/passwd").is_err());
693    }
694
695    #[test]
696    fn validate_arg_value_and_and_rejected() {
697        assert!(validate_arg_value("foo && evil").is_err());
698    }
699
700    #[test]
701    fn validate_arg_value_backtick_rejected() {
702        assert!(validate_arg_value("foo`id`").is_err());
703    }
704
705    #[test]
706    fn validate_arg_value_dollar_paren_rejected() {
707        assert!(validate_arg_value("$(id)").is_err());
708    }
709
710    #[test]
711    fn validate_arg_value_newline_rejected() {
712        assert!(validate_arg_value("foo\nbar").is_err());
713    }
714
715    // -------------------------------------------------------------------------
716    // TaskLogStore tests
717    // -------------------------------------------------------------------------
718
719    fn make_execution(id: &str, task_name: &str) -> TaskExecution {
720        TaskExecution {
721            id: id.to_string(),
722            task_name: task_name.to_string(),
723            args: HashMap::new(),
724            exit_code: Some(0),
725            stdout: "".to_string(),
726            stderr: "".to_string(),
727            started_at: 0,
728            duration_ms: 0,
729            truncated: false,
730        }
731    }
732
733    #[test]
734    fn task_log_store_push_and_get() {
735        let store = TaskLogStore::new(10);
736        let exec = make_execution("id-1", "build");
737        store.push(exec);
738        let retrieved = store.get("id-1").expect("should find id-1");
739        assert_eq!(retrieved.task_name, "build");
740    }
741
742    #[test]
743    fn task_log_store_get_missing() {
744        let store = TaskLogStore::new(10);
745        assert!(store.get("nonexistent").is_none());
746    }
747
748    #[test]
749    fn task_log_store_evicts_oldest_when_full() {
750        let store = TaskLogStore::new(3);
751        store.push(make_execution("id-1", "a"));
752        store.push(make_execution("id-2", "b"));
753        store.push(make_execution("id-3", "c"));
754        store.push(make_execution("id-4", "d")); // evicts id-1
755        assert!(store.get("id-1").is_none(), "id-1 should be evicted");
756        assert!(store.get("id-4").is_some(), "id-4 should exist");
757    }
758
759    #[test]
760    fn task_log_store_recent_newest_first() {
761        let store = TaskLogStore::new(10);
762        store.push(make_execution("id-1", "a"));
763        store.push(make_execution("id-2", "b"));
764        store.push(make_execution("id-3", "c"));
765        let recent = store.recent(2);
766        assert_eq!(recent.len(), 2);
767        assert_eq!(recent[0].id, "id-3", "newest should be first");
768        assert_eq!(recent[1].id, "id-2");
769    }
770
771    #[test]
772    fn task_log_store_recent_n_larger_than_store() {
773        let store = TaskLogStore::new(10);
774        store.push(make_execution("id-1", "a"));
775        let recent = store.recent(5);
776        assert_eq!(recent.len(), 1);
777    }
778}