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 Local {
140 path: String,
141 },
142 Url {
143 url: String,
144 },
145}
146
147impl SourceFields {
148 #[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#[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 #[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#[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
267pub struct LockEntry {
268 pub sha: String,
269 pub raw_url: String,
270}
271
272#[derive(Debug, Clone, Default)]
277pub struct Manifest {
278 pub entries: Vec<Entry>,
279 pub install_targets: Vec<InstallTarget>,
280}
281
282#[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#[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#[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}