Skip to main content

agent_rules_tool/migrate/
mod.rs

1//! Migrate agent rule files between tool-native and canonical formats.
2
3mod 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/// A non-fatal migration finding (lossy or ambiguous field mapping).
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct MigrateWarning {
21    /// Frontmatter field related to the warning, if any.
22    pub field: Option<String>,
23    /// Human-readable explanation.
24    pub message: String,
25}
26
27/// Options for [`migrate_string`] and [`migrate_paths`].
28#[derive(Debug, Clone)]
29pub struct MigrateOptions {
30    /// Source format, or [`RuleFormat::Auto`] to detect from frontmatter.
31    pub from: RuleFormat,
32    /// Target format to emit.
33    pub to: RuleFormat,
34    /// When writing to disk, overwrite existing output files.
35    pub force: bool,
36    /// Filename stem used when inferring `name` for empty frontmatter.
37    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/// One rule file discovered for batch migration.
52#[derive(Debug, Clone)]
53pub struct InputRule {
54    /// Absolute path to the source file on disk.
55    pub source_path: PathBuf,
56    /// Path relative to the scan root (used for output layout).
57    pub relative_path: PathBuf,
58    /// Detected or declared format of the source file.
59    pub format: RuleFormat,
60    /// Raw file contents.
61    pub content: String,
62}
63
64/// Result of migrating one rule file's content in memory.
65#[derive(Debug, Clone)]
66pub struct MigrateResult {
67    /// Migrated markdown (frontmatter + body).
68    pub content: String,
69    /// Lossy or ambiguous mapping warnings.
70    pub warnings: Vec<MigrateWarning>,
71}
72
73/// Summary of files written or skipped by [`migrate_paths`].
74#[derive(Debug, Clone, Default)]
75pub struct MigrateSummary {
76    /// Output paths that were written.
77    pub written: Vec<PathBuf>,
78    /// Output paths that were skipped (duplicate stem or existing file without `force`).
79    pub skipped: Vec<PathBuf>,
80    /// Warnings aggregated across all migrated files.
81    pub warnings: Vec<MigrateWarning>,
82}
83
84/// Migrate one rule file's content between formats in memory.
85///
86/// Parses markdown frontmatter, normalizes to canonical agents frontmatter, then
87/// maps fields to the target format.
88///
89/// # Examples
90///
91/// ```
92/// use agent_rules_tool::{migrate_string, MigrateOptions};
93/// use agent_rules_tool::format::RuleFormat;
94///
95/// let content = "---\nglobs: \"**/*.rs\"\nalwaysApply: false\n---\n\n# Rule\n";
96/// let result = migrate_string(
97///     content,
98///     &MigrateOptions {
99///         from: RuleFormat::Cursor,
100///         to: RuleFormat::Agents,
101///         ..Default::default()
102///     },
103/// )?;
104/// assert!(result.content.contains("trigger:"));
105/// # Ok::<(), agent_rules_tool::Error>(())
106/// ```
107pub 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
143/// Migrate discovered inputs and write results under `output_root`.
144///
145/// Uses atomic writes via [`crate::io::write_file_atomic`]. Skips outputs that
146/// already exist unless [`MigrateOptions::force`] is set.
147pub 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
498/// Build [`InputRule`] entries by scanning tool directories or an explicit path.
499///
500/// When `explicit_dir` is `None`, scans [`crate::discover::existing_tool_dirs`] under
501/// `project_root` (skipping canonical `.agents/rules` as a source).
502pub 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}