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 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// =============================================================================
17// Error
18// =============================================================================
19
20#[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
32// =============================================================================
33// Public API
34// =============================================================================
35
36/// Discover recipes from the justfile at `justfile_path`.
37///
38/// Filtering behaviour depends on `mode`:
39/// - `TaskMode::AgentOnly`: only recipes marked agent-safe are returned.
40/// - `TaskMode::All`: all non-private recipes are returned.
41///
42/// Agent-safe detection is based entirely on the JSON output of `just --dump`:
43/// 1. `[group('allow-agent')]` attribute (preferred, deterministic), or
44/// 2. `[allow-agent]` marker inside the recipe's doc comment (legacy fallback).
45pub 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
53/// Discover and merge recipes from a project justfile and an optional global justfile.
54///
55/// Merge semantics:
56/// - Both sides are filtered by `mode` independently.
57/// - Project recipes have `source = Project`; global recipes have `source = Global`.
58/// - On name collision, the project recipe wins (global is hidden).
59/// - Result order: project recipes (alphabetical) followed by global-only recipes (alphabetical).
60///
61/// When `global_path` is `None`, this is equivalent to `list_recipes`.
62pub 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    // Collect project recipes, tagging them as Project source.
69    // If the project justfile does not exist (e.g. new project with global-only setup),
70    // treat it as an empty list rather than propagating a "file not found" error.
71    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    // Collect global recipes, tagging them as Global source.
83    // Use project_workdir as workdir so global recipes run in project context.
84    let global_recipes =
85        list_recipes_with_source(global_path, mode, project_workdir, RecipeSource::Global).await?;
86
87    // Build name set from project recipes for override detection.
88    let project_names: std::collections::HashSet<&str> =
89        project_recipes.iter().map(|r| r.name.as_str()).collect();
90
91    // Global-only recipes (not overridden by project).
92    let global_only: Vec<Recipe> = global_recipes
93        .into_iter()
94        .filter(|r| !project_names.contains(r.name.as_str()))
95        .collect();
96
97    // Result: project first, then global-only.
98    let mut merged = project_recipes;
99    merged.extend(global_only);
100    Ok(merged)
101}
102
103/// Internal helper: like `list_recipes` but tags each recipe with `source`.
104async 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
130// =============================================================================
131// Internal helpers
132// =============================================================================
133
134/// Run `just --dump --dump-format json --unstable` and return parsed output.
135async 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
162/// Pattern A: check if the recipe has a `[group('allow-agent')]` attribute.
163fn has_allow_agent_group_attribute(recipe: &JustRecipe) -> bool {
164    recipe
165        .attributes
166        .iter()
167        .any(|a| a.group() == Some("allow-agent"))
168}
169
170/// Pattern B: legacy `# [allow-agent]` marker embedded in the recipe doc comment.
171///
172/// `just --dump` preserves the comment line immediately above a recipe as its
173/// `doc` field. When users tag recipes with a `# [allow-agent]` line, that text
174/// shows up here, so we can detect it without re-reading the source file.
175///
176/// Note: `just` only keeps the closest comment line as the doc, so combining
177/// `# [allow-agent]` with a descriptive doc comment causes one of the two to be
178/// dropped. Prefer the `[group('allow-agent')]` attribute for new recipes.
179fn 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
186/// Determine if a recipe is agent-safe via group attribute or legacy doc marker.
187fn is_allow_agent(recipe: &JustRecipe) -> bool {
188    has_allow_agent_group_attribute(recipe) || has_allow_agent_doc(recipe)
189}
190
191/// Resolve justfile path from an optional override, workdir, or the current directory.
192pub 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
202// =============================================================================
203// Output truncation
204// =============================================================================
205
206const MAX_OUTPUT_BYTES: usize = 100 * 1024; // 100 KB
207const HEAD_BYTES: usize = 50 * 1024; // 50 KB
208const TAIL_BYTES: usize = 50 * 1024; // 50 KB
209
210/// Truncate output to at most `MAX_OUTPUT_BYTES`.
211///
212/// If truncation is necessary the result contains:
213/// `{head}\n...[truncated {n} bytes]...\n{tail}`
214///
215/// UTF-8 multi-byte boundaries are respected — the slice points are adjusted
216/// so that we never split a multi-byte character.
217pub fn truncate_output(output: &str) -> (String, bool) {
218    if output.len() <= MAX_OUTPUT_BYTES {
219        return (output.to_string(), false);
220    }
221
222    // Find safe byte boundary for the head (≤ HEAD_BYTES)
223    let head_end = safe_byte_boundary(output, HEAD_BYTES);
224    // Find safe byte boundary for the tail (last TAIL_BYTES)
225    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
238/// Find the largest byte index `<= limit` that lies on a UTF-8 character boundary.
239fn safe_byte_boundary(s: &str, limit: usize) -> usize {
240    if limit >= s.len() {
241        return s.len();
242    }
243    // Walk backwards from `limit` until we hit a valid char boundary
244    let mut idx = limit;
245    while idx > 0 && !s.is_char_boundary(idx) {
246        idx -= 1;
247    }
248    idx
249}
250
251/// Find the smallest byte index `>= hint` that lies on a UTF-8 character boundary.
252fn 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
263// =============================================================================
264// Argument validation
265// =============================================================================
266
267/// Reject argument values that contain control characters.
268///
269/// `tokio::process::Command` bypasses the shell, and arguments are passed to
270/// `just` as OS-level argv entries without shell interpretation.  Shell
271/// metacharacter validation is therefore unnecessary and would block legitimate
272/// values (URLs with `&`, jq filters with `|`, template values with `${}`, etc.).
273///
274/// Control characters (`\n`, `\r`) are still rejected as they are invalid in
275/// single-value arguments and can cause log injection or unexpected behavior
276/// in recipe body text substitution.
277pub 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
287/// Environment variable prefix for content arguments.
288const CONTENT_ENV_PREFIX: &str = "TASK_MCP_CONTENT_";
289
290/// Validate that a content key is a valid environment variable suffix.
291///
292/// Must match `[A-Za-z][A-Za-z0-9_]*`: start with an ASCII letter, followed by
293/// zero or more ASCII letters, digits, or underscores.  Keys are uppercased and
294/// prepended with `TASK_MCP_CONTENT_` before being set as environment variables.
295pub 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
312// =============================================================================
313// Recipe execution
314// =============================================================================
315
316/// Execute a recipe by name, passing `args` as positional parameters.
317///
318/// Steps:
319/// 1. Confirm the recipe exists in `list_recipes(justfile_path, mode)`.
320/// 2. Validate each argument value for dangerous characters.
321/// 3. Run `just --justfile {path} {recipe_name} {arg_values...}` with a
322///    timeout.
323/// 4. Capture stdout/stderr and apply truncation.
324/// 5. Return a `TaskExecution` record.
325pub 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    // 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    // 3. Content key validation (early, before I/O)
347    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/// Execute a recipe resolved from a merged recipe list (project + optional global).
355///
356/// Lookup order: project first, then global. When the recipe is found in the global
357/// justfile, `global_justfile_path` is used as `--justfile` but the cwd remains
358/// `project_workdir` so that recipes that write to `./` target the project directory.
359#[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    // Build merged recipe list to find the target recipe and its source.
371    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    // Validate arguments.
385    for value in args.values() {
386        validate_arg_value(value)?;
387    }
388
389    // Content key validation (early, before I/O)
390    for key in content.keys() {
391        validate_content_key(key)?;
392    }
393
394    // Determine which justfile to invoke based on recipe source.
395    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
412/// Internal helper that constructs and runs a `just` command for the given recipe.
413///
414/// Handles argument ordering, timeout, output capture/truncation, and
415/// `TaskExecution` assembly.  Both `execute_recipe` and `execute_recipe_merged`
416/// delegate here after performing their recipe-lookup and validation steps.
417async 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    // Build positional argument list in parameter definition order.
426    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    // Set content entries as environment variables.
450    // Keys are uppercased and prefixed with TASK_MCP_CONTENT_.
451    // validate_content_key was already called in execute_recipe* callers, but
452    // we call it again here to be safe in case execute_with_justfile is called
453    // directly in the future.
454    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
490// =============================================================================
491// Task log store
492// =============================================================================
493
494/// In-memory ring buffer of recent task executions.
495///
496/// `Arc<TaskLogStore>` is `Clone` because `Arc<T>` implements `Clone` for any
497/// `T: ?Sized`.  `TaskLogStore` itself does not need to implement `Clone`.
498pub 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    /// Append an execution record, evicting the oldest entry when full.
512    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    /// Look up a specific execution by ID.  Returns a clone.
521    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    /// Return summaries of the most recent `n` executions (newest first).
527    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        // The bare 'agent' literal is no longer recognized; only 'allow-agent'
598        // matches Pattern A. This guards against accidental regressions.
599        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        // Prose mentioning the marker as part of a sentence must not match.
631        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        // A recipe with multiple stacked group attributes including
667        // `allow-agent` stays agent-safe. This is the just-native form
668        // (one `[group(...)]` attribute per line).
669        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        // Previously this case would be mistakenly filtered out when the user had
696        // both a `# [allow-agent]` comment and a `[group('profile')]` attribute,
697        // because the source-text scanner tripped on the attribute line.
698        // The doc-based fallback now handles it correctly.
699        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        // override_path takes precedence over workdir
733        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    // -------------------------------------------------------------------------
739    // truncate_output tests
740    // -------------------------------------------------------------------------
741
742    #[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        // Create a string longer than MAX_OUTPUT_BYTES (100 KB)
753        let input = "x".repeat(200 * 1024);
754        let (result, truncated) = truncate_output(&input);
755        assert!(truncated);
756        assert!(result.contains("...[truncated"));
757        // Result should be smaller than the input
758        assert!(result.len() < input.len());
759    }
760
761    #[test]
762    fn truncate_output_utf8_boundary() {
763        // Build a string that is just over HEAD_BYTES using multi-byte chars
764        // Each '日' is 3 bytes; we need HEAD_BYTES+1 bytes to trigger truncation
765        let char_3bytes = '日';
766        // Fill slightly above MAX_OUTPUT_BYTES boundary
767        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        // Verify the result is valid UTF-8 (no panic = success)
771        assert!(std::str::from_utf8(result.as_bytes()).is_ok());
772        if truncated {
773            assert!(result.contains("...[truncated"));
774        }
775    }
776
777    // -------------------------------------------------------------------------
778    // validate_arg_value tests
779    // -------------------------------------------------------------------------
780
781    #[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        // Shell metacharacters are no longer rejected because task-mcp uses
826        // Command::new("just").arg() which bypasses shell interpretation.
827        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    // -------------------------------------------------------------------------
837    // validate_content_key tests
838    // -------------------------------------------------------------------------
839
840    #[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        // Underscore-only start is not a letter — env var convention
886        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    // -------------------------------------------------------------------------
895    // TaskLogStore tests
896    // -------------------------------------------------------------------------
897
898    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")); // evicts id-1
935        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}