agent-rules-tool 0.1.0-rc.2

Lint and migrate agent rules per agent-rules-spec
Documentation
use agent_rules_tool::format::RuleFormat;
use agent_rules_tool::migrate::{InputRule, MigrateOptions, migrate_paths, migrate_string};
use std::path::PathBuf;
use tempfile::TempDir;

const CURSOR_NATIVE: &str = r#"---
description: API design patterns
globs:
  - "src/api/**/*.ts"
alwaysApply: false
---

# API Conventions
Use RESTful naming.
"#;

#[test]
fn migrate_cursor_to_agents() {
    let options = MigrateOptions {
        from: RuleFormat::Cursor,
        to: RuleFormat::Agents,
        force: false,
        filename_hint: Some("api-conventions".to_string()),
    };
    let result = migrate_string(CURSOR_NATIVE, &options).expect("migrate");
    assert!(result.content.contains("trigger: auto"));
    assert!(result.content.contains("paths:"));
    assert!(result.content.contains("src/api/**/*.ts"));
    assert!(!result.content.contains("globs:"));
    assert!(!result.content.contains("alwaysApply"));
}

#[test]
fn migrate_agents_to_cursor_round_trip() {
    let agents = r#"---
name: api-conventions
description: API design patterns
trigger: auto
paths:
  - "src/api/**/*.ts"
---

# API Conventions
Use RESTful naming.
"#;

    let to_cursor = MigrateOptions {
        from: RuleFormat::Agents,
        to: RuleFormat::Cursor,
        force: false,
        filename_hint: Some("api-conventions".to_string()),
    };
    let cursor = migrate_string(agents, &to_cursor).expect("to cursor");
    assert!(cursor.content.contains("globs:"));
    assert!(cursor.content.contains("alwaysApply: false"));

    let back = MigrateOptions {
        from: RuleFormat::Cursor,
        to: RuleFormat::Agents,
        force: false,
        filename_hint: Some("api-conventions".to_string()),
    };
    let agents_again = migrate_string(&cursor.content, &back).expect("back to agents");
    assert!(agents_again.content.contains("trigger: auto"));
    assert!(agents_again.content.contains("paths:"));
}

#[test]
fn migrate_directory_writes_agents_rules() {
    let temp = TempDir::new().expect("tempdir");
    let root = temp.path();

    let cursor_dir = root.join(".cursor/rules");
    std::fs::create_dir_all(&cursor_dir).expect("mkdir");
    let source = cursor_dir.join("api-conventions.mdc");
    std::fs::write(&source, CURSOR_NATIVE).expect("write source");

    let output_root = root.join(".agents/rules");
    let inputs = vec![InputRule {
        source_path: source.clone(),
        relative_path: PathBuf::from("api-conventions.mdc"),
        format: RuleFormat::Cursor,
        content: CURSOR_NATIVE.to_string(),
    }];

    let options = MigrateOptions {
        from: RuleFormat::Cursor,
        to: RuleFormat::Agents,
        force: false,
        filename_hint: None,
    };

    let rt = tokio::runtime::Runtime::new().expect("runtime");
    let summary = rt
        .block_on(migrate_paths(&inputs, &output_root, &options))
        .expect("migrate paths");

    assert_eq!(summary.written.len(), 1);
    let out_file = output_root.join("api-conventions.md");
    assert!(out_file.exists());
    let content = std::fs::read_to_string(&out_file).expect("read output");
    assert!(content.contains("trigger: auto"));
}

#[test]
fn migrate_skips_existing_without_force() {
    let temp = TempDir::new().expect("tempdir");
    let root = temp.path();
    let output_root = root.join(".agents/rules");
    std::fs::create_dir_all(&output_root).expect("mkdir");
    let out_file = output_root.join("api-conventions.md");
    std::fs::write(&out_file, "existing").expect("write existing");

    let inputs = vec![InputRule {
        source_path: PathBuf::from("source.mdc"),
        relative_path: PathBuf::from("api-conventions.mdc"),
        format: RuleFormat::Cursor,
        content: CURSOR_NATIVE.to_string(),
    }];

    let options = MigrateOptions {
        from: RuleFormat::Cursor,
        to: RuleFormat::Agents,
        force: false,
        filename_hint: None,
    };

    let rt = tokio::runtime::Runtime::new().expect("runtime");
    let summary = rt
        .block_on(migrate_paths(&inputs, &output_root, &options))
        .expect("migrate");

    assert_eq!(summary.skipped.len(), 1);
    assert_eq!(
        std::fs::read_to_string(&out_file).expect("read"),
        "existing"
    );
}

#[test]
fn migrate_overwrites_with_force() {
    let temp = TempDir::new().expect("tempdir");
    let root = temp.path();
    let output_root = root.join(".agents/rules");
    std::fs::create_dir_all(&output_root).expect("mkdir");
    let out_file = output_root.join("api-conventions.md");
    std::fs::write(&out_file, "existing").expect("write existing");

    let inputs = vec![InputRule {
        source_path: PathBuf::from("source.mdc"),
        relative_path: PathBuf::from("api-conventions.mdc"),
        format: RuleFormat::Cursor,
        content: CURSOR_NATIVE.to_string(),
    }];

    let options = MigrateOptions {
        from: RuleFormat::Cursor,
        to: RuleFormat::Agents,
        force: true,
        filename_hint: None,
    };

    let rt = tokio::runtime::Runtime::new().expect("runtime");
    let summary = rt
        .block_on(migrate_paths(&inputs, &output_root, &options))
        .expect("migrate");

    assert_eq!(summary.written.len(), 1);
    let content = std::fs::read_to_string(&out_file).expect("read");
    assert!(content.contains("trigger: auto"));
}

#[test]
fn migrate_unknown_cursor_key_errors() {
    let content = r#"---
description: test
globs: "**/*.rs"
alwaysApply: false
activation: paths
---
"#;
    let options = MigrateOptions {
        from: RuleFormat::Cursor,
        to: RuleFormat::Agents,
        ..Default::default()
    };
    let err = migrate_string(content, &options).unwrap_err();
    assert!(
        err.to_string()
            .contains("unknown frontmatter field 'activation'")
    );
    assert!(err.to_string().contains("agent-rules-spec/issues"));
}

#[test]
fn migrate_unknown_agents_key_errors() {
    let content = r#"---
name: test
trigger: always
activation: paths
---
"#;
    let options = MigrateOptions {
        from: RuleFormat::Agents,
        to: RuleFormat::Cursor,
        ..Default::default()
    };
    let err = migrate_string(content, &options).unwrap_err();
    assert!(
        err.to_string()
            .contains("unknown frontmatter field 'activation'")
    );
}

#[test]
fn migrate_windsurf_model_decision_warns() {
    let content = r#"---
description: test
trigger: model_decision
globs:
  - "**/*.rs"
---
"#;
    let options = MigrateOptions {
        from: RuleFormat::Windsurf,
        to: RuleFormat::Agents,
        ..Default::default()
    };
    let result = migrate_string(content, &options).expect("migrate");
    assert!(
        result
            .warnings
            .iter()
            .any(|w| w.message.contains("model_decision"))
    );
}

#[test]
fn migrate_agents_to_cursor_keywords_warns() {
    let content = r#"---
name: test
description: test
trigger: auto
paths:
  - "**/*.rs"
keywords:
  - "api"
---
"#;
    let options = MigrateOptions {
        from: RuleFormat::Agents,
        to: RuleFormat::Cursor,
        ..Default::default()
    };
    let result = migrate_string(content, &options).expect("migrate");
    assert!(
        result
            .warnings
            .iter()
            .any(|w| w.field.as_deref() == Some("keywords"))
    );
}