1use anyhow::{Context, Result};
15use chrono::{DateTime, Utc};
16use fd_lock::RwLock;
17use serde::{Deserialize, Serialize};
18use std::fs::OpenOptions;
19use std::path::{Path, PathBuf};
20use tempfile::NamedTempFile;
21
22pub const STATS_SCHEMA_VERSION: u32 = 1;
23
24#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Copy, Default)]
25#[serde(rename_all = "snake_case")]
26pub enum LifecycleState {
27 #[default]
28 Draft,
29 Emerging,
30 Stable,
31 Canonical,
32 Deprecated,
33 Archived,
34 Destroyed,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct SkillStats {
52 pub schema_version: u32,
53 pub skill_name: String,
54 pub skill_version: String,
55 pub manifest_digest: String,
59
60 pub lifecycle_state: LifecycleState,
61 pub lifecycle_changed_at: DateTime<Utc>,
62 pub pinned: bool,
63 #[serde(default)]
64 pub pinned_reason: String,
65
66 pub usage_count: u64,
67 pub success_count: u64,
68 pub failure_count: u64,
69
70 pub last_used_at: Option<DateTime<Utc>>,
71 pub last_success_at: Option<DateTime<Utc>>,
72 pub first_successful_use_at: Option<DateTime<Utc>>,
73
74 pub anchor_confidence: f64,
79
80 pub rebuilt_from_trace_through: Option<DateTime<Utc>>,
84
85 #[serde(default)]
89 pub resolution_misses: u64,
90
91 #[serde(default)]
97 pub curated_at: Option<DateTime<Utc>>,
98}
99
100impl SkillStats {
101 pub fn new(
102 skill_name: &str,
103 skill_version: &str,
104 manifest_digest: &str,
105 now: DateTime<Utc>,
106 ) -> Self {
107 Self {
108 schema_version: STATS_SCHEMA_VERSION,
109 skill_name: skill_name.to_string(),
110 skill_version: skill_version.to_string(),
111 manifest_digest: manifest_digest.to_string(),
112 lifecycle_state: LifecycleState::default(),
113 lifecycle_changed_at: now,
114 pinned: false,
115 pinned_reason: String::new(),
116 usage_count: 0,
117 success_count: 0,
118 failure_count: 0,
119 last_used_at: None,
120 last_success_at: None,
121 first_successful_use_at: None,
122 anchor_confidence: 1.0,
123 rebuilt_from_trace_through: None,
124 resolution_misses: 0,
125 curated_at: None,
126 }
127 }
128
129 pub fn path(mur_home: &Path, skill_name: &str) -> PathBuf {
130 mur_home.join("skills").join(skill_name).join("stats.json")
131 }
132
133 pub fn path_agent(mur_home: &Path, agent: &str, skill_name: &str) -> PathBuf {
135 mur_home
136 .join("agents")
137 .join(agent)
138 .join("skills")
139 .join(skill_name)
140 .join("stats.json")
141 }
142
143 pub fn load(path: &Path) -> Result<Option<Self>> {
148 match std::fs::read_to_string(path) {
149 Ok(s) => {
150 let stats: Self = serde_json::from_str(&s).context("deserialise stats.json")?;
151 Ok(Some(stats))
152 }
153 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
154 Err(e) => Err(e).context("read stats.json"),
155 }
156 }
157
158 pub fn merge_in_place(
163 path: &Path,
164 default: impl FnOnce() -> Self,
165 merge_fn: impl FnOnce(&mut Self) -> Result<()>,
166 ) -> Result<()> {
167 let lock_path = path.with_extension("lock");
171 let parent = path.parent().context("stats path has no parent")?;
172 std::fs::create_dir_all(parent).ok();
173
174 let mut lock_file = RwLock::new(
175 OpenOptions::new()
176 .create(true)
177 .truncate(true)
178 .write(true)
179 .read(true)
180 .open(&lock_path)
181 .context("open stats lockfile")?,
182 );
183 let _guard = lock_file.write().context("acquire stats lock")?;
184
185 let mut stats = Self::load(path)?.unwrap_or_else(default);
186 merge_fn(&mut stats)?;
187
188 let tmp = NamedTempFile::new_in(parent).context("create temp file for stats")?;
189 serde_json::to_writer_pretty(&tmp, &stats).context("serialise stats")?;
190 tmp.persist(path).context("persist stats")?;
191 Ok(())
192 }
193
194 pub fn is_stale(&self, current_digest: &str) -> bool {
204 self.manifest_digest != current_digest
205 }
206
207 pub fn reset_for_new_manifest(
210 &mut self,
211 new_version: &str,
212 new_digest: &str,
213 now: DateTime<Utc>,
214 ) {
215 self.skill_version = new_version.to_string();
216 self.manifest_digest = new_digest.to_string();
217 self.usage_count = 0;
218 self.success_count = 0;
219 self.failure_count = 0;
220 self.last_used_at = None;
221 self.last_success_at = None;
222 self.anchor_confidence = 1.0;
223 self.rebuilt_from_trace_through = None;
224 self.lifecycle_changed_at = now;
225 }
227}
228
229#[cfg(test)]
230mod tests {
231 use super::*;
232 use std::thread;
233
234 fn temp_stats_path() -> (tempfile::TempDir, PathBuf) {
235 let dir = tempfile::tempdir().unwrap();
236 let path = dir.path().join("test_skill").join("stats.json");
237 let parent = path.parent().unwrap();
238 std::fs::create_dir_all(parent).unwrap();
239 (dir, path)
240 }
241
242 fn dummy_stats(name: &str) -> SkillStats {
243 SkillStats::new(name, "1.0.0", "abc123", Utc::now())
244 }
245
246 #[test]
247 fn load_returns_none_for_missing_path() {
248 let (_dir, path) = temp_stats_path();
249 let result = SkillStats::load(&path).unwrap();
250 assert!(result.is_none());
251 }
252
253 #[test]
254 fn load_returns_stats_for_valid_file() {
255 let (_dir, path) = temp_stats_path();
256 let stats = dummy_stats("test-skill");
257 std::fs::write(&path, serde_json::to_string_pretty(&stats).unwrap()).unwrap();
258 let loaded = SkillStats::load(&path).unwrap().unwrap();
259 assert_eq!(loaded.skill_name, "test-skill");
260 assert_eq!(loaded.usage_count, 0);
261 }
262
263 #[test]
264 fn merge_in_place_counter_increment() {
265 let (_dir, path) = temp_stats_path();
266 let skill_name = "merge-test".to_string();
267 let default = || dummy_stats(&skill_name);
268
269 SkillStats::merge_in_place(&path, default, |s| {
271 s.usage_count += 1;
272 Ok(())
273 })
274 .unwrap();
275
276 let loaded = SkillStats::load(&path).unwrap().unwrap();
277 assert_eq!(loaded.usage_count, 1);
278
279 SkillStats::merge_in_place(
281 &path,
282 || panic!("default should not be called"),
283 |s| {
284 s.usage_count += 2;
285 Ok(())
286 },
287 )
288 .unwrap();
289
290 let loaded = SkillStats::load(&path).unwrap().unwrap();
291 assert_eq!(loaded.usage_count, 3);
292 }
293
294 #[test]
295 fn concurrent_merge_both_increments_commit() {
296 let (_dir, path) = temp_stats_path();
297 let skill_name = "concurrent-test".to_string();
298 let path = std::path::PathBuf::from(path); let path2 = path.clone();
300
301 SkillStats::merge_in_place(&path, || dummy_stats(&skill_name), |_| Ok(())).unwrap();
303
304 let t1 = thread::spawn(move || {
305 SkillStats::merge_in_place(
306 &path,
307 || panic!("default should not be called"),
308 |s| {
309 s.usage_count += 1;
310 Ok(())
311 },
312 )
313 .unwrap();
314 });
315 let t2 = thread::spawn(move || {
316 SkillStats::merge_in_place(
317 &path2,
318 || panic!("default should not be called"),
319 |s| {
320 s.usage_count += 2;
321 Ok(())
322 },
323 )
324 .unwrap();
325 });
326
327 t1.join().unwrap();
328 t2.join().unwrap();
329
330 let loaded = SkillStats::load(&_dir.path().join("test_skill").join("stats.json"))
331 .unwrap()
332 .unwrap();
333 assert_eq!(loaded.usage_count, 3);
335 }
336
337 #[test]
338 fn is_stale_detects_digest_mismatch() {
339 let stats = dummy_stats("test");
340 assert!(!stats.is_stale("abc123"));
341 assert!(stats.is_stale("different"));
342 }
343
344 #[test]
345 fn schema_version_1_deserialises_fixture() {
346 let fixture = r#"{
347 "schema_version": 1,
348 "skill_name": "research-patterns",
349 "skill_version": "2.3.0",
350 "manifest_digest": "abcdef",
351 "lifecycle_state": "emerging",
352 "lifecycle_changed_at": "2026-05-25T00:00:00Z",
353 "pinned": false,
354 "pinned_reason": "",
355 "usage_count": 42,
356 "success_count": 38,
357 "failure_count": 4,
358 "last_used_at": "2026-05-25T12:00:00Z",
359 "last_success_at": "2026-05-25T11:00:00Z",
360 "first_successful_use_at": "2026-05-01T00:00:00Z",
361 "anchor_confidence": 0.95,
362 "rebuilt_from_trace_through": "2026-05-25T10:00:00Z"
363 }"#;
364 let stats: SkillStats = serde_json::from_str(fixture).unwrap();
365 assert_eq!(stats.schema_version, 1);
366 assert_eq!(stats.lifecycle_state, LifecycleState::Emerging);
367 assert_eq!(stats.usage_count, 42);
368 assert_eq!(stats.anchor_confidence, 0.95);
369 assert!(stats.last_used_at.is_some());
370 }
371
372 #[test]
373 fn reset_for_new_manifest_preserves_pinned_and_state() {
374 let mut stats = SkillStats {
375 pinned: true,
376 pinned_reason: "critical".into(),
377 lifecycle_state: LifecycleState::Canonical,
378 first_successful_use_at: Some(Utc::now()),
379 usage_count: 100,
380 success_count: 95,
381 failure_count: 5,
382 ..dummy_stats("test")
383 };
384 stats.reset_for_new_manifest("2.0.0", "newdigest", Utc::now());
385 assert_eq!(stats.skill_version, "2.0.0");
386 assert_eq!(stats.usage_count, 0);
387 assert!(stats.pinned);
388 assert_eq!(stats.lifecycle_state, LifecycleState::Canonical);
389 assert!(stats.first_successful_use_at.is_some());
390 }
391
392 #[test]
393 fn curated_at_defaults_to_none_and_is_backward_compatible() {
394 let legacy = r#"{
396 "schema_version": 1, "skill_name": "x", "skill_version": "1",
397 "manifest_digest": "d", "lifecycle_state": "draft",
398 "lifecycle_changed_at": "2026-01-01T00:00:00Z", "pinned": false,
399 "usage_count": 0, "success_count": 0, "failure_count": 0,
400 "anchor_confidence": 1.0
401 }"#;
402 let s: SkillStats = serde_json::from_str(legacy).unwrap();
403 assert_eq!(s.curated_at, None);
404 }
405}