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#[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
33pub 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
52pub 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 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 let global_recipes =
84 list_recipes_with_source(global_path, mode, project_workdir, RecipeSource::Global).await?;
85
86 let project_names: std::collections::HashSet<&str> =
88 project_recipes.iter().map(|r| r.name.as_str()).collect();
89
90 let global_only: Vec<Recipe> = global_recipes
92 .into_iter()
93 .filter(|r| !project_names.contains(r.name.as_str()))
94 .collect();
95
96 let mut merged = project_recipes;
98 merged.extend(global_only);
99 Ok(merged)
100}
101
102async 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
135async 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
167fn has_group_agent_attribute(recipe: &JustRecipe) -> bool {
169 recipe.attributes.iter().any(|a| a.group() == Some("agent"))
170}
171
172fn extract_comment_tagged_recipes(justfile_text: &str) -> std::collections::HashSet<String> {
180 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 let trimmed = line.trim();
197 if trimmed.is_empty() || trimmed.starts_with('#') {
198 continue;
199 }
200
201 if let Some(cap) = recipe_name_re.captures(line) {
203 let name = cap[1].to_string();
204 if !name.starts_with('[') {
206 tagged.insert(name);
207 }
208 }
209 saw_allow_agent = false;
211 }
212 }
213
214 tagged
215}
216
217fn 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
222pub 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
233const MAX_OUTPUT_BYTES: usize = 100 * 1024; const HEAD_BYTES: usize = 50 * 1024; const TAIL_BYTES: usize = 50 * 1024; pub fn truncate_output(output: &str) -> (String, bool) {
249 if output.len() <= MAX_OUTPUT_BYTES {
250 return (output.to_string(), false);
251 }
252
253 let head_end = safe_byte_boundary(output, HEAD_BYTES);
255 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
269fn safe_byte_boundary(s: &str, limit: usize) -> usize {
271 if limit >= s.len() {
272 return s.len();
273 }
274 let mut idx = limit;
276 while idx > 0 && !s.is_char_boundary(idx) {
277 idx -= 1;
278 }
279 idx
280}
281
282fn 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
294pub 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
313pub 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 let recipes = list_recipes(justfile_path, mode, workdir).await?;
336 let recipe = recipes
337 .iter()
338 .find(|r| r.name == recipe_name)
339 .ok_or_else(|| TaskError::RecipeNotFound(recipe_name.to_string()))?;
340
341 for value in args.values() {
343 validate_arg_value(value)?;
344 }
345
346 execute_with_justfile(recipe, args, justfile_path, workdir, timeout).await
347}
348
349pub 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 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 for value in args.values() {
379 validate_arg_value(value)?;
380 }
381
382 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
392async 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 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
456pub 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 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 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 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 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 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 #[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 let input = "x".repeat(200 * 1024);
651 let (result, truncated) = truncate_output(&input);
652 assert!(truncated);
653 assert!(result.contains("...[truncated"));
654 assert!(result.len() < input.len());
656 }
657
658 #[test]
659 fn truncate_output_utf8_boundary() {
660 let char_3bytes = '日';
663 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 assert!(std::str::from_utf8(result.as_bytes()).is_ok());
669 if truncated {
670 assert!(result.contains("...[truncated"));
671 }
672 }
673
674 #[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 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")); 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}