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    /// Content arguments passed as environment variables (key → value).
147    pub content: HashMap<String, String>,
148    /// Exit code of the process; `None` if killed by signal.
149    pub exit_code: Option<i32>,
150    /// Captured stdout (may be truncated).
151    pub stdout: String,
152    /// Captured stderr (may be truncated).
153    pub stderr: String,
154    /// Unix timestamp (seconds) when the execution started.
155    pub started_at: u64,
156    /// Elapsed time in milliseconds.
157    pub duration_ms: u64,
158    /// `true` if stdout or stderr was truncated due to size limit.
159    pub truncated: bool,
160}
161
162/// Lightweight summary of a task execution (omits stdout/stderr).
163#[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/// Errors that can occur during recipe execution.
187#[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        // just emits attributes in three shapes:
228        //   - bare string ("private")
229        //   - object with string payload ({"group":"allow-agent"})
230        //   - object with null payload ({"confirm":null})
231        // All three must deserialize via the untagged enum.
232        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}