1use std::fmt;
2
3use serde::{Deserialize, Serialize};
4
5pub const DEFAULT_REF: &str = "main";
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
13pub enum Scope {
14 Global,
15 Local,
16}
17
18impl Scope {
19 pub const ALL: &[Scope] = &[Scope::Global, Scope::Local];
21
22 #[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 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
61#[serde(rename_all = "lowercase")]
62pub enum EntityType {
63 Skill,
64 Agent,
65}
66
67impl EntityType {
68 pub const ALL: &[EntityType] = &[EntityType::Agent, EntityType::Skill];
70
71 #[must_use]
80 pub fn parse(s: &str) -> Option<Self> {
81 match s {
82 "skill" => Some(EntityType::Skill),
83 "agent" => Some(EntityType::Agent),
84 _ => None,
85 }
86 }
87
88 #[must_use]
90 pub fn as_str(&self) -> &'static str {
91 match self {
92 EntityType::Skill => "skill",
93 EntityType::Agent => "agent",
94 }
95 }
96
97 #[must_use]
99 pub fn dir_name(&self) -> &'static str {
100 match self {
101 EntityType::Skill => "skills",
102 EntityType::Agent => "agents",
103 }
104 }
105}
106
107impl fmt::Display for EntityType {
108 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
109 f.write_str(self.as_str())
110 }
111}
112
113#[must_use]
124pub fn short_sha(sha: &str) -> &str {
125 &sha[..sha.len().min(12)]
126}
127
128#[derive(Debug, Clone, PartialEq, Eq)]
137pub enum SourceFields {
138 Github {
139 owner_repo: String,
140 path_in_repo: String,
141 ref_: String,
142 },
143 Local {
144 path: String,
145 },
146 Url {
147 url: String,
148 },
149}
150
151impl SourceFields {
152 #[must_use]
154 pub fn source_type(&self) -> &str {
155 match self {
156 SourceFields::Github { .. } => "github",
157 SourceFields::Local { .. } => "local",
158 SourceFields::Url { .. } => "url",
159 }
160 }
161
162 #[must_use]
164 pub fn as_github(&self) -> Option<(&str, &str, &str)> {
165 match self {
166 SourceFields::Github {
167 owner_repo,
168 path_in_repo,
169 ref_,
170 } => Some((owner_repo, path_in_repo, ref_)),
171 _ => None,
172 }
173 }
174
175 #[must_use]
177 pub fn as_local(&self) -> Option<&str> {
178 match self {
179 SourceFields::Local { path } => Some(path),
180 _ => None,
181 }
182 }
183
184 #[must_use]
186 pub fn as_url(&self) -> Option<&str> {
187 match self {
188 SourceFields::Url { url } => Some(url),
189 _ => None,
190 }
191 }
192}
193
194#[derive(Debug, Clone, PartialEq, Eq)]
200pub struct Entry {
201 pub entity_type: EntityType,
202 pub name: String,
203 pub source: SourceFields,
204}
205
206impl Entry {
207 #[must_use]
209 pub fn source_type(&self) -> &str {
210 self.source.source_type()
211 }
212
213 #[cfg(test)]
217 pub fn owner_repo(&self) -> &str {
218 self.source.as_github().map_or("", |(or, _, _)| or)
219 }
220
221 #[cfg(test)]
222 pub fn path_in_repo(&self) -> &str {
223 self.source.as_github().map_or("", |(_, pir, _)| pir)
224 }
225
226 #[cfg(test)]
227 pub fn ref_(&self) -> &str {
228 self.source.as_github().map_or("", |(_, _, r)| r)
229 }
230
231 #[cfg(test)]
232 pub fn local_path(&self) -> &str {
233 self.source.as_local().unwrap_or("")
234 }
235
236 #[cfg(test)]
237 pub fn url(&self) -> &str {
238 self.source.as_url().unwrap_or("")
239 }
240}
241
242impl fmt::Display for Entry {
243 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
244 write!(
245 f,
246 "{}/{}/{}",
247 self.source_type(),
248 self.entity_type,
249 self.name
250 )
251 }
252}
253
254#[derive(Debug, Clone, PartialEq, Eq)]
260pub struct InstallTarget {
261 pub adapter: String,
262 pub scope: Scope,
263}
264
265impl fmt::Display for InstallTarget {
266 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
267 write!(f, "{} ({})", self.adapter, self.scope)
268 }
269}
270
271#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
277pub struct LockEntry {
278 pub sha: String,
279 pub raw_url: String,
280}
281
282#[derive(Debug, Clone, Default)]
288pub struct Manifest {
289 pub entries: Vec<Entry>,
290 pub install_targets: Vec<InstallTarget>,
291}
292
293#[derive(Debug, Clone)]
299pub struct InstallOptions {
300 pub dry_run: bool,
301 pub overwrite: bool,
302}
303
304impl Default for InstallOptions {
305 fn default() -> Self {
306 Self {
307 dry_run: false,
308 overwrite: true,
309 }
310 }
311}
312
313#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
319pub struct ConflictState {
320 pub entry: String,
321 pub entity_type: EntityType,
322 pub old_sha: String,
323 pub new_sha: String,
324}
325
326#[cfg(test)]
331mod tests {
332 use super::*;
333
334 #[test]
335 fn scope_parse_and_display() {
336 assert_eq!(Scope::parse("global"), Some(Scope::Global));
337 assert_eq!(Scope::parse("local"), Some(Scope::Local));
338 assert_eq!(Scope::parse("worldwide"), None);
339 assert_eq!(Scope::Global.to_string(), "global");
340 assert_eq!(Scope::Local.to_string(), "local");
341 assert_eq!(Scope::Global.as_str(), "global");
342 }
343
344 #[test]
345 fn scope_all_variants() {
346 assert_eq!(Scope::ALL.len(), 2);
347 assert!(Scope::ALL.contains(&Scope::Global));
348 assert!(Scope::ALL.contains(&Scope::Local));
349 }
350
351 #[test]
352 fn entity_type_parse_and_display() {
353 assert_eq!(EntityType::parse("skill"), Some(EntityType::Skill));
354 assert_eq!(EntityType::parse("agent"), Some(EntityType::Agent));
355 assert_eq!(EntityType::parse("hook"), None);
356 assert_eq!(EntityType::Skill.to_string(), "skill");
357 assert_eq!(EntityType::Agent.to_string(), "agent");
358 assert_eq!(EntityType::Skill.as_str(), "skill");
359 assert_eq!(EntityType::Agent.as_str(), "agent");
360 }
361
362 #[test]
363 fn entity_type_dir_name() {
364 assert_eq!(EntityType::Skill.dir_name(), "skills");
365 assert_eq!(EntityType::Agent.dir_name(), "agents");
366 }
367
368 #[test]
369 fn entity_type_all_variants() {
370 assert_eq!(EntityType::ALL.len(), 2);
371 assert!(EntityType::ALL.contains(&EntityType::Skill));
372 assert!(EntityType::ALL.contains(&EntityType::Agent));
373 }
374
375 #[test]
376 fn short_sha_truncates() {
377 let sha = "abcdef123456789012345678";
378 assert_eq!(short_sha(sha), "abcdef123456");
379 }
380
381 #[test]
382 fn short_sha_short_input() {
383 assert_eq!(short_sha("abc"), "abc");
384 }
385
386 #[test]
387 fn source_fields_typed_accessors() {
388 let gh = SourceFields::Github {
389 owner_repo: "o/r".into(),
390 path_in_repo: "a.md".into(),
391 ref_: "main".into(),
392 };
393 assert_eq!(gh.as_github(), Some(("o/r", "a.md", "main")));
394 assert_eq!(gh.as_local(), None);
395 assert_eq!(gh.as_url(), None);
396
397 let local = SourceFields::Local {
398 path: "test.md".into(),
399 };
400 assert_eq!(local.as_local(), Some("test.md"));
401 assert_eq!(local.as_github(), None);
402
403 let url = SourceFields::Url {
404 url: "https://x.com/s.md".into(),
405 };
406 assert_eq!(url.as_url(), Some("https://x.com/s.md"));
407 assert_eq!(url.as_github(), None);
408 }
409
410 #[test]
411 fn github_entry_source_type() {
412 let e = Entry {
413 entity_type: EntityType::Agent,
414 name: "test".into(),
415 source: SourceFields::Github {
416 owner_repo: "o/r".into(),
417 path_in_repo: "a.md".into(),
418 ref_: "main".into(),
419 },
420 };
421 assert_eq!(e.source_type(), "github");
422 assert_eq!(e.entity_type, EntityType::Agent);
423 assert_eq!(e.name, "test");
424 assert_eq!(e.owner_repo(), "o/r");
425 assert_eq!(e.path_in_repo(), "a.md");
426 assert_eq!(e.ref_(), "main");
427 assert_eq!(e.local_path(), "");
428 assert_eq!(e.url(), "");
429 }
430
431 #[test]
432 fn github_entry_fields() {
433 let e = Entry {
434 entity_type: EntityType::Skill,
435 name: "my-skill".into(),
436 source: SourceFields::Github {
437 owner_repo: "o/r".into(),
438 path_in_repo: "skills/s.md".into(),
439 ref_: "v1".into(),
440 },
441 };
442 assert_eq!(e.owner_repo(), "o/r");
443 assert_eq!(e.path_in_repo(), "skills/s.md");
444 assert_eq!(e.ref_(), "v1");
445 }
446
447 #[test]
448 fn local_entry_fields() {
449 let e = Entry {
450 entity_type: EntityType::Skill,
451 name: "test".into(),
452 source: SourceFields::Local {
453 path: "test.md".into(),
454 },
455 };
456 assert_eq!(e.source_type(), "local");
457 assert_eq!(e.local_path(), "test.md");
458 assert_eq!(e.owner_repo(), "");
459 assert_eq!(e.url(), "");
460 }
461
462 #[test]
463 fn url_entry_fields() {
464 let e = Entry {
465 entity_type: EntityType::Skill,
466 name: "my-skill".into(),
467 source: SourceFields::Url {
468 url: "https://example.com/skill.md".into(),
469 },
470 };
471 assert_eq!(e.source_type(), "url");
472 assert_eq!(e.url(), "https://example.com/skill.md");
473 assert_eq!(e.owner_repo(), "");
474 }
475
476 #[test]
477 fn entry_display() {
478 let e = Entry {
479 entity_type: EntityType::Agent,
480 name: "test".into(),
481 source: SourceFields::Github {
482 owner_repo: "o/r".into(),
483 path_in_repo: "a.md".into(),
484 ref_: "main".into(),
485 },
486 };
487 assert_eq!(e.to_string(), "github/agent/test");
488 }
489
490 #[test]
491 fn lock_entry() {
492 let le = LockEntry {
493 sha: "abc123".into(),
494 raw_url: "https://example.com".into(),
495 };
496 assert_eq!(le.sha, "abc123");
497 assert_eq!(le.raw_url, "https://example.com");
498 }
499
500 #[test]
501 fn install_target_with_scope_enum() {
502 let t = InstallTarget {
503 adapter: "claude-code".into(),
504 scope: Scope::Global,
505 };
506 assert_eq!(t.adapter, "claude-code");
507 assert_eq!(t.scope, Scope::Global);
508 assert_eq!(t.to_string(), "claude-code (global)");
509 }
510
511 #[test]
512 fn manifest_defaults() {
513 let m = Manifest::default();
514 assert!(m.entries.is_empty());
515 assert!(m.install_targets.is_empty());
516 }
517
518 #[test]
519 fn manifest_with_entries() {
520 let e = Entry {
521 entity_type: EntityType::Skill,
522 name: "test".into(),
523 source: SourceFields::Local {
524 path: "test.md".into(),
525 },
526 };
527 let t = InstallTarget {
528 adapter: "claude-code".into(),
529 scope: Scope::Local,
530 };
531 let m = Manifest {
532 entries: vec![e],
533 install_targets: vec![t],
534 };
535 assert_eq!(m.entries.len(), 1);
536 assert_eq!(m.install_targets.len(), 1);
537 }
538
539 #[test]
540 fn conflict_state_equality() {
541 let a = ConflictState {
542 entry: "foo".into(),
543 entity_type: EntityType::Agent,
544 old_sha: "aaa".into(),
545 new_sha: "bbb".into(),
546 };
547 let b = a.clone();
548 assert_eq!(a, b);
549 }
550}