Skip to main content

task_mcp/just/
mod.rs

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