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 content: HashMap<String, String>,
148 pub exit_code: Option<i32>,
150 pub stdout: String,
152 pub stderr: String,
154 pub started_at: u64,
156 pub duration_ms: u64,
158 pub truncated: bool,
160}
161
162#[derive(Debug, Clone, Serialize, Deserialize)]
164pub struct TaskExecutionSummary {
165 pub id: String,
166 pub task_name: String,
167 pub exit_code: Option<i32>,
168 pub started_at: u64,
169 pub duration_ms: u64,
170 pub truncated: bool,
171}
172
173impl TaskExecutionSummary {
174 pub fn from_execution(exec: &TaskExecution) -> Self {
175 Self {
176 id: exec.id.clone(),
177 task_name: exec.task_name.clone(),
178 exit_code: exec.exit_code,
179 started_at: exec.started_at,
180 duration_ms: exec.duration_ms,
181 truncated: exec.truncated,
182 }
183 }
184}
185
186#[derive(Debug, Error)]
188pub enum TaskError {
189 #[error("recipe not found or not accessible: {0}")]
190 RecipeNotFound(String),
191 #[error("argument contains invalid control character: {0}")]
192 DangerousArgument(String),
193 #[error("invalid content key '{0}': must match [A-Za-z][A-Za-z0-9_]*")]
194 InvalidContentKey(String),
195 #[error("execution timed out")]
196 Timeout,
197 #[error("command failed with exit code {code}: {stderr}")]
198 CommandFailed { code: i32, stderr: String },
199 #[error("I/O error: {0}")]
200 Io(#[from] std::io::Error),
201 #[error("just error: {0}")]
202 JustError(#[from] crate::just::JustError),
203}
204
205#[cfg(test)]
206mod tests {
207 use super::*;
208
209 #[test]
210 fn parse_just_dump_minimal() {
211 let json = r#"{"recipes":{"build":{"name":"build","namepath":"build","doc":null,"attributes":[],"parameters":[],"private":false,"quiet":false}},"source":"/tmp/justfile"}"#;
212 let dump: JustDump = serde_json::from_str(json).expect("parse should succeed");
213 assert!(dump.recipes.contains_key("build"));
214 }
215
216 #[test]
217 fn parse_recipe_with_group_attribute() {
218 let json = r#"{"recipes":{"build":{"name":"build","namepath":"build","doc":"Build the project","attributes":[{"group":"allow-agent"}],"parameters":[],"private":false,"quiet":false}}}"#;
219 let dump: JustDump = serde_json::from_str(json).expect("parse should succeed");
220 let build = dump.recipes.get("build").expect("build recipe");
221 assert_eq!(build.attributes[0].group(), Some("allow-agent"));
222 assert_eq!(build.doc.as_deref(), Some("Build the project"));
223 }
224
225 #[test]
226 fn parse_recipe_with_mixed_attribute_shapes() {
227 let json = r#"{"recipes":{"r":{"name":"r","namepath":"r","doc":null,"attributes":["private",{"group":"allow-agent"},{"confirm":null}],"parameters":[],"private":true,"quiet":false}}}"#;
233 let dump: JustDump = serde_json::from_str(json).expect("parse should succeed");
234 let r = dump.recipes.get("r").expect("r recipe");
235 assert_eq!(r.attributes.len(), 3);
236 assert!(matches!(&r.attributes[0], RecipeAttribute::Bare(s) if s == "private"));
237 assert_eq!(r.attributes[1].group(), Some("allow-agent"));
238 assert!(matches!(&r.attributes[2], RecipeAttribute::Object(_)));
239 assert_eq!(r.attributes[2].group(), None);
240 }
241
242 #[test]
243 fn parse_recipe_with_parameters() {
244 let json = r#"{"recipes":{"test":{"name":"test","namepath":"test","doc":null,"attributes":[],"parameters":[{"name":"filter","kind":"singular","default":"","help":null}],"private":false,"quiet":false}}}"#;
245 let dump: JustDump = serde_json::from_str(json).expect("parse should succeed");
246 let test = dump.recipes.get("test").expect("test recipe");
247 assert_eq!(test.parameters[0].name, "filter");
248 assert_eq!(test.parameters[0].default.as_deref(), Some(""));
249 }
250
251 #[test]
252 fn recipe_from_just_recipe_allow_agent_true() {
253 let raw = JustRecipe {
254 name: "build".to_string(),
255 namepath: "build".to_string(),
256 doc: Some("Build".to_string()),
257 attributes: vec![RecipeAttribute::Object(
258 [("group".to_string(), Some("allow-agent".to_string()))]
259 .into_iter()
260 .collect(),
261 )],
262 parameters: vec![],
263 private: false,
264 quiet: false,
265 };
266 let recipe = Recipe::from_just_recipe(raw, true);
267 assert!(recipe.allow_agent);
268 assert_eq!(recipe.groups, vec!["allow-agent"]);
269 assert_eq!(recipe.description.as_deref(), Some("Build"));
270 assert_eq!(recipe.source, RecipeSource::Project);
271 }
272
273 #[test]
274 fn recipe_from_just_recipe_allow_agent_false() {
275 let raw = JustRecipe {
276 name: "deploy".to_string(),
277 namepath: "deploy".to_string(),
278 doc: None,
279 attributes: vec![],
280 parameters: vec![],
281 private: false,
282 quiet: false,
283 };
284 let recipe = Recipe::from_just_recipe(raw, false);
285 assert!(!recipe.allow_agent);
286 assert!(recipe.groups.is_empty());
287 assert_eq!(recipe.source, RecipeSource::Project);
288 }
289
290 #[test]
291 fn recipe_from_just_recipe_with_source_global() {
292 let raw = JustRecipe {
293 name: "init-project".to_string(),
294 namepath: "init-project".to_string(),
295 doc: Some("Initialize project".to_string()),
296 attributes: vec![RecipeAttribute::Object(
297 [("group".to_string(), Some("allow-agent".to_string()))]
298 .into_iter()
299 .collect(),
300 )],
301 parameters: vec![],
302 private: false,
303 quiet: false,
304 };
305 let recipe = Recipe::from_just_recipe_with_source(raw, true, RecipeSource::Global);
306 assert_eq!(recipe.source, RecipeSource::Global);
307 assert!(recipe.allow_agent);
308 }
309
310 #[test]
311 fn task_execution_summary_from_execution() {
312 let exec = TaskExecution {
313 id: "test-id".to_string(),
314 task_name: "build".to_string(),
315 args: HashMap::new(),
316 content: HashMap::new(),
317 exit_code: Some(0),
318 stdout: "output".to_string(),
319 stderr: "".to_string(),
320 started_at: 1000,
321 duration_ms: 500,
322 truncated: false,
323 };
324 let summary = TaskExecutionSummary::from_execution(&exec);
325 assert_eq!(summary.id, "test-id");
326 assert_eq!(summary.task_name, "build");
327 assert_eq!(summary.exit_code, Some(0));
328 assert_eq!(summary.started_at, 1000);
329 assert_eq!(summary.duration_ms, 500);
330 assert!(!summary.truncated);
331 }
332}