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    Local {
140        path: String,
141    },
142    Url {
143        url: String,
144    },
145}
146
147impl SourceFields {
148    /// The source type identifier used in the Skillfile format and lock keys.
149    #[must_use]
150    pub fn source_type(&self) -> &str {
151        match self {
152            SourceFields::Github { .. } => "github",
153            SourceFields::Local { .. } => "local",
154            SourceFields::Url { .. } => "url",
155        }
156    }
157
158    #[must_use]
159    pub fn as_github(&self) -> Option<(&str, &str, &str)> {
160        match self {
161            SourceFields::Github {
162                owner_repo,
163                path_in_repo,
164                ref_,
165            } => Some((owner_repo, path_in_repo, ref_)),
166            _ => None,
167        }
168    }
169
170    #[must_use]
171    pub fn as_local(&self) -> Option<&str> {
172        match self {
173            SourceFields::Local { path } => Some(path),
174            _ => None,
175        }
176    }
177
178    #[must_use]
179    pub fn as_url(&self) -> Option<&str> {
180        match self {
181            SourceFields::Url { url } => Some(url),
182            _ => None,
183        }
184    }
185}
186
187// ---------------------------------------------------------------------------
188// Entry — a single manifest entry
189// ---------------------------------------------------------------------------
190
191#[derive(Debug, Clone, PartialEq, Eq)]
192pub struct Entry {
193    pub entity_type: EntityType,
194    pub name: String,
195    pub source: SourceFields,
196}
197
198impl Entry {
199    #[must_use]
200    pub fn source_type(&self) -> &str {
201        self.source.source_type()
202    }
203
204    // Legacy convenience accessors — only used in tests.
205    // Prefer matching on `entry.source` directly.
206
207    #[cfg(test)]
208    pub fn owner_repo(&self) -> &str {
209        self.source.as_github().map_or("", |(or, _, _)| or)
210    }
211
212    #[cfg(test)]
213    pub fn path_in_repo(&self) -> &str {
214        self.source.as_github().map_or("", |(_, pir, _)| pir)
215    }
216
217    #[cfg(test)]
218    pub fn ref_(&self) -> &str {
219        self.source.as_github().map_or("", |(_, _, r)| r)
220    }
221
222    #[cfg(test)]
223    pub fn local_path(&self) -> &str {
224        self.source.as_local().unwrap_or("")
225    }
226
227    #[cfg(test)]
228    pub fn url(&self) -> &str {
229        self.source.as_url().unwrap_or("")
230    }
231}
232
233impl fmt::Display for Entry {
234    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
235        write!(
236            f,
237            "{}/{}/{}",
238            self.source_type(),
239            self.entity_type,
240            self.name
241        )
242    }
243}
244
245// ---------------------------------------------------------------------------
246// InstallTarget
247// ---------------------------------------------------------------------------
248
249/// An install target line from the Skillfile: `install <adapter> <scope>`.
250#[derive(Debug, Clone, PartialEq, Eq)]
251pub struct InstallTarget {
252    pub adapter: String,
253    pub scope: Scope,
254}
255
256impl fmt::Display for InstallTarget {
257    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
258        write!(f, "{} ({})", self.adapter, self.scope)
259    }
260}
261
262// ---------------------------------------------------------------------------
263// LockEntry
264// ---------------------------------------------------------------------------
265
266#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
267pub struct LockEntry {
268    pub sha: String,
269    pub raw_url: String,
270}
271
272// ---------------------------------------------------------------------------
273// Manifest
274// ---------------------------------------------------------------------------
275
276#[derive(Debug, Clone, Default)]
277pub struct Manifest {
278    pub entries: Vec<Entry>,
279    pub install_targets: Vec<InstallTarget>,
280}
281
282// ---------------------------------------------------------------------------
283// InstallOptions
284// ---------------------------------------------------------------------------
285
286#[derive(Debug, Clone)]
287pub struct InstallOptions {
288    pub dry_run: bool,
289    pub overwrite: bool,
290}
291
292impl Default for InstallOptions {
293    fn default() -> Self {
294        Self {
295            dry_run: false,
296            overwrite: true,
297        }
298    }
299}
300
301// ---------------------------------------------------------------------------
302// ConflictState
303// ---------------------------------------------------------------------------
304
305/// Conflict state persisted in `.skillfile/conflict`.
306#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
307pub struct ConflictState {
308    pub entry: String,
309    pub entity_type: EntityType,
310    pub old_sha: String,
311    pub new_sha: String,
312}
313
314// ---------------------------------------------------------------------------
315// Tests
316// ---------------------------------------------------------------------------
317
318#[cfg(test)]
319mod tests {
320    use super::*;
321
322    #[test]
323    fn scope_parse_and_display() {
324        assert_eq!(Scope::parse("global"), Some(Scope::Global));
325        assert_eq!(Scope::parse("local"), Some(Scope::Local));
326        assert_eq!(Scope::parse("worldwide"), None);
327        assert_eq!(Scope::Global.to_string(), "global");
328        assert_eq!(Scope::Local.to_string(), "local");
329        assert_eq!(Scope::Global.as_str(), "global");
330    }
331
332    #[test]
333    fn scope_all_variants() {
334        assert_eq!(Scope::ALL.len(), 2);
335        assert!(Scope::ALL.contains(&Scope::Global));
336        assert!(Scope::ALL.contains(&Scope::Local));
337    }
338
339    #[test]
340    fn entity_type_parse_and_display() {
341        assert_eq!(EntityType::parse("skill"), Some(EntityType::Skill));
342        assert_eq!(EntityType::parse("agent"), Some(EntityType::Agent));
343        assert_eq!(EntityType::parse("hook"), None);
344        assert_eq!(EntityType::Skill.to_string(), "skill");
345        assert_eq!(EntityType::Agent.to_string(), "agent");
346        assert_eq!(EntityType::Skill.as_str(), "skill");
347        assert_eq!(EntityType::Agent.as_str(), "agent");
348    }
349
350    #[test]
351    fn entity_type_dir_name() {
352        assert_eq!(EntityType::Skill.dir_name(), "skills");
353        assert_eq!(EntityType::Agent.dir_name(), "agents");
354    }
355
356    #[test]
357    fn entity_type_all_variants() {
358        assert_eq!(EntityType::ALL.len(), 2);
359        assert!(EntityType::ALL.contains(&EntityType::Skill));
360        assert!(EntityType::ALL.contains(&EntityType::Agent));
361    }
362
363    #[test]
364    fn short_sha_truncates() {
365        let sha = "abcdef123456789012345678";
366        assert_eq!(short_sha(sha), "abcdef123456");
367    }
368
369    #[test]
370    fn short_sha_short_input() {
371        assert_eq!(short_sha("abc"), "abc");
372    }
373
374    #[test]
375    fn source_fields_typed_accessors() {
376        let gh = SourceFields::Github {
377            owner_repo: "o/r".into(),
378            path_in_repo: "a.md".into(),
379            ref_: "main".into(),
380        };
381        assert_eq!(gh.as_github(), Some(("o/r", "a.md", "main")));
382        assert_eq!(gh.as_local(), None);
383        assert_eq!(gh.as_url(), None);
384
385        let local = SourceFields::Local {
386            path: "test.md".into(),
387        };
388        assert_eq!(local.as_local(), Some("test.md"));
389        assert_eq!(local.as_github(), None);
390
391        let url = SourceFields::Url {
392            url: "https://x.com/s.md".into(),
393        };
394        assert_eq!(url.as_url(), Some("https://x.com/s.md"));
395        assert_eq!(url.as_github(), None);
396    }
397
398    #[test]
399    fn github_entry_source_type() {
400        let e = Entry {
401            entity_type: EntityType::Agent,
402            name: "test".into(),
403            source: SourceFields::Github {
404                owner_repo: "o/r".into(),
405                path_in_repo: "a.md".into(),
406                ref_: "main".into(),
407            },
408        };
409        assert_eq!(e.source_type(), "github");
410        assert_eq!(e.entity_type, EntityType::Agent);
411        assert_eq!(e.name, "test");
412        assert_eq!(e.owner_repo(), "o/r");
413        assert_eq!(e.path_in_repo(), "a.md");
414        assert_eq!(e.ref_(), "main");
415        assert_eq!(e.local_path(), "");
416        assert_eq!(e.url(), "");
417    }
418
419    #[test]
420    fn github_entry_fields() {
421        let e = Entry {
422            entity_type: EntityType::Skill,
423            name: "my-skill".into(),
424            source: SourceFields::Github {
425                owner_repo: "o/r".into(),
426                path_in_repo: "skills/s.md".into(),
427                ref_: "v1".into(),
428            },
429        };
430        assert_eq!(e.owner_repo(), "o/r");
431        assert_eq!(e.path_in_repo(), "skills/s.md");
432        assert_eq!(e.ref_(), "v1");
433    }
434
435    #[test]
436    fn local_entry_fields() {
437        let e = Entry {
438            entity_type: EntityType::Skill,
439            name: "test".into(),
440            source: SourceFields::Local {
441                path: "test.md".into(),
442            },
443        };
444        assert_eq!(e.source_type(), "local");
445        assert_eq!(e.local_path(), "test.md");
446        assert_eq!(e.owner_repo(), "");
447        assert_eq!(e.url(), "");
448    }
449
450    #[test]
451    fn url_entry_fields() {
452        let e = Entry {
453            entity_type: EntityType::Skill,
454            name: "my-skill".into(),
455            source: SourceFields::Url {
456                url: "https://example.com/skill.md".into(),
457            },
458        };
459        assert_eq!(e.source_type(), "url");
460        assert_eq!(e.url(), "https://example.com/skill.md");
461        assert_eq!(e.owner_repo(), "");
462    }
463
464    #[test]
465    fn entry_display() {
466        let e = Entry {
467            entity_type: EntityType::Agent,
468            name: "test".into(),
469            source: SourceFields::Github {
470                owner_repo: "o/r".into(),
471                path_in_repo: "a.md".into(),
472                ref_: "main".into(),
473            },
474        };
475        assert_eq!(e.to_string(), "github/agent/test");
476    }
477
478    #[test]
479    fn lock_entry() {
480        let le = LockEntry {
481            sha: "abc123".into(),
482            raw_url: "https://example.com".into(),
483        };
484        assert_eq!(le.sha, "abc123");
485        assert_eq!(le.raw_url, "https://example.com");
486    }
487
488    #[test]
489    fn install_target_with_scope_enum() {
490        let t = InstallTarget {
491            adapter: "claude-code".into(),
492            scope: Scope::Global,
493        };
494        assert_eq!(t.adapter, "claude-code");
495        assert_eq!(t.scope, Scope::Global);
496        assert_eq!(t.to_string(), "claude-code (global)");
497    }
498
499    #[test]
500    fn manifest_defaults() {
501        let m = Manifest::default();
502        assert!(m.entries.is_empty());
503        assert!(m.install_targets.is_empty());
504    }
505
506    #[test]
507    fn manifest_with_entries() {
508        let e = Entry {
509            entity_type: EntityType::Skill,
510            name: "test".into(),
511            source: SourceFields::Local {
512                path: "test.md".into(),
513            },
514        };
515        let t = InstallTarget {
516            adapter: "claude-code".into(),
517            scope: Scope::Local,
518        };
519        let m = Manifest {
520            entries: vec![e],
521            install_targets: vec![t],
522        };
523        assert_eq!(m.entries.len(), 1);
524        assert_eq!(m.install_targets.len(), 1);
525    }
526
527    #[test]
528    fn conflict_state_equality() {
529        let a = ConflictState {
530            entry: "foo".into(),
531            entity_type: EntityType::Agent,
532            old_sha: "aaa".into(),
533            new_sha: "bbb".into(),
534        };
535        let b = a.clone();
536        assert_eq!(a, b);
537    }
538}