oy-cli 0.14.0

Focused coding agent integrations with repeatable audits, reviews, and one-finding fixes
Documentation
use super::*;

struct BackupStateGuard;

impl BackupStateGuard {
    fn set(path: PathBuf) -> Self {
        TEST_BACKUP_STATE_DIR.with(|state| {
            assert!(state.replace(Some(path)).is_none());
        });
        Self
    }
}

impl Drop for BackupStateGuard {
    fn drop(&mut self) {
        TEST_BACKUP_STATE_DIR.with(|state| {
            state.replace(None);
        });
    }
}

fn backup_dirs(state: &Path) -> Vec<PathBuf> {
    let mut backups = fs::read_dir(state.join("oy/backups"))
        .map(|entries| {
            entries
                .map(|entry| entry.unwrap().path())
                .collect::<Vec<_>>()
        })
        .unwrap_or_default();
    backups.sort();
    backups
}

fn setup_workspace() -> (tempfile::TempDir, tempfile::TempDir, BackupStateGuard) {
    let workspace = tempfile::tempdir().unwrap();
    let state = tempfile::tempdir().unwrap();
    let backup_state = BackupStateGuard::set(state.path().to_path_buf());
    (workspace, state, backup_state)
}

fn setup_cursor(workspace: &Path, dry_run: bool, remove: bool) -> Result<i32> {
    setup_in(
        SetupScope::Workspace,
        &workspace.join(".cursor"),
        dry_run,
        remove,
    )
}

#[test]
fn global_cursor_directory_is_under_home() {
    assert_eq!(
        cursor_dir_in_home(Path::new("/home/example")),
        PathBuf::from("/home/example/.cursor")
    );
}

#[test]
fn workspace_setup_installs_cursor_rule_agent_and_skills() {
    let (workspace, _state, _backup_state) = setup_workspace();

    setup_cursor(workspace.path(), false, false).unwrap();

    let cursor = workspace.path().join(".cursor");
    assert_eq!(
        fs::read_to_string(cursor.join("rules/oy.mdc")).unwrap(),
        OY_RULE
    );
    assert_eq!(
        fs::read_to_string(cursor.join("agents/oy.md")).unwrap(),
        OY_AGENT
    );
    assert_eq!(
        fs::read_to_string(cursor.join("skills/oy-audit/SKILL.md")).unwrap(),
        OY_AUDIT_SKILL
    );
    assert_eq!(
        fs::read_to_string(cursor.join("skills/oy-review/SKILL.md")).unwrap(),
        OY_REVIEW_SKILL
    );
    assert_eq!(
        fs::read_to_string(cursor.join("skills/oy-enhance/SKILL.md")).unwrap(),
        OY_ENHANCE_SKILL
    );
}

#[test]
fn cursor_setup_is_idempotent() {
    let (workspace, state, _backup_state) = setup_workspace();

    setup_cursor(workspace.path(), false, false).unwrap();
    let first = fs::read(workspace.path().join(".cursor/agents/oy.md")).unwrap();
    setup_cursor(workspace.path(), false, false).unwrap();

    assert_eq!(
        fs::read(workspace.path().join(".cursor/agents/oy.md")).unwrap(),
        first
    );
    assert!(backup_dirs(state.path()).is_empty());
}

#[test]
fn cursor_setup_backs_up_modified_owned_files() {
    let (workspace, state, _backup_state) = setup_workspace();
    setup_cursor(workspace.path(), false, false).unwrap();
    let agent = workspace.path().join(".cursor/agents/oy.md");
    fs::write(&agent, "locally modified\n").unwrap();

    setup_cursor(workspace.path(), false, false).unwrap();

    assert_eq!(fs::read_to_string(agent).unwrap(), OY_AGENT);
    let backups = backup_dirs(state.path());
    assert_eq!(backups.len(), 1);
    {
        use std::os::unix::fs::PermissionsExt as _;
        assert_eq!(
            fs::metadata(&backups[0]).unwrap().permissions().mode() & 0o777,
            0o700
        );
    }
    assert_eq!(
        fs::read_to_string(backups[0].join("agents/oy.md")).unwrap(),
        "locally modified\n"
    );
}

#[test]
fn cursor_setup_dry_run_does_not_write() {
    let (workspace, _state, _backup_state) = setup_workspace();

    setup_cursor(workspace.path(), true, false).unwrap();

    assert!(!workspace.path().join(".cursor").exists());
}

#[test]
fn cursor_remove_backs_up_owned_files_and_preserves_unrelated_files() {
    let (workspace, state, _backup_state) = setup_workspace();
    setup_cursor(workspace.path(), false, false).unwrap();
    let unrelated = workspace.path().join(".cursor/skills/keep/SKILL.md");
    fs::create_dir_all(unrelated.parent().unwrap()).unwrap();
    fs::write(&unrelated, "keep\n").unwrap();

    setup_cursor(workspace.path(), false, true).unwrap();

    assert_eq!(fs::read_to_string(unrelated).unwrap(), "keep\n");
    assert!(!workspace.path().join(".cursor/agents/oy.md").exists());
    assert!(!workspace.path().join(".cursor/rules/oy.mdc").exists());
    let backups = backup_dirs(state.path());
    assert_eq!(backups.len(), 1);
    assert_eq!(
        fs::read_to_string(backups[0].join("skills/oy-review/SKILL.md")).unwrap(),
        OY_REVIEW_SKILL
    );
}

#[test]
fn cursor_setup_rejects_symlinked_namespaces() {
    use std::os::unix::fs::symlink;

    let (workspace, _state, _backup_state) = setup_workspace();
    let outside = tempfile::tempdir().unwrap();
    fs::create_dir(workspace.path().join(".cursor")).unwrap();
    symlink(outside.path(), workspace.path().join(".cursor/skills")).unwrap();

    let error = setup_cursor(workspace.path(), false, false).unwrap_err();

    assert!(error.to_string().contains("symlinked Cursor namespace"));
    assert!(fs::read_dir(outside.path()).unwrap().next().is_none());
}