Skip to main content

devboy_jira/
enricher.rs

1//! Jira schema enricher.
2//!
3//! Dynamic enricher supporting single-project and multi-project configurations.
4
5use devboy_core::{
6    CostModel, FollowUpLink, SideEffectClass, ToolCategory, ToolEnricher, ToolSchema,
7    ToolValueModel, ValueClass, sanitize_field_name,
8};
9use serde_json::{Value, json};
10
11use crate::metadata::{JiraFieldType, JiraMetadata};
12
13/// Dynamic schema enricher for Jira provider.
14///
15/// Single-project mode: removes `projectId`, adds enums, replaces customFields with cf_*.
16/// Multi-project mode: keeps `projectId` as enum, shows known values in descriptions.
17pub struct JiraSchemaEnricher {
18    metadata: JiraMetadata,
19    /// Precomputed category list: always advertises `IssueTracker`, and
20    /// additionally advertises `JiraStructure` only when the metadata
21    /// actually carries accessible structures. `Executor::list_tools` uses
22    /// `supported_categories()` as a visibility filter, so returning
23    /// `JiraStructure` unconditionally would surface the 9 Structure tools
24    /// on Jira hosts without the plugin (pre-existing behaviour was to hide
25    /// them because no enricher claimed the category).
26    supported_categories: Vec<ToolCategory>,
27}
28
29impl JiraSchemaEnricher {
30    /// Build an enricher from cached project metadata (custom fields,
31    /// structures, statuses) used to refine MCP tool schemas at runtime.
32    pub fn new(metadata: JiraMetadata) -> Self {
33        let mut supported_categories = vec![ToolCategory::IssueTracker];
34        if !metadata.structures.is_empty() {
35            supported_categories.push(ToolCategory::JiraStructure);
36        }
37        Self {
38            metadata,
39            supported_categories,
40        }
41    }
42
43    /// Enrich the `structureId` parameter on the 7 Structure tools that
44    /// accept it. When the metadata carries a list of accessible structures
45    /// we embed `id (name) — description` for each one in the parameter's
46    /// description so an LLM sees the concrete IDs it can pick from.
47    ///
48    /// Note this is description-based enrichment, not a strict JSON Schema
49    /// `enum`: `PropertySchema.enum_values` is `Option<Vec<String>>` today
50    /// and widening that to support integer enums would be a cross-workspace
51    /// breaking change. The description-based hint mirrors the existing
52    /// priority-alias behaviour and is sufficient for LLM tool use; a
53    /// strict enum can follow when `PropertySchema` grows a typed enum
54    /// value type.
55    ///
56    /// When `metadata.structures` is empty the category is not advertised
57    /// in [`Self::supported_categories`] so this method will not be
58    /// invoked — but we still short-circuit defensively.
59    fn enrich_structure_id(&self, schema: &mut ToolSchema) {
60        if self.metadata.structures.is_empty() {
61            return;
62        }
63
64        let mut entries: Vec<&crate::metadata::JiraStructureRef> =
65            self.metadata.structures.iter().collect();
66        entries.sort_by_key(|s| s.id);
67
68        let list = entries
69            .iter()
70            .map(|s| match s.description.as_deref() {
71                Some(desc) if !desc.is_empty() => format!("{} ({}) — {}", s.id, s.name, desc),
72                _ => format!("{} ({})", s.id, s.name),
73            })
74            .collect::<Vec<_>>()
75            .join(", ");
76
77        let desc = format!(
78            "Structure ID. Must be one of the accessible structures: {list}. Pick the numeric ID (the part before parentheses).",
79        );
80        schema.set_description("structureId", &desc);
81    }
82}
83
84const REMOVE_PARAMS: &[&str] = &["points"];
85const GET_ISSUES_REMOVE_PARAMS: &[&str] = &["stateCategory"];
86
87/// Every tool in `ToolCategory::JiraStructure`. Structure-category schemas
88/// have a different parameter surface from IssueTracker tools (no
89/// `projectId`, no custom fields, etc), so the enricher routes them to a
90/// dedicated branch and skips the IssueTracker enrichment path entirely —
91/// even for `get_structures` and `create_structure`, which have no
92/// `structureId` to enrich but also nothing for the IssueTracker branch to
93/// say about them.
94const STRUCTURE_TOOLS: &[&str] = &[
95    "get_structures",
96    "get_structure_forest",
97    "add_structure_rows",
98    "move_structure_rows",
99    "remove_structure_row",
100    "get_structure_values",
101    "get_structure_views",
102    "save_structure_view",
103    "create_structure",
104];
105
106/// Subset of [`STRUCTURE_TOOLS`] that carry a `structureId` parameter —
107/// the only ones that receive description enrichment today.
108const STRUCTURE_ID_TOOLS: &[&str] = &[
109    "get_structure_forest",
110    "add_structure_rows",
111    "move_structure_rows",
112    "remove_structure_row",
113    "get_structure_values",
114    "get_structure_views",
115    "save_structure_view",
116];
117
118impl ToolEnricher for JiraSchemaEnricher {
119    fn supported_categories(&self) -> &[ToolCategory] {
120        &self.supported_categories
121    }
122
123    fn enrich_schema(&self, tool_name: &str, schema: &mut ToolSchema) {
124        // Structure-category tools early-return regardless of whether they
125        // take `structureId` — the IssueTracker branch below would try to
126        // `remove_params(REMOVE_PARAMS)` / apply `priority`/`issueType` enums
127        // on schemas that do not own those parameters. Only the
128        // `structureId`-taking subset receives description enrichment today;
129        // `get_structures` and `create_structure` simply pass through.
130        if STRUCTURE_TOOLS.contains(&tool_name) {
131            if STRUCTURE_ID_TOOLS.contains(&tool_name) {
132                self.enrich_structure_id(schema);
133            }
134            return;
135        }
136
137        schema.remove_params(REMOVE_PARAMS);
138
139        if tool_name == "get_issues" {
140            schema.remove_params(GET_ISSUES_REMOVE_PARAMS);
141        }
142
143        let is_single = self.metadata.is_single_project();
144
145        // Project key handling
146        if is_single {
147            schema.remove_params(&["projectId"]);
148        } else {
149            let keys: Vec<String> = self
150                .metadata
151                .project_keys()
152                .iter()
153                .map(|k| k.to_string())
154                .collect();
155            if !keys.is_empty() {
156                schema.set_enum("projectId", &keys);
157                let desc = format!("REQUIRED. Jira project key. Available: {}", keys.join(", "));
158                schema.set_description("projectId", &desc);
159                schema.set_required("projectId", true);
160            }
161        }
162
163        // Issue types enum (non-subtask)
164        let issue_types = self.metadata.all_issue_types();
165        if !issue_types.is_empty() {
166            schema.set_enum("issueType", &issue_types);
167            let desc = format!("Issue type. Available: {}", issue_types.join(", "));
168            schema.set_description("issueType", &desc);
169        }
170
171        // Priorities enum with alias hints
172        let priorities = self.metadata.all_priorities();
173        if !priorities.is_empty() {
174            schema.set_enum("priority", &priorities);
175            let desc = format!(
176                "Priority. Available: {}. Aliases: urgent\u{2192}Highest, high\u{2192}High, normal\u{2192}Medium, low\u{2192}Low",
177                priorities.join(", ")
178            );
179            schema.set_description("priority", &desc);
180        }
181
182        // Components enum
183        let components = self.metadata.all_components();
184        if !components.is_empty() {
185            schema.set_enum("components", &components);
186            let desc = format!("Components. Available: {}", components.join(", "));
187            schema.set_description("components", &desc);
188        }
189
190        // Link types
191        if tool_name == "link_issues" {
192            let link_types = self.metadata.all_link_types();
193            if !link_types.is_empty() {
194                let values: Vec<&str> = link_types.iter().map(|s| s.as_str()).collect();
195                schema.add_enum_param("link_type", &values, "Issue link type");
196            }
197        }
198
199        // Custom fields (only for single-project, too complex to merge for multi)
200        if (tool_name == "create_issue" || tool_name == "update_issue") && is_single {
201            schema.remove_params(&["customFields"]);
202
203            if let Some(project_meta) = self.metadata.projects.values().next() {
204                for field in &project_meta.custom_fields {
205                    let param_name = sanitize_field_name(&field.name);
206                    let field_schema = jira_custom_field_to_schema(field);
207                    schema.add_param(&param_name, field_schema);
208                }
209            }
210        }
211    }
212
213    fn transform_args(&self, tool_name: &str, args: &mut Value) {
214        if tool_name != "create_issue" && tool_name != "update_issue" {
215            return;
216        }
217
218        // Transform priority aliases
219        if let Some(obj) = args.as_object_mut()
220            && let Some(priority) = obj.get("priority").and_then(|v| v.as_str())
221        {
222            let mapped = match priority {
223                "urgent" => "Highest",
224                "high" => "High",
225                "normal" => "Medium",
226                "low" => "Low",
227                other => other,
228            };
229            obj.insert("priority".into(), json!(mapped));
230        }
231
232        // Transform cf_* params to customFields for single-project
233        if !self.metadata.is_single_project() {
234            return;
235        }
236
237        let Some(project_meta) = self.metadata.projects.values().next() else {
238            return;
239        };
240
241        let Some(obj) = args.as_object_mut() else {
242            return;
243        };
244
245        let mut custom_fields = serde_json::Map::new();
246        let mut cf_keys_to_remove: Vec<String> = Vec::new();
247
248        for field in &project_meta.custom_fields {
249            let param_name = sanitize_field_name(&field.name);
250            if let Some(value) = obj.get(&param_name) {
251                let transformed = field.transform_value(value);
252                custom_fields.insert(field.id.clone(), transformed);
253                cf_keys_to_remove.push(param_name);
254            }
255        }
256
257        for key in cf_keys_to_remove {
258            obj.remove(&key);
259        }
260        if !custom_fields.is_empty() {
261            obj.insert("customFields".into(), Value::Object(custom_fields));
262        }
263    }
264
265    /// Paper 3 — Jira read-only chains (issues / comments). Issue
266    /// detail and comments are the highest-value follow-ups; create /
267    /// update / transition are mutating and never speculatable.
268    fn value_model(&self, tool_name: &str) -> Option<ToolValueModel> {
269        let model = match tool_name {
270            "get_issues" => ToolValueModel {
271                value_class: ValueClass::Supporting,
272                cost_model: CostModel {
273                    typical_kb: 4.0,
274                    max_kb: Some(40.0),
275                    latency_ms_p50: Some(450),
276                    freshness_ttl_s: Some(60),
277                    ..CostModel::default()
278                },
279                follow_up: vec![
280                    FollowUpLink {
281                        tool: "get_issue".into(),
282                        probability: 0.55,
283                        projection: Some("key".into()),
284                        projection_arg: Some("key".into()),
285                    },
286                    FollowUpLink {
287                        tool: "get_issue_comments".into(),
288                        probability: 0.45,
289                        projection: Some("key".into()),
290                        projection_arg: Some("key".into()),
291                    },
292                ],
293                side_effect_class: SideEffectClass::ReadOnly,
294                ..ToolValueModel::default()
295            },
296            "get_issue" => ToolValueModel {
297                value_class: ValueClass::Critical,
298                cost_model: CostModel {
299                    typical_kb: 1.5,
300                    latency_ms_p50: Some(220),
301                    freshness_ttl_s: Some(60),
302                    ..CostModel::default()
303                },
304                follow_up: vec![FollowUpLink {
305                    tool: "get_issue_comments".into(),
306                    probability: 0.50,
307                    projection: Some("key".into()),
308                    projection_arg: Some("key".into()),
309                }],
310                side_effect_class: SideEffectClass::ReadOnly,
311                ..ToolValueModel::default()
312            },
313            "get_issue_comments" => ToolValueModel {
314                value_class: ValueClass::Critical,
315                cost_model: CostModel {
316                    typical_kb: 2.5,
317                    latency_ms_p50: Some(280),
318                    freshness_ttl_s: Some(60),
319                    ..CostModel::default()
320                },
321                side_effect_class: SideEffectClass::ReadOnly,
322                ..ToolValueModel::default()
323            },
324            "create_issue" | "update_issue" | "add_issue_comment" | "link_issues"
325            | "transition_issue" => ToolValueModel {
326                value_class: ValueClass::Supporting,
327                cost_model: CostModel {
328                    typical_kb: 0.6,
329                    latency_ms_p50: Some(380),
330                    ..CostModel::default()
331                },
332                side_effect_class: SideEffectClass::MutatesExternal,
333                ..ToolValueModel::default()
334            },
335            _ => return None,
336        };
337        Some(model)
338    }
339
340    /// Jira hosts vary per deployment (Cloud vs Server vs Data
341    /// Center). Operators set `rate_limit_host` per-tool in TOML for
342    /// shared rate-limit grouping; we don't statically assume Cloud.
343    fn rate_limit_host(&self, _tool_name: &str, _args: &Value) -> Option<String> {
344        None
345    }
346}
347
348/// Convert a Jira custom field definition to a JSON Schema property.
349fn jira_custom_field_to_schema(field: &crate::metadata::JiraCustomField) -> Value {
350    match field.field_type {
351        JiraFieldType::Option => {
352            let options: Vec<&str> = field.options.iter().map(|o| o.name.as_str()).collect();
353            json!({
354                "type": "string",
355                "enum": options,
356                "description": format!("Custom field: {} (select). Choose one option.", field.name),
357                "x-enriched": true,
358            })
359        }
360        JiraFieldType::Array => {
361            let options: Vec<&str> = field.options.iter().map(|o| o.name.as_str()).collect();
362            json!({
363                "type": "array",
364                "items": { "type": "string", "enum": options },
365                "description": format!("Custom field: {} (multi-select). Choose one or more.", field.name),
366                "x-enriched": true,
367            })
368        }
369        JiraFieldType::Number => json!({
370            "type": "number",
371            "description": format!("Custom field: {} (number).", field.name),
372            "x-enriched": true,
373        }),
374        JiraFieldType::Date => json!({
375            "type": "string",
376            "description": format!("Custom field: {} (date, YYYY-MM-DD).", field.name),
377            "x-enriched": true,
378        }),
379        JiraFieldType::DateTime => json!({
380            "type": "string",
381            "description": format!("Custom field: {} (datetime, ISO 8601).", field.name),
382            "x-enriched": true,
383        }),
384        JiraFieldType::String | JiraFieldType::Any => json!({
385            "type": "string",
386            "description": format!("Custom field: {} (text).", field.name),
387            "x-enriched": true,
388        }),
389    }
390}
391
392#[cfg(test)]
393mod tests {
394    use super::*;
395    use crate::metadata::*;
396    use std::collections::HashMap;
397
398    fn single_project_metadata() -> JiraMetadata {
399        let mut projects = HashMap::new();
400        projects.insert(
401            "PROJ".into(),
402            JiraProjectMetadata {
403                issue_types: vec![
404                    JiraIssueType {
405                        id: "1".into(),
406                        name: "Task".into(),
407                        subtask: false,
408                    },
409                    JiraIssueType {
410                        id: "2".into(),
411                        name: "Bug".into(),
412                        subtask: false,
413                    },
414                    JiraIssueType {
415                        id: "3".into(),
416                        name: "Sub-task".into(),
417                        subtask: true,
418                    },
419                ],
420                priorities: vec![
421                    JiraPriority {
422                        id: "1".into(),
423                        name: "Highest".into(),
424                    },
425                    JiraPriority {
426                        id: "2".into(),
427                        name: "High".into(),
428                    },
429                    JiraPriority {
430                        id: "3".into(),
431                        name: "Medium".into(),
432                    },
433                    JiraPriority {
434                        id: "4".into(),
435                        name: "Low".into(),
436                    },
437                ],
438                components: vec![
439                    JiraComponent {
440                        id: "10".into(),
441                        name: "API".into(),
442                    },
443                    JiraComponent {
444                        id: "11".into(),
445                        name: "Frontend".into(),
446                    },
447                ],
448                link_types: vec![JiraLinkType {
449                    id: "1".into(),
450                    name: "Blocks".into(),
451                    outward: Some("blocks".into()),
452                    inward: Some("is blocked by".into()),
453                }],
454                custom_fields: vec![JiraCustomField {
455                    id: "customfield_10001".into(),
456                    name: "Story Points".into(),
457                    field_type: JiraFieldType::Number,
458                    required: false,
459                    options: vec![],
460                }],
461            },
462        );
463        JiraMetadata {
464            flavor: JiraFlavor::Cloud,
465            projects,
466            structures: vec![],
467        }
468    }
469
470    #[test]
471    fn test_jira_enricher_single_project_removes_project_id() {
472        let enricher = JiraSchemaEnricher::new(single_project_metadata());
473        let mut schema = ToolSchema::from_json(&json!({
474            "type": "object",
475            "properties": {
476                "projectId": { "type": "string" },
477                "issueType": { "type": "string" },
478                "priority": { "type": "string" },
479            },
480        }));
481
482        enricher.enrich_schema("create_issue", &mut schema);
483
484        assert!(!schema.properties.contains_key("projectId"));
485        assert_eq!(
486            schema.properties["issueType"].enum_values,
487            Some(vec!["Bug".into(), "Task".into()]) // sorted, no Sub-task
488        );
489        assert_eq!(
490            schema.properties["priority"].enum_values,
491            Some(vec![
492                "High".into(),
493                "Highest".into(),
494                "Low".into(),
495                "Medium".into()
496            ]) // sorted
497        );
498    }
499
500    #[test]
501    fn test_jira_enricher_adds_custom_fields() {
502        let enricher = JiraSchemaEnricher::new(single_project_metadata());
503        let mut schema = ToolSchema::from_json(&json!({
504            "type": "object",
505            "properties": {
506                "customFields": { "type": "object" },
507            },
508        }));
509
510        enricher.enrich_schema("create_issue", &mut schema);
511
512        assert!(!schema.properties.contains_key("customFields"));
513        assert!(schema.properties.contains_key("cf_story_points"));
514        assert_eq!(schema.properties["cf_story_points"].schema_type, "number");
515    }
516
517    #[test]
518    fn test_jira_enricher_transform_priority_alias() {
519        let enricher = JiraSchemaEnricher::new(single_project_metadata());
520        let mut args = json!({ "title": "Test", "priority": "urgent" });
521
522        enricher.transform_args("create_issue", &mut args);
523
524        assert_eq!(args["priority"], "Highest");
525    }
526
527    #[test]
528    fn test_jira_enricher_transform_custom_fields() {
529        let enricher = JiraSchemaEnricher::new(single_project_metadata());
530        let mut args = json!({
531            "title": "Test",
532            "cf_story_points": 8,
533        });
534
535        enricher.transform_args("create_issue", &mut args);
536
537        assert!(args.get("cf_story_points").is_none());
538        assert_eq!(args["customFields"]["customfield_10001"], 8);
539    }
540
541    #[test]
542    fn test_jira_enricher_multi_project_keeps_project_id() {
543        let mut meta = single_project_metadata();
544        meta.projects.insert(
545            "INFRA".into(),
546            JiraProjectMetadata {
547                issue_types: vec![],
548                priorities: vec![],
549                components: vec![],
550                link_types: vec![],
551                custom_fields: vec![],
552            },
553        );
554        let enricher = JiraSchemaEnricher::new(meta);
555        let mut schema = ToolSchema::from_json(&json!({
556            "type": "object",
557            "properties": {
558                "projectId": { "type": "string" },
559                "customFields": { "type": "object" },
560            },
561        }));
562
563        enricher.enrich_schema("create_issue", &mut schema);
564
565        // projectId kept as enum
566        assert!(schema.properties.contains_key("projectId"));
567        let project_enum = schema.properties["projectId"].enum_values.as_ref().unwrap();
568        assert!(project_enum.contains(&"PROJ".to_string()));
569        assert!(project_enum.contains(&"INFRA".to_string()));
570
571        // customFields NOT replaced (multi-project)
572        assert!(schema.properties.contains_key("customFields"));
573        assert!(!schema.properties.contains_key("cf_story_points"));
574    }
575
576    #[test]
577    fn test_jira_enricher_transform_args_skips_non_create() {
578        let enricher = JiraSchemaEnricher::new(single_project_metadata());
579        let mut args = json!({"priority": "urgent"});
580        enricher.transform_args("get_issues", &mut args);
581        // Should not transform priority for get_issues
582        assert_eq!(args["priority"], "urgent");
583    }
584
585    #[test]
586    fn test_jira_enricher_transform_args_normal_priority() {
587        let enricher = JiraSchemaEnricher::new(single_project_metadata());
588        let mut args = json!({"title": "T", "priority": "normal"});
589        enricher.transform_args("create_issue", &mut args);
590        assert_eq!(args["priority"], "Medium");
591    }
592
593    #[test]
594    fn test_jira_enricher_transform_args_non_alias_priority() {
595        let enricher = JiraSchemaEnricher::new(single_project_metadata());
596        let mut args = json!({"title": "T", "priority": "Highest"});
597        enricher.transform_args("create_issue", &mut args);
598        assert_eq!(args["priority"], "Highest"); // pass-through
599    }
600
601    #[test]
602    fn test_jira_enricher_multi_project_no_cf_transform() {
603        let mut meta = single_project_metadata();
604        meta.projects.insert(
605            "INFRA".into(),
606            JiraProjectMetadata {
607                issue_types: vec![],
608                priorities: vec![],
609                components: vec![],
610                link_types: vec![],
611                custom_fields: vec![],
612            },
613        );
614        let enricher = JiraSchemaEnricher::new(meta);
615        let mut args = json!({"title": "T", "cf_story_points": 5});
616        enricher.transform_args("create_issue", &mut args);
617        // Multi-project: cf_* NOT transformed
618        assert!(args.get("cf_story_points").is_some());
619        assert!(args.get("customFields").is_none());
620    }
621
622    #[test]
623    fn test_jira_enricher_components_enum() {
624        let enricher = JiraSchemaEnricher::new(single_project_metadata());
625        let mut schema = ToolSchema::from_json(&json!({
626            "type": "object",
627            "properties": {
628                "components": { "type": "array" }
629            }
630        }));
631        enricher.enrich_schema("create_issue", &mut schema);
632        let comp = schema.properties.get("components").unwrap();
633        assert_eq!(
634            comp.enum_values,
635            Some(vec!["API".into(), "Frontend".into()])
636        );
637    }
638
639    #[test]
640    fn test_jira_enricher_link_types() {
641        let enricher = JiraSchemaEnricher::new(single_project_metadata());
642        let mut schema = ToolSchema::new();
643        enricher.enrich_schema("link_issues", &mut schema);
644        let lt = schema.properties.get("link_type").unwrap();
645        assert_eq!(lt.enum_values, Some(vec!["Blocks".into()]));
646    }
647
648    #[test]
649    fn test_jira_enricher_get_issues_removes_state_category() {
650        let enricher = JiraSchemaEnricher::new(single_project_metadata());
651        let mut schema = ToolSchema::from_json(&json!({
652            "type": "object",
653            "properties": {
654                "state": { "type": "string" },
655                "stateCategory": { "type": "string" }
656            }
657        }));
658        enricher.enrich_schema("get_issues", &mut schema);
659        assert!(!schema.properties.contains_key("stateCategory"));
660        assert!(schema.properties.contains_key("state"));
661    }
662
663    // -------------------------------------------------------------------------
664    // Structure tool enrichment (depends on JiraMetadata.structures)
665    // -------------------------------------------------------------------------
666
667    fn metadata_with_structures(refs: Vec<crate::metadata::JiraStructureRef>) -> JiraMetadata {
668        let mut meta = single_project_metadata();
669        meta.structures = refs;
670        meta
671    }
672
673    fn structureid_schema() -> ToolSchema {
674        ToolSchema::from_json(&json!({
675            "type": "object",
676            "properties": {
677                "structureId": {
678                    "type": "integer",
679                    "description": "Structure ID. Use get_structures to find it."
680                }
681            },
682            "required": ["structureId"],
683        }))
684    }
685
686    #[test]
687    fn jira_enricher_does_not_advertise_jira_structure_when_no_structures() {
688        // `Executor::list_tools` uses `supported_categories()` as a visibility
689        // filter. Advertising `JiraStructure` unconditionally would surface
690        // the 9 Structure tools on Jira hosts without the plugin — a
691        // regression relative to pre-patch behaviour, where no enricher
692        // claimed the category and the tools were hidden.
693        let enricher = JiraSchemaEnricher::new(single_project_metadata());
694        let categories = enricher.supported_categories();
695        assert!(categories.contains(&ToolCategory::IssueTracker));
696        assert!(!categories.contains(&ToolCategory::JiraStructure));
697    }
698
699    #[test]
700    fn jira_enricher_advertises_jira_structure_when_metadata_has_structures() {
701        let enricher = JiraSchemaEnricher::new(metadata_with_structures(vec![
702            crate::metadata::JiraStructureRef {
703                id: 1,
704                name: "Only One".into(),
705                description: None,
706            },
707        ]));
708        let categories = enricher.supported_categories();
709        assert!(categories.contains(&ToolCategory::IssueTracker));
710        assert!(categories.contains(&ToolCategory::JiraStructure));
711    }
712
713    #[test]
714    fn jira_enricher_populates_structureid_description_for_all_seven_tools() {
715        let enricher = JiraSchemaEnricher::new(metadata_with_structures(vec![
716            crate::metadata::JiraStructureRef {
717                id: 1,
718                name: "Q1 Planning".into(),
719                description: Some("Quarter 1 plan".into()),
720            },
721            crate::metadata::JiraStructureRef {
722                id: 7,
723                name: "Sprint Board".into(),
724                description: None,
725            },
726        ]));
727
728        for tool in [
729            "get_structure_forest",
730            "add_structure_rows",
731            "move_structure_rows",
732            "remove_structure_row",
733            "get_structure_values",
734            "get_structure_views",
735            "save_structure_view",
736        ] {
737            let mut schema = structureid_schema();
738            enricher.enrich_schema(tool, &mut schema);
739
740            let prop = schema.properties.get("structureId").unwrap();
741            let desc = prop.description.as_deref().unwrap_or("");
742            assert!(
743                desc.contains("Must be one of the accessible structures"),
744                "tool={tool} desc={desc}",
745            );
746            assert!(
747                desc.contains("1 (Q1 Planning) — Quarter 1 plan"),
748                "tool={tool}"
749            );
750            assert!(desc.contains("7 (Sprint Board)"), "tool={tool}");
751        }
752    }
753
754    #[test]
755    fn jira_enricher_sorts_structures_by_id_in_description() {
756        let enricher = JiraSchemaEnricher::new(metadata_with_structures(vec![
757            crate::metadata::JiraStructureRef {
758                id: 42,
759                name: "Roadmap".into(),
760                description: None,
761            },
762            crate::metadata::JiraStructureRef {
763                id: 1,
764                name: "Q1".into(),
765                description: None,
766            },
767            crate::metadata::JiraStructureRef {
768                id: 7,
769                name: "Sprint".into(),
770                description: None,
771            },
772        ]));
773
774        let mut schema = structureid_schema();
775        enricher.enrich_schema("get_structure_forest", &mut schema);
776
777        let desc = schema.properties["structureId"]
778            .description
779            .clone()
780            .unwrap();
781        let idx_1 = desc.find("1 (Q1)").expect("id 1 missing");
782        let idx_7 = desc.find("7 (Sprint)").expect("id 7 missing");
783        let idx_42 = desc.find("42 (Roadmap)").expect("id 42 missing");
784        assert!(
785            idx_1 < idx_7 && idx_7 < idx_42,
786            "structures not sorted: {desc}"
787        );
788    }
789
790    #[test]
791    fn jira_enricher_leaves_schema_untouched_when_no_structures() {
792        let enricher = JiraSchemaEnricher::new(metadata_with_structures(vec![]));
793
794        let mut schema = structureid_schema();
795        let original_desc = schema.properties["structureId"]
796            .description
797            .clone()
798            .unwrap();
799
800        enricher.enrich_schema("get_structure_forest", &mut schema);
801
802        let desc_after = schema.properties["structureId"]
803            .description
804            .clone()
805            .unwrap();
806        assert_eq!(desc_after, original_desc);
807    }
808
809    #[test]
810    fn jira_enricher_does_not_touch_get_structures_or_create_structure() {
811        // These two tools carry the `JiraStructure` category but do not take
812        // `structureId`. Previously the IssueTracker branch still ran on
813        // them (because the early-return only covered the 7 id-taking
814        // tools), which risked mutating unrelated params. After the fix
815        // both should pass through unchanged: the enricher must not invent
816        // `structureId` AND must not mutate parameters the Structure tools
817        // actually own (e.g. `name` on `create_structure`).
818        let enricher = JiraSchemaEnricher::new(metadata_with_structures(vec![
819            crate::metadata::JiraStructureRef {
820                id: 1,
821                name: "One".into(),
822                description: None,
823            },
824        ]));
825
826        for tool in ["get_structures", "create_structure"] {
827            let mut schema = ToolSchema::from_json(&json!({
828                "type": "object",
829                "properties": {
830                    "name": { "type": "string", "description": "original" },
831                    "points": { "type": "integer", "description": "must survive" }
832                }
833            }));
834            enricher.enrich_schema(tool, &mut schema);
835            assert!(
836                !schema.properties.contains_key("structureId"),
837                "enricher inserted structureId on {tool}",
838            );
839            // `points` is in REMOVE_PARAMS for the IssueTracker branch —
840            // must stay intact here because we early-return for
841            // Structure-category tools.
842            assert!(
843                schema.properties.contains_key("points"),
844                "enricher dropped `points` on {tool} — IssueTracker branch leaked into Structure handling",
845            );
846            assert_eq!(
847                schema.properties["name"].description.as_deref(),
848                Some("original"),
849                "enricher mutated `name` description on {tool}",
850            );
851        }
852    }
853
854    #[test]
855    fn jira_enricher_skips_issuetracker_branch_for_structure_tools() {
856        // Structure schemas have different parameters from IssueTracker tools
857        // (e.g. no `points`). The Structure branch must early-return so the
858        // IssueTracker `remove_params`/enum steps do not accidentally mutate
859        // unrelated parameters.
860        let enricher = JiraSchemaEnricher::new(metadata_with_structures(vec![
861            crate::metadata::JiraStructureRef {
862                id: 1,
863                name: "One".into(),
864                description: None,
865            },
866        ]));
867
868        let mut schema = ToolSchema::from_json(&json!({
869            "type": "object",
870            "properties": {
871                "structureId": { "type": "integer", "description": "old" },
872                "points": { "type": "integer", "description": "must survive" }
873            }
874        }));
875        enricher.enrich_schema("get_structure_forest", &mut schema);
876
877        assert!(schema.properties.contains_key("points"));
878    }
879}