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)]
42#[serde(untagged)]
43pub enum RecipeAttribute {
44 Bare(String),
46 Object(HashMap<String, Option<String>>),
48}
49
50impl RecipeAttribute {
51 pub fn group(&self) -> Option<&str> {
53 match self {
54 RecipeAttribute::Object(map) => map.get("group").and_then(|v| v.as_deref()),
55 RecipeAttribute::Bare(_) => None,
56 }
57 }
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize, Default)]
62#[serde(default)]
63pub struct RecipeParameter {
64 pub name: String,
65 pub kind: String,
66 pub default: Option<String>,
67 pub help: Option<String>,
68}
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
76#[serde(rename_all = "lowercase")]
77pub enum RecipeSource {
78 #[default]
80 Project,
81 Global,
83}
84
85#[derive(Debug, Clone, Serialize, Deserialize)]
87pub struct Recipe {
88 pub name: String,
90 pub namepath: String,
92 pub description: Option<String>,
94 pub parameters: Vec<RecipeParameter>,
96 pub groups: Vec<String>,
98 pub allow_agent: bool,
100 pub source: RecipeSource,
102}
103
104impl Recipe {
105 pub fn from_just_recipe(raw: JustRecipe, allow_agent: bool) -> Self {
107 Self::from_just_recipe_with_source(raw, allow_agent, RecipeSource::Project)
108 }
109
110 pub fn from_just_recipe_with_source(
112 raw: JustRecipe,
113 allow_agent: bool,
114 source: RecipeSource,
115 ) -> Self {
116 let groups = raw
117 .attributes
118 .iter()
119 .filter_map(|a| a.group().map(str::to_owned))
120 .collect();
121 Self {
122 name: raw.name,
123 namepath: raw.namepath,
124 description: raw.doc,
125 parameters: raw.parameters,
126 groups,
127 allow_agent,
128 source,
129 }
130 }
131}
132
133#[derive(Debug, Clone, Serialize, Deserialize)]
139pub struct TaskExecution {
140 pub id: String,
142 pub task_name: String,
144 pub args: HashMap<String, String>,
146 pub exit_code: Option<i32>,
148 pub stdout: String,
150 pub stderr: String,
152 pub started_at: u64,
154 pub duration_ms: u64,
156 pub truncated: bool,
158}
159
160#[derive(Debug, Clone, Serialize, Deserialize)]
162pub struct TaskExecutionSummary {
163 pub id: String,
164 pub task_name: String,
165 pub exit_code: Option<i32>,
166 pub started_at: u64,
167 pub duration_ms: u64,
168 pub truncated: bool,
169}
170
171impl TaskExecutionSummary {
172 pub fn from_execution(exec: &TaskExecution) -> Self {
173 Self {
174 id: exec.id.clone(),
175 task_name: exec.task_name.clone(),
176 exit_code: exec.exit_code,
177 started_at: exec.started_at,
178 duration_ms: exec.duration_ms,
179 truncated: exec.truncated,
180 }
181 }
182}
183
184#[derive(Debug, Error)]
186pub enum TaskError {
187 #[error("recipe not found or not accessible: {0}")]
188 RecipeNotFound(String),
189 #[error("dangerous argument value rejected: {0}")]
190 DangerousArgument(String),
191 #[error("execution timed out")]
192 Timeout,
193 #[error("command failed with exit code {code}: {stderr}")]
194 CommandFailed { code: i32, stderr: String },
195 #[error("I/O error: {0}")]
196 Io(#[from] std::io::Error),
197 #[error("just error: {0}")]
198 JustError(#[from] crate::just::JustError),
199}
200
201#[cfg(test)]
202mod tests {
203 use super::*;
204
205 #[test]
206 fn parse_just_dump_minimal() {
207 let json = r#"{"recipes":{"build":{"name":"build","namepath":"build","doc":null,"attributes":[],"parameters":[],"private":false,"quiet":false}},"source":"/tmp/justfile"}"#;
208 let dump: JustDump = serde_json::from_str(json).expect("parse should succeed");
209 assert!(dump.recipes.contains_key("build"));
210 }
211
212 #[test]
213 fn parse_recipe_with_group_attribute() {
214 let json = r#"{"recipes":{"build":{"name":"build","namepath":"build","doc":"Build the project","attributes":[{"group":"allow-agent"}],"parameters":[],"private":false,"quiet":false}}}"#;
215 let dump: JustDump = serde_json::from_str(json).expect("parse should succeed");
216 let build = dump.recipes.get("build").expect("build recipe");
217 assert_eq!(build.attributes[0].group(), Some("allow-agent"));
218 assert_eq!(build.doc.as_deref(), Some("Build the project"));
219 }
220
221 #[test]
222 fn parse_recipe_with_mixed_attribute_shapes() {
223 let json = r#"{"recipes":{"r":{"name":"r","namepath":"r","doc":null,"attributes":["private",{"group":"allow-agent"},{"confirm":null}],"parameters":[],"private":true,"quiet":false}}}"#;
229 let dump: JustDump = serde_json::from_str(json).expect("parse should succeed");
230 let r = dump.recipes.get("r").expect("r recipe");
231 assert_eq!(r.attributes.len(), 3);
232 assert!(matches!(&r.attributes[0], RecipeAttribute::Bare(s) if s == "private"));
233 assert_eq!(r.attributes[1].group(), Some("allow-agent"));
234 assert!(matches!(&r.attributes[2], RecipeAttribute::Object(_)));
235 assert_eq!(r.attributes[2].group(), None);
236 }
237
238 #[test]
239 fn parse_recipe_with_parameters() {
240 let json = r#"{"recipes":{"test":{"name":"test","namepath":"test","doc":null,"attributes":[],"parameters":[{"name":"filter","kind":"singular","default":"","help":null}],"private":false,"quiet":false}}}"#;
241 let dump: JustDump = serde_json::from_str(json).expect("parse should succeed");
242 let test = dump.recipes.get("test").expect("test recipe");
243 assert_eq!(test.parameters[0].name, "filter");
244 assert_eq!(test.parameters[0].default.as_deref(), Some(""));
245 }
246
247 #[test]
248 fn recipe_from_just_recipe_allow_agent_true() {
249 let raw = JustRecipe {
250 name: "build".to_string(),
251 namepath: "build".to_string(),
252 doc: Some("Build".to_string()),
253 attributes: vec![RecipeAttribute::Object(
254 [("group".to_string(), Some("allow-agent".to_string()))]
255 .into_iter()
256 .collect(),
257 )],
258 parameters: vec![],
259 private: false,
260 quiet: false,
261 };
262 let recipe = Recipe::from_just_recipe(raw, true);
263 assert!(recipe.allow_agent);
264 assert_eq!(recipe.groups, vec!["allow-agent"]);
265 assert_eq!(recipe.description.as_deref(), Some("Build"));
266 assert_eq!(recipe.source, RecipeSource::Project);
267 }
268
269 #[test]
270 fn recipe_from_just_recipe_allow_agent_false() {
271 let raw = JustRecipe {
272 name: "deploy".to_string(),
273 namepath: "deploy".to_string(),
274 doc: None,
275 attributes: vec![],
276 parameters: vec![],
277 private: false,
278 quiet: false,
279 };
280 let recipe = Recipe::from_just_recipe(raw, false);
281 assert!(!recipe.allow_agent);
282 assert!(recipe.groups.is_empty());
283 assert_eq!(recipe.source, RecipeSource::Project);
284 }
285
286 #[test]
287 fn recipe_from_just_recipe_with_source_global() {
288 let raw = JustRecipe {
289 name: "init-project".to_string(),
290 namepath: "init-project".to_string(),
291 doc: Some("Initialize project".to_string()),
292 attributes: vec![RecipeAttribute::Object(
293 [("group".to_string(), Some("allow-agent".to_string()))]
294 .into_iter()
295 .collect(),
296 )],
297 parameters: vec![],
298 private: false,
299 quiet: false,
300 };
301 let recipe = Recipe::from_just_recipe_with_source(raw, true, RecipeSource::Global);
302 assert_eq!(recipe.source, RecipeSource::Global);
303 assert!(recipe.allow_agent);
304 }
305
306 #[test]
307 fn task_execution_summary_from_execution() {
308 let exec = TaskExecution {
309 id: "test-id".to_string(),
310 task_name: "build".to_string(),
311 args: HashMap::new(),
312 exit_code: Some(0),
313 stdout: "output".to_string(),
314 stderr: "".to_string(),
315 started_at: 1000,
316 duration_ms: 500,
317 truncated: false,
318 };
319 let summary = TaskExecutionSummary::from_execution(&exec);
320 assert_eq!(summary.id, "test-id");
321 assert_eq!(summary.task_name, "build");
322 assert_eq!(summary.exit_code, Some(0));
323 assert_eq!(summary.started_at, 1000);
324 assert_eq!(summary.duration_ms, 500);
325 assert!(!summary.truncated);
326 }
327}