mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +08:00
9336001746
Create a local backup under ~/.cc-switch/skill-backups/ before removing skill directories. The backup includes all skill files and a meta.json with original skill metadata. Old backups are pruned to keep at most 20. The backup path is returned to the frontend and shown in the success toast. Bump version to 3.12.3.
242 lines
7.6 KiB
Rust
242 lines
7.6 KiB
Rust
use std::fs;
|
|
|
|
use cc_switch_lib::{
|
|
migrate_skills_to_ssot, AppType, ImportSkillSelection, InstalledSkill, SkillApps, SkillService,
|
|
};
|
|
|
|
#[path = "support.rs"]
|
|
mod support;
|
|
use support::{create_test_state, ensure_test_home, reset_test_fs, test_mutex};
|
|
|
|
fn write_skill(dir: &std::path::Path, name: &str) {
|
|
fs::create_dir_all(dir).expect("create skill dir");
|
|
fs::write(
|
|
dir.join("SKILL.md"),
|
|
format!("---\nname: {name}\ndescription: Test skill\n---\n"),
|
|
)
|
|
.expect("write SKILL.md");
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
fn symlink_dir(src: &std::path::Path, dest: &std::path::Path) {
|
|
std::os::unix::fs::symlink(src, dest).expect("create symlink");
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
fn symlink_dir(src: &std::path::Path, dest: &std::path::Path) {
|
|
std::os::windows::fs::symlink_dir(src, dest).expect("create symlink");
|
|
}
|
|
|
|
#[test]
|
|
fn import_from_apps_respects_explicit_app_selection() {
|
|
let _guard = test_mutex().lock().expect("acquire test mutex");
|
|
reset_test_fs();
|
|
let home = ensure_test_home();
|
|
|
|
write_skill(
|
|
&home.join(".claude").join("skills").join("shared-skill"),
|
|
"Shared",
|
|
);
|
|
write_skill(
|
|
&home
|
|
.join(".config")
|
|
.join("opencode")
|
|
.join("skills")
|
|
.join("shared-skill"),
|
|
"Shared",
|
|
);
|
|
|
|
let state = create_test_state().expect("create test state");
|
|
|
|
let imported = SkillService::import_from_apps(
|
|
&state.db,
|
|
vec![ImportSkillSelection {
|
|
directory: "shared-skill".to_string(),
|
|
apps: SkillApps {
|
|
claude: false,
|
|
codex: false,
|
|
gemini: false,
|
|
opencode: true,
|
|
},
|
|
}],
|
|
)
|
|
.expect("import skills");
|
|
|
|
assert_eq!(imported.len(), 1, "expected exactly one imported skill");
|
|
let skill = imported.first().expect("imported skill");
|
|
assert!(
|
|
skill.apps.opencode,
|
|
"explicitly selected OpenCode app should remain enabled"
|
|
);
|
|
assert!(
|
|
!skill.apps.claude && !skill.apps.codex && !skill.apps.gemini,
|
|
"import should no longer infer apps from every matching source path"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn sync_to_app_removes_disabled_and_orphaned_ssot_symlinks() {
|
|
let _guard = test_mutex().lock().expect("acquire test mutex");
|
|
reset_test_fs();
|
|
let home = ensure_test_home();
|
|
|
|
let ssot_dir = home.join(".cc-switch").join("skills");
|
|
let disabled_skill = ssot_dir.join("disabled-skill");
|
|
let orphan_skill = ssot_dir.join("orphan-skill");
|
|
write_skill(&disabled_skill, "Disabled");
|
|
write_skill(&orphan_skill, "Orphan");
|
|
|
|
let opencode_skills_dir = home.join(".config").join("opencode").join("skills");
|
|
fs::create_dir_all(&opencode_skills_dir).expect("create opencode skills dir");
|
|
symlink_dir(&disabled_skill, &opencode_skills_dir.join("disabled-skill"));
|
|
symlink_dir(&orphan_skill, &opencode_skills_dir.join("orphan-skill"));
|
|
|
|
let state = create_test_state().expect("create test state");
|
|
state
|
|
.db
|
|
.save_skill(&InstalledSkill {
|
|
id: "local:disabled-skill".to_string(),
|
|
name: "Disabled".to_string(),
|
|
description: None,
|
|
directory: "disabled-skill".to_string(),
|
|
repo_owner: None,
|
|
repo_name: None,
|
|
repo_branch: None,
|
|
readme_url: None,
|
|
apps: SkillApps {
|
|
claude: false,
|
|
codex: false,
|
|
gemini: false,
|
|
opencode: false,
|
|
},
|
|
installed_at: 0,
|
|
})
|
|
.expect("save disabled skill");
|
|
|
|
SkillService::sync_to_app(&state.db, &AppType::OpenCode).expect("reconcile skills");
|
|
|
|
assert!(
|
|
!opencode_skills_dir.join("disabled-skill").exists(),
|
|
"DB-known disabled skill should be removed from OpenCode live dir"
|
|
);
|
|
assert!(
|
|
!opencode_skills_dir.join("orphan-skill").exists(),
|
|
"orphaned symlink into SSOT should be cleaned up"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn uninstall_skill_creates_backup_before_removing_ssot() {
|
|
let _guard = test_mutex().lock().expect("acquire test mutex");
|
|
reset_test_fs();
|
|
let home = ensure_test_home();
|
|
|
|
let ssot_skill_dir = home.join(".cc-switch").join("skills").join("backup-skill");
|
|
write_skill(&ssot_skill_dir, "Backup Skill");
|
|
fs::write(ssot_skill_dir.join("prompt.md"), "backup me").expect("write prompt.md");
|
|
|
|
let state = create_test_state().expect("create test state");
|
|
state
|
|
.db
|
|
.save_skill(&InstalledSkill {
|
|
id: "local:backup-skill".to_string(),
|
|
name: "Backup Skill".to_string(),
|
|
description: Some("Back me up before uninstall".to_string()),
|
|
directory: "backup-skill".to_string(),
|
|
repo_owner: None,
|
|
repo_name: None,
|
|
repo_branch: None,
|
|
readme_url: None,
|
|
apps: SkillApps {
|
|
claude: true,
|
|
codex: false,
|
|
gemini: false,
|
|
opencode: false,
|
|
},
|
|
installed_at: 123,
|
|
})
|
|
.expect("save skill");
|
|
|
|
let result = SkillService::uninstall(&state.db, "local:backup-skill").expect("uninstall skill");
|
|
let backup_path = result.backup_path.expect("backup path should be returned");
|
|
let backup_dir = std::path::PathBuf::from(&backup_path);
|
|
|
|
assert!(backup_dir.exists(), "backup directory should exist");
|
|
assert!(
|
|
backup_dir.join("skill").join("SKILL.md").exists(),
|
|
"backup should include SKILL.md"
|
|
);
|
|
assert_eq!(
|
|
fs::read_to_string(backup_dir.join("skill").join("prompt.md"))
|
|
.expect("read backed up prompt"),
|
|
"backup me"
|
|
);
|
|
|
|
let metadata: serde_json::Value = serde_json::from_str(
|
|
&fs::read_to_string(backup_dir.join("meta.json")).expect("read backup metadata"),
|
|
)
|
|
.expect("parse backup metadata");
|
|
assert_eq!(metadata["skill"]["directory"], "backup-skill");
|
|
assert_eq!(metadata["skill"]["name"], "Backup Skill");
|
|
|
|
assert!(
|
|
!ssot_skill_dir.exists(),
|
|
"SSOT skill directory should be removed after uninstall"
|
|
);
|
|
assert!(
|
|
state
|
|
.db
|
|
.get_installed_skill("local:backup-skill")
|
|
.expect("query skill")
|
|
.is_none(),
|
|
"database row should be deleted after uninstall"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn migration_snapshot_overrides_multi_source_directory_inference() {
|
|
let _guard = test_mutex().lock().expect("acquire test mutex");
|
|
reset_test_fs();
|
|
let home = ensure_test_home();
|
|
|
|
write_skill(
|
|
&home.join(".claude").join("skills").join("demo-skill"),
|
|
"Demo",
|
|
);
|
|
write_skill(
|
|
&home
|
|
.join(".config")
|
|
.join("opencode")
|
|
.join("skills")
|
|
.join("demo-skill"),
|
|
"Demo",
|
|
);
|
|
|
|
let state = create_test_state().expect("create test state");
|
|
state
|
|
.db
|
|
.set_setting(
|
|
"skills_ssot_migration_snapshot",
|
|
r#"[{"directory":"demo-skill","app_type":"claude"}]"#,
|
|
)
|
|
.expect("seed migration snapshot");
|
|
|
|
let count = migrate_skills_to_ssot(&state.db).expect("migrate skills to ssot");
|
|
assert_eq!(count, 1, "expected one migrated skill");
|
|
|
|
let skills = state.db.get_all_installed_skills().expect("get skills");
|
|
let migrated = skills
|
|
.values()
|
|
.find(|skill| skill.directory == "demo-skill")
|
|
.expect("migrated demo-skill");
|
|
|
|
assert!(
|
|
migrated.apps.claude,
|
|
"legacy snapshot should preserve Claude enablement"
|
|
);
|
|
assert!(
|
|
!migrated.apps.opencode,
|
|
"migration should no longer infer OpenCode enablement from a duplicate directory alone"
|
|
);
|
|
}
|