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)]
61pub enum EntityType {
62 Skill,
63 Agent,
64}
65
66impl EntityType {
67 pub const ALL: &[EntityType] = &[EntityType::Agent, EntityType::Skill];
69
70 #[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 #[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 #[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#[must_use]
123pub fn short_sha(sha: &str) -> &str {
124 &sha[..sha.len().min(12)]
125}
126
127#[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 #[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 #[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 #[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 #[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#[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 #[must_use]
208 pub fn source_type(&self) -> &str {
209 self.source.source_type()
210 }
211
212 #[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#[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
276pub struct LockEntry {
277 pub sha: String,
278 pub raw_url: String,
279}
280
281#[derive(Debug, Clone, Default)]
287pub struct Manifest {
288 pub entries: Vec<Entry>,
289 pub install_targets: Vec<InstallTarget>,
290}
291
292#[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#[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#[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}