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 (e.g. `[group: 'agent']`).
32///
33/// just encodes attributes as objects; we only need the `group` field for now.
34#[derive(Debug, Clone, Deserialize, Default)]
35#[serde(default)]
36pub struct RecipeAttribute {
37    pub group: Option<String>,
38}
39
40/// Parameter of a recipe.
41#[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// =============================================================================
51// Domain types (used by MCP layer)
52// =============================================================================
53
54/// A recipe exposed to MCP callers.
55#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct Recipe {
57    /// Recipe name.
58    pub name: String,
59    /// Dotted name-path (e.g. `module::recipe`).
60    pub namepath: String,
61    /// Description from doc comment.
62    pub description: Option<String>,
63    /// Parameters the recipe accepts.
64    pub parameters: Vec<RecipeParameter>,
65    /// Groups the recipe belongs to (from `[group: '...']`).
66    pub groups: Vec<String>,
67    /// Whether the recipe is marked agent-safe.
68    pub allow_agent: bool,
69}
70
71impl Recipe {
72    /// Build a `Recipe` from `JustRecipe`, setting `allow_agent` explicitly.
73    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// =============================================================================
91// Execution types (used by run/logs tools)
92// =============================================================================
93
94/// Result of a single task execution.
95#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct TaskExecution {
97    /// UUID v4 identifier for this execution.
98    pub id: String,
99    /// Name of the executed recipe.
100    pub task_name: String,
101    /// Arguments passed to the recipe (parameter name → value).
102    pub args: HashMap<String, String>,
103    /// Exit code of the process; `None` if killed by signal.
104    pub exit_code: Option<i32>,
105    /// Captured stdout (may be truncated).
106    pub stdout: String,
107    /// Captured stderr (may be truncated).
108    pub stderr: String,
109    /// Unix timestamp (seconds) when the execution started.
110    pub started_at: u64,
111    /// Elapsed time in milliseconds.
112    pub duration_ms: u64,
113    /// `true` if stdout or stderr was truncated due to size limit.
114    pub truncated: bool,
115}
116
117/// Lightweight summary of a task execution (omits stdout/stderr).
118#[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/// Errors that can occur during recipe execution.
142#[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}