1use std::collections::HashMap;
2
3use serde::{Deserialize, Serialize};
4use thiserror::Error;
5
6#[derive(Debug, Clone, Deserialize, Default)]
12#[serde(default)]
13pub struct JustDump {
14 pub recipes: HashMap<String, JustRecipe>,
15 pub source: Option<String>,
16}
17
18#[derive(Debug, Clone, Deserialize, Default)]
20#[serde(default)]
21pub struct JustRecipe {
22 pub name: String,
23 pub namepath: String,
24 pub doc: Option<String>,
25 pub attributes: Vec<RecipeAttribute>,
26 pub parameters: Vec<RecipeParameter>,
27 pub private: bool,
28 pub quiet: bool,
29}
30
31#[derive(Debug, Clone, Deserialize, Default)]
35#[serde(default)]
36pub struct RecipeAttribute {
37 pub group: Option<String>,
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize, Default)]
42#[serde(default)]
43pub struct RecipeParameter {
44 pub name: String,
45 pub kind: String,
46 pub default: Option<String>,
47 pub help: Option<String>,
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct Recipe {
57 pub name: String,
59 pub namepath: String,
61 pub description: Option<String>,
63 pub parameters: Vec<RecipeParameter>,
65 pub groups: Vec<String>,
67 pub allow_agent: bool,
69}
70
71impl Recipe {
72 pub fn from_just_recipe(raw: JustRecipe, allow_agent: bool) -> Self {
74 let groups = raw
75 .attributes
76 .iter()
77 .filter_map(|a| a.group.clone())
78 .collect();
79 Self {
80 name: raw.name,
81 namepath: raw.namepath,
82 description: raw.doc,
83 parameters: raw.parameters,
84 groups,
85 allow_agent,
86 }
87 }
88}
89
90#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct TaskExecution {
97 pub id: String,
99 pub task_name: String,
101 pub args: HashMap<String, String>,
103 pub exit_code: Option<i32>,
105 pub stdout: String,
107 pub stderr: String,
109 pub started_at: u64,
111 pub duration_ms: u64,
113 pub truncated: bool,
115}
116
117#[derive(Debug, Clone, Serialize, Deserialize)]
119pub struct TaskExecutionSummary {
120 pub id: String,
121 pub task_name: String,
122 pub exit_code: Option<i32>,
123 pub started_at: u64,
124 pub duration_ms: u64,
125 pub truncated: bool,
126}
127
128impl TaskExecutionSummary {
129 pub fn from_execution(exec: &TaskExecution) -> Self {
130 Self {
131 id: exec.id.clone(),
132 task_name: exec.task_name.clone(),
133 exit_code: exec.exit_code,
134 started_at: exec.started_at,
135 duration_ms: exec.duration_ms,
136 truncated: exec.truncated,
137 }
138 }
139}
140
141#[derive(Debug, Error)]
143pub enum TaskError {
144 #[error("recipe not found or not accessible: {0}")]
145 RecipeNotFound(String),
146 #[error("dangerous argument value rejected: {0}")]
147 DangerousArgument(String),
148 #[error("execution timed out")]
149 Timeout,
150 #[error("command failed with exit code {code}: {stderr}")]
151 CommandFailed { code: i32, stderr: String },
152 #[error("I/O error: {0}")]
153 Io(#[from] std::io::Error),
154 #[error("just error: {0}")]
155 JustError(#[from] crate::just::JustError),
156}
157
158#[cfg(test)]
159mod tests {
160 use super::*;
161
162 #[test]
163 fn parse_just_dump_minimal() {
164 let json = r#"{"recipes":{"build":{"name":"build","namepath":"build","doc":null,"attributes":[],"parameters":[],"private":false,"quiet":false}},"source":"/tmp/justfile"}"#;
165 let dump: JustDump = serde_json::from_str(json).expect("parse should succeed");
166 assert!(dump.recipes.contains_key("build"));
167 }
168
169 #[test]
170 fn parse_recipe_with_group_attribute() {
171 let json = r#"{"recipes":{"build":{"name":"build","namepath":"build","doc":"Build the project","attributes":[{"group":"agent"}],"parameters":[],"private":false,"quiet":false}}}"#;
172 let dump: JustDump = serde_json::from_str(json).expect("parse should succeed");
173 let build = dump.recipes.get("build").expect("build recipe");
174 assert_eq!(build.attributes[0].group.as_deref(), Some("agent"));
175 assert_eq!(build.doc.as_deref(), Some("Build the project"));
176 }
177
178 #[test]
179 fn parse_recipe_with_parameters() {
180 let json = r#"{"recipes":{"test":{"name":"test","namepath":"test","doc":null,"attributes":[],"parameters":[{"name":"filter","kind":"singular","default":"","help":null}],"private":false,"quiet":false}}}"#;
181 let dump: JustDump = serde_json::from_str(json).expect("parse should succeed");
182 let test = dump.recipes.get("test").expect("test recipe");
183 assert_eq!(test.parameters[0].name, "filter");
184 assert_eq!(test.parameters[0].default.as_deref(), Some(""));
185 }
186
187 #[test]
188 fn recipe_from_just_recipe_allow_agent_true() {
189 let raw = JustRecipe {
190 name: "build".to_string(),
191 namepath: "build".to_string(),
192 doc: Some("Build".to_string()),
193 attributes: vec![RecipeAttribute {
194 group: Some("agent".to_string()),
195 }],
196 parameters: vec![],
197 private: false,
198 quiet: false,
199 };
200 let recipe = Recipe::from_just_recipe(raw, true);
201 assert!(recipe.allow_agent);
202 assert_eq!(recipe.groups, vec!["agent"]);
203 assert_eq!(recipe.description.as_deref(), Some("Build"));
204 }
205
206 #[test]
207 fn recipe_from_just_recipe_allow_agent_false() {
208 let raw = JustRecipe {
209 name: "deploy".to_string(),
210 namepath: "deploy".to_string(),
211 doc: None,
212 attributes: vec![],
213 parameters: vec![],
214 private: false,
215 quiet: false,
216 };
217 let recipe = Recipe::from_just_recipe(raw, false);
218 assert!(!recipe.allow_agent);
219 assert!(recipe.groups.is_empty());
220 }
221
222 #[test]
223 fn task_execution_summary_from_execution() {
224 let exec = TaskExecution {
225 id: "test-id".to_string(),
226 task_name: "build".to_string(),
227 args: HashMap::new(),
228 exit_code: Some(0),
229 stdout: "output".to_string(),
230 stderr: "".to_string(),
231 started_at: 1000,
232 duration_ms: 500,
233 truncated: false,
234 };
235 let summary = TaskExecutionSummary::from_execution(&exec);
236 assert_eq!(summary.id, "test-id");
237 assert_eq!(summary.task_name, "build");
238 assert_eq!(summary.exit_code, Some(0));
239 assert_eq!(summary.started_at, 1000);
240 assert_eq!(summary.duration_ms, 500);
241 assert!(!summary.truncated);
242 }
243}