Skip to main content

task_mcp/just/
model.rs

1use std::collections::HashMap;
2
3use serde::{Deserialize, Serialize};
4use thiserror::Error;
5
6// =============================================================================
7// just --dump --dump-format json --unstable output types
8// =============================================================================
9
10/// Top-level output of `just --dump --dump-format json --unstable`.
11#[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/// A single recipe from just dump json.
19#[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/// Attribute attached to a recipe.
32///
33/// `just --dump --dump-format json` encodes recipe attributes in three shapes:
34/// 1. Bare string for nullary attributes:        `"private"`
35/// 2. Object with string payload:                `{"group": "allow-agent"}`
36/// 3. Object with null payload (other unary):    `{"confirm": null}`
37///
38/// We model all three with an untagged enum so deserialization is total. Only
39/// `Group` is consumed by current logic; the rest are preserved as `Other`
40/// for forward compatibility.
41#[derive(Debug, Clone, Deserialize)]
42#[serde(untagged)]
43pub enum RecipeAttribute {
44    /// Bare-string attribute, e.g. `"private"`, `"no-cd"`, `"linux"`.
45    Bare(String),
46    /// Object attribute, e.g. `{"group": "allow-agent"}` or `{"confirm": null}`.
47    Object(HashMap<String, Option<String>>),
48}
49
50impl RecipeAttribute {
51    /// Returns the group name if this attribute is `[group: '<name>']`.
52    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/// Parameter of a recipe.
61#[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// =============================================================================
71// Domain types (used by MCP layer)
72// =============================================================================
73
74/// Origin of a recipe in a merged recipe list.
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
76#[serde(rename_all = "lowercase")]
77pub enum RecipeSource {
78    /// Recipe comes from the project-local justfile.
79    #[default]
80    Project,
81    /// Recipe comes from the global (~/.config/task-mcp) justfile.
82    Global,
83}
84
85/// A recipe exposed to MCP callers.
86#[derive(Debug, Clone, Serialize, Deserialize)]
87pub struct Recipe {
88    /// Recipe name.
89    pub name: String,
90    /// Dotted name-path (e.g. `module::recipe`).
91    pub namepath: String,
92    /// Description from doc comment.
93    pub description: Option<String>,
94    /// Parameters the recipe accepts.
95    pub parameters: Vec<RecipeParameter>,
96    /// Groups the recipe belongs to (from `[group: '...']`).
97    pub groups: Vec<String>,
98    /// Whether the recipe is marked agent-safe.
99    pub allow_agent: bool,
100    /// Where this recipe originates (project or global).
101    pub source: RecipeSource,
102}
103
104impl Recipe {
105    /// Build a `Recipe` from `JustRecipe`, setting `allow_agent` and `source` explicitly.
106    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    /// Build a `Recipe` from `JustRecipe` with an explicit source tag.
111    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// =============================================================================
134// Execution types (used by run/logs tools)
135// =============================================================================
136
137/// Result of a single task execution.
138#[derive(Debug, Clone, Serialize, Deserialize)]
139pub struct TaskExecution {
140    /// UUID v4 identifier for this execution.
141    pub id: String,
142    /// Name of the executed recipe.
143    pub task_name: String,
144    /// Arguments passed to the recipe (parameter name → value).
145    pub args: HashMap<String, String>,
146    /// Exit code of the process; `None` if killed by signal.
147    pub exit_code: Option<i32>,
148    /// Captured stdout (may be truncated).
149    pub stdout: String,
150    /// Captured stderr (may be truncated).
151    pub stderr: String,
152    /// Unix timestamp (seconds) when the execution started.
153    pub started_at: u64,
154    /// Elapsed time in milliseconds.
155    pub duration_ms: u64,
156    /// `true` if stdout or stderr was truncated due to size limit.
157    pub truncated: bool,
158}
159
160/// Lightweight summary of a task execution (omits stdout/stderr).
161#[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/// Errors that can occur during recipe execution.
185#[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        // just emits attributes in three shapes:
224        //   - bare string ("private")
225        //   - object with string payload ({"group":"allow-agent"})
226        //   - object with null payload ({"confirm":null})
227        // All three must deserialize via the untagged enum.
228        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}