1use minijinja::{context, Environment};
12
13use crate::error::JoyError;
14
15const 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
39const 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
56pub 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
67pub 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
78pub 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
95pub 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
109pub 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
123pub fn setup_instructions() -> &'static str {
125 SETUP_TMPL
126}
127
128fn 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
139pub 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
163pub 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
179pub 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
194pub fn agent_name(agent: &serde_json::Value) -> Option<&str> {
196 agent["name"].as_str()
197}
198
199pub 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 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 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}