Skip to main content

skillfile_core/
models.rs

1use std::fmt;
2
3use serde::{Deserialize, Serialize};
4
5pub const DEFAULT_REF: &str = "main";
6
7// ---------------------------------------------------------------------------
8// Scope — typed replacement for bare "global"/"local" strings
9// ---------------------------------------------------------------------------
10
11/// Install scope: either the user's global config directory or the local repo.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
13pub enum Scope {
14    Global,
15    Local,
16}
17
18impl Scope {
19    /// All valid scope values, in alphabetical order.
20    pub const ALL: &[Scope] = &[Scope::Global, Scope::Local];
21
22    /// Parse a scope string. Returns `None` for unrecognised values.
23    ///
24    /// ```
25    /// use skillfile_core::models::Scope;
26    /// assert_eq!(Scope::parse("global"), Some(Scope::Global));
27    /// assert_eq!(Scope::parse("local"), Some(Scope::Local));
28    /// assert_eq!(Scope::parse("invalid"), None);
29    /// ```
30    #[must_use]
31    pub fn parse(s: &str) -> Option<Self> {
32        match s {
33            "global" => Some(Scope::Global),
34            "local" => Some(Scope::Local),
35            _ => None,
36        }
37    }
38
39    /// The canonical string representation (used in Skillfile format).
40    #[must_use]
41    pub fn as_str(&self) -> &'static str {
42        match self {
43            Scope::Global => "global",
44            Scope::Local => "local",
45        }
46    }
47}
48
49impl fmt::Display for Scope {
50    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
51        f.write_str(self.as_str())
52    }
53}
54
55// ---------------------------------------------------------------------------
56// EntityType — typed replacement for bare "skill"/"agent" strings
57// ---------------------------------------------------------------------------
58
59/// Entity type: either a skill or an agent.
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
61#[serde(rename_all = "lowercase")]
62pub enum EntityType {
63    Skill,
64    Agent,
65}
66
67impl EntityType {
68    /// All valid entity type values, in alphabetical order.
69    pub const ALL: &[EntityType] = &[EntityType::Agent, EntityType::Skill];
70
71    /// Parse an entity type string. Returns `None` for unrecognised values.
72    ///
73    /// ```
74    /// use skillfile_core::models::EntityType;
75    /// assert_eq!(EntityType::parse("skill"), Some(EntityType::Skill));
76    /// assert_eq!(EntityType::parse("agent"), Some(EntityType::Agent));
77    /// assert_eq!(EntityType::parse("rule"), None);
78    /// ```
79    #[must_use]
80    pub fn parse(s: &str) -> Option<Self> {
81        match s {
82            "skill" => Some(EntityType::Skill),
83            "agent" => Some(EntityType::Agent),
84            _ => None,
85        }
86    }
87
88    /// The canonical string representation (used in Skillfile format).
89    #[must_use]
90    pub fn as_str(&self) -> &'static str {
91        match self {
92            EntityType::Skill => "skill",
93            EntityType::Agent => "agent",
94        }
95    }
96
97    /// Pluralized directory name (e.g. "skills", "agents").
98    #[must_use]
99    pub fn dir_name(&self) -> &'static str {
100        match self {
101            EntityType::Skill => "skills",
102            EntityType::Agent => "agents",
103        }
104    }
105}
106
107impl fmt::Display for EntityType {
108    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
109        f.write_str(self.as_str())
110    }
111}
112
113// ---------------------------------------------------------------------------
114// short_sha — truncate SHA for display
115// ---------------------------------------------------------------------------
116
117/// Return the first 12 characters of a SHA (or the full string if shorter).
118///
119/// ```
120/// assert_eq!(skillfile_core::models::short_sha("abcdef1234567890"), "abcdef123456");
121/// assert_eq!(skillfile_core::models::short_sha("short"), "short");
122/// ```
123#[must_use]
124pub fn short_sha(sha: &str) -> &str {
125    &sha[..sha.len().min(12)]
126}
127
128// ---------------------------------------------------------------------------
129// SourceFields — making illegal states unrepresentable
130// ---------------------------------------------------------------------------
131
132/// Source-specific fields, making illegal states unrepresentable.
133///
134/// Instead of optional fields on Entry (Python's approach), we use an enum
135/// so each variant carries exactly the fields it needs.
136#[derive(Debug, Clone, PartialEq, Eq)]
137pub enum SourceFields {
138    Github {
139        owner_repo: String,
140        path_in_repo: String,
141        ref_: String,
142    },
143    Local {
144        path: String,
145    },
146    Url {
147        url: String,
148    },
149}
150
151impl SourceFields {
152    /// The source type identifier used in the Skillfile format and lock keys.
153    #[must_use]
154    pub fn source_type(&self) -> &str {
155        match self {
156            SourceFields::Github { .. } => "github",
157            SourceFields::Local { .. } => "local",
158            SourceFields::Url { .. } => "url",
159        }
160    }
161
162    /// Access GitHub-specific fields. Returns `None` for non-GitHub sources.
163    #[must_use]
164    pub fn as_github(&self) -> Option<(&str, &str, &str)> {
165        match self {
166            SourceFields::Github {
167                owner_repo,
168                path_in_repo,
169                ref_,
170            } => Some((owner_repo, path_in_repo, ref_)),
171            _ => None,
172        }
173    }
174
175    /// Access the local path. Returns `None` for non-Local sources.
176    #[must_use]
177    pub fn as_local(&self) -> Option<&str> {
178        match self {
179            SourceFields::Local { path } => Some(path),
180            _ => None,
181        }
182    }
183
184    /// Access the URL. Returns `None` for non-Url sources.
185    #[must_use]
186    pub fn as_url(&self) -> Option<&str> {
187        match self {
188            SourceFields::Url { url } => Some(url),
189            _ => None,
190        }
191    }
192}
193
194// ---------------------------------------------------------------------------
195// Entry — a single manifest entry
196// ---------------------------------------------------------------------------
197
198/// A single entry in the Skillfile manifest.
199#[derive(Debug, Clone, PartialEq, Eq)]
200pub struct Entry {
201    pub entity_type: EntityType,
202    pub name: String,
203    pub source: SourceFields,
204}
205
206impl Entry {
207    /// Shorthand: the source type identifier.
208    #[must_use]
209    pub fn source_type(&self) -> &str {
210        self.source.source_type()
211    }
212
213    // Legacy convenience accessors — only used in tests.
214    // Prefer matching on `entry.source` directly.
215
216    #[cfg(test)]
217    pub fn owner_repo(&self) -> &str {
218        self.source.as_github().map_or("", |(or, _, _)| or)
219    }
220
221    #[cfg(test)]
222    pub fn path_in_repo(&self) -> &str {
223        self.source.as_github().map_or("", |(_, pir, _)| pir)
224    }
225
226    #[cfg(test)]
227    pub fn ref_(&self) -> &str {
228        self.source.as_github().map_or("", |(_, _, r)| r)
229    }
230
231    #[cfg(test)]
232    pub fn local_path(&self) -> &str {
233        self.source.as_local().unwrap_or("")
234    }
235
236    #[cfg(test)]
237    pub fn url(&self) -> &str {
238        self.source.as_url().unwrap_or("")
239    }
240}
241
242impl fmt::Display for Entry {
243    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
244        write!(
245            f,
246            "{}/{}/{}",
247            self.source_type(),
248            self.entity_type,
249            self.name
250        )
251    }
252}
253
254// ---------------------------------------------------------------------------
255// InstallTarget
256// ---------------------------------------------------------------------------
257
258/// An install target line from the Skillfile: `install <adapter> <scope>`.
259#[derive(Debug, Clone, PartialEq, Eq)]
260pub struct InstallTarget {
261    pub adapter: String,
262    pub scope: Scope,
263}
264
265impl fmt::Display for InstallTarget {
266    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
267        write!(f, "{} ({})", self.adapter, self.scope)
268    }
269}
270
271// ---------------------------------------------------------------------------
272// LockEntry
273// ---------------------------------------------------------------------------
274
275/// A lock file entry recording resolved SHA and download URL.
276#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
277pub struct LockEntry {
278    pub sha: String,
279    pub raw_url: String,
280}
281
282// ---------------------------------------------------------------------------
283// Manifest
284// ---------------------------------------------------------------------------
285
286/// The fully parsed Skillfile manifest.
287#[derive(Debug, Clone, Default)]
288pub struct Manifest {
289    pub entries: Vec<Entry>,
290    pub install_targets: Vec<InstallTarget>,
291}
292
293// ---------------------------------------------------------------------------
294// InstallOptions
295// ---------------------------------------------------------------------------
296
297/// Options controlling install behavior.
298#[derive(Debug, Clone)]
299pub struct InstallOptions {
300    pub dry_run: bool,
301    pub overwrite: bool,
302}
303
304impl Default for InstallOptions {
305    fn default() -> Self {
306        Self {
307            dry_run: false,
308            overwrite: true,
309        }
310    }
311}
312
313// ---------------------------------------------------------------------------
314// ConflictState
315// ---------------------------------------------------------------------------
316
317/// Conflict state persisted in `.skillfile/conflict`.
318#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
319pub struct ConflictState {
320    pub entry: String,
321    pub entity_type: EntityType,
322    pub old_sha: String,
323    pub new_sha: String,
324}
325
326// ---------------------------------------------------------------------------
327// Tests
328// ---------------------------------------------------------------------------
329
330#[cfg(test)]
331mod tests {
332    use super::*;
333
334    #[test]
335    fn scope_parse_and_display() {
336        assert_eq!(Scope::parse("global"), Some(Scope::Global));
337        assert_eq!(Scope::parse("local"), Some(Scope::Local));
338        assert_eq!(Scope::parse("worldwide"), None);
339        assert_eq!(Scope::Global.to_string(), "global");
340        assert_eq!(Scope::Local.to_string(), "local");
341        assert_eq!(Scope::Global.as_str(), "global");
342    }
343
344    #[test]
345    fn scope_all_variants() {
346        assert_eq!(Scope::ALL.len(), 2);
347        assert!(Scope::ALL.contains(&Scope::Global));
348        assert!(Scope::ALL.contains(&Scope::Local));
349    }
350
351    #[test]
352    fn entity_type_parse_and_display() {
353        assert_eq!(EntityType::parse("skill"), Some(EntityType::Skill));
354        assert_eq!(EntityType::parse("agent"), Some(EntityType::Agent));
355        assert_eq!(EntityType::parse("hook"), None);
356        assert_eq!(EntityType::Skill.to_string(), "skill");
357        assert_eq!(EntityType::Agent.to_string(), "agent");
358        assert_eq!(EntityType::Skill.as_str(), "skill");
359        assert_eq!(EntityType::Agent.as_str(), "agent");
360    }
361
362    #[test]
363    fn entity_type_dir_name() {
364        assert_eq!(EntityType::Skill.dir_name(), "skills");
365        assert_eq!(EntityType::Agent.dir_name(), "agents");
366    }
367
368    #[test]
369    fn entity_type_all_variants() {
370        assert_eq!(EntityType::ALL.len(), 2);
371        assert!(EntityType::ALL.contains(&EntityType::Skill));
372        assert!(EntityType::ALL.contains(&EntityType::Agent));
373    }
374
375    #[test]
376    fn short_sha_truncates() {
377        let sha = "abcdef123456789012345678";
378        assert_eq!(short_sha(sha), "abcdef123456");
379    }
380
381    #[test]
382    fn short_sha_short_input() {
383        assert_eq!(short_sha("abc"), "abc");
384    }
385
386    #[test]
387    fn source_fields_typed_accessors() {
388        let gh = SourceFields::Github {
389            owner_repo: "o/r".into(),
390            path_in_repo: "a.md".into(),
391            ref_: "main".into(),
392        };
393        assert_eq!(gh.as_github(), Some(("o/r", "a.md", "main")));
394        assert_eq!(gh.as_local(), None);
395        assert_eq!(gh.as_url(), None);
396
397        let local = SourceFields::Local {
398            path: "test.md".into(),
399        };
400        assert_eq!(local.as_local(), Some("test.md"));
401        assert_eq!(local.as_github(), None);
402
403        let url = SourceFields::Url {
404            url: "https://x.com/s.md".into(),
405        };
406        assert_eq!(url.as_url(), Some("https://x.com/s.md"));
407        assert_eq!(url.as_github(), None);
408    }
409
410    #[test]
411    fn github_entry_source_type() {
412        let e = Entry {
413            entity_type: EntityType::Agent,
414            name: "test".into(),
415            source: SourceFields::Github {
416                owner_repo: "o/r".into(),
417                path_in_repo: "a.md".into(),
418                ref_: "main".into(),
419            },
420        };
421        assert_eq!(e.source_type(), "github");
422        assert_eq!(e.entity_type, EntityType::Agent);
423        assert_eq!(e.name, "test");
424        assert_eq!(e.owner_repo(), "o/r");
425        assert_eq!(e.path_in_repo(), "a.md");
426        assert_eq!(e.ref_(), "main");
427        assert_eq!(e.local_path(), "");
428        assert_eq!(e.url(), "");
429    }
430
431    #[test]
432    fn github_entry_fields() {
433        let e = Entry {
434            entity_type: EntityType::Skill,
435            name: "my-skill".into(),
436            source: SourceFields::Github {
437                owner_repo: "o/r".into(),
438                path_in_repo: "skills/s.md".into(),
439                ref_: "v1".into(),
440            },
441        };
442        assert_eq!(e.owner_repo(), "o/r");
443        assert_eq!(e.path_in_repo(), "skills/s.md");
444        assert_eq!(e.ref_(), "v1");
445    }
446
447    #[test]
448    fn local_entry_fields() {
449        let e = Entry {
450            entity_type: EntityType::Skill,
451            name: "test".into(),
452            source: SourceFields::Local {
453                path: "test.md".into(),
454            },
455        };
456        assert_eq!(e.source_type(), "local");
457        assert_eq!(e.local_path(), "test.md");
458        assert_eq!(e.owner_repo(), "");
459        assert_eq!(e.url(), "");
460    }
461
462    #[test]
463    fn url_entry_fields() {
464        let e = Entry {
465            entity_type: EntityType::Skill,
466            name: "my-skill".into(),
467            source: SourceFields::Url {
468                url: "https://example.com/skill.md".into(),
469            },
470        };
471        assert_eq!(e.source_type(), "url");
472        assert_eq!(e.url(), "https://example.com/skill.md");
473        assert_eq!(e.owner_repo(), "");
474    }
475
476    #[test]
477    fn entry_display() {
478        let e = Entry {
479            entity_type: EntityType::Agent,
480            name: "test".into(),
481            source: SourceFields::Github {
482                owner_repo: "o/r".into(),
483                path_in_repo: "a.md".into(),
484                ref_: "main".into(),
485            },
486        };
487        assert_eq!(e.to_string(), "github/agent/test");
488    }
489
490    #[test]
491    fn lock_entry() {
492        let le = LockEntry {
493            sha: "abc123".into(),
494            raw_url: "https://example.com".into(),
495        };
496        assert_eq!(le.sha, "abc123");
497        assert_eq!(le.raw_url, "https://example.com");
498    }
499
500    #[test]
501    fn install_target_with_scope_enum() {
502        let t = InstallTarget {
503            adapter: "claude-code".into(),
504            scope: Scope::Global,
505        };
506        assert_eq!(t.adapter, "claude-code");
507        assert_eq!(t.scope, Scope::Global);
508        assert_eq!(t.to_string(), "claude-code (global)");
509    }
510
511    #[test]
512    fn manifest_defaults() {
513        let m = Manifest::default();
514        assert!(m.entries.is_empty());
515        assert!(m.install_targets.is_empty());
516    }
517
518    #[test]
519    fn manifest_with_entries() {
520        let e = Entry {
521            entity_type: EntityType::Skill,
522            name: "test".into(),
523            source: SourceFields::Local {
524                path: "test.md".into(),
525            },
526        };
527        let t = InstallTarget {
528            adapter: "claude-code".into(),
529            scope: Scope::Local,
530        };
531        let m = Manifest {
532            entries: vec![e],
533            install_targets: vec![t],
534        };
535        assert_eq!(m.entries.len(), 1);
536        assert_eq!(m.install_targets.len(), 1);
537    }
538
539    #[test]
540    fn conflict_state_equality() {
541        let a = ConflictState {
542            entry: "foo".into(),
543            entity_type: EntityType::Agent,
544            old_sha: "aaa".into(),
545            new_sha: "bbb".into(),
546        };
547        let b = a.clone();
548        assert_eq!(a, b);
549    }
550}