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.
268#[derive(Debug, Clone, PartialEq, Eq)]
269pub enum InstallTarget {
270    Platform {
271        adapter: String,
272        scope: Scope,
273    },
274    Path {
275        tool_name: String,
276        entity_type: EntityType,
277        path: String,
278    },
279}
280
281impl InstallTarget {
282    #[must_use]
283    pub fn platform(adapter: impl Into<String>, scope: Scope) -> Self {
284        Self::Platform {
285            adapter: adapter.into(),
286            scope,
287        }
288    }
289
290    #[must_use]
291    pub fn path(
292        tool_name: impl Into<String>,
293        entity_type: EntityType,
294        path: impl Into<String>,
295    ) -> Self {
296        Self::Path {
297            tool_name: tool_name.into(),
298            entity_type,
299            path: path.into(),
300        }
301    }
302
303    #[must_use]
304    pub fn platform_name(&self) -> &str {
305        match self {
306            Self::Platform { adapter, .. } => adapter,
307            Self::Path { tool_name, .. } => tool_name,
308        }
309    }
310
311    #[must_use]
312    pub fn scope(&self) -> Option<Scope> {
313        match self {
314            Self::Platform { scope, .. } => Some(*scope),
315            Self::Path { .. } => None,
316        }
317    }
318
319    #[must_use]
320    pub fn entity_type(&self) -> Option<EntityType> {
321        match self {
322            Self::Platform { .. } => None,
323            Self::Path { entity_type, .. } => Some(*entity_type),
324        }
325    }
326
327    #[must_use]
328    pub fn path_str(&self) -> Option<&str> {
329        match self {
330            Self::Platform { .. } => None,
331            Self::Path { path, .. } => Some(path),
332        }
333    }
334
335    #[must_use]
336    pub fn manifest_line(&self) -> String {
337        match self {
338            Self::Platform { adapter, scope } => format!("install  {adapter}  {scope}"),
339            Self::Path {
340                tool_name,
341                entity_type,
342                path,
343            } => format!(
344                "install-path  {}  {entity_type}  {}",
345                quote_manifest_field(tool_name),
346                quote_manifest_field(path)
347            ),
348        }
349    }
350}
351
352fn quote_manifest_field(value: &str) -> String {
353    match shlex::try_quote(value) {
354        Ok(quoted) => quoted.into_owned(),
355        Err(_) => value.to_string(),
356    }
357}
358
359impl fmt::Display for InstallTarget {
360    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
361        match self {
362            Self::Platform { adapter, scope } => write!(f, "{adapter} ({scope})"),
363            Self::Path {
364                tool_name,
365                entity_type,
366                path,
367            } => write!(f, "{tool_name} {entity_type} ({path})"),
368        }
369    }
370}
371
372// ---------------------------------------------------------------------------
373// LockEntry
374// ---------------------------------------------------------------------------
375
376#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
377pub struct LockEntry {
378    pub sha: String,
379    pub raw_url: String,
380}
381
382// ---------------------------------------------------------------------------
383// Manifest
384// ---------------------------------------------------------------------------
385
386#[derive(Debug, Clone, Default)]
387pub struct Manifest {
388    pub entries: Vec<Entry>,
389    pub install_targets: Vec<InstallTarget>,
390}
391
392// ---------------------------------------------------------------------------
393// InstallOptions
394// ---------------------------------------------------------------------------
395
396#[derive(Debug, Clone)]
397pub struct InstallOptions {
398    pub dry_run: bool,
399    pub overwrite: bool,
400}
401
402impl Default for InstallOptions {
403    fn default() -> Self {
404        Self {
405            dry_run: false,
406            overwrite: true,
407        }
408    }
409}
410
411// ---------------------------------------------------------------------------
412// ConflictState
413// ---------------------------------------------------------------------------
414
415/// Conflict state persisted in `.skillfile/conflict`.
416#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
417pub struct ConflictState {
418    pub entry: String,
419    pub entity_type: EntityType,
420    pub old_sha: String,
421    pub new_sha: String,
422}
423
424// ---------------------------------------------------------------------------
425// Tests
426// ---------------------------------------------------------------------------
427
428#[cfg(test)]
429mod tests {
430    use super::*;
431
432    #[test]
433    fn scope_parse_and_display() {
434        assert_eq!(Scope::parse("global"), Some(Scope::Global));
435        assert_eq!(Scope::parse("local"), Some(Scope::Local));
436        assert_eq!(Scope::parse("worldwide"), None);
437        assert_eq!(Scope::Global.to_string(), "global");
438        assert_eq!(Scope::Local.to_string(), "local");
439        assert_eq!(Scope::Global.as_str(), "global");
440    }
441
442    #[test]
443    fn scope_all_variants() {
444        assert_eq!(Scope::ALL.len(), 2);
445        assert!(Scope::ALL.contains(&Scope::Global));
446        assert!(Scope::ALL.contains(&Scope::Local));
447    }
448
449    #[test]
450    fn entity_type_parse_and_display() {
451        assert_eq!(EntityType::parse("skill"), Some(EntityType::Skill));
452        assert_eq!(EntityType::parse("agent"), Some(EntityType::Agent));
453        assert_eq!(EntityType::parse("hook"), None);
454        assert_eq!(EntityType::Skill.to_string(), "skill");
455        assert_eq!(EntityType::Agent.to_string(), "agent");
456        assert_eq!(EntityType::Skill.as_str(), "skill");
457        assert_eq!(EntityType::Agent.as_str(), "agent");
458    }
459
460    #[test]
461    fn entity_type_dir_name() {
462        assert_eq!(EntityType::Skill.dir_name(), "skills");
463        assert_eq!(EntityType::Agent.dir_name(), "agents");
464    }
465
466    #[test]
467    fn entity_type_all_variants() {
468        assert_eq!(EntityType::ALL.len(), 2);
469        assert!(EntityType::ALL.contains(&EntityType::Skill));
470        assert!(EntityType::ALL.contains(&EntityType::Agent));
471    }
472
473    #[test]
474    fn short_sha_truncates() {
475        let sha = "abcdef123456789012345678";
476        assert_eq!(short_sha(sha), "abcdef123456");
477    }
478
479    #[test]
480    fn short_sha_short_input() {
481        assert_eq!(short_sha("abc"), "abc");
482    }
483
484    #[test]
485    fn source_fields_typed_accessors() {
486        let gh = SourceFields::Github {
487            owner_repo: "o/r".into(),
488            path_in_repo: "a.md".into(),
489            ref_: "main".into(),
490        };
491        assert_eq!(gh.as_github(), Some(("o/r", "a.md", "main")));
492        assert_eq!(gh.as_local(), None);
493        assert_eq!(gh.as_url(), None);
494
495        let local = SourceFields::Local {
496            path: "test.md".into(),
497        };
498        assert_eq!(local.as_local(), Some("test.md"));
499        assert_eq!(local.as_github(), None);
500
501        let url = SourceFields::Url {
502            url: "https://x.com/s.md".into(),
503        };
504        assert_eq!(url.as_url(), Some("https://x.com/s.md"));
505        assert_eq!(url.as_github(), None);
506    }
507
508    #[test]
509    fn github_entry_source_type() {
510        let e = Entry {
511            entity_type: EntityType::Agent,
512            name: "test".into(),
513            source: SourceFields::Github {
514                owner_repo: "o/r".into(),
515                path_in_repo: "a.md".into(),
516                ref_: "main".into(),
517            },
518        };
519        assert_eq!(e.source_type(), "github");
520        assert_eq!(e.entity_type, EntityType::Agent);
521        assert_eq!(e.name, "test");
522        assert_eq!(e.owner_repo(), "o/r");
523        assert_eq!(e.path_in_repo(), "a.md");
524        assert_eq!(e.ref_(), "main");
525        assert_eq!(e.local_path(), "");
526        assert_eq!(e.url(), "");
527    }
528
529    #[test]
530    fn github_entry_fields() {
531        let e = Entry {
532            entity_type: EntityType::Skill,
533            name: "my-skill".into(),
534            source: SourceFields::Github {
535                owner_repo: "o/r".into(),
536                path_in_repo: "skills/s.md".into(),
537                ref_: "v1".into(),
538            },
539        };
540        assert_eq!(e.owner_repo(), "o/r");
541        assert_eq!(e.path_in_repo(), "skills/s.md");
542        assert_eq!(e.ref_(), "v1");
543    }
544
545    #[test]
546    fn local_entry_fields() {
547        let e = Entry {
548            entity_type: EntityType::Skill,
549            name: "test".into(),
550            source: SourceFields::Local {
551                path: "test.md".into(),
552            },
553        };
554        assert_eq!(e.source_type(), "local");
555        assert_eq!(e.local_path(), "test.md");
556        assert_eq!(e.owner_repo(), "");
557        assert_eq!(e.url(), "");
558    }
559
560    #[test]
561    fn url_entry_fields() {
562        let e = Entry {
563            entity_type: EntityType::Skill,
564            name: "my-skill".into(),
565            source: SourceFields::Url {
566                url: "https://example.com/skill.md".into(),
567            },
568        };
569        assert_eq!(e.source_type(), "url");
570        assert_eq!(e.url(), "https://example.com/skill.md");
571        assert_eq!(e.owner_repo(), "");
572    }
573
574    #[test]
575    fn entry_display() {
576        let e = Entry {
577            entity_type: EntityType::Agent,
578            name: "test".into(),
579            source: SourceFields::Github {
580                owner_repo: "o/r".into(),
581                path_in_repo: "a.md".into(),
582                ref_: "main".into(),
583            },
584        };
585        assert_eq!(e.to_string(), "github/agent/test");
586    }
587
588    #[test]
589    fn lock_entry() {
590        let le = LockEntry {
591            sha: "abc123".into(),
592            raw_url: "https://example.com".into(),
593        };
594        assert_eq!(le.sha, "abc123");
595        assert_eq!(le.raw_url, "https://example.com");
596    }
597
598    #[test]
599    fn install_target_with_scope_enum() {
600        let t = InstallTarget::platform("claude-code", Scope::Global);
601        assert_eq!(t.platform_name(), "claude-code");
602        assert_eq!(t.scope(), Some(Scope::Global));
603        assert_eq!(t.to_string(), "claude-code (global)");
604    }
605
606    #[test]
607    fn install_path_target_fields() {
608        let t = InstallTarget::path("openclaw", EntityType::Skill, "~/.openclaw/skills");
609        assert_eq!(t.platform_name(), "openclaw");
610        assert_eq!(t.entity_type(), Some(EntityType::Skill));
611        assert_eq!(t.path_str(), Some("~/.openclaw/skills"));
612        assert_eq!(t.to_string(), "openclaw skill (~/.openclaw/skills)");
613    }
614
615    #[test]
616    fn install_path_manifest_line_quotes_path_when_needed() {
617        let t = InstallTarget::path("openclaw", EntityType::Skill, "./custom skills");
618        assert_eq!(
619            t.manifest_line(),
620            "install-path  openclaw  skill  './custom skills'"
621        );
622    }
623
624    #[test]
625    fn install_path_manifest_line_quotes_tool_name_when_needed() {
626        let t = InstallTarget::path("my tool", EntityType::Skill, "./out");
627        assert_eq!(t.manifest_line(), "install-path  'my tool'  skill  ./out");
628    }
629
630    #[test]
631    fn install_path_manifest_line_quotes_hash_path() {
632        let t = InstallTarget::path("openclaw", EntityType::Skill, "./skills#custom");
633        assert_eq!(
634            t.manifest_line(),
635            "install-path  openclaw  skill  './skills#custom'"
636        );
637    }
638
639    #[test]
640    fn install_path_manifest_line_quotes_embedded_double_quote() {
641        let t = InstallTarget::path("openclaw", EntityType::Skill, "./custom \"skills\"");
642        assert_eq!(
643            t.manifest_line(),
644            "install-path  openclaw  skill  './custom \"skills\"'"
645        );
646    }
647
648    #[test]
649    fn manifest_defaults() {
650        let m = Manifest::default();
651        assert!(m.entries.is_empty());
652        assert!(m.install_targets.is_empty());
653    }
654
655    #[test]
656    fn manifest_with_entries() {
657        let e = Entry {
658            entity_type: EntityType::Skill,
659            name: "test".into(),
660            source: SourceFields::Local {
661                path: "test.md".into(),
662            },
663        };
664        let t = InstallTarget::platform("claude-code", Scope::Local);
665        let m = Manifest {
666            entries: vec![e],
667            install_targets: vec![t],
668        };
669        assert_eq!(m.entries.len(), 1);
670        assert_eq!(m.install_targets.len(), 1);
671    }
672
673    #[test]
674    fn source_fields_gitlab_accessors() {
675        let gl = SourceFields::Gitlab {
676            owner_repo: "group/project".into(),
677            path_in_repo: "skills/my-skill.md".into(),
678            ref_: "main".into(),
679        };
680        assert_eq!(gl.source_type(), "gitlab");
681        assert_eq!(
682            gl.as_gitlab(),
683            Some(("group/project", "skills/my-skill.md", "main"))
684        );
685        assert_eq!(gl.as_github(), None);
686        assert_eq!(gl.as_local(), None);
687        assert_eq!(gl.as_url(), None);
688    }
689
690    #[test]
691    fn gitlab_entry_source_type() {
692        let e = Entry {
693            entity_type: EntityType::Agent,
694            name: "test".into(),
695            source: SourceFields::Gitlab {
696                owner_repo: "g/p".into(),
697                path_in_repo: "a.md".into(),
698                ref_: "main".into(),
699            },
700        };
701        assert_eq!(e.source_type(), "gitlab");
702        assert_eq!(e.to_string(), "gitlab/agent/test");
703    }
704
705    #[test]
706    fn conflict_state_equality() {
707        let a = ConflictState {
708            entry: "foo".into(),
709            entity_type: EntityType::Agent,
710            old_sha: "aaa".into(),
711            new_sha: "bbb".into(),
712        };
713        let b = a.clone();
714        assert_eq!(a, b);
715    }
716}