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#[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
32pub 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
53pub 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 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 let global_recipes =
85 list_recipes_with_source(global_path, mode, project_workdir, RecipeSource::Global).await?;
86
87 let project_names: std::collections::HashSet<&str> =
89 project_recipes.iter().map(|r| r.name.as_str()).collect();
90
91 let global_only: Vec<Recipe> = global_recipes
93 .into_iter()
94 .filter(|r| !project_names.contains(r.name.as_str()))
95 .collect();
96
97 let mut merged = project_recipes;
99 merged.extend(global_only);
100 Ok(merged)
101}
102
103async 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
130async 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
162fn has_allow_agent_group_attribute(recipe: &JustRecipe) -> bool {
164 recipe
165 .attributes
166 .iter()
167 .any(|a| a.group() == Some("allow-agent"))
168}
169
170fn 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
186fn is_allow_agent(recipe: &JustRecipe) -> bool {
188 has_allow_agent_group_attribute(recipe) || has_allow_agent_doc(recipe)
189}
190
191pub 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
202const 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) {
218 if output.len() <= MAX_OUTPUT_BYTES {
219 return (output.to_string(), false);
220 }
221
222 let head_end = safe_byte_boundary(output, HEAD_BYTES);
224 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
238fn safe_byte_boundary(s: &str, limit: usize) -> usize {
240 if limit >= s.len() {
241 return s.len();
242 }
243 let mut idx = limit;
245 while idx > 0 && !s.is_char_boundary(idx) {
246 idx -= 1;
247 }
248 idx
249}
250
251fn 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
263pub fn validate_arg_value(value: &str) -> Result<(), TaskError> {
273 const DANGEROUS: &[&str] = &[";", "|", "&&", "||", "`", "$(", "${", "\n", "\r"];
274 for pattern in DANGEROUS {
275 if value.contains(pattern) {
276 return Err(TaskError::DangerousArgument(value.to_string()));
277 }
278 }
279 Ok(())
280}
281
282pub async fn execute_recipe(
296 recipe_name: &str,
297 args: &HashMap<String, String>,
298 justfile_path: &Path,
299 timeout: Duration,
300 mode: &TaskMode,
301 workdir: Option<&Path>,
302) -> Result<TaskExecution, TaskError> {
303 let recipes = list_recipes(justfile_path, mode, workdir).await?;
305 let recipe = recipes
306 .iter()
307 .find(|r| r.name == recipe_name)
308 .ok_or_else(|| TaskError::RecipeNotFound(recipe_name.to_string()))?;
309
310 for value in args.values() {
312 validate_arg_value(value)?;
313 }
314
315 execute_with_justfile(recipe, args, justfile_path, workdir, timeout).await
316}
317
318pub async fn execute_recipe_merged(
324 recipe_name: &str,
325 args: &HashMap<String, String>,
326 project_justfile_path: &Path,
327 global_justfile_path: Option<&Path>,
328 timeout: Duration,
329 mode: &TaskMode,
330 project_workdir: Option<&Path>,
331) -> Result<TaskExecution, TaskError> {
332 let recipes = list_recipes_merged(
334 project_justfile_path,
335 global_justfile_path,
336 mode,
337 project_workdir,
338 )
339 .await?;
340
341 let recipe = recipes
342 .iter()
343 .find(|r| r.name == recipe_name)
344 .ok_or_else(|| TaskError::RecipeNotFound(recipe_name.to_string()))?;
345
346 for value in args.values() {
348 validate_arg_value(value)?;
349 }
350
351 let effective_justfile = match recipe.source {
353 RecipeSource::Global => global_justfile_path
354 .ok_or_else(|| TaskError::RecipeNotFound(recipe_name.to_string()))?,
355 RecipeSource::Project => project_justfile_path,
356 };
357
358 execute_with_justfile(recipe, args, effective_justfile, project_workdir, timeout).await
359}
360
361async fn execute_with_justfile(
367 recipe: &Recipe,
368 args: &HashMap<String, String>,
369 effective_justfile: &Path,
370 project_workdir: Option<&Path>,
371 timeout: Duration,
372) -> Result<TaskExecution, TaskError> {
373 let positional: Vec<&str> = recipe
375 .parameters
376 .iter()
377 .filter_map(|p| args.get(&p.name).map(|v| v.as_str()))
378 .collect();
379
380 let started_at = SystemTime::now()
381 .duration_since(UNIX_EPOCH)
382 .unwrap_or_default()
383 .as_secs();
384 let start_instant = std::time::Instant::now();
385
386 let mut cmd = Command::new("just");
387 cmd.arg("--justfile").arg(effective_justfile);
388 if let Some(dir) = project_workdir {
389 cmd.arg("--working-directory").arg(dir);
390 cmd.current_dir(dir);
391 }
392 cmd.arg(&recipe.name);
393 for arg in &positional {
394 cmd.arg(arg);
395 }
396
397 let run_result = tokio::time::timeout(timeout, cmd.output()).await;
398 let duration_ms = start_instant.elapsed().as_millis() as u64;
399
400 let output = match run_result {
401 Err(_) => return Err(TaskError::Timeout),
402 Ok(Err(io_err)) => return Err(TaskError::Io(io_err)),
403 Ok(Ok(out)) => out,
404 };
405
406 let exit_code = output.status.code();
407 let raw_stdout = String::from_utf8_lossy(&output.stdout).into_owned();
408 let raw_stderr = String::from_utf8_lossy(&output.stderr).into_owned();
409 let (stdout, stdout_truncated) = truncate_output(&raw_stdout);
410 let (stderr, stderr_truncated) = truncate_output(&raw_stderr);
411 let truncated = stdout_truncated || stderr_truncated;
412
413 Ok(TaskExecution {
414 id: Uuid::new_v4().to_string(),
415 task_name: recipe.name.clone(),
416 args: args.clone(),
417 exit_code,
418 stdout,
419 stderr,
420 started_at,
421 duration_ms,
422 truncated,
423 })
424}
425
426pub struct TaskLogStore {
435 logs: Mutex<VecDeque<TaskExecution>>,
436 max_entries: usize,
437}
438
439impl TaskLogStore {
440 pub fn new(max_entries: usize) -> Self {
441 Self {
442 logs: Mutex::new(VecDeque::new()),
443 max_entries,
444 }
445 }
446
447 pub fn push(&self, execution: TaskExecution) {
449 let mut guard = self.logs.lock().expect("log store lock poisoned");
450 if guard.len() >= self.max_entries {
451 guard.pop_front();
452 }
453 guard.push_back(execution);
454 }
455
456 pub fn get(&self, id: &str) -> Option<TaskExecution> {
458 let guard = self.logs.lock().expect("log store lock poisoned");
459 guard.iter().find(|e| e.id == id).cloned()
460 }
461
462 pub fn recent(&self, n: usize) -> Vec<TaskExecutionSummary> {
464 let guard = self.logs.lock().expect("log store lock poisoned");
465 guard
466 .iter()
467 .rev()
468 .take(n)
469 .map(TaskExecutionSummary::from_execution)
470 .collect()
471 }
472}
473
474#[cfg(test)]
475mod tests {
476 use super::*;
477 use crate::just::model::RecipeAttribute;
478
479 fn make_recipe(name: &str, attributes: Vec<RecipeAttribute>) -> JustRecipe {
480 make_recipe_with_doc(name, attributes, None)
481 }
482
483 fn make_recipe_with_doc(
484 name: &str,
485 attributes: Vec<RecipeAttribute>,
486 doc: Option<&str>,
487 ) -> JustRecipe {
488 crate::just::model::JustRecipe {
489 name: name.to_string(),
490 namepath: name.to_string(),
491 doc: doc.map(str::to_string),
492 attributes,
493 parameters: vec![],
494 private: false,
495 quiet: false,
496 }
497 }
498
499 #[test]
500 fn has_allow_agent_group_attribute_true() {
501 let recipe = make_recipe(
502 "build",
503 vec![RecipeAttribute::Object(
504 [("group".to_string(), Some("allow-agent".to_string()))]
505 .into_iter()
506 .collect(),
507 )],
508 );
509 assert!(has_allow_agent_group_attribute(&recipe));
510 }
511
512 #[test]
513 fn has_allow_agent_group_attribute_false_no_attrs() {
514 let recipe = make_recipe("deploy", vec![]);
515 assert!(!has_allow_agent_group_attribute(&recipe));
516 }
517
518 #[test]
519 fn has_allow_agent_group_attribute_false_other_group() {
520 let recipe = make_recipe(
521 "build",
522 vec![RecipeAttribute::Object(
523 [("group".to_string(), Some("ci".to_string()))]
524 .into_iter()
525 .collect(),
526 )],
527 );
528 assert!(!has_allow_agent_group_attribute(&recipe));
529 }
530
531 #[test]
532 fn has_allow_agent_group_attribute_false_legacy_agent_literal() {
533 let recipe = make_recipe(
536 "build",
537 vec![RecipeAttribute::Object(
538 [("group".to_string(), Some("agent".to_string()))]
539 .into_iter()
540 .collect(),
541 )],
542 );
543 assert!(!has_allow_agent_group_attribute(&recipe));
544 }
545
546 #[test]
547 fn has_allow_agent_doc_true() {
548 let recipe = make_recipe_with_doc("build", vec![], Some("[allow-agent]"));
549 assert!(has_allow_agent_doc(&recipe));
550 }
551
552 #[test]
553 fn has_allow_agent_doc_false_no_doc() {
554 let recipe = make_recipe("build", vec![]);
555 assert!(!has_allow_agent_doc(&recipe));
556 }
557
558 #[test]
559 fn has_allow_agent_doc_false_other_doc() {
560 let recipe = make_recipe_with_doc("build", vec![], Some("Build the project"));
561 assert!(!has_allow_agent_doc(&recipe));
562 }
563
564 #[test]
565 fn has_allow_agent_doc_false_substring_in_prose() {
566 let recipe = make_recipe_with_doc(
568 "build",
569 vec![],
570 Some("do not add-[allow-agent]-here casually"),
571 );
572 assert!(!has_allow_agent_doc(&recipe));
573 }
574
575 #[test]
576 fn has_allow_agent_doc_true_with_surrounding_whitespace() {
577 let recipe = make_recipe_with_doc("build", vec![], Some(" [allow-agent] "));
578 assert!(has_allow_agent_doc(&recipe));
579 }
580
581 #[test]
582 fn is_allow_agent_pattern_a() {
583 let recipe = make_recipe(
584 "build",
585 vec![RecipeAttribute::Object(
586 [("group".to_string(), Some("allow-agent".to_string()))]
587 .into_iter()
588 .collect(),
589 )],
590 );
591 assert!(is_allow_agent(&recipe));
592 }
593
594 #[test]
595 fn is_allow_agent_pattern_b() {
596 let recipe = make_recipe_with_doc("build", vec![], Some("[allow-agent]"));
597 assert!(is_allow_agent(&recipe));
598 }
599
600 #[test]
601 fn is_allow_agent_pattern_a_plus_other_groups() {
602 let recipe = make_recipe(
606 "build",
607 vec![
608 RecipeAttribute::Object(
609 [("group".to_string(), Some("allow-agent".to_string()))]
610 .into_iter()
611 .collect(),
612 ),
613 RecipeAttribute::Object(
614 [("group".to_string(), Some("profile".to_string()))]
615 .into_iter()
616 .collect(),
617 ),
618 ],
619 );
620 assert!(is_allow_agent(&recipe));
621 }
622
623 #[test]
624 fn is_allow_agent_neither() {
625 let recipe = make_recipe("deploy", vec![]);
626 assert!(!is_allow_agent(&recipe));
627 }
628
629 #[test]
630 fn is_allow_agent_non_agent_group_only() {
631 let recipe = make_recipe_with_doc(
636 "foo",
637 vec![RecipeAttribute::Object(
638 [("group".to_string(), Some("profile".to_string()))]
639 .into_iter()
640 .collect(),
641 )],
642 Some("[allow-agent]"),
643 );
644 assert!(is_allow_agent(&recipe));
645 }
646
647 #[test]
648 fn resolve_justfile_path_override() {
649 let p = resolve_justfile_path(Some("/custom/justfile"), None);
650 assert_eq!(p, PathBuf::from("/custom/justfile"));
651 }
652
653 #[test]
654 fn resolve_justfile_path_default() {
655 let p = resolve_justfile_path(None, None);
656 assert_eq!(p, PathBuf::from("justfile"));
657 }
658
659 #[test]
660 fn resolve_justfile_path_with_workdir() {
661 let workdir = Path::new("/some/project");
662 let p = resolve_justfile_path(None, Some(workdir));
663 assert_eq!(p, PathBuf::from("/some/project/justfile"));
664 }
665
666 #[test]
667 fn resolve_justfile_path_override_ignores_workdir() {
668 let workdir = Path::new("/some/project");
670 let p = resolve_justfile_path(Some("/custom/justfile"), Some(workdir));
671 assert_eq!(p, PathBuf::from("/custom/justfile"));
672 }
673
674 #[test]
679 fn truncate_output_short_input_unchanged() {
680 let input = "hello";
681 let (result, truncated) = truncate_output(input);
682 assert!(!truncated);
683 assert_eq!(result, input);
684 }
685
686 #[test]
687 fn truncate_output_long_input_truncated() {
688 let input = "x".repeat(200 * 1024);
690 let (result, truncated) = truncate_output(&input);
691 assert!(truncated);
692 assert!(result.contains("...[truncated"));
693 assert!(result.len() < input.len());
695 }
696
697 #[test]
698 fn truncate_output_utf8_boundary() {
699 let char_3bytes = '日';
702 let count = (MAX_OUTPUT_BYTES / 3) + 10;
704 let input: String = std::iter::repeat_n(char_3bytes, count).collect();
705 let (result, truncated) = truncate_output(&input);
706 assert!(std::str::from_utf8(result.as_bytes()).is_ok());
708 if truncated {
709 assert!(result.contains("...[truncated"));
710 }
711 }
712
713 #[test]
718 fn validate_arg_value_safe_values() {
719 assert!(validate_arg_value("hello world").is_ok());
720 assert!(validate_arg_value("value_123-abc").is_ok());
721 assert!(validate_arg_value("path/to/file.txt").is_ok());
722 }
723
724 #[test]
725 fn validate_arg_value_semicolon_rejected() {
726 assert!(validate_arg_value("foo; rm -rf /").is_err());
727 }
728
729 #[test]
730 fn validate_arg_value_pipe_rejected() {
731 assert!(validate_arg_value("foo | cat /etc/passwd").is_err());
732 }
733
734 #[test]
735 fn validate_arg_value_and_and_rejected() {
736 assert!(validate_arg_value("foo && evil").is_err());
737 }
738
739 #[test]
740 fn validate_arg_value_backtick_rejected() {
741 assert!(validate_arg_value("foo`id`").is_err());
742 }
743
744 #[test]
745 fn validate_arg_value_dollar_paren_rejected() {
746 assert!(validate_arg_value("$(id)").is_err());
747 }
748
749 #[test]
750 fn validate_arg_value_newline_rejected() {
751 assert!(validate_arg_value("foo\nbar").is_err());
752 }
753
754 fn make_execution(id: &str, task_name: &str) -> TaskExecution {
759 TaskExecution {
760 id: id.to_string(),
761 task_name: task_name.to_string(),
762 args: HashMap::new(),
763 exit_code: Some(0),
764 stdout: "".to_string(),
765 stderr: "".to_string(),
766 started_at: 0,
767 duration_ms: 0,
768 truncated: false,
769 }
770 }
771
772 #[test]
773 fn task_log_store_push_and_get() {
774 let store = TaskLogStore::new(10);
775 let exec = make_execution("id-1", "build");
776 store.push(exec);
777 let retrieved = store.get("id-1").expect("should find id-1");
778 assert_eq!(retrieved.task_name, "build");
779 }
780
781 #[test]
782 fn task_log_store_get_missing() {
783 let store = TaskLogStore::new(10);
784 assert!(store.get("nonexistent").is_none());
785 }
786
787 #[test]
788 fn task_log_store_evicts_oldest_when_full() {
789 let store = TaskLogStore::new(3);
790 store.push(make_execution("id-1", "a"));
791 store.push(make_execution("id-2", "b"));
792 store.push(make_execution("id-3", "c"));
793 store.push(make_execution("id-4", "d")); assert!(store.get("id-1").is_none(), "id-1 should be evicted");
795 assert!(store.get("id-4").is_some(), "id-4 should exist");
796 }
797
798 #[test]
799 fn task_log_store_recent_newest_first() {
800 let store = TaskLogStore::new(10);
801 store.push(make_execution("id-1", "a"));
802 store.push(make_execution("id-2", "b"));
803 store.push(make_execution("id-3", "c"));
804 let recent = store.recent(2);
805 assert_eq!(recent.len(), 2);
806 assert_eq!(recent[0].id, "id-3", "newest should be first");
807 assert_eq!(recent[1].id, "id-2");
808 }
809
810 #[test]
811 fn task_log_store_recent_n_larger_than_store() {
812 let store = TaskLogStore::new(10);
813 store.push(make_execution("id-1", "a"));
814 let recent = store.recent(5);
815 assert_eq!(recent.len(), 1);
816 }
817}