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#[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
36pub 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
55pub 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 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 let global_recipes =
87 list_recipes_with_source(global_path, mode, project_workdir, RecipeSource::Global).await?;
88
89 let project_names: std::collections::HashSet<&str> =
91 project_recipes.iter().map(|r| r.name.as_str()).collect();
92
93 let global_only: Vec<Recipe> = global_recipes
95 .into_iter()
96 .filter(|r| !project_names.contains(r.name.as_str()))
97 .collect();
98
99 let mut merged = project_recipes;
101 merged.extend(global_only);
102 Ok(merged)
103}
104
105async 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
138async 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
170async 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
209fn has_group_agent_attribute(recipe: &JustRecipe) -> bool {
211 recipe.attributes.iter().any(|a| a.group() == Some("agent"))
212}
213
214fn extract_comment_tagged_recipes(justfile_text: &str) -> std::collections::HashSet<String> {
222 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 let trimmed = line.trim();
239 if trimmed.is_empty() || trimmed.starts_with('#') {
240 continue;
241 }
242
243 if let Some(cap) = recipe_name_re.captures(line) {
245 let name = cap[1].to_string();
246 if !name.starts_with('[') {
248 tagged.insert(name);
249 }
250 }
251 saw_allow_agent = false;
253 }
254 }
255
256 tagged
257}
258
259fn 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
264pub 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
275const 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) {
291 if output.len() <= MAX_OUTPUT_BYTES {
292 return (output.to_string(), false);
293 }
294
295 let head_end = safe_byte_boundary(output, HEAD_BYTES);
297 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
311fn safe_byte_boundary(s: &str, limit: usize) -> usize {
313 if limit >= s.len() {
314 return s.len();
315 }
316 let mut idx = limit;
318 while idx > 0 && !s.is_char_boundary(idx) {
319 idx -= 1;
320 }
321 idx
322}
323
324fn 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
336pub 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
355pub 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 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 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
391pub 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 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 for value in args.values() {
421 validate_arg_value(value)?;
422 }
423
424 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
434async 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 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
499pub 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 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 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 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 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 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 #[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 let input = "x".repeat(200 * 1024);
694 let (result, truncated) = truncate_output(&input);
695 assert!(truncated);
696 assert!(result.contains("...[truncated"));
697 assert!(result.len() < input.len());
699 }
700
701 #[test]
702 fn truncate_output_utf8_boundary() {
703 let char_3bytes = '日';
706 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 assert!(std::str::from_utf8(result.as_bytes()).is_ok());
712 if truncated {
713 assert!(result.contains("...[truncated"));
714 }
715 }
716
717 #[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 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")); 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}