eval_magic/adapters/
claude_code_session.rs1use std::path::{Path, PathBuf};
11
12use crate::core::AvailableSkill;
13
14pub fn render_available_skills_block(skills: &[AvailableSkill]) -> String {
19 if skills.is_empty() {
20 return String::new();
21 }
22 let mut sorted: Vec<&AvailableSkill> = skills.iter().collect();
23 sorted.sort_by(|a, b| a.name.cmp(&b.name));
24 let mut out = String::from("The following skills are available for use with the Skill tool:\n");
25 for s in sorted {
26 out.push_str(&format!("\n- {}: {}", s.name, s.description));
27 }
28 out
29}
30
31pub fn render_plan_mode_context(profile_text: &str) -> String {
36 let trimmed = profile_text.trim();
37 if trimmed.is_empty() {
38 return String::new();
39 }
40 format!("<system-reminder>\n{trimmed}\n</system-reminder>")
41}
42
43pub fn slugify_project_path(path: &Path) -> String {
48 path.to_string_lossy()
49 .chars()
50 .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
51 .collect()
52}
53
54pub fn resolve_subagents_dir_for_session(
63 config_dir: &Path,
64 cwd: &Path,
65 session_id: &str,
66) -> Option<PathBuf> {
67 let projects = config_dir.join("projects");
68 let primary = projects
69 .join(slugify_project_path(cwd))
70 .join(session_id)
71 .join("subagents");
72 if primary.is_dir() {
73 return Some(primary);
74 }
75 let entries = std::fs::read_dir(&projects).ok()?;
76 for entry in entries.flatten() {
77 let candidate = entry.path().join(session_id).join("subagents");
78 if candidate.is_dir() {
79 return Some(candidate);
80 }
81 }
82 None
83}
84
85#[cfg(test)]
86mod tests {
87 use super::*;
88 use crate::core::AvailableSkill;
89 use std::fs;
90 use tempfile::TempDir;
91
92 fn skill(name: &str, description: &str) -> AvailableSkill {
93 AvailableSkill {
94 name: name.into(),
95 path: format!("/x/{name}/SKILL.md"),
96 description: description.into(),
97 }
98 }
99
100 #[test]
101 fn uses_harness_native_header_and_one_bullet_per_skill() {
102 let block = render_available_skills_block(&[skill("foo", "the foo skill")]);
103 assert!(block.contains("The following skills are available for use with the Skill tool:"));
104 assert!(block.contains("- foo: the foo skill"));
105 assert!(!block.contains("staged and discoverable"));
107 assert!(!block.contains("*Trigger:*"));
108 }
109
110 #[test]
111 fn sorts_skills_by_name() {
112 let block = render_available_skills_block(&[skill("zebra", "z"), skill("alpha", "a")]);
113 assert!(block.find("- alpha:").unwrap() < block.find("- zebra:").unwrap());
114 }
115
116 #[test]
117 fn empty_list_renders_empty_string() {
118 assert_eq!(render_available_skills_block(&[]), "");
119 }
120
121 #[test]
122 fn plan_mode_wraps_in_system_reminder() {
123 let block = render_plan_mode_context("Plan mode is active. Do not edit.");
124 assert!(block.contains("<system-reminder>"));
125 assert!(block.contains("</system-reminder>"));
126 assert!(block.contains("Plan mode is active. Do not edit."));
127 }
128
129 #[test]
130 fn plan_mode_trims_surrounding_whitespace() {
131 let block = render_plan_mode_context("\n\n PROFILE-BODY \n\n");
132 assert_eq!(block, "<system-reminder>\nPROFILE-BODY\n</system-reminder>");
133 }
134
135 #[test]
136 fn plan_mode_empty_or_whitespace_renders_empty_string() {
137 assert_eq!(render_plan_mode_context(""), "");
138 assert_eq!(render_plan_mode_context(" \n "), "");
139 }
140
141 #[test]
142 fn slugify_matches_claude_code_double_hyphen() {
143 assert_eq!(
146 slugify_project_path(Path::new("/Users/maxhaarhaus/.config/opencode")),
147 "-Users-maxhaarhaus--config-opencode"
148 );
149 }
150
151 #[test]
152 fn slugify_replaces_all_non_alphanumerics_keeping_alnum() {
153 assert_eq!(
154 slugify_project_path(Path::new("/a-b/c.d_e f")),
155 "-a-b-c-d-e-f"
156 );
157 assert_eq!(slugify_project_path(Path::new("/Proj9/v2")), "-Proj9-v2");
158 }
159
160 fn make_subagents(config: &Path, project_dir: &str, sid: &str) -> std::path::PathBuf {
162 let dir = config
163 .join("projects")
164 .join(project_dir)
165 .join(sid)
166 .join("subagents");
167 fs::create_dir_all(&dir).unwrap();
168 dir
169 }
170
171 #[test]
172 fn resolve_finds_primary_cwd_slug_path() {
173 let tmp = TempDir::new().unwrap();
174 let cwd = Path::new("/tmp/proj");
175 let sid = "5ade3f59-dda3-4f40-8776-79f82ba0fab2";
176 let expected = make_subagents(tmp.path(), "-tmp-proj", sid);
177 assert_eq!(
178 resolve_subagents_dir_for_session(tmp.path(), cwd, sid),
179 Some(expected)
180 );
181 }
182
183 #[test]
184 fn resolve_falls_back_to_scan_when_cwd_slug_differs() {
185 let tmp = TempDir::new().unwrap();
186 let cwd = Path::new("/tmp/proj"); let sid = "11111111-2222-3333-4444-555555555555";
188 let expected = make_subagents(tmp.path(), "some-other-project-slug", sid);
189 assert_eq!(
190 resolve_subagents_dir_for_session(tmp.path(), cwd, sid),
191 Some(expected)
192 );
193 }
194
195 #[test]
196 fn resolve_prefers_primary_over_scan_match() {
197 let tmp = TempDir::new().unwrap();
198 let cwd = Path::new("/tmp/proj");
199 let sid = "99999999-aaaa-bbbb-cccc-dddddddddddd";
200 make_subagents(tmp.path(), "aaa-other", sid);
202 let primary = make_subagents(tmp.path(), "-tmp-proj", sid);
203 assert_eq!(
204 resolve_subagents_dir_for_session(tmp.path(), cwd, sid),
205 Some(primary)
206 );
207 }
208
209 #[test]
210 fn resolve_none_when_session_dir_absent() {
211 let tmp = TempDir::new().unwrap();
212 fs::create_dir_all(tmp.path().join("projects")).unwrap();
213 assert_eq!(
214 resolve_subagents_dir_for_session(tmp.path(), Path::new("/tmp/proj"), "no-such-sid"),
215 None
216 );
217 }
218
219 #[test]
220 fn resolve_none_when_subagents_subdir_missing() {
221 let tmp = TempDir::new().unwrap();
222 let sid = "abcdabcd-0000-1111-2222-333333333333";
223 fs::create_dir_all(tmp.path().join("projects").join("-tmp-proj").join(sid)).unwrap();
225 assert_eq!(
226 resolve_subagents_dir_for_session(tmp.path(), Path::new("/tmp/proj"), sid),
227 None
228 );
229 }
230}