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 path2 = path.clone();
299
300 SkillStats::merge_in_place(&path, || dummy_stats(&skill_name), |_| Ok(())).unwrap();
302
303 let t1 = thread::spawn(move || {
304 SkillStats::merge_in_place(
305 &path,
306 || panic!("default should not be called"),
307 |s| {
308 s.usage_count += 1;
309 Ok(())
310 },
311 )
312 .unwrap();
313 });
314 let t2 = thread::spawn(move || {
315 SkillStats::merge_in_place(
316 &path2,
317 || panic!("default should not be called"),
318 |s| {
319 s.usage_count += 2;
320 Ok(())
321 },
322 )
323 .unwrap();
324 });
325
326 t1.join().unwrap();
327 t2.join().unwrap();
328
329 let loaded = SkillStats::load(&_dir.path().join("test_skill").join("stats.json"))
330 .unwrap()
331 .unwrap();
332 assert_eq!(loaded.usage_count, 3);
334 }
335
336 #[test]
337 fn is_stale_detects_digest_mismatch() {
338 let stats = dummy_stats("test");
339 assert!(!stats.is_stale("abc123"));
340 assert!(stats.is_stale("different"));
341 }
342
343 #[test]
344 fn schema_version_1_deserialises_fixture() {
345 let fixture = r#"{
346 "schema_version": 1,
347 "skill_name": "research-patterns",
348 "skill_version": "2.3.0",
349 "manifest_digest": "abcdef",
350 "lifecycle_state": "emerging",
351 "lifecycle_changed_at": "2026-05-25T00:00:00Z",
352 "pinned": false,
353 "pinned_reason": "",
354 "usage_count": 42,
355 "success_count": 38,
356 "failure_count": 4,
357 "last_used_at": "2026-05-25T12:00:00Z",
358 "last_success_at": "2026-05-25T11:00:00Z",
359 "first_successful_use_at": "2026-05-01T00:00:00Z",
360 "anchor_confidence": 0.95,
361 "rebuilt_from_trace_through": "2026-05-25T10:00:00Z"
362 }"#;
363 let stats: SkillStats = serde_json::from_str(fixture).unwrap();
364 assert_eq!(stats.schema_version, 1);
365 assert_eq!(stats.lifecycle_state, LifecycleState::Emerging);
366 assert_eq!(stats.usage_count, 42);
367 assert_eq!(stats.anchor_confidence, 0.95);
368 assert!(stats.last_used_at.is_some());
369 }
370
371 #[test]
372 fn reset_for_new_manifest_preserves_pinned_and_state() {
373 let mut stats = SkillStats {
374 pinned: true,
375 pinned_reason: "critical".into(),
376 lifecycle_state: LifecycleState::Canonical,
377 first_successful_use_at: Some(Utc::now()),
378 usage_count: 100,
379 success_count: 95,
380 failure_count: 5,
381 ..dummy_stats("test")
382 };
383 stats.reset_for_new_manifest("2.0.0", "newdigest", Utc::now());
384 assert_eq!(stats.skill_version, "2.0.0");
385 assert_eq!(stats.usage_count, 0);
386 assert!(stats.pinned);
387 assert_eq!(stats.lifecycle_state, LifecycleState::Canonical);
388 assert!(stats.first_successful_use_at.is_some());
389 }
390
391 #[test]
392 fn curated_at_defaults_to_none_and_is_backward_compatible() {
393 let legacy = r#"{
395 "schema_version": 1, "skill_name": "x", "skill_version": "1",
396 "manifest_digest": "d", "lifecycle_state": "draft",
397 "lifecycle_changed_at": "2026-01-01T00:00:00Z", "pinned": false,
398 "usage_count": 0, "success_count": 0, "failure_count": 0,
399 "anchor_confidence": 1.0
400 }"#;
401 let s: SkillStats = serde_json::from_str(legacy).unwrap();
402 assert_eq!(s.curated_at, None);
403 }
404}