1mod mapping;
4
5use crate::discover::{find_tool_dir, infer_format_from_path};
6use crate::error::Error;
7use crate::format::RuleFormat;
8use crate::io::{WriteOutcome, write_file_atomic};
9use crate::migrate::mapping::{collect_migration_warnings, validate_source_keys};
10use crate::parse::{is_empty_frontmatter, parse_rule};
11use serde_json::{Map, Value, json};
12use std::collections::HashSet;
13use std::path::{Path, PathBuf};
14use tracing::warn;
15
16pub(crate) const ISSUE_URL: &str = "https://github.com/rameshsunkara/agent-rules-spec/issues";
17
18#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct MigrateWarning {
21 pub field: Option<String>,
23 pub message: String,
25}
26
27#[derive(Debug, Clone)]
29pub struct MigrateOptions {
30 pub from: RuleFormat,
32 pub to: RuleFormat,
34 pub force: bool,
36 pub filename_hint: Option<String>,
38}
39
40impl Default for MigrateOptions {
41 fn default() -> Self {
42 Self {
43 from: RuleFormat::Auto,
44 to: RuleFormat::Agents,
45 force: false,
46 filename_hint: None,
47 }
48 }
49}
50
51#[derive(Debug, Clone)]
53pub struct InputRule {
54 pub source_path: PathBuf,
56 pub relative_path: PathBuf,
58 pub format: RuleFormat,
60 pub content: String,
62}
63
64#[derive(Debug, Clone)]
66pub struct MigrateResult {
67 pub content: String,
69 pub warnings: Vec<MigrateWarning>,
71}
72
73#[derive(Debug, Clone, Default)]
75pub struct MigrateSummary {
76 pub written: Vec<PathBuf>,
78 pub skipped: Vec<PathBuf>,
80 pub warnings: Vec<MigrateWarning>,
82}
83
84pub fn migrate_string(content: &str, options: &MigrateOptions) -> Result<MigrateResult, Error> {
108 let parsed = parse_rule(content)?;
109 let source = options.from.resolve(&parsed.frontmatter);
110
111 let src_obj = if is_empty_frontmatter(&parsed.frontmatter) {
112 None
113 } else {
114 Some(
115 parsed
116 .frontmatter
117 .as_object()
118 .ok_or_else(|| Error::Migrate("frontmatter must be an object".to_string()))?
119 .clone(),
120 )
121 };
122
123 if let Some(ref obj) = src_obj {
124 validate_source_keys(source, obj)?;
125 }
126
127 let agents = to_agents(
128 &parsed.frontmatter,
129 source,
130 options.filename_hint.as_deref(),
131 )?;
132
133 let empty = Map::new();
134 let src_ref = src_obj.as_ref().unwrap_or(&empty);
135 let warnings = collect_migration_warnings(source, options.to, src_ref, &agents);
136
137 let output_fm = from_agents(&agents, options.to)?;
138 let content = assemble_markdown(&output_fm, &parsed.body)?;
139
140 Ok(MigrateResult { content, warnings })
141}
142
143pub async fn migrate_paths(
148 inputs: &[InputRule],
149 output_root: &Path,
150 options: &MigrateOptions,
151) -> Result<MigrateSummary, Error> {
152 let mut summary = MigrateSummary::default();
153 let mut seen_stems: HashSet<String> = HashSet::new();
154
155 for input in inputs {
156 let stem = normalized_stem(&input.relative_path, options.to)?;
157 if !seen_stems.insert(stem.clone()) && !options.force {
158 warn!(
159 path = %input.source_path.display(),
160 stem = %stem,
161 "duplicate rule stem; skipping (use --force to overwrite)"
162 );
163 summary
164 .skipped
165 .push(output_root.join(output_relative(&input.relative_path, options.to)));
166 continue;
167 }
168
169 let migrated = migrate_string(
170 &input.content,
171 &MigrateOptions {
172 from: if input.format == RuleFormat::Auto {
173 options.from
174 } else {
175 input.format
176 },
177 to: options.to,
178 force: options.force,
179 filename_hint: Some(stem),
180 },
181 )?;
182
183 summary.warnings.extend(migrated.warnings);
184
185 let out_rel = output_relative(&input.relative_path, options.to);
186 let out_path = output_root.join(&out_rel);
187
188 match write_file_atomic(&out_path, &migrated.content, options.force).await? {
189 WriteOutcome::Written => summary.written.push(out_path),
190 WriteOutcome::Skipped => {
191 warn!(path = %out_path.display(), "output exists; skipping (use --force to overwrite)");
192 summary.skipped.push(out_path);
193 }
194 }
195 }
196
197 Ok(summary)
198}
199
200fn to_agents(
201 frontmatter: &Value,
202 source: RuleFormat,
203 filename_hint: Option<&str>,
204) -> Result<Map<String, Value>, Error> {
205 let mut agents = Map::new();
206
207 if is_empty_frontmatter(frontmatter) {
208 if let Some(stem) = filename_hint {
209 agents.insert("name".to_string(), json!(stem));
210 }
211 agents.insert("trigger".to_string(), json!("always"));
212 return Ok(agents);
213 }
214
215 let obj = frontmatter
216 .as_object()
217 .ok_or_else(|| Error::Migrate("frontmatter must be an object".to_string()))?;
218
219 match source {
220 RuleFormat::Agents | RuleFormat::Auto => {
221 for (k, v) in obj {
222 agents.insert(k.clone(), v.clone());
223 }
224 }
225 RuleFormat::Cursor => convert_cursor_to_agents(obj, &mut agents)?,
226 RuleFormat::Windsurf => convert_windsurf_to_agents(obj, &mut agents)?,
227 RuleFormat::Copilot => convert_copilot_to_agents(obj, &mut agents)?,
228 RuleFormat::Claude | RuleFormat::Cline => convert_claude_to_agents(obj, &mut agents)?,
229 RuleFormat::Jetbrains => convert_jetbrains_to_agents(obj, &mut agents)?,
230 RuleFormat::AmazonQ => convert_amazonq_to_agents(obj, &mut agents)?,
231 }
232
233 if !agents.contains_key("name")
234 && let Some(stem) = filename_hint
235 {
236 agents.insert("name".to_string(), json!(stem));
237 }
238
239 Ok(agents)
240}
241
242fn convert_cursor_to_agents(
243 src: &Map<String, Value>,
244 dst: &mut Map<String, Value>,
245) -> Result<(), Error> {
246 copy_field(src, dst, "description");
247 let globs = src.get("globs").cloned();
248 let always_apply = src.get("alwaysApply").and_then(|v| v.as_bool());
249
250 if let Some(globs) = globs {
251 dst.insert("paths".to_string(), globs);
252 }
253
254 let trigger = if always_apply == Some(true) {
255 "always"
256 } else if src.contains_key("globs") {
257 "auto"
258 } else {
259 "manual"
260 };
261 dst.insert("trigger".to_string(), json!(trigger));
262 copy_optional_fields(src, dst, &["name"]);
263 Ok(())
264}
265
266fn convert_windsurf_to_agents(
267 src: &Map<String, Value>,
268 dst: &mut Map<String, Value>,
269) -> Result<(), Error> {
270 copy_field(src, dst, "description");
271 if let Some(globs) = src.get("globs") {
272 dst.insert("paths".to_string(), globs.clone());
273 }
274 let trigger = match src.get("trigger").and_then(|v| v.as_str()) {
275 Some("always_on") => "always",
276 Some("glob") => "auto",
277 Some("manual") => "manual",
278 Some("model_decision") => "auto",
279 _ if src.contains_key("globs") => "auto",
280 _ => "always",
281 };
282 dst.insert("trigger".to_string(), json!(trigger));
283 copy_optional_fields(src, dst, &["name"]);
284 Ok(())
285}
286
287fn convert_copilot_to_agents(
288 src: &Map<String, Value>,
289 dst: &mut Map<String, Value>,
290) -> Result<(), Error> {
291 copy_field(src, dst, "description");
292 if let Some(apply_to) = src.get("applyTo") {
293 let paths = match apply_to {
294 Value::String(s) => json!(
295 s.split(',')
296 .map(str::trim)
297 .filter(|p| !p.is_empty())
298 .collect::<Vec<_>>()
299 ),
300 other => other.clone(),
301 };
302 dst.insert("paths".to_string(), paths);
303 dst.insert("trigger".to_string(), json!("auto"));
304 } else {
305 dst.insert("trigger".to_string(), json!("always"));
306 }
307 copy_optional_fields(src, dst, &["name"]);
308 Ok(())
309}
310
311fn convert_claude_to_agents(
312 src: &Map<String, Value>,
313 dst: &mut Map<String, Value>,
314) -> Result<(), Error> {
315 copy_field(src, dst, "description");
316 if let Some(paths) = src.get("paths") {
317 dst.insert("paths".to_string(), paths.clone());
318 dst.insert("trigger".to_string(), json!("auto"));
319 } else {
320 dst.insert("trigger".to_string(), json!("always"));
321 }
322 copy_optional_fields(src, dst, &["name"]);
323 Ok(())
324}
325
326fn convert_jetbrains_to_agents(
327 src: &Map<String, Value>,
328 dst: &mut Map<String, Value>,
329) -> Result<(), Error> {
330 copy_field(src, dst, "description");
331 copy_field(src, dst, "name");
332 if let Some(paths) = src.get("paths") {
333 dst.insert("paths".to_string(), paths.clone());
334 dst.insert("trigger".to_string(), json!("auto"));
335 } else if let Some(trigger) = src.get("trigger").and_then(|v| v.as_str()) {
336 let mapped = match trigger {
337 "always" | "always_on" => "always",
338 "auto" | "glob" | "model_decision" => "auto",
339 "manual" => "manual",
340 other => other,
341 };
342 dst.insert("trigger".to_string(), json!(mapped));
343 } else {
344 dst.insert("trigger".to_string(), json!("always"));
345 }
346 if let Some(keywords) = src.get("keywords") {
347 dst.insert("keywords".to_string(), keywords.clone());
348 }
349 Ok(())
350}
351
352fn convert_amazonq_to_agents(
353 src: &Map<String, Value>,
354 dst: &mut Map<String, Value>,
355) -> Result<(), Error> {
356 copy_field(src, dst, "description");
357 copy_field(src, dst, "name");
358 dst.insert("trigger".to_string(), json!("always"));
359 Ok(())
360}
361
362fn from_agents(
363 agents: &Map<String, Value>,
364 target: RuleFormat,
365) -> Result<Map<String, Value>, Error> {
366 let mut out = Map::new();
367 match target {
368 RuleFormat::Agents | RuleFormat::Auto => {
369 for (k, v) in agents {
370 out.insert(k.clone(), v.clone());
371 }
372 }
373 RuleFormat::Cursor => {
374 copy_field(agents, &mut out, "description");
375 if let Some(paths) = agents.get("paths") {
376 out.insert("globs".to_string(), paths.clone());
377 }
378 let trigger = agents
379 .get("trigger")
380 .and_then(|v| v.as_str())
381 .unwrap_or("always");
382 match trigger {
383 "always" => {
384 out.insert("alwaysApply".to_string(), json!(true));
385 }
386 "auto" => {
387 out.insert("alwaysApply".to_string(), json!(false));
388 }
389 "manual" => {}
390 _ => {}
391 }
392 }
393 RuleFormat::Windsurf => {
394 copy_field(agents, &mut out, "description");
395 if let Some(paths) = agents.get("paths") {
396 out.insert("globs".to_string(), paths.clone());
397 }
398 let trigger = agents
399 .get("trigger")
400 .and_then(|v| v.as_str())
401 .unwrap_or("always");
402 let ws_trigger = match trigger {
403 "always" => "always_on",
404 "auto" => "glob",
405 "manual" => "manual",
406 _ => "always_on",
407 };
408 out.insert("trigger".to_string(), json!(ws_trigger));
409 }
410 RuleFormat::Copilot => {
411 copy_field(agents, &mut out, "description");
412 if let Some(paths) = agents.get("paths").and_then(|v| v.as_array()) {
413 let joined: Vec<&str> = paths.iter().filter_map(|p| p.as_str()).collect();
414 if !joined.is_empty() {
415 out.insert("applyTo".to_string(), json!(joined.join(",")));
416 }
417 }
418 }
419 RuleFormat::Claude | RuleFormat::Cline => {
420 if let Some(paths) = agents.get("paths") {
421 out.insert("paths".to_string(), paths.clone());
422 }
423 }
424 RuleFormat::Jetbrains | RuleFormat::AmazonQ => {
425 copy_field(agents, &mut out, "description");
426 copy_field(agents, &mut out, "name");
427 if let Some(paths) = agents.get("paths") {
428 out.insert("paths".to_string(), paths.clone());
429 }
430 }
431 }
432
433 Ok(out)
434}
435
436fn copy_field(src: &Map<String, Value>, dst: &mut Map<String, Value>, field: &str) {
437 if let Some(v) = src.get(field) {
438 dst.insert(field.to_string(), v.clone());
439 }
440}
441
442fn copy_optional_fields(src: &Map<String, Value>, dst: &mut Map<String, Value>, fields: &[&str]) {
443 for field in fields {
444 copy_field(src, dst, field);
445 }
446}
447
448fn assemble_markdown(frontmatter: &Map<String, Value>, body: &str) -> Result<String, Error> {
449 if frontmatter.is_empty() {
450 return Ok(body.to_string());
451 }
452 let yaml = serde_saphyr::to_string(frontmatter).map_err(|e| Error::Yaml(e.to_string()))?;
453 let trimmed_body = body.trim_start_matches('\n');
454 Ok(format!("---\n{yaml}---\n\n{trimmed_body}"))
455}
456
457fn normalized_stem(relative: &Path, target: RuleFormat) -> Result<String, Error> {
458 let file_name = relative
459 .file_name()
460 .and_then(|n| n.to_str())
461 .ok_or_else(|| Error::Migrate("invalid file name".to_string()))?;
462
463 let stem = if file_name.ends_with(".instructions.md") {
464 file_name.trim_end_matches(".instructions.md")
465 } else {
466 relative
467 .file_stem()
468 .and_then(|s| s.to_str())
469 .unwrap_or(file_name)
470 };
471
472 let normalized = if target == RuleFormat::Agents {
473 stem.to_ascii_lowercase().replace('_', "-")
474 } else {
475 stem.to_string()
476 };
477
478 Ok(normalized)
479}
480
481fn output_relative(relative: &Path, target: RuleFormat) -> PathBuf {
482 let stem = relative
483 .file_stem()
484 .and_then(|s| s.to_str())
485 .unwrap_or("rule");
486 let parent = relative.parent().unwrap_or(Path::new(""));
487 let normalized_stem = stem.to_ascii_lowercase().replace('_', "-");
488
489 let file_name = match target {
490 RuleFormat::Cursor => format!("{normalized_stem}.mdc"),
491 RuleFormat::Copilot => format!("{normalized_stem}.instructions.md"),
492 _ => format!("{normalized_stem}.md"),
493 };
494
495 parent.join(file_name)
496}
497
498pub fn build_inputs_from_dirs(
503 project_root: &Path,
504 explicit_dir: Option<&Path>,
505 from_hint: RuleFormat,
506) -> Result<Vec<InputRule>, Error> {
507 let mut inputs = Vec::new();
508
509 if let Some(dir) = explicit_dir {
510 let dir = if dir.is_absolute() {
511 dir.to_path_buf()
512 } else {
513 project_root.join(dir)
514 };
515 let tool_dir = find_tool_dir(&dir);
516 let format = tool_dir
517 .map(|d| d.format)
518 .or_else(|| infer_format_from_path(&dir))
519 .unwrap_or(from_hint);
520
521 if let Some(td) = tool_dir {
522 for file in crate::walk::walk_tool_dir(&dir, td)? {
523 let content = std::fs::read_to_string(&file.path)?;
524 inputs.push(InputRule {
525 source_path: file.path,
526 relative_path: file.relative,
527 format,
528 content,
529 });
530 }
531 } else {
532 for file in crate::walk::walk_md_files(&dir)? {
533 let relative = file.strip_prefix(&dir).unwrap_or(&file).to_path_buf();
534 let content = std::fs::read_to_string(&file)?;
535 inputs.push(InputRule {
536 source_path: file,
537 relative_path: relative,
538 format,
539 content,
540 });
541 }
542 }
543 } else {
544 for (dir_path, tool_dir) in crate::discover::existing_tool_dirs(project_root) {
545 if tool_dir.format == RuleFormat::Agents {
546 continue;
547 }
548 for file in crate::walk::walk_tool_dir(&dir_path, tool_dir)? {
549 let content = std::fs::read_to_string(&file.path)?;
550 inputs.push(InputRule {
551 source_path: file.path,
552 relative_path: file.relative,
553 format: tool_dir.format,
554 content,
555 });
556 }
557 }
558 }
559
560 Ok(inputs)
561}