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