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