1use std::fmt;
2
3use serde::{Deserialize, Serialize};
4
5pub const DEFAULT_REF: &str = "main";
6
7#[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 #[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 #[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#[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 #[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 #[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 #[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#[must_use]
120pub fn short_sha(sha: &str) -> &str {
121 &sha[..sha.len().min(12)]
122}
123
124#[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 #[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#[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 #[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#[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
285pub struct LockEntry {
286 pub sha: String,
287 pub raw_url: String,
288}
289
290#[derive(Debug, Clone, Default)]
295pub struct Manifest {
296 pub entries: Vec<Entry>,
297 pub install_targets: Vec<InstallTarget>,
298}
299
300#[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#[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#[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}