Skip to main content

joy_core/
ai_templates.rs

1// Copyright (c) 2026 Joydev GmbH (joydev.com)
2// SPDX-License-Identifier: MIT
3
4//! Rendering engine for AI tool integration files.
5//!
6//! Loads structured data (workflow, agents) and MiniJinja templates,
7//! then renders complete, self-contained output files for each AI tool.
8//! This replaces the previous approach of syncing intermediate files
9//! to `.joy/ai/` and `.joy/capabilities/` (see ADR-024).
10
11use minijinja::{context, Environment};
12
13use crate::error::JoyError;
14
15// ---------------------------------------------------------------------------
16// Embedded data (YAML, parsed at runtime)
17// ---------------------------------------------------------------------------
18
19const WORKFLOW_DATA: &str = include_str!("../data/process/workflow.yaml");
20
21const AGENT_CONCEIVER: &str = include_str!("../data/ai/agents/conceiver.yaml");
22const AGENT_PLANNER: &str = include_str!("../data/ai/agents/planner.yaml");
23const AGENT_DESIGNER: &str = include_str!("../data/ai/agents/designer.yaml");
24const AGENT_IMPLEMENTER: &str = include_str!("../data/ai/agents/implementer.yaml");
25const AGENT_TESTER: &str = include_str!("../data/ai/agents/tester.yaml");
26const AGENT_REVIEWER: &str = include_str!("../data/ai/agents/reviewer.yaml");
27const AGENT_DOCUMENTER: &str = include_str!("../data/ai/agents/documenter.yaml");
28
29const ALL_AGENT_SOURCES: &[&str] = &[
30    AGENT_CONCEIVER,
31    AGENT_PLANNER,
32    AGENT_DESIGNER,
33    AGENT_IMPLEMENTER,
34    AGENT_TESTER,
35    AGENT_REVIEWER,
36    AGENT_DOCUMENTER,
37];
38
39// ---------------------------------------------------------------------------
40// Embedded templates (MiniJinja, rendered at runtime)
41// ---------------------------------------------------------------------------
42
43const INSTRUCTIONS_TMPL: &str = include_str!("../templates/ai/instructions.md");
44const SETUP_TMPL: &str = include_str!("../templates/ai/instructions/setup.md");
45const SKILL_TMPL: &str = include_str!("../templates/ai/skills/joy/SKILL.md");
46const JOY_BLOCK_TMPL: &str = include_str!("../templates/ai/joy-block.md");
47
48const CLAUDE_AGENT_TMPL: &str = include_str!("../templates/ai/tools/claude-code/agent.md");
49const QWEN_AGENT_TMPL: &str = include_str!("../templates/ai/tools/qwen-code/agent.md");
50const VIBE_AGENT_TMPL: &str = include_str!("../templates/ai/tools/mistral-vibe/agent.toml");
51const COPILOT_AGENT_TMPL: &str =
52    include_str!("../templates/ai/tools/github-copilot/agent.agent.md");
53const COPILOT_PROMPT_TMPL: &str =
54    include_str!("../templates/ai/tools/github-copilot/prompts/joy.prompt.md");
55
56// ---------------------------------------------------------------------------
57// Public API
58// ---------------------------------------------------------------------------
59
60/// Load the workflow definition from embedded YAML.
61pub fn load_workflow() -> Result<serde_json::Value, JoyError> {
62    let value: serde_json::Value =
63        serde_yaml_ng::from_str(WORKFLOW_DATA).map_err(|e| JoyError::Template(e.to_string()))?;
64    Ok(value)
65}
66
67/// Load all agent definitions from embedded YAML.
68pub fn load_agents() -> Result<Vec<serde_json::Value>, JoyError> {
69    let mut agents = Vec::with_capacity(ALL_AGENT_SOURCES.len());
70    for source in ALL_AGENT_SOURCES {
71        let value: serde_json::Value =
72            serde_yaml_ng::from_str(source).map_err(|e| JoyError::Template(e.to_string()))?;
73        agents.push(value);
74    }
75    Ok(agents)
76}
77
78/// Render the joy-block (identity section inserted between markers in tool instruction files).
79pub fn render_joy_block(member_id: &str, has_skill: bool) -> Result<String, JoyError> {
80    let mut env = Environment::new();
81    env.add_template("joy-block", JOY_BLOCK_TMPL)
82        .map_err(|e| JoyError::Template(e.to_string()))?;
83    let tmpl = env
84        .get_template("joy-block")
85        .map_err(|e| JoyError::Template(e.to_string()))?;
86    let rendered = tmpl
87        .render(context! {
88            member_id => member_id,
89            has_skill => has_skill,
90        })
91        .map_err(|e| JoyError::Template(e.to_string()))?;
92    Ok(rendered.trim().to_string())
93}
94
95/// Render the full instructions.md with workflow context.
96pub fn render_instructions(workflow: &serde_json::Value) -> Result<String, JoyError> {
97    let mut env = Environment::new();
98    env.add_template("instructions", INSTRUCTIONS_TMPL)
99        .map_err(|e| JoyError::Template(e.to_string()))?;
100    let tmpl = env
101        .get_template("instructions")
102        .map_err(|e| JoyError::Template(e.to_string()))?;
103    let rendered = tmpl
104        .render(context! { workflow => workflow })
105        .map_err(|e| JoyError::Template(e.to_string()))?;
106    Ok(rendered)
107}
108
109/// Render the SKILL.md with workflow context.
110pub fn render_skill(workflow: &serde_json::Value) -> Result<String, JoyError> {
111    let mut env = Environment::new();
112    env.add_template("skill", SKILL_TMPL)
113        .map_err(|e| JoyError::Template(e.to_string()))?;
114    let tmpl = env
115        .get_template("skill")
116        .map_err(|e| JoyError::Template(e.to_string()))?;
117    let rendered = tmpl
118        .render(context! { workflow => workflow })
119        .map_err(|e| JoyError::Template(e.to_string()))?;
120    Ok(rendered)
121}
122
123/// Get the setup instructions content (no templating needed).
124pub fn setup_instructions() -> &'static str {
125    SETUP_TMPL
126}
127
128/// Agent template name for each supported tool.
129fn agent_template_for_tool(tool: &str) -> Option<(&'static str, &'static str)> {
130    match tool {
131        "claude" => Some(("claude-agent", CLAUDE_AGENT_TMPL)),
132        "qwen" => Some(("qwen-agent", QWEN_AGENT_TMPL)),
133        "vibe" => Some(("vibe-agent", VIBE_AGENT_TMPL)),
134        "copilot" => Some(("copilot-agent", COPILOT_AGENT_TMPL)),
135        _ => None,
136    }
137}
138
139/// Render an agent file for a specific tool.
140pub fn render_agent(
141    agent: &serde_json::Value,
142    workflow: &serde_json::Value,
143    tool: &str,
144) -> Result<String, JoyError> {
145    let (tmpl_name, tmpl_source) = agent_template_for_tool(tool)
146        .ok_or_else(|| JoyError::Template(format!("Unknown tool: {tool}")))?;
147
148    let mut env = Environment::new();
149    env.add_template(tmpl_name, tmpl_source)
150        .map_err(|e| JoyError::Template(e.to_string()))?;
151    let tmpl = env
152        .get_template(tmpl_name)
153        .map_err(|e| JoyError::Template(e.to_string()))?;
154    let rendered = tmpl
155        .render(context! {
156            agent => agent,
157            workflow => workflow,
158        })
159        .map_err(|e| JoyError::Template(e.to_string()))?;
160    Ok(rendered)
161}
162
163/// Render the Copilot skill wrapper (prompts/joy.prompt.md).
164pub fn render_copilot_prompt(workflow: &serde_json::Value) -> Result<String, JoyError> {
165    let mut env = Environment::new();
166    env.add_template("copilot-prompt", COPILOT_PROMPT_TMPL)
167        .map_err(|e| JoyError::Template(e.to_string()))?;
168    let tmpl = env
169        .get_template("copilot-prompt")
170        .map_err(|e| JoyError::Template(e.to_string()))?;
171    let rendered = tmpl
172        .render(context! {
173            workflow => workflow,
174        })
175        .map_err(|e| JoyError::Template(e.to_string()))?;
176    Ok(rendered)
177}
178
179/// Check if an agent is applicable to a given tool.
180pub fn agent_applicable_to_tool(agent: &serde_json::Value, tool: &str) -> bool {
181    let tool_key = match tool {
182        "claude" => "claude-code",
183        "qwen" => "qwen-code",
184        "vibe" => "mistral-vibe",
185        "copilot" => "github-copilot",
186        _ => return false,
187    };
188    agent["applicable_tools"]
189        .as_array()
190        .map(|tools| tools.iter().any(|t| t.as_str() == Some(tool_key)))
191        .unwrap_or(false)
192}
193
194/// Get the agent name from an agent definition.
195pub fn agent_name(agent: &serde_json::Value) -> Option<&str> {
196    agent["name"].as_str()
197}
198
199/// Agent file extension for each tool.
200pub fn agent_filename(agent: &serde_json::Value, tool: &str) -> Option<String> {
201    let name = agent_name(agent)?;
202    match tool {
203        "claude" => Some(format!("{name}.md")),
204        "qwen" => Some(format!("{name}.md")),
205        "vibe" => Some(format!("{name}.toml")),
206        "copilot" => Some(format!("{name}.agent.md")),
207        _ => None,
208    }
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214
215    #[test]
216    fn load_workflow_parses() {
217        let wf = load_workflow().unwrap();
218        let statuses = wf["statuses"].as_array().unwrap();
219        assert_eq!(statuses.len(), 6);
220        assert_eq!(statuses[0]["name"].as_str().unwrap(), "new");
221    }
222
223    #[test]
224    fn load_agents_parses() {
225        let agents = load_agents().unwrap();
226        assert_eq!(agents.len(), 7);
227        let names: Vec<&str> = agents.iter().filter_map(|a| a["name"].as_str()).collect();
228        assert!(names.contains(&"implementer"));
229        assert!(names.contains(&"reviewer"));
230    }
231
232    #[test]
233    fn render_joy_block_contains_member_id() {
234        let block = render_joy_block("ai:claude@joy", true).unwrap();
235        assert!(block.contains("ai:claude@joy"));
236        assert!(block.contains("/joy"));
237    }
238
239    #[test]
240    fn render_joy_block_without_skill() {
241        let block = render_joy_block("ai:copilot@joy", false).unwrap();
242        assert!(block.contains("Joy CLI commands"));
243        assert!(!block.contains("`/joy` skill"));
244    }
245
246    #[test]
247    fn render_instructions_contains_workflow() {
248        let wf = load_workflow().unwrap();
249        let instructions = render_instructions(&wf).unwrap();
250        assert!(instructions.contains("## Workflow"));
251        assert!(instructions.contains("in-progress"));
252        assert!(instructions.contains("review"));
253        assert!(instructions.contains("joy start"));
254    }
255
256    #[test]
257    fn render_skill_contains_workflow() {
258        let wf = load_workflow().unwrap();
259        let skill = render_skill(&wf).unwrap();
260        assert!(skill.contains("### Workflow"));
261        assert!(skill.contains("joy submit"));
262    }
263
264    #[test]
265    fn render_claude_agent() {
266        let wf = load_workflow().unwrap();
267        let agents = load_agents().unwrap();
268        let implementer = agents
269            .iter()
270            .find(|a| a["name"].as_str() == Some("implementer"))
271            .unwrap();
272        let rendered = render_agent(implementer, &wf, "claude").unwrap();
273        assert!(rendered.contains("implementer"));
274        assert!(rendered.contains("write, edit"));
275    }
276
277    #[test]
278    fn render_vibe_agent() {
279        let wf = load_workflow().unwrap();
280        let agents = load_agents().unwrap();
281        let reviewer = agents
282            .iter()
283            .find(|a| a["name"].as_str() == Some("reviewer"))
284            .unwrap();
285        let rendered = render_agent(reviewer, &wf, "vibe").unwrap();
286        assert!(rendered.contains("display_name = \"reviewer\""));
287        assert!(rendered.contains("safety = \"high\""));
288    }
289
290    #[test]
291    fn agent_applicability() {
292        let agents = load_agents().unwrap();
293        let implementer = agents
294            .iter()
295            .find(|a| a["name"].as_str() == Some("implementer"))
296            .unwrap();
297        assert!(agent_applicable_to_tool(implementer, "claude"));
298        assert!(agent_applicable_to_tool(implementer, "qwen"));
299
300        let conceiver = agents
301            .iter()
302            .find(|a| a["name"].as_str() == Some("conceiver"))
303            .unwrap();
304        assert!(!agent_applicable_to_tool(conceiver, "qwen"));
305    }
306
307    #[test]
308    fn render_copilot_prompt_contains_workflow() {
309        let wf = load_workflow().unwrap();
310        let prompt = render_copilot_prompt(&wf).unwrap();
311        assert!(prompt.contains("## Workflow"));
312    }
313
314    // -----------------------------------------------------------------------
315    // Integration tests: verify all generated files for all tools
316    // -----------------------------------------------------------------------
317
318    const ALL_TOOLS: &[&str] = &["claude", "qwen", "vibe", "copilot"];
319    const WORK_AGENTS: &[&str] = &[
320        "conceiver",
321        "planner",
322        "designer",
323        "implementer",
324        "tester",
325        "reviewer",
326        "documenter",
327    ];
328
329    #[test]
330    fn workflow_has_all_statuses() {
331        let wf = load_workflow().unwrap();
332        let statuses = wf["statuses"].as_array().unwrap();
333        let names: Vec<&str> = statuses.iter().filter_map(|s| s["name"].as_str()).collect();
334        for expected in ["new", "open", "in-progress", "review", "closed", "deferred"] {
335            assert!(names.contains(&expected), "missing status: {expected}");
336        }
337    }
338
339    #[test]
340    fn workflow_has_all_transitions() {
341        let wf = load_workflow().unwrap();
342        let transitions = wf["transitions"].as_array().unwrap();
343        let expected = [
344            ("new", "open"),
345            ("open", "in-progress"),
346            ("in-progress", "review"),
347            ("review", "closed"),
348            ("review", "in-progress"),
349            ("deferred", "open"),
350            ("closed", "open"),
351        ];
352        for (from, to) in expected {
353            assert!(
354                transitions
355                    .iter()
356                    .any(|t| { t["from"].as_str() == Some(from) && t["to"].as_str() == Some(to) }),
357                "missing transition: {from} -> {to}"
358            );
359        }
360    }
361
362    #[test]
363    fn workflow_transitions_have_capabilities() {
364        let wf = load_workflow().unwrap();
365        let transitions = wf["transitions"].as_array().unwrap();
366        for t in transitions {
367            assert!(
368                t["capability"].as_str().is_some(),
369                "transition {} -> {} missing capability",
370                t["from"],
371                t["to"]
372            );
373        }
374    }
375
376    #[test]
377    fn all_agents_have_required_fields() {
378        let agents = load_agents().unwrap();
379        for agent in &agents {
380            let name = agent["name"].as_str().expect("agent missing name");
381            assert!(
382                agent["capability"].as_str().is_some(),
383                "{name} missing capability"
384            );
385            assert!(
386                agent["description"].as_str().is_some(),
387                "{name} missing description"
388            );
389            assert!(
390                agent["default_mode"].as_str().is_some(),
391                "{name} missing default_mode"
392            );
393            assert!(
394                agent["permissions"]["allowed"].as_array().is_some(),
395                "{name} missing permissions.allowed"
396            );
397            assert!(
398                agent["permissions"]["denied"].as_array().is_some(),
399                "{name} missing permissions.denied"
400            );
401            assert!(
402                agent["constraints"].as_array().is_some(),
403                "{name} missing constraints"
404            );
405            assert!(
406                agent["applicable_tools"].as_array().is_some(),
407                "{name} missing applicable_tools"
408            );
409        }
410    }
411
412    #[test]
413    fn instructions_contain_all_sections() {
414        let wf = load_workflow().unwrap();
415        let instructions = render_instructions(&wf).unwrap();
416        for section in [
417            "## Session start",
418            "## Identity and capabilities",
419            "## Workflow",
420            "## Core commands",
421            "## Rules",
422            "## Project context",
423            "## Commit messages",
424            "## Working style",
425        ] {
426            assert!(
427                instructions.contains(section),
428                "instructions missing section: {section}"
429            );
430        }
431    }
432
433    #[test]
434    fn instructions_do_not_reference_joy_dir() {
435        let wf = load_workflow().unwrap();
436        let instructions = render_instructions(&wf).unwrap();
437        assert!(
438            !instructions.contains(".joy/ai/"),
439            "instructions must not reference .joy/ai/"
440        );
441        assert!(
442            !instructions.contains(".joy/capabilities/"),
443            "instructions must not reference .joy/capabilities/"
444        );
445    }
446
447    #[test]
448    fn skill_contains_all_sections() {
449        let wf = load_workflow().unwrap();
450        let skill = render_skill(&wf).unwrap();
451        for section in [
452            "## Prerequisites",
453            "## First session check",
454            "### Viewing and navigating",
455            "### Planning and creating items",
456            "### Status changes",
457            "### Workflow",
458            "### Editing and organizing",
459            "### Implementing items",
460            "### Discovered bugs and ad-hoc fixes",
461            "## General rules",
462        ] {
463            assert!(skill.contains(section), "skill missing section: {section}");
464        }
465    }
466
467    #[test]
468    fn skill_does_not_reference_joy_dir() {
469        let wf = load_workflow().unwrap();
470        let skill = render_skill(&wf).unwrap();
471        assert!(
472            !skill.contains(".joy/ai/instructions"),
473            "skill must not reference .joy/ai/"
474        );
475    }
476
477    #[test]
478    fn skill_starts_with_yaml_frontmatter() {
479        let wf = load_workflow().unwrap();
480        let skill = render_skill(&wf).unwrap();
481        assert!(
482            skill.starts_with("---\n"),
483            "skill must start with YAML frontmatter delimiter"
484        );
485        assert!(
486            skill.contains("name: joy"),
487            "skill must have name: joy in frontmatter"
488        );
489    }
490
491    #[test]
492    fn render_agent_for_all_tools() {
493        let wf = load_workflow().unwrap();
494        let agents = load_agents().unwrap();
495
496        for tool in ALL_TOOLS {
497            for agent in &agents {
498                if !agent_applicable_to_tool(agent, tool) {
499                    continue;
500                }
501                let name = agent_name(agent).unwrap();
502                let rendered = render_agent(agent, &wf, tool)
503                    .unwrap_or_else(|_| panic!("failed to render {name} for {tool}"));
504                assert!(!rendered.is_empty(), "empty render for {name}/{tool}");
505                assert!(
506                    rendered.contains(name),
507                    "{name}/{tool}: rendered output missing agent name"
508                );
509            }
510        }
511    }
512
513    #[test]
514    fn md_agents_start_with_yaml_frontmatter() {
515        let wf = load_workflow().unwrap();
516        let agents = load_agents().unwrap();
517        for tool in ["claude", "qwen"] {
518            for agent in &agents {
519                if !agent_applicable_to_tool(agent, tool) {
520                    continue;
521                }
522                let name = agent_name(agent).unwrap();
523                let rendered = render_agent(agent, &wf, tool).unwrap();
524                assert!(
525                    rendered.starts_with("---\n"),
526                    "{name}/{tool}: must start with YAML frontmatter"
527                );
528            }
529        }
530    }
531
532    #[test]
533    fn vibe_agents_start_with_toml_section() {
534        let wf = load_workflow().unwrap();
535        let agents = load_agents().unwrap();
536        for agent in &agents {
537            if !agent_applicable_to_tool(agent, "vibe") {
538                continue;
539            }
540            let name = agent_name(agent).unwrap();
541            let rendered = render_agent(agent, &wf, "vibe").unwrap();
542            assert!(
543                rendered.starts_with("[agent]"),
544                "{name}/vibe: must start with [agent] section, not a comment"
545            );
546        }
547    }
548
549    #[test]
550    fn agent_filenames_have_correct_extensions() {
551        let agents = load_agents().unwrap();
552        for agent in &agents {
553            let name = agent_name(agent).unwrap();
554            for (tool, ext) in [
555                ("claude", ".md"),
556                ("qwen", ".md"),
557                ("vibe", ".toml"),
558                ("copilot", ".agent.md"),
559            ] {
560                if !agent_applicable_to_tool(agent, tool) {
561                    continue;
562                }
563                let filename = agent_filename(agent, tool).unwrap();
564                assert!(
565                    filename.ends_with(ext),
566                    "{name}/{tool}: expected extension {ext}, got {filename}"
567                );
568            }
569        }
570    }
571
572    #[test]
573    fn vibe_agents_have_toml_structure() {
574        let wf = load_workflow().unwrap();
575        let agents = load_agents().unwrap();
576        for agent in &agents {
577            if !agent_applicable_to_tool(agent, "vibe") {
578                continue;
579            }
580            let name = agent_name(agent).unwrap();
581            let rendered = render_agent(agent, &wf, "vibe").unwrap();
582            assert!(
583                rendered.contains("[agent]"),
584                "{name}/vibe: missing [agent] section"
585            );
586            assert!(
587                rendered.contains("display_name = "),
588                "{name}/vibe: missing display_name"
589            );
590            assert!(
591                rendered.contains("enabled_tools = "),
592                "{name}/vibe: missing enabled_tools"
593            );
594        }
595    }
596
597    #[test]
598    fn claude_agents_have_yaml_frontmatter() {
599        let wf = load_workflow().unwrap();
600        let agents = load_agents().unwrap();
601        for agent in &agents {
602            if !agent_applicable_to_tool(agent, "claude") {
603                continue;
604            }
605            let name = agent_name(agent).unwrap();
606            let rendered = render_agent(agent, &wf, "claude").unwrap();
607            assert!(
608                rendered.contains("---\nname:"),
609                "{name}/claude: missing YAML frontmatter"
610            );
611        }
612    }
613
614    #[test]
615    fn copilot_prompt_contains_all_sections() {
616        let wf = load_workflow().unwrap();
617        let prompt = render_copilot_prompt(&wf).unwrap();
618        for section in ["## Status changes", "## Workflow", "## Implementing items"] {
619            assert!(
620                prompt.contains(section),
621                "copilot prompt missing section: {section}"
622            );
623        }
624    }
625
626    #[test]
627    fn reviewer_agent_has_restricted_permissions() {
628        let agents = load_agents().unwrap();
629        let reviewer = agents
630            .iter()
631            .find(|a| a["name"].as_str() == Some("reviewer"))
632            .unwrap();
633        let denied = reviewer["permissions"]["denied"].as_array().unwrap();
634        let denied_strs: Vec<&str> = denied.iter().filter_map(|v| v.as_str()).collect();
635        assert!(denied_strs.contains(&"write"), "reviewer must deny write");
636        assert!(denied_strs.contains(&"edit"), "reviewer must deny edit");
637    }
638
639    #[test]
640    fn implementer_agent_has_write_permissions() {
641        let agents = load_agents().unwrap();
642        let implementer = agents
643            .iter()
644            .find(|a| a["name"].as_str() == Some("implementer"))
645            .unwrap();
646        let allowed = implementer["permissions"]["allowed"].as_array().unwrap();
647        let allowed_strs: Vec<&str> = allowed.iter().filter_map(|v| v.as_str()).collect();
648        assert!(
649            allowed_strs.contains(&"write"),
650            "implementer must allow write"
651        );
652        assert!(
653            allowed_strs.contains(&"edit"),
654            "implementer must allow edit"
655        );
656        assert!(
657            allowed_strs.contains(&"bash"),
658            "implementer must allow bash"
659        );
660    }
661
662    #[test]
663    fn all_agent_names_covered() {
664        let agents = load_agents().unwrap();
665        let names: Vec<&str> = agents.iter().filter_map(|a| a["name"].as_str()).collect();
666        for expected in WORK_AGENTS {
667            assert!(names.contains(expected), "missing agent: {expected}");
668        }
669    }
670
671    #[test]
672    fn setup_instructions_not_empty() {
673        let content = setup_instructions();
674        assert!(!content.is_empty());
675        assert!(content.contains("Vision"));
676    }
677
678    #[test]
679    fn no_version_comments_in_rendered_output() {
680        let wf = load_workflow().unwrap();
681        let skill = render_skill(&wf).unwrap();
682        assert!(
683            !skill.contains("Generated by Joy"),
684            "rendered output must not contain version comments"
685        );
686
687        let block = render_joy_block("ai:test@joy", true).unwrap();
688        assert!(
689            !block.contains("Generated by Joy"),
690            "joy-block must not contain version comments"
691        );
692
693        let prompt = render_copilot_prompt(&wf).unwrap();
694        assert!(
695            !prompt.contains("Generated by Joy"),
696            "copilot prompt must not contain version comments"
697        );
698    }
699
700    // -----------------------------------------------------------------------
701    // Line count enforcement (ADR-026: max 200 lines per generated file)
702    // -----------------------------------------------------------------------
703
704    const MAX_LINES: usize = 200;
705
706    #[test]
707    fn rendered_instructions_under_200_lines() {
708        let wf = load_workflow().unwrap();
709        let block = render_joy_block("ai:test@joy", true).unwrap();
710        let instructions = render_instructions(&wf).unwrap();
711        let combined = format!("{}\n\n{}", block, instructions);
712        let lines = combined.lines().count();
713        assert!(
714            lines <= MAX_LINES,
715            "instruction file would be {lines} lines (max {MAX_LINES})"
716        );
717    }
718
719    #[test]
720    fn rendered_skill_under_200_lines() {
721        let wf = load_workflow().unwrap();
722        let skill = render_skill(&wf).unwrap();
723        let lines = skill.lines().count();
724        assert!(
725            lines <= MAX_LINES,
726            "SKILL.md would be {lines} lines (max {MAX_LINES})"
727        );
728    }
729}