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