use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use crate::error::{Result, SkillError};
use crate::types::AGENTS_DIR;
const LOCK_FILE: &str = ".skill-lock.json";
const CURRENT_VERSION: u32 = 3;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SkillLockEntry {
pub source: String,
pub source_type: String,
pub source_url: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub skill_path: Option<String>,
pub skill_folder_hash: String,
pub installed_at: String,
pub updated_at: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub plugin_name: Option<String>,
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DismissedPrompts {
#[serde(skip_serializing_if = "Option::is_none")]
pub find_skills_prompt: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SkillLockFile {
pub version: u32,
pub skills: std::collections::HashMap<String, SkillLockEntry>,
#[serde(skip_serializing_if = "Option::is_none")]
pub dismissed: Option<DismissedPrompts>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_selected_agents: Option<Vec<String>>,
}
impl SkillLockFile {
fn empty() -> Self {
Self {
version: CURRENT_VERSION,
skills: std::collections::HashMap::new(),
dismissed: Some(DismissedPrompts::default()),
last_selected_agents: None,
}
}
}
#[must_use]
pub fn lock_file_path() -> PathBuf {
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("~"));
home.join(AGENTS_DIR).join(LOCK_FILE)
}
pub async fn read_skill_lock() -> Result<SkillLockFile> {
let path = lock_file_path();
match tokio::fs::read_to_string(&path).await {
Ok(content) => {
let parsed: SkillLockFile = serde_json::from_str(&content)?;
if parsed.version < CURRENT_VERSION {
return Ok(SkillLockFile::empty());
}
Ok(parsed)
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(SkillLockFile::empty()),
Err(e) => Err(SkillError::io(path, e)),
}
}
pub async fn write_skill_lock(lock: &SkillLockFile) -> Result<()> {
let path = lock_file_path();
let Some(parent) = path.parent() else {
return Err(SkillError::io(
&path,
std::io::Error::other("lock file path has no parent"),
));
};
tokio::fs::create_dir_all(parent)
.await
.map_err(|e| SkillError::io(parent, e))?;
let content = serde_json::to_string_pretty(lock)?;
let tmp_path = parent.join(".skill-lock.tmp");
tokio::fs::write(&tmp_path, &content)
.await
.map_err(|e| SkillError::io(&tmp_path, e))?;
tokio::fs::rename(&tmp_path, &path)
.await
.map_err(|e| SkillError::io(&path, e))
}
#[derive(Debug)]
pub struct AddLockInput<'a> {
pub name: &'a str,
pub source: &'a str,
pub source_type: &'a str,
pub source_url: &'a str,
pub skill_path: Option<&'a str>,
pub skill_folder_hash: &'a str,
pub plugin_name: Option<&'a str>,
}
pub async fn add_skill_to_lock(input: &AddLockInput<'_>) -> Result<()> {
let mut lock = read_skill_lock().await?;
let now = iso_now();
let installed_at = lock
.skills
.get(input.name)
.map_or_else(|| now.clone(), |e| e.installed_at.clone());
lock.skills.insert(
input.name.to_owned(),
SkillLockEntry {
source: input.source.to_owned(),
source_type: input.source_type.to_owned(),
source_url: input.source_url.to_owned(),
skill_path: input.skill_path.map(String::from),
skill_folder_hash: input.skill_folder_hash.to_owned(),
installed_at,
updated_at: now,
plugin_name: input.plugin_name.map(String::from),
},
);
write_skill_lock(&lock).await
}
pub async fn remove_skill_from_lock(skill_name: &str) -> Result<bool> {
let mut lock = read_skill_lock().await?;
let removed = lock.skills.remove(skill_name).is_some();
if removed {
write_skill_lock(&lock).await?;
}
Ok(removed)
}
pub async fn get_skill_from_lock(skill_name: &str) -> Result<Option<SkillLockEntry>> {
let lock = read_skill_lock().await?;
Ok(lock.skills.get(skill_name).cloned())
}
pub async fn get_all_locked_skills() -> Result<std::collections::HashMap<String, SkillLockEntry>> {
let lock = read_skill_lock().await?;
Ok(lock.skills)
}
pub async fn is_prompt_dismissed(prompt: &str) -> Result<bool> {
let lock = read_skill_lock().await?;
Ok(match prompt {
"findSkillsPrompt" => lock
.dismissed
.as_ref()
.and_then(|d| d.find_skills_prompt)
.unwrap_or(false),
_ => false,
})
}
pub async fn dismiss_prompt(prompt: &str) -> Result<()> {
let mut lock = read_skill_lock().await?;
let dismissed = lock.dismissed.get_or_insert_with(DismissedPrompts::default);
if prompt == "findSkillsPrompt" {
dismissed.find_skills_prompt = Some(true);
}
write_skill_lock(&lock).await
}
pub async fn save_selected_agents(agents: &[String]) -> Result<()> {
let mut lock = read_skill_lock().await?;
lock.last_selected_agents = Some(agents.to_vec());
write_skill_lock(&lock).await
}
pub async fn get_last_selected_agents() -> Result<Option<Vec<String>>> {
let lock = read_skill_lock().await?;
Ok(lock.last_selected_agents)
}
fn iso_now() -> String {
let dur = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default();
let total_secs = dur.as_secs();
let days = total_secs / 86_400;
let time_of_day = total_secs % 86_400;
let hh = time_of_day / 3_600;
let mm = (time_of_day % 3_600) / 60;
let ss = time_of_day % 60;
#[allow(
clippy::cast_possible_wrap,
reason = "u64 days fits in i64 for valid timestamps"
)]
let z = (days as i64) + 719_468;
let era = z.div_euclid(146_097);
let doe = z.rem_euclid(146_097);
let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
let yr = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let dd = doy - (153 * mp + 2) / 5 + 1;
let month = if mp < 10 { mp + 3 } else { mp - 9 };
let year = if month <= 2 { yr + 1 } else { yr };
format!("{year:04}-{month:02}-{dd:02}T{hh:02}:{mm:02}:{ss:02}Z")
}