use anyhow::{Context, Result};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
use crate::memory::MemoryProvider;
pub const SCHEMA_VERSION: u32 = 1;
pub const INTEGRATION_VERSION: u32 = 1;
const MANIFEST_FILENAME: &str = "whetstone.json";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ProviderTag {
Icm,
Skip,
}
impl From<MemoryProvider> for ProviderTag {
fn from(p: MemoryProvider) -> Self {
match p {
MemoryProvider::Icm => Self::Icm,
MemoryProvider::Skip => Self::Skip,
}
}
}
impl From<ProviderTag> for MemoryProvider {
fn from(t: ProviderTag) -> Self {
match t {
ProviderTag::Icm => Self::Icm,
ProviderTag::Skip => Self::Skip,
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct ToolVersions {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rtk: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub icm: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub headroom: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct WhetstoneManifest {
pub schema: u32,
pub whetstone_version: String,
pub integration_version: u32,
pub provider: ProviderTag,
#[serde(default)]
pub tool_versions: ToolVersions,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub migration_id: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
impl WhetstoneManifest {
pub fn new(provider: MemoryProvider, tool_versions: ToolVersions) -> Self {
let now = Utc::now();
Self {
schema: SCHEMA_VERSION,
whetstone_version: crate::version::current().to_string(),
integration_version: INTEGRATION_VERSION,
provider: provider.into(),
tool_versions,
migration_id: None,
created_at: now,
updated_at: now,
}
}
pub fn path_for(project_dir: &Path) -> PathBuf {
project_dir.join(".claude").join(MANIFEST_FILENAME)
}
pub fn load(path: &Path) -> Result<Option<Self>> {
if !path.exists() {
return Ok(None);
}
let raw =
fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
let parsed: Self = serde_json::from_str(&raw)
.with_context(|| format!("parsing manifest at {}", path.display()))?;
Ok(Some(parsed))
}
pub fn save(&self, path: &Path) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?;
}
let pretty = serde_json::to_string_pretty(self).context("serializing whetstone.json")?;
fs::write(path, pretty).with_context(|| format!("writing {}", path.display()))?;
Ok(())
}
pub fn touch_and_save(&mut self, path: &Path) -> Result<()> {
self.updated_at = Utc::now();
self.save(path)
}
pub fn migration_id(&self) -> Option<&str> {
self.migration_id.as_deref()
}
pub fn set_migration_id(&mut self, id: &str) {
self.migration_id = Some(id.to_string());
}
pub fn clear_migration_id(&mut self) {
self.migration_id = None;
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::NamedTempFile;
#[test]
fn provider_round_trip() {
let icm: ProviderTag = MemoryProvider::Icm.into();
assert_eq!(MemoryProvider::from(icm), MemoryProvider::Icm);
let skip: ProviderTag = MemoryProvider::Skip.into();
assert_eq!(MemoryProvider::from(skip), MemoryProvider::Skip);
}
#[test]
fn provider_serializes_snake_case() {
let tag = ProviderTag::Icm;
let s = serde_json::to_string(&tag).unwrap();
assert_eq!(s, "\"icm\"");
}
#[test]
fn new_manifest_has_current_schema_and_integration_version() {
let m = WhetstoneManifest::new(MemoryProvider::Icm, ToolVersions::default());
assert_eq!(m.schema, SCHEMA_VERSION);
assert_eq!(m.integration_version, INTEGRATION_VERSION);
assert_eq!(m.provider, ProviderTag::Icm);
assert!(m.migration_id.is_none());
}
#[test]
fn save_and_load_round_trip() {
let m = WhetstoneManifest::new(
MemoryProvider::Icm,
ToolVersions {
rtk: Some("0.42.3".into()),
icm: Some("0.10.43".into()),
headroom: Some("0.23.0".into()),
},
);
let f = NamedTempFile::new().unwrap();
m.save(f.path()).unwrap();
let loaded = WhetstoneManifest::load(f.path()).unwrap().unwrap();
assert_eq!(loaded.provider, m.provider);
assert_eq!(loaded.tool_versions.rtk.as_deref(), Some("0.42.3"));
}
#[test]
fn load_returns_none_when_file_missing() {
let path = Path::new("/nonexistent-whetstone-test/whetstone.json");
assert!(WhetstoneManifest::load(path).unwrap().is_none());
}
#[test]
fn load_errors_on_malformed_json() {
let mut f = NamedTempFile::new().unwrap();
f.write_all(b"not json").unwrap();
assert!(WhetstoneManifest::load(f.path()).is_err());
}
#[test]
fn path_for_uses_dot_claude() {
let p = WhetstoneManifest::path_for(Path::new("/tmp/proj"));
assert!(p.ends_with(".claude/whetstone.json"));
}
#[test]
fn touch_and_save_bumps_updated_at() {
let mut m = WhetstoneManifest::new(MemoryProvider::Skip, ToolVersions::default());
let original = m.updated_at;
let f = NamedTempFile::new().unwrap();
std::thread::sleep(std::time::Duration::from_millis(10));
m.touch_and_save(f.path()).unwrap();
assert!(m.updated_at > original);
}
}